diff --git a/docs/source/contributing/conventions/protocol-family-grouping.rst b/docs/source/contributing/conventions/protocol-family-grouping.rst index 4a53d65aee..313b9fa75a 100644 --- a/docs/source/contributing/conventions/protocol-family-grouping.rst +++ b/docs/source/contributing/conventions/protocol-family-grouping.rst @@ -21,12 +21,12 @@ The member list takes either of these forms: :class:`~pcapkit.protocols.internet.esp.ESP` are not in it, since they belong to the separate abstract :class:`~pcapkit.protocols.internet.ipsec.IPsec`, whose :meth:`~pcapkit.protocols.internet.ipsec.IPsec.id` is ``('AH', 'ESP')``. + :meth:`HTTP.id() ` is + ``('HTTP/1', 'HTTP/2')``. * **The base's own name, then the members.** :meth:`VLAN.id() ` is - ``('VLAN', 'C_Tag', 'S_Tag')``, - :meth:`L2TP.id() ` is ``('L2TP', 'L2TPv2')`` - and :meth:`HTTP.id() ` is - ``('HTTP', 'HTTPv1', 'HTTPv2')``. + ``('VLAN', 'C_Tag', 'S_Tag')`` and + :meth:`L2TP.id() ` is ``('L2TP', 'L2TPv2')``. :meth:`ARP.id() ` is ``('ARP',)`` alone; see :ref:`protocol-family-shapes`. @@ -48,7 +48,29 @@ HTTP/2 share :class:`~pcapkit.protocols.application.http.HTTP`, IPv4 and IPv6 sh :class:`~pcapkit.protocols.internet.ip.IP` and :class:`~pcapkit.protocols.internet.ipsec.IPsec` define no ``read()``. The HTTP class -does, and a parsed HTTP/1.1 or HTTP/2 layer is the HTTP class itself. +does, but yields no instance of itself (:ref:`below `). + +.. _protocol-family-dispatch: + +Dispatching Bases +~~~~~~~~~~~~~~~~~ + +**A versioned family's base dispatches on both paths** (:issue:`2524`). Parsing +octets through it, constructing it from fields and its ``from_data`` give the member +the octets, fields or data model describe, never an instance of the base. In +:class:`~pcapkit.protocols.application.http.HTTP`, +:meth:`~pcapkit.protocols.application.http.HTTP._dispatch_version` picks the member +from the octets, for a payload a transport port binds to HTTP as well, and +:meth:`~pcapkit.protocols.application.http.HTTP._dispatch_fields` from the fields. +The other versioned families are tracked on :issue:`2574`. + +**Octets neither version takes**, a frameless HTTP/2 connection preface among them, +are :class:`~pcapkit.protocols.misc.raw.Raw` with no ``error`` on the transport path, +and a direct ``HTTP(...)`` on them raises +:exc:`~pcapkit.utilities.exceptions.ProtocolError` (:issue:`2524`). Octets the +dispatcher gives :class:`~pcapkit.protocols.application.httpv2.HTTPv2` that its parser +then refuses, such as a preface and three octets, are +:class:`~pcapkit.protocols.misc.raw.Raw` with the parser's ``error``. .. _protocol-family-shapes: @@ -70,15 +92,14 @@ Member Shapes :class:`IGMPv3 ` ``('IGMPv3',)`` -- :class:`IGMPv1 ` narrows the same way, but to the RFC canonical name rather than a versioned one - (:ref:`protocol-family-canonical-names`) + (:ref:`protocol-family-canonical-names`); + :class:`HTTPv1 ` ``('HTTP/1',)`` + and :class:`HTTPv2 ` + ``('HTTP/2',)`` * - Family-inclusive - the member's label plus the family name - :class:`~pcapkit.protocols.link.c_tag.C_Tag` ``('C_Tag', 'VLAN')``, :class:`~pcapkit.protocols.link.s_tag.S_Tag` ``('S_Tag', 'VLAN')``; - :class:`HTTPv1 ` - ``('HTTP', 'HTTPv1')`` and - :class:`HTTPv2 ` - ``('HTTP', 'HTTPv2')``; :class:`~pcapkit.protocols.link.l2tpv2.L2TPv2` ``('L2TP', 'L2TPv2')`` * - One parser, dynamic alias - fixed, whichever protocol was read @@ -99,7 +120,7 @@ A family-inclusive member is selected by any sibling's registry key (a key of ``pcapkit.protocols.__proto__``), because each expands to the shared family name (:ref:`below `). A narrow member is selected by its own name and by the key of each family whose ``id()`` lists it: ``AH`` and ``ESP`` by ``IPsec`` -and by ``IPv6_Ext``. +and by ``IPv6_Ext``, ``HTTPv1`` and ``HTTPv2`` by ``HTTP``. Chained Records ~~~~~~~~~~~~~~~ @@ -142,6 +163,16 @@ them with one. The family base's ``id()`` follows: canonical name and the family's own name are the same string -- the same shape :meth:`ICMP.id() ` takes, ``('ICMP', 'ICMPv6')``. +**HTTP takes the RFC names as well** (:issue:`2524`). :rfc:`9113` names its protocol +``HTTP/2``, so :meth:`HTTPv2.id() ` is +``('HTTP/2',)``. :rfc:`9112` names ``HTTP/1.1``, but the HTTP/1 class also reads +messages declaring 1.0 or 0.9, so +:meth:`HTTPv1.id() ` is the major +version, ``('HTTP/1',)``, and ``alias`` carries the exact one. No member's name +is the family's, so :meth:`HTTP.id() ` +lists the members alone, ``('HTTP/1', 'HTTP/2')``, the shape of +:meth:`IP.id() `. + .. _protocol-family-selectors: Selector Matching @@ -151,6 +182,10 @@ Selector Matching :pr:`2443`. On an IPv4 frame, ``'IP' in`` is ``True`` and ``'IPv6' in`` is ``False``. On an IPv6 frame, ``'IP' in`` is ``True`` and ``'IPv4' in`` is ``False``. +HTTP follows it too (:issue:`2524`). On an HTTP/1.1 frame, ``'HTTP' in`` and +``'HTTP/1' in`` are ``True`` and ``'HTTP/2' in`` is ``False``, since the layer is +:class:`~pcapkit.protocols.application.httpv1.HTTPv1`; an HTTP/2 frame is the reverse. + What a selector on a frame or a layer reaches is defined by :meth:`~pcapkit.protocols.protocol.Protocol.expand_comp`, :meth:`~pcapkit.protocols.protocol.Protocol.__contains__`, @@ -161,7 +196,9 @@ Selectors are case-insensitive: ``'ipv4' in`` an IPv4 frame is ``True``. is not expanded. On a parsed IPv4 layer, ``ipv4 == 'IPv4'`` is ``True`` and ``ipv4 == 'IP'`` is ``False``. On a parsed C-Tag layer, ``== 'VLAN'`` is ``True``, since ``VLAN`` is in its ``id()``, and ``== 'S_Tag'`` is ``False``, though - ``'S_Tag' in`` the layer is ``True``. + ``'S_Tag' in`` the layer is ``True``. On a parsed HTTP/1.1 layer, ``== 'HTTP/1'`` is + ``True`` and ``== 'HTTP'`` is ``False``, since no member's ``id()`` carries + ``HTTP``, though ``'HTTP' in`` the layer is ``True``. * ``in`` **and** ``[]`` expand the selector first. A registry key becomes its class plus that class's ``id()``, as does a class object, and a layer matches when any of those appears among its own class and ``id()``. So ``'S_Tag' in`` a C-Tag frame is @@ -204,8 +241,6 @@ holds :class:`~pcapkit.protocols.link.linux_sll1.LinuxSLL1`. **The** ``schema/`` name, and sub-structure classes such as :class:`~pcapkit.protocols.schema.internet.igmpv3.Query` share its module. Ruled on :issue:`2529`, where the classes that do not match yet are pending: -:class:`httpv1.HTTP `, -:class:`httpv2.HTTP `, :class:`arp.InARP `, :class:`tls.SSLv3 ` and :class:`null.NoPayload `. diff --git a/docs/source/contributing/conventions/protocol-member-order.rst b/docs/source/contributing/conventions/protocol-member-order.rst index d56cc1ebb2..de577a04f8 100644 --- a/docs/source/contributing/conventions/protocol-member-order.rst +++ b/docs/source/contributing/conventions/protocol-member-order.rst @@ -41,7 +41,7 @@ The Order :issue:`2490`. d. **Other helpers.** Every helper that ``read()``, ``make()`` and the registries do not reach goes last, including one that only another helper calls, such as - ``_decode_text()`` in :class:`~pcapkit.protocols.application.httpv1.HTTP`. + ``_decode_text()`` in :class:`~pcapkit.protocols.application.httpv1.HTTPv1`. Ruled on :issue:`2490`. **Registry maps.** The registry maps themselves (``__proto__``, ``__option__``, diff --git a/docs/source/contributing/pep.rst b/docs/source/contributing/pep.rst index 1a3918c1fe..6e46f34539 100644 --- a/docs/source/contributing/pep.rst +++ b/docs/source/contributing/pep.rst @@ -361,7 +361,7 @@ Follow-ups to the bindings above: ` and :attr:`UDP.__proto__ ` both point their HTTP ports at the version-identifying proxy :class:`pcapkit.protocols.application.http.HTTP` - rather than at :class:`pcapkit.protocols.application.httpv1.HTTP`, so a + rather than at :class:`pcapkit.protocols.application.httpv1.HTTPv1`, so a segment's HTTP version is decided by its payload rather than asserted by its port number. That depends on the proxy identifying the version positively instead of guessing, since a guess path is wrong on non-HTTP input. diff --git a/docs/source/ext.rst b/docs/source/ext.rst index 66556b7bd9..c77920ca5e 100644 --- a/docs/source/ext.rst +++ b/docs/source/ext.rst @@ -98,9 +98,9 @@ The protocol classes :mod:`pcapkit` ships: + Application Layer +----------------+-----------------------+-------------------------------------------------------------+ | (:class:`~pcapkit.protocols.application.application.Application` | | :class:`pcapkit.protocols.application.http.HTTP` | + subclasses) + +-----------------------+-------------------------------------------------------------+ -| | HTTP Family | :class:`pcapkit.protocols.application.httpv1.HTTP` | +| | HTTP Family | :class:`pcapkit.protocols.application.httpv1.HTTPv1` | + + +-----------------------+-------------------------------------------------------------+ -| | | :class:`pcapkit.protocols.application.httpv2.HTTP` | +| | | :class:`pcapkit.protocols.application.httpv2.HTTPv2` | + +----------------+-----------------------+-------------------------------------------------------------+ | | :class:`pcapkit.protocols.application.ngap.NGAP` | + +----------------+-----------------------+-------------------------------------------------------------+ @@ -406,7 +406,7 @@ writes them: + + +-----------------------------------------------------------------------------------------------+----------------------------------------------------------------------------+ | | | :attr:`SCTP.__cause__ ` | :func:`~pcapkit.foundation.registry.protocols.register_sctp_cause` | +-------------------+------------------------------------------------------------+-----------------------------------------------------------------------------------------------+----------------------------------------------------------------------------+ -| Application Layer | :class:`~pcapkit.protocols.application.httpv2.HTTP` | :attr:`HTTP.__frame__ ` | :func:`~pcapkit.foundation.registry.protocols.register_http_frame` | +| Application Layer | :class:`~pcapkit.protocols.application.httpv2.HTTPv2` | :attr:`HTTPv2.__frame__ ` | :func:`~pcapkit.foundation.registry.protocols.register_http_frame` | +-------------------+------------------------------------------------------------+-----------------------------------------------------------------------------------------------+----------------------------------------------------------------------------+ | | | :attr:`PCAPNG.__block__ ` | :func:`~pcapkit.foundation.registry.protocols.register_pcapng_block` | + + +-----------------------------------------------------------------------------------------------+----------------------------------------------------------------------------+ diff --git a/docs/source/pcapkit/protocols/application/application.rst b/docs/source/pcapkit/protocols/application/application.rst index 99dec22581..5c9c7c698b 100644 --- a/docs/source/pcapkit/protocols/application/application.rst +++ b/docs/source/pcapkit/protocols/application/application.rst @@ -6,8 +6,8 @@ Application Layer Base Protocol :mod:`pcapkit.protocols.application.application` contains only :class:`~pcapkit.protocols.application.application.Application`, which is a base class for application layer protocols, e.g. -:class:`HTTP/1.* ` and -:class:`HTTP/2 `. +:class:`HTTP/1.* ` and +:class:`HTTP/2 `. .. versionchanged:: 1.5.0 diff --git a/docs/source/pcapkit/protocols/application/http.rst b/docs/source/pcapkit/protocols/application/http.rst index 99aa249cf5..7950d40cb5 100644 --- a/docs/source/pcapkit/protocols/application/http.rst +++ b/docs/source/pcapkit/protocols/application/http.rst @@ -7,11 +7,21 @@ HTTP - Hypertext Transfer Protocol :class:`~pcapkit.protocols.application.http.HTTP`, which is a base class for Hypertext Transfer Protocol (HTTP) [*]_ family, e.g. -:class:`HTTP/1.* ` -and :class:`HTTP/2 `, +:class:`HTTP/1.* ` +and :class:`HTTP/2 `, and :func:`~pcapkit.protocols.application.http.test_start_line`, which tells whether a payload opens with an HTTP/1.* start line. +.. versionchanged:: 1.6.0 + + A payload a transport port binds to + :class:`~pcapkit.protocols.application.http.HTTP`, and constructing or + rebuilding that class, give + :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`). + .. autoclass:: pcapkit.protocols.application.http.HTTP :no-members: :show-inheritance: @@ -28,7 +38,12 @@ which tells whether a payload opens with an HTTP/1.* start line. .. automethod:: _make_data + .. automethod:: __new__ + .. automethod:: _guess_version + .. automethod:: _dispatch_version + .. automethod:: _dispatch_fields + .. automethod:: _member_of_version .. autoattribute:: _preface_length diff --git a/docs/source/pcapkit/protocols/application/httpv1.rst b/docs/source/pcapkit/protocols/application/httpv1.rst index e3735cc674..0a981810e6 100644 --- a/docs/source/pcapkit/protocols/application/httpv1.rst +++ b/docs/source/pcapkit/protocols/application/httpv1.rst @@ -4,7 +4,7 @@ HTTP/1.* - Hypertext Transfer Protocol .. module:: pcapkit.protocols.application.httpv1 :mod:`pcapkit.protocols.application.httpv1` contains -:class:`~pcapkit.protocols.application.httpv1.HTTP`, +:class:`~pcapkit.protocols.application.httpv1.HTTPv1`, which implements an extractor for Hypertext Transfer Protocol (HTTP/1.*) [*]_, and its :class:`~pcapkit.protocols.application.httpv1.Type` enumeration. The @@ -31,7 +31,13 @@ structure of an HTTP/1.* message is described as below: rather than normalised or re-encoded (:issue:`1241`, :issue:`1302`, :issue:`1333`). -.. autoclass:: pcapkit.protocols.application.httpv1.HTTP +.. versionremoved:: 1.6.0 + + ``pcapkit.protocols.application.httpv1.HTTP``, with no alias. The + class is renamed :class:`~pcapkit.protocols.application.httpv1.HTTPv1`, + after its module (:issue:`2529`). + +.. autoclass:: pcapkit.protocols.application.httpv1.HTTPv1 :no-members: :show-inheritance: @@ -60,7 +66,13 @@ Header Schemas .. module:: pcapkit.protocols.schema.application.httpv1 -.. autoclass:: pcapkit.protocols.schema.application.httpv1.HTTP +.. versionremoved:: 1.6.0 + + ``pcapkit.protocols.schema.application.httpv1.HTTP``, with no alias. The + class is renamed :class:`~pcapkit.protocols.schema.application.httpv1.HTTPv1`, + after its module (:issue:`2529`). + +.. autoclass:: pcapkit.protocols.schema.application.httpv1.HTTPv1 :members: :show-inheritance: @@ -69,7 +81,13 @@ Data Models .. module:: pcapkit.protocols.data.application.httpv1 -.. autoclass:: pcapkit.protocols.data.application.httpv1.HTTP +.. versionremoved:: 1.6.0 + + ``pcapkit.protocols.data.application.httpv1.HTTP``, with no alias. The + class is renamed :class:`~pcapkit.protocols.data.application.httpv1.HTTPv1`, + after its module (:issue:`2529`). + +.. autoclass:: pcapkit.protocols.data.application.httpv1.HTTPv1 :members: :show-inheritance: diff --git a/docs/source/pcapkit/protocols/application/httpv2.rst b/docs/source/pcapkit/protocols/application/httpv2.rst index d35ac6740f..b4d4b22488 100644 --- a/docs/source/pcapkit/protocols/application/httpv2.rst +++ b/docs/source/pcapkit/protocols/application/httpv2.rst @@ -4,7 +4,7 @@ HTTP/2 - Hypertext Transfer Protocol .. module:: pcapkit.protocols.application.httpv2 :mod:`pcapkit.protocols.application.httpv2` contains -:class:`~pcapkit.protocols.application.httpv2.HTTP` +:class:`~pcapkit.protocols.application.httpv2.HTTPv2` only, which implements an extractor for Hypertext Transfer Protocol (HTTP/2) [*]_, whose structure is described as below: @@ -31,11 +31,17 @@ Octets Bits Name Description are kept (:issue:`652`, :issue:`668`, :issue:`1244`, :issue:`1245`, :issue:`1246`, :issue:`1247`). +.. versionremoved:: 1.6.0 + + ``pcapkit.protocols.application.httpv2.HTTP``, with no alias. The + class is renamed :class:`~pcapkit.protocols.application.httpv2.HTTPv2`, + after its module (:issue:`2529`). + .. raw:: html
-.. autoclass:: pcapkit.protocols.application.httpv2.HTTP +.. autoclass:: pcapkit.protocols.application.httpv2.HTTPv2 :no-members: :show-inheritance: @@ -85,7 +91,13 @@ Header Schemas .. module:: pcapkit.protocols.schema.application.httpv2 -.. autoclass:: pcapkit.protocols.schema.application.httpv2.HTTP +.. versionremoved:: 1.6.0 + + ``pcapkit.protocols.schema.application.httpv2.HTTP``, with no alias. The + class is renamed :class:`~pcapkit.protocols.schema.application.httpv2.HTTPv2`, + after its module (:issue:`2529`). + +.. autoclass:: pcapkit.protocols.schema.application.httpv2.HTTPv2 :members: :show-inheritance: @@ -170,7 +182,13 @@ Data Models .. module:: pcapkit.protocols.data.application.httpv2 -.. autoclass:: pcapkit.protocols.data.application.httpv2.HTTP +.. versionremoved:: 1.6.0 + + ``pcapkit.protocols.data.application.httpv2.HTTP``, with no alias. The + class is renamed :class:`~pcapkit.protocols.data.application.httpv2.HTTPv2`, + after its module (:issue:`2529`). + +.. autoclass:: pcapkit.protocols.data.application.httpv2.HTTPv2 :members: :show-inheritance: diff --git a/docs/source/pcapkit/protocols/index.rst b/docs/source/pcapkit/protocols/index.rst index 157f0f127c..080bb356eb 100644 --- a/docs/source/pcapkit/protocols/index.rst +++ b/docs/source/pcapkit/protocols/index.rst @@ -174,8 +174,8 @@ from given information. The class hierarchy of :mod:`pcapkit.protocols`: click Application "application/application.html#pcapkit.protocols.application.application.Application" click HTTP "application/http.html#pcapkit.protocols.application.http.HTTP" - click h1 "application/httpv1.html#pcapkit.protocols.application.httpv1.HTTP" - click h2 "application/httpv2.html#pcapkit.protocols.application.httpv2.HTTP" + click h1 "application/httpv1.html#pcapkit.protocols.application.httpv1.HTTPv1" + click h2 "application/httpv2.html#pcapkit.protocols.application.httpv2.HTTPv2" click FTP "application/ftp.html#pcapkit.protocols.application.ftp.FTP" click FTP_DATA "application/ftp_data.html#pcapkit.protocols.application.ftp_data.FTP_DATA" click NGAP "application/ngap.html#pcapkit.protocols.application.ngap.NGAP" diff --git a/docs/source/pcapkit/protocols/link/l2tp.rst b/docs/source/pcapkit/protocols/link/l2tp.rst index 1c240c4845..3d3787d9f6 100644 --- a/docs/source/pcapkit/protocols/link/l2tp.rst +++ b/docs/source/pcapkit/protocols/link/l2tp.rst @@ -53,7 +53,7 @@ the value 1 "to permit detection of L2F packets should they arrive intermixed wi L2TP packets". L2F is a separate protocol with its own header, so it is to be implemented as ``L2F``, the canonical name, with ``L2TPv1`` only as an alias in its :meth:`~pcapkit.protocols.protocol.Protocol.id`. See -:meth:`HTTPv1.id ` for +:meth:`L2TPv2.id ` for how a version-flavoured alias is spelled: canonical name first, alias second, since callers take element zero as canonical. diff --git a/examples/generators/dispatch.py b/examples/generators/dispatch.py index e69f1e92db..7136e968fd 100644 --- a/examples/generators/dispatch.py +++ b/examples/generators/dispatch.py @@ -718,7 +718,7 @@ def _link_enum() -> 'Any': 'tcp/20': ('pcapkit.protocols.application.ftp_data', 'FTP_DATA'), 'tcp/21': ('pcapkit.protocols.application.ftp', 'FTP'), # The version-identifying proxy (#682), as for ``udp/80`` and ``udp/8080`` - # below. + # below; it dispatches to HTTPv1 or HTTPv2 (#2524). 'tcp/80': ('pcapkit.protocols.application.http', 'HTTP'), 'tcp/443': ('pcapkit.protocols.application.tls', 'TLS'), 'tcp/8080': ('pcapkit.protocols.application.http', 'HTTP'), @@ -936,10 +936,12 @@ def probe(case: 'Case') -> 'Outcome': # protochain`` on the string alone, even though no AH instance exists # anywhere in the chain. ``.protocols`` holds the actual ``type(instance)`` # for each layer, which that substitution cannot spoof. - # An abstract target is a dispatcher: the chain holds the concrete class it - # picked, so a subclass of it counts as reaching it (IGMP -> IGMPv1/IGMPv2). - reached = any(klass is case.target - or (inspect.isabstract(case.target) and issubclass(klass, case.target)) + # A dispatcher target -- abstract, or defining ``_dispatch_version`` -- puts the + # member it picked in the chain, so a subclass of it counts as reaching it + # (IGMP -> IGMPv1/IGMPv2, HTTP -> HTTPv1/HTTPv2, #2524). + dispatcher = (inspect.isabstract(case.target) + or '_dispatch_version' in vars(case.target)) + reached = any(klass is case.target or (dispatcher and issubclass(klass, case.target)) for klass in frame.protochain.protocols) error = '' if reached else _find_error(frame.info.to_dict()) return Outcome(case, chain, reached, '', error) diff --git a/examples/generators/options.py b/examples/generators/options.py index af07da81fd..c8a4fe7ae1 100644 --- a/examples/generators/options.py +++ b/examples/generators/options.py @@ -1109,8 +1109,8 @@ def _hip_rebuild(parameters: 'Any') -> 'Any': def _httpv2_registry() -> 'Any': - from pcapkit.protocols.application.httpv2 import HTTP - return HTTP.__frame__ + from pcapkit.protocols.application.httpv2 import HTTPv2 + return HTTPv2.__frame__ def _httpv2_overrides() -> 'dict[Any, dict[str, Any]]': @@ -1138,15 +1138,15 @@ def _httpv2_overrides() -> 'dict[Any, dict[str, Any]]': def _httpv2_build(code: 'Any', kwargs: 'dict[str, Any]') -> 'Any': - from pcapkit.protocols.application.httpv2 import HTTP + from pcapkit.protocols.application.httpv2 import HTTPv2 sid = 0 if int(code) in HTTPV2_CONNECTION_FRAMES else HTTPV2_STREAM - return HTTP(type=code, sid=sid, frame=dict(kwargs)) + return HTTPv2(type=code, sid=sid, frame=dict(kwargs)) def _httpv2_parse(octets: 'bytes') -> 'Any': - from pcapkit.protocols.application.httpv2 import HTTP - return HTTP(octets, len(octets)) + from pcapkit.protocols.application.httpv2 import HTTPv2 + return HTTPv2(octets, len(octets)) def _httpv2_extract(parsed: 'Any') -> 'Any': @@ -1154,14 +1154,14 @@ def _httpv2_extract(parsed: 'Any') -> 'Any': def _httpv2_rebuild(frame: 'Any') -> 'Any': - from pcapkit.protocols.application.httpv2 import HTTP + from pcapkit.protocols.application.httpv2 import HTTPv2 # ``HTTP.make`` ORs ``flags`` into the bits the ``_make_http_*`` constructor # derives from ``frame``, and that constructor knows only the flags its # frame type defines. So the raw flags octet is passed on, as # ``HTTP._make_data`` does, or a bit the type leaves undefined is dropped. # ``reserved`` is passed on for the same reason: ``make`` defaults it to 0. - return HTTP(type=frame.type, sid=frame.sid, reserved=frame.reserved, + return HTTPv2(type=frame.type, sid=frame.sid, reserved=frame.reserved, flags=frame.flags.__value__ if frame.flags is not None else 0, frame=frame) diff --git a/pcapkit/foundation/registry/protocols.py b/pcapkit/foundation/registry/protocols.py index 71fbae9ba9..02a3767c20 100644 --- a/pcapkit/foundation/registry/protocols.py +++ b/pcapkit/foundation/registry/protocols.py @@ -50,7 +50,7 @@ from pcapkit.corekit.module import ModuleDescriptor from pcapkit.corekit.sentinels import NULL from pcapkit.protocols import __proto__ as protocol_registry -from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 +from pcapkit.protocols.application.httpv2 import HTTPv2 from pcapkit.protocols.internet.hip import HIP from pcapkit.protocols.internet.hopopt import HOPOPT from pcapkit.protocols.internet.icmpv4 import ICMPv4 @@ -181,15 +181,11 @@ def register_protocol(protocol: 'Type[ProtocolBase]') -> 'None': :class:`~pcapkit.protocols.protocol.ProtocolBase`, and will be registered to the :data:`pcapkit.protocols.__proto__` registry. - The registry is keyed on ``protocol.__name__.upper()``, which is **not** - unique across this package: several dispatchable protocol classes are - named ``HTTP`` -- the generic base - :class:`pcapkit.protocols.application.http.HTTP` and the version - implementations :class:`pcapkit.protocols.application.httpv1.HTTP` and - :class:`pcapkit.protocols.application.httpv2.HTTP` -- so they all compete - for the single key ``'HTTP'``. Registering one of them replaces whichever - was there, and the replacement is observable through every reader of the - registry, e.g. :meth:`Protocol.expand_comp + The registry is keyed on ``protocol.__name__.upper()``, which nothing makes + unique: two protocol classes of the same name, such as a user's class + named after a shipped one, compete for one key. Registering one of them + replaces whichever was there, and the replacement is observable through + every reader of the registry, e.g. :meth:`Protocol.expand_comp `, which resolves a bare protocol name through it. Per :issue:`675` the overwrite is reported, matching :meth:`Protocol.register @@ -208,7 +204,7 @@ class rather than supplied by a caller, and this function is the funnel overwrite that overwrote nothing. Warning on the harmless case is not free: it teaches a caller to filter :exc:`~pcapkit.utilities.warnings.RegistryWarning` wholesale, and that - filter would then hide the ``HTTP`` collision this warning exists to + filter would then hide the name collision this warning exists to surface. The sibling ``register`` methods across the package, each keyed on a caller-supplied ``code``, apply the same identity criterion (:issue:`718`). @@ -240,6 +236,15 @@ class rather than supplied by a caller, and this function is the funnel registration displaced another protocol class. Re-registering the same class under the same name is silent. + .. versionchanged:: 1.6.0 + + The HTTP member classes are named + :class:`~pcapkit.protocols.application.httpv1.HTTPv1` and + :class:`~pcapkit.protocols.application.httpv2.HTTPv2`, so registering + one keeps its own key and no longer displaces + :class:`~pcapkit.protocols.application.http.HTTP` under ``'HTTP'`` + (:issue:`2529`). + """ if not isinstance(protocol, type): raise RegistryError(f'protocol must be a class, not {protocol!r}') @@ -1833,16 +1838,16 @@ def register_sctp_cause(code: 'SCTP_CauseCode', meth: 'str | tuple[SCTP_CausePar ############################################################################### -# NOTE: pcapkit.protocols.application.httpv2.HTTP.__frame__ +# NOTE: pcapkit.protocols.application.httpv2.HTTPv2.__frame__ def register_http_frame(code: 'HTTP_Frame', meth: 'str | tuple[HTTP_FrameParser, HTTP_FrameConstructor]', *, schema: 'Optional[Type[Schema_HTTP_FrameType]]' = None) -> 'None': """Register a frame parser. The function will register the given frame parser to the - :data:`pcapkit.protocols.application.httpv2.HTTP.__frame__` registry. + :data:`pcapkit.protocols.application.httpv2.HTTPv2.__frame__` registry. Args: - code: :class:`HTTP/2 ` frame type + code: :class:`HTTP/2 ` frame type code as in :class:`~pcapkit.const.http.frame.Frame`. meth: Method name, or a ``(parser, constructor)`` pair of callables, to parse and construct the frame. A name names the diff --git a/pcapkit/protocols/application/__init__.py b/pcapkit/protocols/application/__init__.py index a63291800f..4884619597 100644 --- a/pcapkit/protocols/application/__init__.py +++ b/pcapkit/protocols/application/__init__.py @@ -23,8 +23,8 @@ # Utility Classes for Protocols from pcapkit.protocols.application.ftp import FTP from pcapkit.protocols.application.ftp_data import FTP_DATA -from pcapkit.protocols.application.httpv1 import HTTP as HTTPv1 -from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 +from pcapkit.protocols.application.httpv1 import HTTPv1 +from pcapkit.protocols.application.httpv2 import HTTPv2 from pcapkit.protocols.application.ngap import NGAP from pcapkit.protocols.application.ospf import OSPF from pcapkit.protocols.application.rarp import RARP diff --git a/pcapkit/protocols/application/application.py b/pcapkit/protocols/application/application.py index dd46a07c04..56e4e47255 100644 --- a/pcapkit/protocols/application/application.py +++ b/pcapkit/protocols/application/application.py @@ -7,8 +7,8 @@ :mod:`pcapkit.protocols.application.application` contains only :class:`~pcapkit.protocols.application.application.Application`, which is a base class for application layer protocols, e.g. -:class:`HTTP/1.* ` and -:class:`HTTP/2 `. +:class:`HTTP/1.* ` and +:class:`HTTP/2 `. .. versionchanged:: 1.5.0 diff --git a/pcapkit/protocols/application/http.py b/pcapkit/protocols/application/http.py index 562bb69656..b8f6403263 100644 --- a/pcapkit/protocols/application/http.py +++ b/pcapkit/protocols/application/http.py @@ -8,15 +8,26 @@ :class:`~pcapkit.protocols.application.http.HTTP`, which is a base class for Hypertext Transfer Protocol (HTTP) [*]_ family, e.g. -:class:`HTTP/1.* ` -and :class:`HTTP/2 `, +:class:`HTTP/1.* ` +and :class:`HTTP/2 `, and :func:`~pcapkit.protocols.application.http.test_start_line`, which tells whether a payload opens with an HTTP/1.* start line. .. [*] https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol +.. versionchanged:: 1.6.0 + + A payload a transport port binds to + :class:`~pcapkit.protocols.application.http.HTTP`, and constructing or + rebuilding that class, give + :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`). + """ import contextlib +import inspect import re import struct from typing import TYPE_CHECKING, Generic, cast @@ -29,10 +40,12 @@ from pcapkit.utilities.exceptions import ProtocolError if TYPE_CHECKING: - from typing import Any, Optional + from typing import IO, Any, Optional, Type from typing_extensions import Literal, Self + from pcapkit.protocols.protocol import ProtocolBase + __all__ = ['HTTP', 'test_start_line'] #: The HTTP/2 connection preface (:rfc:`9113#section-3.4`). A client opens every @@ -69,7 +82,7 @@ def _test_http2_frame(data: 'bytes') -> 'bool': """Test whether ``data`` opens with a frame header a conforming sender could write. This is a plausibility test for :meth:`HTTP._guess_version`'s fall-through - only. :class:`httpv2.HTTP ` itself parses every frame + only. :class:`httpv2.HTTPv2 ` itself parses every frame :rfc:`9113` lets a receiver accept, which includes frames this test turns down. Args: @@ -115,7 +128,7 @@ def _test_http2_frame(data: 'bytes') -> 'bool': _RE_VERSION = re.compile(rb"HTTP/(?P\d\.\d)") # Regular expression to match HTTP status code. Anchored for the same reason as # ``_RE_METHOD``, and it matters more here: this pattern is only a guard, and -# ``httpv1.HTTP`` takes the value from ``int(para2)`` on the raw token, so an +# ``httpv1.HTTPv1`` takes the value from ``int(para2)`` on the raw token, so an # unanchored prefix match would let ``200x`` and ``2000`` past the guard and then # out of ``int()`` as a bare ``ValueError`` -- where ``_read_http_header`` # documents ``ProtocolError``. @@ -125,14 +138,29 @@ def _test_http2_frame(data: 'bytes') -> 'bool': # the syntax. _RE_STATUS = re.compile(rb'\d{3}\Z') -# The HTTP/2 connection preface's header block, as ``httpv1.HTTP.read`` splits it +# The HTTP/2 connection preface's header block, as ``httpv1.HTTPv1.read`` splits it # off at the first CRLFCRLF: ``PRI * HTTP/2.0``. A start line with zero field # lines, so the patterns above accept it; it is refused by name in -# ``httpv1.HTTP._read_http_header`` and :func:`test_start_line` so that the +# ``httpv1.HTTPv1._read_http_header`` and :func:`test_start_line` so that the # preface never parses as HTTP/1. _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. @@ -152,7 +180,7 @@ def test_start_line(data: 'bytes') -> 'bool': Note: The acceptance rule is deliberately the *same* one :meth:`HTTP._read_http_header - ` applies + ` applies -- ``_RE_METHOD`` with ``_RE_VERSION`` for a request, ``_RE_VERSION`` with ``_RE_STATUS`` for a response -- which is why those patterns are defined beside this predicate and @@ -170,7 +198,7 @@ def test_start_line(data: 'bytes') -> 'bool': HTTP/1.1 *request line* (:rfc:`9113#section-3.4`), so a predicate that tested only the first line would answer :data:`True` for it. Split at the header/body separator first, as :meth:`HTTP.read - ` does, and the preface's + ` does, and the preface's header is exactly ``PRI * HTTP/2.0`` (``_HTTP2_PREFACE_HEADER``), which the parser refuses by name and so does this. Any *other* header with no CRLF in it is a start line with zero field lines, which @@ -205,6 +233,12 @@ class HTTP(Application[_PT, _ST], Generic[_PT, _ST]): - Hypertext Transfer Protocol (HTTP/1.1) [:rfc:`7230`] - Hypertext Transfer Protocol version 2 (HTTP/2) [:rfc:`7540`] + It dispatches on both paths: constructing it, from octets or from fields, + 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__`. + """ if TYPE_CHECKING: @@ -224,8 +258,8 @@ class HTTP(Application[_PT, _ST], Generic[_PT, _ST]): #: This class is a version dispatcher rather than a protocol with a header of #: its own, so its construction keywords cannot be enumerated: :meth:`make` #: declares only ``version`` and forwards everything else to - #: :meth:`HTTPv1.make ` or - #: :meth:`HTTPv2.make ` + #: :meth:`HTTPv1.make ` or + #: :meth:`HTTPv2.make ` #: according to that value, so the correct set of names depends on an #: argument. :obj:`None` therefore opts out of the construction keyword check #: that :meth:`Protocol.__init__ @@ -262,9 +296,27 @@ def version(self) -> 'Literal["0.9", "1.0", "1.1", "2"]': ########################################################################## @classmethod - def id(cls) -> 'tuple[Literal["HTTP"], Literal["HTTPv1"], Literal["HTTPv2"]]': - """Index ID of the protocol.""" - return ('HTTP', 'HTTPv1', 'HTTPv2') + def id(cls) -> 'tuple[Literal["HTTP/1"], Literal["HTTP/2"]]': + """Index ID of the protocol. + + The family base lists its members' names alone, as + :meth:`IP.id() ` does: ``HTTP/1`` + for :rfc:`9112`'s HTTP/1.1 and the versions before it, and :rfc:`9113`'s + ``HTTP/2``. A dispatched layer is the member :meth:`_dispatch_version` + picks, so each name selects its own version, and the ``HTTP`` key + selects either through this tuple. + + Returns: + Index ID of the protocol; the versioned classes narrow it to their + own name. + + .. versionchanged:: 1.6.0 + + ``('HTTP/1', 'HTTP/2')``, where it was ``('HTTP', 'HTTPv1', 'HTTPv2')`` + (:issue:`2524`). + + """ + return ('HTTP/1', 'HTTP/2') def read(self, length: 'Optional[int]' = None, *, version: 'Optional[Literal[1, 2]]' = None, @@ -294,9 +346,9 @@ def read(self, length: 'Optional[int]' = None, *, http = self._guess_version(length, **kwargs) else: if version == 1: - from pcapkit.protocols.application.httpv1 import HTTP as protocol # isort: skip # pylint: disable=line-too-long,import-outside-toplevel + 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 HTTP as protocol # type: ignore[assignment] # isort: skip # pylint: disable=line-too-long,import-outside-toplevel + 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}') @@ -354,9 +406,9 @@ def make(self, raise ProtocolError(f'HTTP/{version}: connection preface is HTTP/2 only') if version == 1: - from pcapkit.protocols.application.httpv1 import HTTP as protocol # isort: skip # pylint: disable=line-too-long,import-outside-toplevel + 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 HTTP as protocol # type: ignore[assignment] # isort: skip # pylint: disable=line-too-long,import-outside-toplevel + 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}') @@ -390,10 +442,6 @@ def pack(self, **kwargs: 'Any') -> 'bytes': return _HTTP2_PREFACE + data return data - ########################################################################## - # Utilities. - ########################################################################## - @classmethod def from_data(cls, data: '_PT | dict[str, Any]', **kwargs: 'Any') -> 'Self': """Create protocol instance from data. @@ -408,18 +456,88 @@ def from_data(cls, data: '_PT | dict[str, Any]', **kwargs: 'Any') -> 'Self': `. Returns: - Protocol instance. + Protocol instance; on this class, an instance of the member the data + model belongs to (:issue:`2524`). + + Raises: + ProtocolError: If ``data`` is neither HTTP/1.* nor HTTP/2 data, on + this class. """ - from pcapkit.protocols.data.application.httpv1 import HTTP as Data_HTTPv1 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel - from pcapkit.protocols.data.application.httpv2 import HTTP as Data_HTTPv2 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel + 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)) - return super().from_data(data, **kwargs) + 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__}') + + ########################################################################## + # Data models. + ########################################################################## + + 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. + + Args: + file: Source packet stream, read here without being consumed. + length: Length of packet data. + **kwargs: Arbitrary keyword arguments. + + Returns: + 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. + + """ + 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: + member = cls._member_of_version(kwargs['version']) + else: + if isinstance(file, bytes): + data = file + else: + offset = file.tell() + data = file.read(length) # type: ignore[arg-type] + file.seek(offset) + from pcapkit.protocols.misc.raw import Raw # isort: skip # pylint: disable=import-outside-toplevel + + data = data[:length] + member = cls._dispatch_version(data, len(data)) + if member is Raw: + if data == _HTTP2_PREFACE: + raise ProtocolError('HTTP/2: connection preface with no frame') + raise ProtocolError('HTTP: unknown version') + return member.__new__(member, file, length, **kwargs) + + ########################################################################## + # Utilities. + ########################################################################## @classmethod def _make_data(cls, data: '_PT') -> 'dict[str, Any]': # type: ignore[override] @@ -440,10 +558,10 @@ def _make_data(cls, data: '_PT') -> 'dict[str, Any]': # type: ignore[override] # HTTP/1.*'s lives in ``receipt`` as the start line's string, and HTTP/2 # has none -- so the version is the class of ``data`` that :meth:`read` # returned from the versioned parser. - from pcapkit.protocols.application.httpv1 import HTTP as HTTPv1 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel - from pcapkit.protocols.data.application.httpv1 import HTTP as Data_HTTPv1 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel - from pcapkit.protocols.data.application.httpv2 import HTTP as Data_HTTPv2 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel + 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 isinstance(data, Data_HTTPv1): return {'version': 1, **HTTPv1._make_data(data)} @@ -532,11 +650,11 @@ def _guess_version(self, length: 'int', **kwargs: 'Any') -> 'HTTP': # preface must not be claimed out of octets outside this payload. preface_len = len(_HTTP2_PREFACE) if self._test_preface(length): - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 # 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 # The preface is not a frame -- the frames begin after it # (:rfc:`9113#section-3.4` requires a ``SETTINGS`` frame immediately - # following), so it is skipped rather than fed to ``httpv2.HTTP``, + # following), so it is skipped rather than fed to ``httpv2.HTTPv2``, # which would misread it as framing. if length == preface_len: # A preface with nothing after it *is* HTTP/2, but this library's @@ -569,10 +687,10 @@ def _guess_version(self, length: 'int', **kwargs: 'Any') -> 'HTTP': self._preface_length = preface_len return http - from pcapkit.protocols.application.httpv1 import HTTP as HTTPv1 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel + from pcapkit.protocols.application.httpv1 import HTTPv1 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel # NOTE: The second identification: an HTTP/1 ``request-line`` or - # ``status-line``, tested with the very patterns ``httpv1.HTTP`` parses + # ``status-line``, tested with the very patterns ``httpv1.HTTPv1`` parses # with (``_RE_METHOD``, ``_RE_VERSION`` and ``_RE_STATUS`` above, all # anchored, which ``httpv1.py`` imports from this module), so the # predicate and the parser cannot disagree about what HTTP/1 looks like. @@ -602,7 +720,7 @@ def _guess_version(self, length: 'int', **kwargs: 'Any') -> 'HTTP': # too short for HTTP/2's nine-octet frame header, or whose frame-specific # fields exceed what the buffer holds, must not escape as a stdlib # exception that no caller of this method can catch. Such payloads raise - # ``ProtocolError`` at the source -- ``httpv2.HTTP.unpack`` rejects a + # ``ProtocolError`` at the source -- ``httpv2.HTTPv2.unpack`` rejects a # buffer under nine octets, and :attr:`FieldBase.length # ` re-raises a # negative-length template as @@ -610,7 +728,7 @@ def _guess_version(self, length: 'int', **kwargs: 'Any') -> 'HTTP': # suppression only guards whatever path is not known to do likewise. # # Suppressing it on the *first* arm is wrong: nine byte patterns over - # lengths 0-24 raised only ``ProtocolError`` through ``httpv1.HTTP``, so + # lengths 0-24 raised only ``ProtocolError`` through ``httpv1.HTTPv1``, so # it buys nothing, and # :class:`~pcapkit.utilities.exceptions.StructError` *subclasses* # :exc:`struct.error`, so suppressing it on a non-final arm swallows @@ -635,8 +753,135 @@ def _guess_version(self, length: 'int', **kwargs: 'Any') -> 'HTTP': if not _test_http2_frame(self._data[:length]): raise ProtocolError('HTTP: unknown version') - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 # 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 with contextlib.suppress(ProtocolError, struct.error): return HTTPv2(self._data, length, **kwargs) raise ProtocolError('HTTP: unknown version') + + @classmethod + def _dispatch_version(cls, data: 'bytes', length: 'int') -> 'Type[ProtocolBase]': + """Pick the class that parses the HTTP message at the start of ``data``. + + The payload is identified before any class is built, as + :meth:`_guess_version` identifies it: the connection preface, or else a + frame header a conforming sender could write, is HTTP/2, and an HTTP/1 + start line is HTTP/1. A transport layer whose port binds this class + parses its payload with the class this returns, so a dispatched layer + is :class:`~pcapkit.protocols.application.httpv1.HTTPv1` or + :class:`~pcapkit.protocols.application.httpv2.HTTPv2`, never this base + (:issue:`2524`). + + It is the counterpart of :meth:`IGMP._dispatch + `, named apart from it + because, in the transport layer, ``_dispatch`` names + :meth:`AppType._dispatch + `, the port + registries' lookup hook. + + Args: + data: Payload of the transport layer, starting at the HTTP message. + length: The HTTP message's length, in octets. + + Returns: + :class:`~pcapkit.protocols.application.httpv1.HTTPv1` or + :class:`~pcapkit.protocols.application.httpv2.HTTPv2` for the version + the payload identifies as; :class:`~pcapkit.protocols.misc.raw.Raw` + for one it does not, which then keeps its octets undissected. + + Note: + A connection preface with no frame after it is HTTP/2, and can be + real traffic, since a client may send the preface and its + ``SETTINGS`` frame in separate segments. It is + :class:`~pcapkit.protocols.misc.raw.Raw` here, because the HTTP/2 + data model holds one frame per packet: the frame header's Length, + Type, Flags and Stream Identifier are required fields of + :class:`~pcapkit.protocols.data.application.httpv2.HTTPv2`, so a + frameless segment has no form in it, and + :class:`~pcapkit.protocols.application.httpv2.HTTPv2` refuses one. + So this does not pick that class for it, and the two agree. + + """ + 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 + from pcapkit.protocols.misc.raw import Raw # isort: skip # pylint: disable=import-outside-toplevel + + data = data[:length] + if data.startswith(_HTTP2_PREFACE): + return HTTPv2 if len(data) > len(_HTTP2_PREFACE) else Raw + if test_start_line(data): + return HTTPv1 + if _test_http2_frame(data): + 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. + + Args: + version: Version of HTTP. + + Returns: + :class:`~pcapkit.protocols.application.httpv1.HTTPv1` or + :class:`~pcapkit.protocols.application.httpv2.HTTPv2`. + + Raises: + ProtocolError: If ``version`` is neither. + + """ + 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 + raise ProtocolError(f'HTTP: invalid version: {version!r}') diff --git a/pcapkit/protocols/application/httpv1.py b/pcapkit/protocols/application/httpv1.py index 1f9e895d24..6c75f022b3 100644 --- a/pcapkit/protocols/application/httpv1.py +++ b/pcapkit/protocols/application/httpv1.py @@ -5,7 +5,7 @@ .. module:: pcapkit.protocols.application.httpv1 :mod:`pcapkit.protocols.application.httpv1` contains -:class:`~pcapkit.protocols.application.httpv1.HTTP`, +:class:`~pcapkit.protocols.application.httpv1.HTTPv1`, which implements an extractor for Hypertext Transfer Protocol (HTTP/1.*) [*]_, and its :class:`~pcapkit.protocols.application.httpv1.Type` enumeration. The @@ -34,6 +34,12 @@ rather than normalised or re-encoded (:issue:`1241`, :issue:`1302`, :issue:`1333`). +.. versionremoved:: 1.6.0 + + ``pcapkit.protocols.application.httpv1.HTTP``, with no alias. The + class is renamed :class:`~pcapkit.protocols.application.httpv1.HTTPv1`, + after its module (:issue:`2529`). + """ import re from typing import TYPE_CHECKING @@ -45,10 +51,10 @@ from pcapkit.protocols.application.http import (_HTTP2_PREFACE_HEADER, _RE_METHOD, _RE_STATUS, _RE_VERSION) from pcapkit.protocols.application.http import HTTP as HTTPBase -from pcapkit.protocols.data.application.httpv1 import HTTP as Data_HTTP +from pcapkit.protocols.data.application.httpv1 import HTTPv1 as Data_HTTP from pcapkit.protocols.data.application.httpv1 import RequestHeader as Data_RequestHeader from pcapkit.protocols.data.application.httpv1 import ResponseHeader as Data_ResponseHeader -from pcapkit.protocols.schema.application.httpv1 import HTTP as Schema_HTTP +from pcapkit.protocols.schema.application.httpv1 import HTTPv1 as Schema_HTTP from pcapkit.utilities.chardet import detect from pcapkit.utilities.compat import StrEnum, auto from pcapkit.utilities.exceptions import ProtocolError @@ -63,7 +69,7 @@ from pcapkit.protocols.data.application.httpv1 import Header as Data_Header -__all__ = ['HTTP'] +__all__ = ['HTTPv1'] # NOTE: The start-line patterns ``_RE_METHOD``, ``_RE_VERSION`` and ``_RE_STATUS``, # and ``_HTTP2_PREFACE_HEADER``, are imported from ``http.py``, where @@ -86,7 +92,7 @@ class Type(EnumLookup, StrEnum): RESPONSE = auto() -class HTTP(HTTPBase[Data_HTTP, Schema_HTTP], +class HTTPv1(HTTPBase[Data_HTTP, Schema_HTTP], data=Data_HTTP, schema=Schema_HTTP): """This class implements Hypertext Transfer Protocol (HTTP/1.*).""" @@ -278,14 +284,23 @@ def make(self, # type: ignore[override] ) @classmethod - def id(cls) -> 'tuple[Literal["HTTP"], Literal["HTTPv1"]]': # type: ignore[override] + def id(cls) -> 'tuple[Literal["HTTP/1"]]': # type: ignore[override] """Index ID of the protocol. + :rfc:`9112` names the protocol ``HTTP/1.1``. This class also reads + messages declaring HTTP/1.0 or HTTP/0.9, so its name carries the major + version alone, and :attr:`alias` the exact one. + Returns: Index ID of the protocol. + .. versionchanged:: 1.6.0 + + ``('HTTP/1',)``, narrowed to this class, where it was + ``('HTTP', 'HTTPv1')`` (:issue:`2524`). + """ - return (cls.__name__, 'HTTPv1') # type: ignore[return-value] + return ('HTTP/1',) ########################################################################## # Utilities. diff --git a/pcapkit/protocols/application/httpv2.py b/pcapkit/protocols/application/httpv2.py index d9045cadc4..7d5d2e9de0 100644 --- a/pcapkit/protocols/application/httpv2.py +++ b/pcapkit/protocols/application/httpv2.py @@ -5,7 +5,7 @@ .. module:: pcapkit.protocols.application.httpv2 :mod:`pcapkit.protocols.application.httpv2` contains -:class:`~pcapkit.protocols.application.httpv2.HTTP` +:class:`~pcapkit.protocols.application.httpv2.HTTPv2` only, which implements an extractor for Hypertext Transfer Protocol (HTTP/2) [*]_, whose structure is described as below: @@ -34,6 +34,12 @@ are kept (:issue:`652`, :issue:`668`, :issue:`1244`, :issue:`1245`, :issue:`1246`, :issue:`1247`). +.. versionremoved:: 1.6.0 + + ``pcapkit.protocols.application.httpv2.HTTP``, with no alias. The + class is renamed :class:`~pcapkit.protocols.application.httpv2.HTTPv2`, + after its module (:issue:`2529`). + """ import collections from typing import TYPE_CHECKING, cast @@ -42,8 +48,8 @@ from pcapkit.const.http.frame import Frame as Enum_Frame from pcapkit.const.http.setting import Setting as Enum_Setting from pcapkit.corekit.multidict import MultiDict, OrderedMultiDict +from pcapkit.protocols.application.http import _HTTP2_PREFACE from pcapkit.protocols.application.http import HTTP as HTTPBase -from pcapkit.protocols.data.application.httpv2 import HTTP as Data_HTTP from pcapkit.protocols.data.application.httpv2 import ContinuationFrame as Data_ContinuationFrame from pcapkit.protocols.data.application.httpv2 import \ ContinuationFrameFlags as Data_ContinuationFrameFlags @@ -53,6 +59,7 @@ from pcapkit.protocols.data.application.httpv2 import GoawayFrame as Data_GoawayFrame from pcapkit.protocols.data.application.httpv2 import HeadersFrame as Data_HeadersFrame from pcapkit.protocols.data.application.httpv2 import HeadersFrameFlags as Data_HeadersFrameFlags +from pcapkit.protocols.data.application.httpv2 import HTTPv2 as Data_HTTP from pcapkit.protocols.data.application.httpv2 import PingFrame as Data_PingFrame from pcapkit.protocols.data.application.httpv2 import PingFrameFlags as Data_PingFrameFlags from pcapkit.protocols.data.application.httpv2 import PriorityFrame as Data_PriorityFrame @@ -65,13 +72,13 @@ from pcapkit.protocols.data.application.httpv2 import SettingsFrameFlags as Data_SettingsFrameFlags from pcapkit.protocols.data.application.httpv2 import UnassignedFrame as Data_UnassignedFrame from pcapkit.protocols.data.application.httpv2 import WindowUpdateFrame as Data_WindowUpdateFrame -from pcapkit.protocols.schema.application.httpv2 import HTTP as Schema_HTTP from pcapkit.protocols.schema.application.httpv2 import \ ContinuationFrame as Schema_ContinuationFrame from pcapkit.protocols.schema.application.httpv2 import DataFrame as Schema_DataFrame from pcapkit.protocols.schema.application.httpv2 import FrameType as Schema_FrameType from pcapkit.protocols.schema.application.httpv2 import GoawayFrame as Schema_GoawayFrame from pcapkit.protocols.schema.application.httpv2 import HeadersFrame as Schema_HeadersFrame +from pcapkit.protocols.schema.application.httpv2 import HTTPv2 as Schema_HTTP from pcapkit.protocols.schema.application.httpv2 import PingFrame as Schema_PingFrame from pcapkit.protocols.schema.application.httpv2 import PriorityFrame as Schema_PriorityFrame from pcapkit.protocols.schema.application.httpv2 import PushPromiseFrame as Schema_PushPromiseFrame @@ -105,10 +112,10 @@ FrameConstructor = Callable[[DefaultArg(Optional[Data_HTTP]), KwArg(Any)], Tuple[Schema_FrameType, Flags]] -__all__ = ['HTTP'] +__all__ = ['HTTPv2'] -class HTTP(HTTPBase[Data_HTTP, Schema_HTTP], +class HTTPv2(HTTPBase[Data_HTTP, Schema_HTTP], schema=Schema_HTTP, data=Data_HTTP): """This class implements Hypertext Transfer Protocol (HTTP/2). @@ -123,35 +130,42 @@ class HTTP(HTTPBase[Data_HTTP, Schema_HTTP], - Frame Parser - Frame Constructor * - :attr:`~pcapkit.const.http.frame.Frame.DATA` - - :meth:`~pcapkit.protocols.application.httpv2.HTTP._read_http_data` - - :meth:`~pcapkit.protocols.application.httpv2.HTTP._make_http_data` + - :meth:`~pcapkit.protocols.application.httpv2.HTTPv2._read_http_data` + - :meth:`~pcapkit.protocols.application.httpv2.HTTPv2._make_http_data` * - :attr:`~pcapkit.const.http.frame.Frame.HEADERS` - - :meth:`~pcapkit.protocols.application.httpv2.HTTP._read_http_headers` - - :meth:`~pcapkit.protocols.application.httpv2.HTTP._make_http_headers` + - :meth:`~pcapkit.protocols.application.httpv2.HTTPv2._read_http_headers` + - :meth:`~pcapkit.protocols.application.httpv2.HTTPv2._make_http_headers` * - :attr:`~pcapkit.const.http.frame.Frame.PRIORITY` - - :meth:`~pcapkit.protocols.application.httpv2.HTTP._read_http_priority` - - :meth:`~pcapkit.protocols.application.httpv2.HTTP._make_http_priority` + - :meth:`~pcapkit.protocols.application.httpv2.HTTPv2._read_http_priority` + - :meth:`~pcapkit.protocols.application.httpv2.HTTPv2._make_http_priority` * - :attr:`~pcapkit.const.http.frame.Frame.RST_STREAM` - - :meth:`~pcapkit.protocols.application.httpv2.HTTP._read_http_rst_stream` - - :meth:`~pcapkit.protocols.application.httpv2.HTTP._make_http_rst_stream` + - :meth:`~pcapkit.protocols.application.httpv2.HTTPv2._read_http_rst_stream` + - :meth:`~pcapkit.protocols.application.httpv2.HTTPv2._make_http_rst_stream` * - :attr:`~pcapkit.const.http.frame.Frame.SETTINGS` - - :meth:`~pcapkit.protocols.application.httpv2.HTTP._read_http_settings` - - :meth:`~pcapkit.protocols.application.httpv2.HTTP._make_http_settings` + - :meth:`~pcapkit.protocols.application.httpv2.HTTPv2._read_http_settings` + - :meth:`~pcapkit.protocols.application.httpv2.HTTPv2._make_http_settings` * - :attr:`~pcapkit.const.http.frame.Frame.PUSH_PROMISE` - - :meth:`~pcapkit.protocols.application.httpv2.HTTP._read_http_push_promise` - - :meth:`~pcapkit.protocols.application.httpv2.HTTP._make_http_push_promise` + - :meth:`~pcapkit.protocols.application.httpv2.HTTPv2._read_http_push_promise` + - :meth:`~pcapkit.protocols.application.httpv2.HTTPv2._make_http_push_promise` * - :attr:`~pcapkit.const.http.frame.Frame.PING` - - :meth:`~pcapkit.protocols.application.httpv2.HTTP._read_http_ping` - - :meth:`~pcapkit.protocols.application.httpv2.HTTP._make_http_ping` + - :meth:`~pcapkit.protocols.application.httpv2.HTTPv2._read_http_ping` + - :meth:`~pcapkit.protocols.application.httpv2.HTTPv2._make_http_ping` * - :attr:`~pcapkit.const.http.frame.Frame.GOAWAY` - - :meth:`~pcapkit.protocols.application.httpv2.HTTP._read_http_goaway` - - :meth:`~pcapkit.protocols.application.httpv2.HTTP._make_http_goaway` + - :meth:`~pcapkit.protocols.application.httpv2.HTTPv2._read_http_goaway` + - :meth:`~pcapkit.protocols.application.httpv2.HTTPv2._make_http_goaway` * - :attr:`~pcapkit.const.http.frame.Frame.WINDOW_UPDATE` - - :meth:`~pcapkit.protocols.application.httpv2.HTTP._read_http_window_update` - - :meth:`~pcapkit.protocols.application.httpv2.HTTP._make_http_window_update` + - :meth:`~pcapkit.protocols.application.httpv2.HTTPv2._read_http_window_update` + - :meth:`~pcapkit.protocols.application.httpv2.HTTPv2._make_http_window_update` * - :attr:`~pcapkit.const.http.frame.Frame.CONTINUATION` - - :meth:`~pcapkit.protocols.application.httpv2.HTTP._read_http_continuation` - - :meth:`~pcapkit.protocols.application.httpv2.HTTP._make_http_continuation` + - :meth:`~pcapkit.protocols.application.httpv2.HTTPv2._read_http_continuation` + - :meth:`~pcapkit.protocols.application.httpv2.HTTPv2._make_http_continuation` + + .. versionchanged:: 1.6.0 + + A payload opening with the connection preface parses, with the preface + kept as ``preface`` and counted in :attr:`length`, and :meth:`make` takes + ``preface``, since a dispatched HTTP/2 payload reaches this class rather + than :class:`~pcapkit.protocols.application.http.HTTP` (:issue:`2524`). """ @@ -190,9 +204,10 @@ def alias(self) -> 'Literal["HTTP/2"]': return 'HTTP/2' @property - def length(self) -> 'Literal[9]': - """Header length of current protocol.""" - return 9 + def length(self) -> 'int': + """Header length of current protocol: the 9-octet frame header, after the + 24-octet connection preface when one opens the payload.""" + return 9 + self._preface_length @property def version(self) -> 'Literal["2"]': @@ -247,7 +262,26 @@ def unpack(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_HTTP' *non-zero* short buffer is malformed either way, so it is rejected here whether or not ``length`` was given. + A payload opening with the HTTP/2 connection preface + (:rfc:`9113#section-3.4`) has the frame parsed from the octets after + it, as :meth:`HTTP._guess_version + ` parses it, + since a dispatched payload reaches this class rather than that one + (:issue:`2524`); the preface counts towards :attr:`length`. A + preface with no frame after it is refused, since the data model + holds one frame per packet and has no form for a frameless segment. + """ + preface_len = len(_HTTP2_PREFACE) + if self.__header__ is None and self._data[:preface_len] == _HTTP2_PREFACE \ + and (length is None or length >= preface_len): + remaining = (len(self) if length is None else length) - preface_len + if remaining == 0: + raise ProtocolError('HTTP/2: connection preface with no frame') + self._preface_length = preface_len + self._file.seek(preface_len) + length = remaining + if length is None: # ``0 < ... < 9`` rather than ``... < 9``: an exhausted stream # (``len(self) == 0``) is left alone, so ``length`` reaches @@ -296,7 +330,7 @@ def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_HTTP': """ if length is None: - length = len(self) + length = len(self) - self._preface_length schema = self.__header__ # NOTE: ``schema.length`` is the *declared* length off the wire -- the @@ -322,6 +356,10 @@ def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_HTTP': else: meth = name[0] http = meth(schema.frame, header=schema) + if self._preface_length: + http.__update__({ + 'preface': _HTTP2_PREFACE, + }) # The frame ends at its declared Length; whatever follows it in the # buffer (e.g. the next frame) is the undissected remainder. @@ -337,6 +375,7 @@ def make(self, # type: ignore[override] sid: 'int' = 0, frame: 'Optional[bytes | Data_HTTP | Schema_FrameType | dict[str, Any]]' = None, payload: 'bytes | ProtocolBase | Schema' = b'', + preface: 'bool' = False, # pylint: disable=unused-argument **kwargs: 'Any') -> 'Schema_HTTP': """Make (construct) packet data. @@ -354,6 +393,8 @@ def make(self, # type: ignore[override] does. An explicit :obj:`bytes` value, ``b''`` included, is packed as raw octets instead. payload: Octets after the frame, e.g. the next frame. + preface: Whether the HTTP/2 connection preface goes ahead of the + frame; :meth:`pack` writes it. **kwargs: Arbitrary keyword arguments. Returns: @@ -362,7 +403,7 @@ def make(self, # type: ignore[override] Raises: ProtocolError: If ``frame`` is none of the forms this maker reads -- raw :obj:`bytes`, a :obj:`dict` of frame arguments, an - :class:`~pcapkit.protocols.data.application.httpv2.HTTP` data + :class:`~pcapkit.protocols.data.application.httpv2.HTTPv2` data model or a :class:`~pcapkit.protocols.schema.schema.Schema`. .. versionchanged:: 1.5.0 @@ -453,14 +494,21 @@ def pack(self, **kwargs: 'Any') -> 'bytes': return data @classmethod - def id(cls) -> 'tuple[Literal["HTTP"], Literal["HTTPv2"]]': # type: ignore[override] + def id(cls) -> 'tuple[Literal["HTTP/2"]]': # type: ignore[override] """Index ID of the protocol. + :rfc:`9113` names the protocol ``HTTP/2``. + Returns: Index ID of the protocol. + .. versionchanged:: 1.6.0 + + ``('HTTP/2',)``, narrowed to this class, where it was + ``('HTTP', 'HTTPv2')`` (:issue:`2524`). + """ - return (cls.__name__, 'HTTPv2') # type: ignore[return-value] + return ('HTTP/2',) @classmethod def register_frame(cls, code: 'Enum_Frame', meth: 'str | tuple[FrameParser, FrameConstructor]') -> 'None': @@ -521,6 +569,7 @@ def _make_data(cls, data: 'Data_HTTP') -> 'dict[str, Any]': # type: ignore[over 'sid': data.sid, 'frame': data, 'payload': cls._make_payload(data), + 'preface': bool(data.get('preface')), } @staticmethod diff --git a/pcapkit/protocols/data/application/__init__.py b/pcapkit/protocols/data/application/__init__.py index 341864dc17..e8c949fa5f 100644 --- a/pcapkit/protocols/data/application/__init__.py +++ b/pcapkit/protocols/data/application/__init__.py @@ -12,13 +12,13 @@ from pcapkit.protocols.data.application.ftp import Response as FTP_Response # Hypertext Transfer Protocol (HTTP/1.*) -from pcapkit.protocols.data.application.httpv1 import HTTP as HTTPv1 +from pcapkit.protocols.data.application.httpv1 import HTTPv1 from pcapkit.protocols.data.application.httpv1 import Header as HTTPv1_Header from pcapkit.protocols.data.application.httpv1 import RequestHeader as HTTPv1_RequestHeader from pcapkit.protocols.data.application.httpv1 import ResponseHeader as HTTPv1_ResponseHeader # Hypertext Transfer Protocol (HTTP/2) -from pcapkit.protocols.data.application.httpv2 import HTTP as HTTPv2 +from pcapkit.protocols.data.application.httpv2 import HTTPv2 from pcapkit.protocols.data.application.httpv2 import ContinuationFrame as HTTPv2_ContinuationFrame from pcapkit.protocols.data.application.httpv2 import \ ContinuationFrameFlags as HTTPv2_ContinuationFrameFlags diff --git a/pcapkit/protocols/data/application/httpv1.py b/pcapkit/protocols/data/application/httpv1.py index 298fe38ffc..4061e6fe68 100644 --- a/pcapkit/protocols/data/application/httpv1.py +++ b/pcapkit/protocols/data/application/httpv1.py @@ -1,5 +1,13 @@ # -*- coding: utf-8 -*- -"""data model for HTTP/1.* protocol""" +"""data model for HTTP/1.* protocol + +.. versionremoved:: 1.6.0 + + ``pcapkit.protocols.data.application.httpv1.HTTP``, with no alias. The + class is renamed :class:`~pcapkit.protocols.data.application.httpv1.HTTPv1`, + after its module (:issue:`2529`). + +""" from typing import TYPE_CHECKING @@ -17,7 +25,7 @@ from pcapkit.protocols.application.httpv1 import Type as HTTP_Type __all__ = [ - 'HTTP', + 'HTTPv1', 'Header', 'RequestHeader', 'ResponseHeader', @@ -25,7 +33,7 @@ @info_final -class HTTP(Protocol): +class HTTPv1(Protocol): """Data model for HTTP/1.* protocol.""" #: HTTP receipt. diff --git a/pcapkit/protocols/data/application/httpv2.py b/pcapkit/protocols/data/application/httpv2.py index 06a56d7da7..ae1105d1b0 100644 --- a/pcapkit/protocols/data/application/httpv2.py +++ b/pcapkit/protocols/data/application/httpv2.py @@ -1,5 +1,13 @@ # -*- coding: utf-8 -*- -"""data model for HTTP/2 protocol""" +"""data model for HTTP/2 protocol + +.. versionremoved:: 1.6.0 + + ``pcapkit.protocols.data.application.httpv2.HTTP``, with no alias. The + class is renamed :class:`~pcapkit.protocols.data.application.httpv2.HTTPv2`, + after its module (:issue:`2529`). + +""" from typing import TYPE_CHECKING @@ -16,7 +24,7 @@ from pcapkit.protocols.schema.application.httpv2 import FrameType __all__ = [ - 'HTTP', + 'HTTPv2', 'Flags', 'Settings', 'DataFrameFlags', 'HeadersFrameFlags', 'SettingsFrameFlags', @@ -41,7 +49,7 @@ class Flags(Data): __value__: 'FrameType.Flags' -class HTTP(Protocol): +class HTTPv2(Protocol): """Data model for HTTP/2 protocol.""" #: Length of the frame payload, the 9-octet header excluded. @@ -57,7 +65,7 @@ class HTTP(Protocol): @info_final -class UnassignedFrame(HTTP): +class UnassignedFrame(HTTPv2): """Data model for HTTP/2 unassigned frame.""" #: Flags. @@ -83,7 +91,7 @@ def __init__(self, END_STREAM: 'bool', PADDED: 'bool') -> 'None': ... # pylint: @info_final -class DataFrame(HTTP): +class DataFrame(HTTPv2): """Data model for HTTP/2 ``DATA`` frame.""" #: Flags. @@ -117,7 +125,7 @@ def __init__(self, END_STREAM: 'bool', END_HEADERS: 'bool', PADDED: 'bool', PRIO @info_final -class HeadersFrame(HTTP): +class HeadersFrame(HTTPv2): """Data model for HTTP/2 ``HEADERS`` frame.""" #: Flags. @@ -140,7 +148,7 @@ def __init__(self, length: 'int', type: 'Frame', flags: 'HeadersFrameFlags', res @info_final -class PriorityFrame(HTTP): +class PriorityFrame(HTTPv2): """Data model for HTTP/2 ``PRIORITY`` frame.""" #: Flags. @@ -157,7 +165,7 @@ def __init__(self, length: 'int', type: 'Frame', flags: 'Flags', reserved: 'int' @info_final -class RSTStreamFrame(HTTP): +class RSTStreamFrame(HTTPv2): """Data model for HTTP/2 ``RST_STREAM`` frame.""" #: Flags. @@ -212,7 +220,7 @@ def __init__(self, ACK: 'bool') -> 'None': ... # pylint: disable=unused-argumen @info_final -class SettingsFrame(HTTP): +class SettingsFrame(HTTPv2): """Data model for HTTP/2 ``SETTINGS`` frame.""" #: Flags. @@ -238,7 +246,7 @@ def __init__(self, END_HEADERS: 'bool', PADDED: 'bool') -> 'None': ... # pylint @info_final -class PushPromiseFrame(HTTP): +class PushPromiseFrame(HTTPv2): """Data model for HTTP/2 ``PUSH_PROMISE`` frame.""" #: Flags. @@ -270,7 +278,7 @@ def __init__(self, ACK: 'bool') -> 'None': ... # pylint: disable=unused-argumen @info_final -class PingFrame(HTTP): +class PingFrame(HTTPv2): """Data model for HTTP/2 ``PING`` frame.""" #: Flags. @@ -283,7 +291,7 @@ def __init__(self, length: 'int', type: 'Frame', flags: 'Flags', reserved: 'int' @info_final -class GoawayFrame(HTTP): +class GoawayFrame(HTTPv2): """Data model for HTTP/2 ``GOAWAY`` frame.""" #: Flags. @@ -302,7 +310,7 @@ def __init__(self, length: 'int', type: 'Frame', flags: 'Flags', reserved: 'int' @info_final -class WindowUpdateFrame(HTTP): +class WindowUpdateFrame(HTTPv2): """Data model for HTTP/2 ``WINDOW_UPDATE`` frame.""" #: Flags. @@ -328,7 +336,7 @@ def __init__(self, END_HEADERS: 'bool') -> 'None': ... # pylint: disable=unused @info_final -class ContinuationFrame(HTTP): +class ContinuationFrame(HTTPv2): """Data model for HTTP/2 ``CONTINUATION`` frame.""" #: Flags. diff --git a/pcapkit/protocols/link/l2tp.py b/pcapkit/protocols/link/l2tp.py index ffa90c08a6..755fa91af8 100644 --- a/pcapkit/protocols/link/l2tp.py +++ b/pcapkit/protocols/link/l2tp.py @@ -63,7 +63,7 @@ with L2TP packets". L2F is a separate protocol with its own header. It is therefore to be implemented as ``L2F``, the canonical name, carrying ``L2TPv1`` only as an alias in its :meth:`~pcapkit.protocols.protocol.Protocol.id`. See -:meth:`HTTPv1.id ` for how a +:meth:`L2TPv2.id ` for how a version-flavoured alias is spelled: canonical name first, alias second, since callers take element zero as canonical. @@ -156,7 +156,7 @@ def id(cls) -> 'tuple[Literal["L2TP"], Literal["L2TPv2"]]': Returns: Index ID of the protocol -- the family name, then every version in - it, as :meth:`HTTP.id ` + it, as :meth:`VLAN.id ` does for its own family. ``L2F`` and ``L2TPv3`` join this tuple when they are implemented. diff --git a/pcapkit/protocols/link/l2tpv2.py b/pcapkit/protocols/link/l2tpv2.py index 92dbd950d7..d2c4b3f6da 100644 --- a/pcapkit/protocols/link/l2tpv2.py +++ b/pcapkit/protocols/link/l2tpv2.py @@ -367,10 +367,9 @@ def id(cls) -> 'tuple[Literal["L2TP"], Literal["L2TPv2"]]': Returns: Index ID of the protocol -- the canonical family name first, then - this version's own label, as - :meth:`HTTPv1.id ` - does. Element zero is what callers treat as canonical, so both - ``protocol='L2TP'`` and ``protocol='L2TPv2'`` select this class. + this version's own label. Element zero is what callers treat as + canonical, so both ``protocol='L2TP'`` and ``protocol='L2TPv2'`` + select this class. """ return ('L2TP', 'L2TPv2') diff --git a/pcapkit/protocols/link/vlan.py b/pcapkit/protocols/link/vlan.py index c5444fe285..6ffe5bdb6b 100644 --- a/pcapkit/protocols/link/vlan.py +++ b/pcapkit/protocols/link/vlan.py @@ -122,8 +122,8 @@ def id(cls) -> 'tuple[Literal["VLAN"], Literal["C_Tag"], Literal["S_Tag"]]': Returns: Index ID of the protocol -- the family name, then every tag in it, as - :meth:`HTTP.id ` does for - its own family. Note that unlike HTTP's versions, the two tags are + :meth:`L2TP.id ` does for + its own family. Note that unlike L2TP's versions, the two tags are *distinct protocols* rather than flavours of one, so each is its own canonical name; they carry ``VLAN`` only as a secondary alias. diff --git a/pcapkit/protocols/protocol.py b/pcapkit/protocols/protocol.py index 725a8b73fd..93d66119d1 100644 --- a/pcapkit/protocols/protocol.py +++ b/pcapkit/protocols/protocol.py @@ -310,8 +310,8 @@ def _declared_keywords(cls: 'type') -> 'Optional[frozenset[str]]': # declares it dispatches, not a property its subclasses share: # :class:`~pcapkit.protocols.application.http.HTTP` cannot enumerate # its keywords because it forwards them to whichever of - # :class:`HTTPv1 ` and - # :class:`HTTPv2 ` the + # :class:`HTTPv1 ` and + # :class:`HTTPv2 ` the # ``version`` names, but each of those declares its own in full, and # inheriting the opt-out would exempt the very classes that can be # checked. A subclass that dispatches in turn says so itself. @@ -564,8 +564,8 @@ class ProtocolBase(Generic[_PT, _ST], metaclass=ProtocolMeta): #: belongs to a class chosen at call time: #: :meth:`HTTP.make ` declares #: only ``version`` and forwards everything else to - #: :meth:`HTTPv1.make ` or - #: :meth:`HTTPv2.make ` + #: :meth:`HTTPv1.make ` or + #: :meth:`HTTPv2.make ` #: depending on that value, so no set of names is right for it. Use it only #: for that shape, since a protocol that skips the check silently discards an #: unknown keyword. Unlike a set, the :obj:`None` is **not** inherited: a diff --git a/pcapkit/protocols/schema/application/__init__.py b/pcapkit/protocols/schema/application/__init__.py index 396f27f418..19ad3d9d19 100644 --- a/pcapkit/protocols/schema/application/__init__.py +++ b/pcapkit/protocols/schema/application/__init__.py @@ -9,10 +9,10 @@ from pcapkit.protocols.schema.application.ftp import FTP # Hypertext Transfer Protocol (HTTP/1.*) -from pcapkit.protocols.schema.application.httpv1 import HTTP as HTTPv1 +from pcapkit.protocols.schema.application.httpv1 import HTTPv1 # Hypertext Transfer Protocol (HTTP/2) -from pcapkit.protocols.schema.application.httpv2 import HTTP as HTTPv2 +from pcapkit.protocols.schema.application.httpv2 import HTTPv2 from pcapkit.protocols.schema.application.httpv2 import \ ContinuationFrame as HTTPv2_ContinuationFrame from pcapkit.protocols.schema.application.httpv2 import DataFrame as HTTPv2_DataFrame diff --git a/pcapkit/protocols/schema/application/httpv1.py b/pcapkit/protocols/schema/application/httpv1.py index 351456a499..148b9bbbcb 100644 --- a/pcapkit/protocols/schema/application/httpv1.py +++ b/pcapkit/protocols/schema/application/httpv1.py @@ -1,17 +1,25 @@ # -*- coding: utf-8 -*- # mypy: disable-error-code=assignment -"""header schema for HTTP/1.* protocol""" +"""header schema for HTTP/1.* protocol + +.. versionremoved:: 1.6.0 + + ``pcapkit.protocols.schema.application.httpv1.HTTP``, with no alias. The + class is renamed :class:`~pcapkit.protocols.schema.application.httpv1.HTTPv1`, + after its module (:issue:`2529`). + +""" from typing import TYPE_CHECKING from pcapkit.corekit.fields.strings import BytesField from pcapkit.protocols.schema.schema import Schema, schema_final -__all__ = ['HTTP'] +__all__ = ['HTTPv1'] @schema_final -class HTTP(Schema): +class HTTPv1(Schema): r"""Header schema for HTTP/1.\* packet.""" #: Packet data. diff --git a/pcapkit/protocols/schema/application/httpv2.py b/pcapkit/protocols/schema/application/httpv2.py index cabf5307b4..dd3770e307 100644 --- a/pcapkit/protocols/schema/application/httpv2.py +++ b/pcapkit/protocols/schema/application/httpv2.py @@ -1,6 +1,14 @@ # -*- coding: utf-8 -*- # mypy: disable-error-code=assignment -"""header schema for HTTP/2 protocol""" +"""header schema for HTTP/2 protocol + +.. versionremoved:: 1.6.0 + + ``pcapkit.protocols.schema.application.httpv2.HTTP``, with no alias. The + class is renamed :class:`~pcapkit.protocols.schema.application.httpv2.HTTPv2`, + after its module (:issue:`2529`). + +""" import collections import enum @@ -18,7 +26,7 @@ from pcapkit.utilities.logging import SPHINX_TYPE_CHECKING __all__ = [ - 'HTTP', + 'HTTPv2', 'FrameType', 'UnassignedFrame', 'DataFrame', 'HeadersFrame', 'PriorityFrame', @@ -73,7 +81,7 @@ class WindowSize(TypedDict): def http_frame_selector(pkt: 'dict[str, Any]') -> 'Field': - """Selector function for :attr:`HTTP.frame` field. + """Selector function for :attr:`HTTPv2.frame` field. Args: pkt: Packet data. @@ -87,15 +95,15 @@ def http_frame_selector(pkt: 'dict[str, Any]') -> 'Field': type = cast('Enum_Frame', pkt['type']) schema = FrameType.registry[type] # The frame payload is bounded by the declared Length, so anything after - # it -- typically the next frame -- is left for :attr:`HTTP.payload`. It is + # it -- typically the next frame -- is left for :attr:`HTTPv2.payload`. It is # bounded by the buffer as well, so that a Length the buffer cannot back - # reaches :meth:`HTTP.read `, + # reaches :meth:`HTTPv2.read `, # which rejects it with :exc:`~pcapkit.utilities.exceptions.ProtocolError`. return SchemaField(length=min(pkt['length'], pkt['__length__']), schema=schema) @schema_final -class HTTP(Schema): +class HTTPv2(Schema): """Header schema for HTTP/2 packet.""" #: Length of the frame payload, the 9-octet header excluded. diff --git a/pcapkit/protocols/schema/application/sslv2.py b/pcapkit/protocols/schema/application/sslv2.py index e03b10079a..223371c150 100644 --- a/pcapkit/protocols/schema/application/sslv2.py +++ b/pcapkit/protocols/schema/application/sslv2.py @@ -169,7 +169,7 @@ class HandshakeBody(EnumSchema[Enum_HandshakeType]): the same arrangement :class:`~pcapkit.protocols.schema.application.httpv2.FrameType` uses for HTTP/2 frame payloads, where ``type`` is consumed by the outer - :class:`~pcapkit.protocols.schema.application.httpv2.HTTP` schema. + :class:`~pcapkit.protocols.schema.application.httpv2.HTTPv2` schema. """ @@ -266,8 +266,8 @@ class SSLv2(Schema): :attr:`msg_type` is the one octet the `SSLv2 draft`_ puts at the front of every record's data rather than in the header proper, and it is read here, ahead of :attr:`body`, for the same reason - :attr:`~pcapkit.protocols.schema.application.httpv2.HTTP.type` is read - ahead of :attr:`~pcapkit.protocols.schema.application.httpv2.HTTP.frame`. + :attr:`~pcapkit.protocols.schema.application.httpv2.HTTPv2.type` is read + ahead of :attr:`~pcapkit.protocols.schema.application.httpv2.HTTPv2.frame`. .. _SSLv2 draft: https://datatracker.ietf.org/doc/html/draft-hickman-netscape-ssl-00 diff --git a/pcapkit/protocols/transport/tcp.py b/pcapkit/protocols/transport/tcp.py index 158990c017..dfbd53921a 100644 --- a/pcapkit/protocols/transport/tcp.py +++ b/pcapkit/protocols/transport/tcp.py @@ -366,13 +366,13 @@ class TCP(Transport[Data_TCP, Schema_TCP], # # The HTTP ports bind the version-dispatching # :class:`pcapkit.protocols.application.http.HTTP` rather than - # ``httpv1.HTTP``, because HTTP/1 and HTTP/2 share these ports on the + # ``httpv1.HTTPv1``, because HTTP/1 and HTTP/2 share these ports on the # wire -- RFC 9113 calls the cleartext form ``http`` and keeps it on # 80 -- so the port cannot decide the version and the payload has to. - # That dispatch identifies the payload positively (the RFC 9113 §3.4 - # connection preface, then an HTTP/1 start line) and trial-parses - # only as a last resort. UDP's table binds the same class for the - # same ports. + # ``HTTP._dispatch_version`` identifies the payload (the RFC 9113 §3.4 + # connection preface, an HTTP/1 start line, then a plausible HTTP/2 + # frame header) and the layer is ``HTTPv1`` or ``HTTPv2`` (#2524). + # UDP's table binds the same class for the same ports. 20: ModuleDescriptor('pcapkit.protocols.application.ftp_data', 'FTP_DATA'), 21: ModuleDescriptor('pcapkit.protocols.application.ftp', 'FTP'), 80: ModuleDescriptor('pcapkit.protocols.application.http', 'HTTP'), diff --git a/pcapkit/protocols/transport/transport.py b/pcapkit/protocols/transport/transport.py index 0449ded00d..1363b46817 100644 --- a/pcapkit/protocols/transport/transport.py +++ b/pcapkit/protocols/transport/transport.py @@ -200,8 +200,8 @@ def analyze(cls, ports: 'tuple[Enum_AppType, Enum_AppType]', payload: 'bytes', * """ port = ports[0] if ports[0] in cls.__proto__ else ports[1] - protocol = cls._lookup_next_layer( - cls.__proto__, int(port) if isinstance(port, Enum_AppType) else port) + protocol = cls._dispatch_family(cls._lookup_next_layer( + cls.__proto__, int(port) if isinstance(port, Enum_AppType) else port), payload, len(payload)) payload_io = io.BytesIO(payload) try: @@ -261,6 +261,55 @@ def __post_init__(self, file: 'Optional[IO[bytes] | bytes]' = None, # Utilities. ########################################################################## + @staticmethod + def _dispatch_family(klass: 'Type[ProtocolBase]', file_: 'bytes', + length: 'int') -> 'Type[ProtocolBase]': + r"""Resolve a family base the registry binds to the member that parses ``file_``. + + The HTTP ports bind the family base + :class:`~pcapkit.protocols.application.http.HTTP`, whose + :meth:`~pcapkit.protocols.application.http.HTTP._dispatch_version` picks + :class:`~pcapkit.protocols.application.httpv1.HTTPv1` or + :class:`~pcapkit.protocols.application.httpv2.HTTPv2` from the payload, + as :meth:`Internet._import_next_layer + ` does + for :class:`~pcapkit.protocols.internet.igmp.IGMP` (:issue:`2524`). + + Arguments: + klass: next layer protocol class, as the registry binds it + file\_: payload of the current layer + length: valid (*non-padding*) length + + Returns: + The member for a family base, otherwise ``klass`` itself. + + """ + from pcapkit.protocols.application.http import HTTP as HTTPBase # isort: skip # pylint: disable=import-outside-toplevel + + if klass is HTTPBase: + return HTTPBase._dispatch_version(file_, length) + return klass + + @staticmethod + def _parse_next_layer(klass: 'Type[ProtocolBase]', file_: 'bytes', length: 'int', + **kwargs: 'Any') -> 'ProtocolBase': + r"""Parse the next layer, resolving a family base to its member first. + + Arguments: + klass: next layer protocol class + file\_: payload of the current layer + length: valid (*non-padding*) length + **kwargs: keyword arguments for the next layer's constructor + + Returns: + As :meth:`Protocol._parse_next_layer + `, for the + class :meth:`_dispatch_family` resolves ``klass`` to. + + """ + return ProtocolBase._parse_next_layer(Transport._dispatch_family(klass, file_, length), + file_, length, **kwargs) + @classmethod def _make_port(cls, port: 'Enum_AppType | int', proto: 'Enum_TransportProtocol') -> 'Enum_AppType': diff --git a/pcapkit/protocols/transport/udp.py b/pcapkit/protocols/transport/udp.py index 968c3b8bff..782a28955d 100644 --- a/pcapkit/protocols/transport/udp.py +++ b/pcapkit/protocols/transport/udp.py @@ -69,8 +69,10 @@ class UDP(Transport[Data_UDP, Schema_UDP], Note: The HTTP ports resolve to - :class:`pcapkit.protocols.application.http.HTTP`, which identifies the - version from the payload and delegates. + :class:`pcapkit.protocols.application.http.HTTP`, whose + :meth:`~pcapkit.protocols.application.http.HTTP._dispatch_version` picks + :class:`~pcapkit.protocols.application.httpv1.HTTPv1` or + :class:`~pcapkit.protocols.application.httpv2.HTTPv2` from the payload. :attr:`TCP.__proto__ ` binds the same class for its HTTP ports. diff --git a/tests/const/test_const_method_value_lookup_908_unit.py b/tests/const/test_const_method_value_lookup_908_unit.py index 8b2464ec89..09b62a96db 100644 --- a/tests/const/test_const_method_value_lookup_908_unit.py +++ b/tests/const/test_const_method_value_lookup_908_unit.py @@ -19,7 +19,7 @@ ``_RE_METHOD = re.compile(rb"(?P[A-Z][A-Z-]*)\\Z")``, which ``httpv1.py`` imports, admits a hyphen, so a request line carrying ``BASELINE-CONTROL`` matches, reaches ``Enum_Method.get(...)`` in -``httpv1.HTTP._read_start_line``, parses successfully, and is +``httpv1.HTTPv1._read_start_line``, parses successfully, and is reported with the wrong ``idempotent`` -- the harder kind of defect to notice, since the parse itself never fails. :class:`HTTPv1EndToEndTests` below pins that whole path, not just the direct @@ -192,12 +192,12 @@ class HTTPv1EndToEndTests(unittest.TestCase): def test_a_baseline_control_request_line_reports_the_registrys_idempotent_flag( self) -> None: """``_RE_METHOD`` admits the hyphen, so this request line reaches - ``Enum_Method.get(...)`` in ``httpv1.HTTP._read_start_line``, which without the + ``Enum_Method.get(...)`` in ``httpv1.HTTPv1._read_start_line``, which without the fix comes back with ``idempotent=False`` -- the wrong answer for a parse that otherwise succeeds silently. Pins the whole path, not only the direct ``Method.get`` call above.""" from pcapkit.const.http.method import Method - from pcapkit.protocols.application.httpv1 import HTTP as HTTPv1 + from pcapkit.protocols.application.httpv1 import HTTPv1 proto = object.__new__(HTTPv1) header, _ = proto._read_http_header( @@ -212,7 +212,7 @@ def test_a_version_control_request_line_reports_the_registrys_idempotent_flag( self) -> None: """The issue's second repro, through the same end-to-end path.""" from pcapkit.const.http.method import Method - from pcapkit.protocols.application.httpv1 import HTTP as HTTPv1 + from pcapkit.protocols.application.httpv1 import HTTPv1 proto = object.__new__(HTTPv1) header, _ = proto._read_http_header( @@ -272,7 +272,7 @@ def test_a_bytes_key_raises_instead_of_returning_a_bytes_valued_member(self) -> ``value`` are both ``b'GET'``. ``bytes`` is the plausible mistake here, since ``_RE_METHOD`` (in ``pcapkit/protocols/application/http.py``) is a bytes pattern and ``httpv1`` is bytes throughout -- the live call in - ``httpv1.HTTP._read_start_line`` is safe only because it wraps the match in + ``httpv1.HTTPv1._read_start_line`` is safe only because it wraps the match in ``self.decode(...)``. """ from pcapkit.const.http.method import Method diff --git a/tests/const/test_const_str_payload_870_unit.py b/tests/const/test_const_str_payload_870_unit.py index b20074b2c5..367c259b7a 100644 --- a/tests/const/test_const_str_payload_870_unit.py +++ b/tests/const/test_const_str_payload_870_unit.py @@ -29,11 +29,11 @@ :data:`False`). Nothing under :mod:`pcapkit.protocols` compares a member this way (the one caller of :meth:`~pcapkit.const.http.method.Method.get`, -:meth:`pcapkit.protocols.application.httpv1.HTTP._read_start_line`, reached -through :meth:`~pcapkit.protocols.application.httpv1.HTTP._read_http_header` -and :meth:`~pcapkit.protocols.application.httpv1.HTTP.make`, only builds a +:meth:`pcapkit.protocols.application.httpv1.HTTPv1._read_start_line`, reached +through :meth:`~pcapkit.protocols.application.httpv1.HTTPv1._read_http_header` +and :meth:`~pcapkit.protocols.application.httpv1.HTTPv1.make`, only builds a member from the wire string, and -:meth:`~pcapkit.protocols.application.httpv1.HTTP._make_start_line` reads +:meth:`~pcapkit.protocols.application.httpv1.HTTPv1._make_start_line` reads ``.value``; neither calls ``str()`` or ``==`` on the member itself), so the fix is observable only to external callers and to this module's own dunders (``__repr__`` reads ``_value_`` and is unaffected). diff --git a/tests/foundation/registry/test_protocols.py b/tests/foundation/registry/test_protocols.py index b4ef103a8f..9df2d3c3b3 100644 --- a/tests/foundation/registry/test_protocols.py +++ b/tests/foundation/registry/test_protocols.py @@ -89,35 +89,39 @@ def _registry_warnings(caught, category) -> 'list[str]': if issubclass(item.category, category)] def test_register_protocol_warns_when_a_colliding_name_overwrites(self) -> None: - """Three dispatchable classes are all named ``HTTP``, so they share a key. + """Two dispatchable classes named ``HTTP`` share a key. The registry is keyed on ``protocol.__name__.upper()``, so - :class:`pcapkit.protocols.application.http.HTTP`, - :class:`pcapkit.protocols.application.httpv1.HTTP` and - :class:`pcapkit.protocols.application.httpv2.HTTP` all land on ``'HTTP'`` - and registering one replaces whichever was there. Guards against the - replacement being silent (#675) -- no exception, no warning -- so that a - later bare-name lookup simply returns a different class than it did - before, with nothing connecting the symptom to the registration that - caused it. + :class:`pcapkit.protocols.application.http.HTTP` and a user class of the + same name both land on ``'HTTP'``, and registering one replaces whichever + was there. Guards against the replacement being silent (#675) -- no + exception, no warning -- so that a later bare-name lookup simply returns a + different class than it did before, with nothing connecting the symptom + to the registration that caused it. No two shipped HTTP classes share a + name since #2529, so the second class is built here. """ from pcapkit.foundation.registry import protocols as registry - from pcapkit.protocols.application import http, httpv1, httpv2 + from pcapkit.protocols.application import http + from pcapkit.protocols.application.application import Application from pcapkit.utilities.warnings import RegistryWarning self._guard_registry(registry.protocol_registry, 'HTTP') - # The premise the defect rests on: three *different* classes, one key. - colliding = (http.HTTP, httpv1.HTTP, httpv2.HTTP) - self.assertEqual(len(set(colliding)), 3) + # Defining a Protocol subclass registers it, so this displaces the base. + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + custom = type('HTTP', (Application,), {'__module__': __name__}) + + # The premise the defect rests on: two *different* classes, one key. + colliding = (http.HTTP, custom) + self.assertEqual(len(set(colliding)), 2) self.assertEqual({cls.__name__.upper() for cls in colliding}, {'HTTP'}) registry.protocol_registry['HTTP'] = http.HTTP # Both directions, so the test is not pinned to one registration order. - for incumbent, replacement in ((http.HTTP, httpv2.HTTP), - (httpv2.HTTP, httpv1.HTTP)): + for incumbent, replacement in ((http.HTTP, custom), (custom, http.HTTP)): with self.subTest(replacing=incumbent.__module__): with warnings.catch_warnings(record=True) as caught: warnings.simplefilter('always') @@ -133,11 +137,7 @@ def test_register_protocol_warns_when_a_colliding_name_overwrites(self) -> None: # Naming both classes is the point of the message: 'HTTP already # registered' on its own does not say *which* HTTP was lost, and - # the module is the only thing that distinguishes these three. - # ``repr`` rather than ``__module__`` because - # 'pcapkit.protocols.application.http' is a prefix of - # '...application.httpv1', so a substring test on the module name - # alone would pass on the wrong class. + # the module is the only thing that distinguishes the two. self.assertIn(repr(incumbent), messages[0]) self.assertIn(repr(replacement), messages[0]) self.assertLess(messages[0].index(repr(incumbent)), diff --git a/tests/foundation/registry/test_registrar_code_check_unit.py b/tests/foundation/registry/test_registrar_code_check_unit.py index 915fdfea34..fafb8ddcc2 100644 --- a/tests/foundation/registry/test_registrar_code_check_unit.py +++ b/tests/foundation/registry/test_registrar_code_check_unit.py @@ -70,7 +70,7 @@ 'pcapkit.protocols.schema.transport.sctp:Parameter'), ('register_sctp_cause', 'pcapkit.protocols.transport.sctp:SCTP', 'pcapkit.protocols.schema.transport.sctp:ErrorCause'), - ('register_http_frame', 'pcapkit.protocols.application.httpv2:HTTP', + ('register_http_frame', 'pcapkit.protocols.application.httpv2:HTTPv2', 'pcapkit.protocols.schema.application.httpv2:FrameType'), ('register_pcapng_block', 'pcapkit.protocols.misc.pcapng:PCAPNG', 'pcapkit.protocols.schema.misc.pcapng:BlockType'), diff --git a/tests/foundation/registry/test_registry_name_restore_unit.py b/tests/foundation/registry/test_registry_name_restore_unit.py index 2b6f29ab2d..73d82eb2ee 100644 --- a/tests/foundation/registry/test_registry_name_restore_unit.py +++ b/tests/foundation/registry/test_registry_name_restore_unit.py @@ -229,7 +229,7 @@ def test_any_sequence_restored_in_full_leaves_names_as_found(self) -> None: shipped = {('tcp', code): TCP.__proto__[code] for code in (21, 80, 8080)} shipped.update({('udp', code): UDP.__proto__[code] for code in (80, 1701, 8080)}) targets = sorted(shipped) - builtins = [IPv4, http.HTTP, httpv1.HTTP, httpv2.HTTP] + builtins = [IPv4, http.HTTP, httpv1.HTTPv1, httpv2.HTTPv2] def expected(held: 'dict[Any, tuple[int, Any]]', name: 'str') -> 'Any': latest = max((entry for entry in held.values() if entry[1].__name__.upper() == name), diff --git a/tests/foundation/registry/test_registry_symmetry_unit.py b/tests/foundation/registry/test_registry_symmetry_unit.py index 878d76db2a..019cc6011d 100644 --- a/tests/foundation/registry/test_registry_symmetry_unit.py +++ b/tests/foundation/registry/test_registry_symmetry_unit.py @@ -137,7 +137,7 @@ class MethodRegistrar(NamedTuple): MethodRegistrar('register_sctp_cause', 'pcapkit.protocols.transport.sctp:SCTP', '__cause__', 'pcapkit.protocols.schema.transport.sctp:ErrorCause', ('pcapkit.const.sctp.cause_code:CauseCode.Stale_Cookie',)), - MethodRegistrar('register_http_frame', 'pcapkit.protocols.application.httpv2:HTTP', '__frame__', + MethodRegistrar('register_http_frame', 'pcapkit.protocols.application.httpv2:HTTPv2', '__frame__', 'pcapkit.protocols.schema.application.httpv2:FrameType', ('pcapkit.const.http.frame:Frame.HEADERS',)), MethodRegistrar('register_pcapng_block', 'pcapkit.protocols.misc.pcapng:PCAPNG', '__block__', @@ -378,10 +378,13 @@ def _steps(label: 'str') -> 'list[Callable[[], None]]': if head == 'register_protocol': from pcapkit.foundation.registry import register_protocol from pcapkit.protocols import __proto__ as names - from pcapkit.protocols.application import httpv2 + from pcapkit.protocols.application.application import Application shipped = names['HTTP'] - return [lambda: register_protocol(httpv2.HTTP), lambda: register_protocol(shipped)] + # No shipped class shares HTTP's name since #2529, so a user class named + # like it displaces the entry; defining it registers it. + custom = type('HTTP', (Application,), {'__module__': __name__}) + return [lambda: register_protocol(custom), lambda: register_protocol(shipped)] name, _, member = head.partition(':') for module in MODULE_REGISTRARS: if module.name == name: diff --git a/tests/integration/test_frame_iteration.py b/tests/integration/test_frame_iteration.py index 5506f8780b..6e4ca97858 100644 --- a/tests/integration/test_frame_iteration.py +++ b/tests/integration/test_frame_iteration.py @@ -173,7 +173,7 @@ def test_no_limit_parses_to_the_top_of_the_stack(self) -> None: self.assertIn(pcapkit.HTTP, frame) self.assertEqual( [type(protocol).__name__ for protocol in stack(frame)], - ['Ethernet', 'IPv6', 'TCP', 'HTTP'], + ['Ethernet', 'IPv6', 'TCP', 'HTTPv1'], ) def test_layer_limit_stops_at_the_named_layer_and_leaves_raw_above_it(self) -> None: diff --git a/tests/project/test_docs_internal_refs_1940_unit.py b/tests/project/test_docs_internal_refs_1940_unit.py index 4401e3cbc2..20f80a45b9 100644 --- a/tests/project/test_docs_internal_refs_1940_unit.py +++ b/tests/project/test_docs_internal_refs_1940_unit.py @@ -68,7 +68,7 @@ ('pcapkit/foundation/extraction.rst', 'pcapkit.foundation.extraction.Extractor._register_internal_engine'), ('pcapkit/foundation/reassembly/tcp.rst', 'pcapkit.foundation.reassembly.tcp.TCP._make_ports'), ('pcapkit/protocols/application/http.rst', 'pcapkit.protocols.application.http.HTTP.from_data'), - ('pcapkit/protocols/application/httpv2.rst', 'pcapkit.protocols.application.httpv2.HTTP.unpack'), + ('pcapkit/protocols/application/httpv2.rst', 'pcapkit.protocols.application.httpv2.HTTPv2.unpack'), ('pcapkit/protocols/application/ngap.rst', 'pcapkit.protocols.application.ngap._convert'), ('pcapkit/protocols/application/ngap.rst', 'pcapkit.protocols.application.ngap._revert'), ('pcapkit/protocols/application/ngap.rst', 'pcapkit.protocols.application.ngap._PYCRATE'), diff --git a/tests/project/test_docs_preserve_defaults_1859_unit.py b/tests/project/test_docs_preserve_defaults_1859_unit.py index f27eba4807..0f4ce87c5b 100644 --- a/tests/project/test_docs_preserve_defaults_1859_unit.py +++ b/tests/project/test_docs_preserve_defaults_1859_unit.py @@ -82,11 +82,11 @@ def test_sentinel_and_lambda_render_as_written(self) -> 'None': def test_aliased_registries_are_named_by_their_class(self) -> 'None': """``Enum_TransType`` is the module's private name for ``TransType``.""" # pylint: disable=import-outside-toplevel - from pcapkit.protocols.application.httpv2 import HTTP + from pcapkit.protocols.application.httpv2 import HTTPv2 from pcapkit.protocols.internet.ah import AH from pcapkit.protocols.misc.pcapng import PCAPNG - for function, expected in ((AH.make, 'next=TransType.UDP'), (HTTP.make, 'type=Frame.DATA'), + for function, expected in ((AH.make, 'next=TransType.UDP'), (HTTPv2.make, 'type=Frame.DATA'), (PCAPNG._make_option_if_filter, # pylint: disable=protected-access 'filter=FilterType(0)')): with self.subTest(function=function.__qualname__): diff --git a/tests/project/test_no_moved_name_re_exports_1744_unit.py b/tests/project/test_no_moved_name_re_exports_1744_unit.py index 7aaf643d4d..cca2ce0287 100644 --- a/tests/project/test_no_moved_name_re_exports_1744_unit.py +++ b/tests/project/test_no_moved_name_re_exports_1744_unit.py @@ -61,6 +61,13 @@ ('pcapkit.protocols.application.ngap', 'ProtocolIE'), ('pcapkit.protocols.internet.mh', 'NTPTimestamp'), ('pcapkit.protocols.internet.mh', 'PMIPv6Timestamp'), + # #2529: renamed after their modules. + ('pcapkit.protocols.application.httpv1', 'HTTP'), + ('pcapkit.protocols.application.httpv2', 'HTTP'), + ('pcapkit.protocols.schema.application.httpv1', 'HTTP'), + ('pcapkit.protocols.schema.application.httpv2', 'HTTP'), + ('pcapkit.protocols.data.application.httpv1', 'HTTP'), + ('pcapkit.protocols.data.application.httpv2', 'HTTP'), ) #: Removed paths the old module still uses, as ``(old module, name, new home)``. @@ -91,6 +98,25 @@ ('pcapkit.protocols.application.rarp', 'DRARP', 'pcapkit/protocols/application/rarp.py', 'pcapkit/protocols/application/rarp.rst', '1.6.0'), + # #2529: shipped in 1.5.x, renamed after their modules. + ('pcapkit.protocols.application.httpv1', 'HTTP', + 'pcapkit/protocols/application/httpv1.py', + 'pcapkit/protocols/application/httpv1.rst', '1.6.0'), + ('pcapkit.protocols.application.httpv2', 'HTTP', + 'pcapkit/protocols/application/httpv2.py', + 'pcapkit/protocols/application/httpv2.rst', '1.6.0'), + ('pcapkit.protocols.schema.application.httpv1', 'HTTP', + 'pcapkit/protocols/schema/application/httpv1.py', + 'pcapkit/protocols/application/httpv1.rst', '1.6.0'), + ('pcapkit.protocols.schema.application.httpv2', 'HTTP', + 'pcapkit/protocols/schema/application/httpv2.py', + 'pcapkit/protocols/application/httpv2.rst', '1.6.0'), + ('pcapkit.protocols.data.application.httpv1', 'HTTP', + 'pcapkit/protocols/data/application/httpv1.py', + 'pcapkit/protocols/application/httpv1.rst', '1.6.0'), + ('pcapkit.protocols.data.application.httpv2', 'HTTP', + 'pcapkit/protocols/data/application/httpv2.py', + 'pcapkit/protocols/application/httpv2.rst', '1.6.0'), ) diff --git a/tests/protocols/application/test_application_edge_roundtrip_unit.py b/tests/protocols/application/test_application_edge_roundtrip_unit.py index 59572fd231..403abbc16d 100644 --- a/tests/protocols/application/test_application_edge_roundtrip_unit.py +++ b/tests/protocols/application/test_application_edge_roundtrip_unit.py @@ -25,8 +25,8 @@ __tier__ = 'ALL' FTP = 'pcapkit.protocols.application.ftp:FTP' -HTTP1 = 'pcapkit.protocols.application.httpv1:HTTP' -HTTP2 = 'pcapkit.protocols.application.httpv2:HTTP' +HTTP1 = 'pcapkit.protocols.application.httpv1:HTTPv1' +HTTP2 = 'pcapkit.protocols.application.httpv2:HTTPv2' def frame(type_: int, flags: int, sid: int, payload: bytes) -> bytes: diff --git a/tests/protocols/application/test_ftp_http1_raw_unit.py b/tests/protocols/application/test_ftp_http1_raw_unit.py index 2b3e86d793..33051a8b1c 100644 --- a/tests/protocols/application/test_ftp_http1_raw_unit.py +++ b/tests/protocols/application/test_ftp_http1_raw_unit.py @@ -139,7 +139,7 @@ def setUp(self) -> None: def test_parse_then_rebuild_is_byte_exact(self) -> None: from pcapkit.protocols.application.http import HTTP - from pcapkit.protocols.application.httpv1 import HTTP as HTTPv1 + from pcapkit.protocols.application.httpv1 import HTTPv1 for cls in (HTTPv1, HTTP): for name, raw, _, _ in HTTP1_MESSAGES: @@ -147,11 +147,11 @@ def test_parse_then_rebuild_is_byte_exact(self) -> None: self.assertEqual(cls.from_data(cls(raw, len(raw)).info).data, raw) def test_parsed_values_stay_normalised(self) -> None: - from pcapkit.protocols.application.httpv1 import HTTP + from pcapkit.protocols.application.httpv1 import HTTPv1 for name, raw, receipt, fields in HTTP1_MESSAGES: with self.subTest(name=name): - info = HTTP(raw, len(raw)).info + info = HTTPv1(raw, len(raw)).info for key, value in receipt.items(): self.assertEqual(getattr(info.receipt, key), value) self.assertEqual(list(info.header.items(multi=True)), fields) @@ -160,28 +160,28 @@ def test_parsed_values_stay_normalised(self) -> None: def test_changed_values_are_written_from_the_change(self) -> None: from pcapkit.corekit.multidict import OrderedMultiDict - from pcapkit.protocols.application.httpv1 import HTTP + from pcapkit.protocols.application.httpv1 import HTTPv1 raw = b'HTTP/1.1 200 OK\r\nA:1\r\nB:\tcaf\xe9\r\n\r\n' - info = HTTP(raw, len(raw)).info - http = object.__new__(HTTP) + info = HTTPv1(raw, len(raw)).info + http = object.__new__(HTTPv1) headers = OrderedMultiDict([('A', '2'), ('B', 'café')]) self.assertEqual( - http.make(**{**HTTP._make_data(info), 'headers': headers}).data, + http.make(**{**HTTPv1._make_data(info), 'headers': headers}).data, b'HTTP/1.1 200 OK\r\nA: 2\r\nB:\tcaf\xe9\r\n\r\n', ) self.assertEqual( - http.make(**{**HTTP._make_data(info), 'message': 'Fine'}).data, + http.make(**{**HTTPv1._make_data(info), 'message': 'Fine'}).data, b'HTTP/1.1 200 Fine\r\nA:1\r\nB:\tcaf\xe9\r\n\r\n', ) def test_raw_lines_cannot_write_a_line_break_their_values_lack(self) -> None: from pcapkit.corekit.multidict import OrderedMultiDict - from pcapkit.protocols.application.httpv1 import HTTP + from pcapkit.protocols.application.httpv1 import HTTPv1 from pcapkit.utilities.exceptions import ProtocolError - http = object.__new__(HTTP) + http = object.__new__(HTTPv1) for raw in (b'X: a\r', b'X: a\n', b'\nX: a', b'X: a\r\n \nInjected: b'): with self.subTest(raw_header=raw): headers = OrderedMultiDict([http._read_field_line(raw)]) @@ -193,19 +193,19 @@ def test_raw_lines_cannot_write_a_line_break_their_values_lack(self) -> None: http.make(status=200, message='OK', raw_line=raw) def test_line_breaks_the_values_carry_still_rebuild(self) -> None: - from pcapkit.protocols.application.httpv1 import HTTP + from pcapkit.protocols.application.httpv1 import HTTPv1 for raw in (b'GET / HTTP/1.1\r\nX: a\nb\r\n\r\n', b'GET / HTTP/1.1\r\nX: a\r\n\tb\r\n\r\n'): with self.subTest(raw=raw): - self.assertEqual(HTTP.from_data(HTTP(raw, len(raw)).info).data, raw) + self.assertEqual(HTTPv1.from_data(HTTPv1(raw, len(raw)).info).data, raw) def test_field_lines_are_matched_by_content(self) -> None: from pcapkit.corekit.multidict import OrderedMultiDict - from pcapkit.protocols.application.httpv1 import HTTP + from pcapkit.protocols.application.httpv1 import HTTPv1 raw = b'GET / HTTP/1.1\r\nA:1\r\nB: 2\r\nC:3\r\n\r\n' - data = HTTP._make_data(HTTP(raw, len(raw)).info) - http = object.__new__(HTTP) + data = HTTPv1._make_data(HTTPv1(raw, len(raw)).info) + http = object.__new__(HTTPv1) for headers, expected in ( ([('B', '2'), ('C', '3')], b'B: 2\r\nC:3\r\n'), ([('A', '1'), ('N', 'new'), ('B', '2'), ('C', '3')], b'A:1\r\nN: new\r\nB: 2\r\nC:3\r\n'), diff --git a/tests/protocols/application/test_ftp_http1_roundtrip_unit.py b/tests/protocols/application/test_ftp_http1_roundtrip_unit.py index 0df4e0793b..efcbb8dffb 100644 --- a/tests/protocols/application/test_ftp_http1_roundtrip_unit.py +++ b/tests/protocols/application/test_ftp_http1_roundtrip_unit.py @@ -126,22 +126,22 @@ def setUp(self) -> None: reimport_once_per_class(self) def test_parse_then_rebuild_is_byte_exact(self) -> None: - from pcapkit.protocols.application.httpv1 import HTTP + from pcapkit.protocols.application.httpv1 import HTTPv1 for name, raw in HTTP1_MESSAGES: with self.subTest(name=name): - self.assertEqual(HTTP.from_data(HTTP(raw, len(raw)).info).data, raw) + self.assertEqual(HTTPv1.from_data(HTTPv1(raw, len(raw)).info).data, raw) def test_non_utf8_start_line_keeps_its_charset(self) -> None: - from pcapkit.protocols.application.httpv1 import HTTP + from pcapkit.protocols.application.httpv1 import HTTPv1 raw = b'GET /caf\xe9 HTTP/1.1\r\n\r\n' - receipt = HTTP(raw, len(raw)).info.receipt + receipt = HTTPv1(raw, len(raw)).info.receipt self.assertEqual(receipt.uri, '/café') self.assertIsNotNone(receipt.charset) raw = b'GET /caf\xc3\xa9 HTTP/1.1\r\n\r\n' - self.assertIsNone(HTTP(raw, len(raw)).info.receipt.charset) + self.assertIsNone(HTTPv1(raw, len(raw)).info.receipt.charset) if __name__ == '__main__': diff --git a/tests/protocols/application/test_ftp_http_unassigned_code_1443_unit.py b/tests/protocols/application/test_ftp_http_unassigned_code_1443_unit.py index fd1b11f026..87feae4aa7 100644 --- a/tests/protocols/application/test_ftp_http_unassigned_code_1443_unit.py +++ b/tests/protocols/application/test_ftp_http_unassigned_code_1443_unit.py @@ -88,14 +88,14 @@ def setUp(self) -> None: reimport_once_per_class(self) def test_every_three_digit_status_rebuilds_byte_for_byte(self) -> None: - from pcapkit.protocols.application.httpv1 import HTTP + from pcapkit.protocols.application.httpv1 import HTTPv1 for code in range(1000): raw = b'HTTP/1.1 %03d X\r\n\r\n' % code with self.subTest(code=code): - parsed = HTTP(raw, len(raw)) + parsed = HTTPv1(raw, len(raw)) self.assertEqual(int(parsed.info.receipt.status), code) - self.assertEqual(HTTP.from_data(parsed.info).data, raw) + self.assertEqual(HTTPv1.from_data(parsed.info).data, raw) def test_unassigned_and_unknown_pseudo_members(self) -> None: from pcapkit.const.http.status_code import StatusCode @@ -118,11 +118,11 @@ def test_value_outside_three_digits_still_raises(self) -> None: StatusCode.get(value) def test_make_keeps_the_leading_zero(self) -> None: - from pcapkit.protocols.application.httpv1 import HTTP + from pcapkit.protocols.application.httpv1 import HTTPv1 - proto = HTTP(status=99, message='X', http_version='1.1') + proto = HTTPv1(status=99, message='X', http_version='1.1') self.assertEqual(proto.data, b'HTTP/1.1 099 X\r\n\r\n') - reparsed = HTTP(proto.data, len(proto.data)) + reparsed = HTTPv1(proto.data, len(proto.data)) self.assertEqual(int(reparsed.info.receipt.status), 99) diff --git a/tests/protocols/application/test_http2_roundtrip_unit.py b/tests/protocols/application/test_http2_roundtrip_unit.py index 696789a7bf..6957727f42 100644 --- a/tests/protocols/application/test_http2_roundtrip_unit.py +++ b/tests/protocols/application/test_http2_roundtrip_unit.py @@ -58,12 +58,12 @@ def setUp(self) -> None: reimport_once_per_class(self) def test_from_data_rebuilds_byte_for_byte(self) -> None: - from pcapkit.protocols.application.httpv2 import HTTP + from pcapkit.protocols.application.httpv2 import HTTPv2 for name, wire in FRAMES.items(): with self.subTest(frame=name): - parsed = HTTP(wire, len(wire)) - self.assertEqual(bytes(HTTP.from_data(parsed.info)).hex(), wire.hex()) + parsed = HTTPv2(wire, len(wire)) + self.assertEqual(bytes(HTTPv2.from_data(parsed.info)).hex(), wire.hex()) def test_preface_is_parsed_and_rebuilt(self) -> None: from pcapkit.protocols.application.http import HTTP @@ -78,20 +78,20 @@ def test_preface_is_parsed_and_rebuilt(self) -> None: self.assertEqual(bytes(HTTP.from_data(parsed.info)).hex(), wire.hex()) def test_payload_is_bounded_by_the_declared_length(self) -> None: - from pcapkit.protocols.application.httpv2 import HTTP + from pcapkit.protocols.application.httpv2 import HTTPv2 wire = FRAMES['1243 DATA then SETTINGS ACK'] - parsed = HTTP(wire, len(wire)) + parsed = HTTPv2(wire, len(wire)) self.assertEqual(parsed.info.length, 3) self.assertEqual(parsed.info.data, b'abc') self.assertEqual(bytes(parsed.payload), SETTINGS_ACK) def test_reserved_and_undefined_bits_are_parsed(self) -> None: - from pcapkit.protocols.application.httpv2 import HTTP + from pcapkit.protocols.application.httpv2 import HTTPv2 def parse(name: str) -> 'object': wire = FRAMES[name] - return HTTP(wire, len(wire)).info + return HTTPv2(wire, len(wire)).info self.assertEqual(parse('1246 header R bit').reserved, 1) self.assertEqual(parse('1246 PUSH_PROMISE R bit').promised_reserved, 1) @@ -102,10 +102,10 @@ def parse(name: str) -> 'object': def test_repeated_settings_keep_their_order(self) -> None: from pcapkit.const.http.setting import Setting - from pcapkit.protocols.application.httpv2 import HTTP + from pcapkit.protocols.application.httpv2 import HTTPv2 wire = FRAMES['1247 SETTINGS A, B, A'] - settings = HTTP(wire, len(wire)).info.settings + settings = HTTPv2(wire, len(wire)).info.settings self.assertEqual(list(settings.items(multi=True)), [ (Setting.HEADER_TABLE_SIZE, 1), (Setting.ENABLE_PUSH, 0), @@ -115,10 +115,10 @@ def test_repeated_settings_keep_their_order(self) -> None: def test_repeated_settings_index_to_the_last_value(self) -> None: """The last value of a repeated identifier wins (RFC 9113 section 6.5).""" from pcapkit.const.http.setting import Setting - from pcapkit.protocols.application.httpv2 import HTTP + from pcapkit.protocols.application.httpv2 import HTTPv2 wire = FRAMES['1247 SETTINGS A, B, A'] - info = HTTP(wire, len(wire)).info + info = HTTPv2(wire, len(wire)).info settings = info.settings self.assertEqual(settings[Setting.HEADER_TABLE_SIZE], 3) self.assertEqual(settings.get(Setting.HEADER_TABLE_SIZE), 3) @@ -127,18 +127,18 @@ def test_repeated_settings_index_to_the_last_value(self) -> None: # The Info export keeps every entry, in wire order (#1484). self.assertEqual(list(settings.to_dict().items(multi=True)), list(settings.items(multi=True))) self.assertEqual(settings.getlist(Setting.HEADER_TABLE_SIZE), [1, 3]) - self.assertEqual(bytes(HTTP.from_data(info)).hex(), wire.hex()) + self.assertEqual(bytes(HTTPv2.from_data(info)).hex(), wire.hex()) def test_make_padded_with_zero_pad_length(self) -> None: from pcapkit.const.http.frame import Frame - from pcapkit.protocols.application.httpv2 import HTTP + from pcapkit.protocols.application.httpv2 import HTTPv2 - made = HTTP(type=Frame.DATA, sid=1, frame={'padded': True, 'data': b'abc'}) + made = HTTPv2(type=Frame.DATA, sid=1, frame={'padded': True, 'data': b'abc'}) self.assertEqual(bytes(made), FRAMES['1244 DATA PADDED, Pad Length 0']) def test_make_accepts_frame_bytes(self) -> None: from pcapkit.const.http.frame import Frame - from pcapkit.protocols.application.httpv2 import HTTP + from pcapkit.protocols.application.httpv2 import HTTPv2 for frame_type, sid, payload in ( (Frame.DATA, 1, b'abc'), @@ -154,29 +154,29 @@ def test_make_accepts_frame_bytes(self) -> None: (Frame(0xfa), 1, b'abc'), ): with self.subTest(type=frame_type): - made = HTTP(type=frame_type, sid=sid, frame=payload) + made = HTTPv2(type=frame_type, sid=sid, frame=payload) wire = len(payload).to_bytes(3, 'big') + bytes([int(frame_type), 0]) + sid.to_bytes(4, 'big') + payload self.assertEqual(bytes(made), wire) self.assertEqual(made.info.type, frame_type) def test_make_frame_bytes_wrong_for_the_type_raise_protocol_error(self) -> None: from pcapkit.const.http.frame import Frame - from pcapkit.protocols.application.httpv2 import HTTP + from pcapkit.protocols.application.httpv2 import HTTPv2 from pcapkit.utilities.exceptions import ProtocolError with self.assertRaises(ProtocolError): - HTTP(type=Frame.PRIORITY, sid=1, frame=b'abc') + HTTPv2(type=Frame.PRIORITY, sid=1, frame=b'abc') def test_make_ping_rejects_wrong_width_opaque_data(self) -> None: from pcapkit.const.http.frame import Frame - from pcapkit.protocols.application.httpv2 import HTTP + from pcapkit.protocols.application.httpv2 import HTTPv2 from pcapkit.utilities.exceptions import ProtocolError for data in (b'abc', b'0123456789'): with self.subTest(data=data): with self.assertRaises(ProtocolError): - HTTP(type=Frame.PING, sid=0, frame={'opaque_data': data}) - self.assertEqual(bytes(HTTP(type=Frame.PING, sid=0, frame={'opaque_data': b'01234567'}))[9:], + HTTPv2(type=Frame.PING, sid=0, frame={'opaque_data': data}) + self.assertEqual(bytes(HTTPv2(type=Frame.PING, sid=0, frame={'opaque_data': b'01234567'}))[9:], b'01234567') diff --git a/tests/protocols/application/test_http_base_dispatch_2544_unit.py b/tests/protocols/application/test_http_base_dispatch_2544_unit.py new file mode 100644 index 0000000000..091444959d --- /dev/null +++ b/tests/protocols/application/test_http_base_dispatch_2544_unit.py @@ -0,0 +1,201 @@ +# -*- coding: utf-8 -*- +"""The HTTP family base dispatches on both paths. C.f. #2544. + +A versioned family's base never yields an instance of itself: parsing octets +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. + +This module is unit-tier: every packet is built in memory. + +""" + +from __future__ import annotations + +import importlib +import importlib.util +import io +import unittest +import warnings +from typing import TYPE_CHECKING + +from tests._support import reimport_once_per_class + +if TYPE_CHECKING: + from typing import Any + +RUNTIME_DEPS = ('tbtrim', 'aenum', 'chardet', 'dictdumper') +HAS_RUNTIME = all(importlib.util.find_spec(name) is not None for name in RUNTIME_DEPS) + +#: The HTTP/2 connection preface (RFC 9113, section 3.4). +_PREFACE = b'PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n' + +#: Octets, with the member that parses them. +OCTETS = { + 'request': (b'GET / HTTP/1.1\r\nHost: example.invalid\r\n\r\n', 'HTTPv1'), + 'response': (b'HTTP/1.0 200 OK\r\nContent-Length: 2\r\n\r\nhi', 'HTTPv1'), + 'frame': (bytes.fromhex('000000000000000001'), 'HTTPv2'), + 'preface': (_PREFACE + bytes.fromhex('000000040000000000'), 'HTTPv2'), +} + +#: Octets neither version takes, with the refusal's message. +UNRECOGNISED = { + 'zeros': (b'\x00' * 9, 'HTTP: unknown version'), + 'text': (b'not an http message', 'HTTP: unknown version'), + 'preface alone': (_PREFACE, 'HTTP/2: connection preface with no frame'), +} + + +@unittest.skipUnless(HAS_RUNTIME, 'runtime dependencies not installed') +class HTTPBaseDispatchTests(unittest.TestCase): + """``HTTP(...)`` and ``HTTP.from_data`` give the member, never the base.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + @staticmethod + def classes() -> 'tuple[Any, Any, Any]': + from pcapkit.protocols.application.http import HTTP + from pcapkit.protocols.application.httpv1 import HTTPv1 + from pcapkit.protocols.application.httpv2 import HTTPv2 + + return HTTP, HTTPv1, HTTPv2 + + def member(self, name: str) -> 'Any': + return dict(zip(('HTTP', 'HTTPv1', 'HTTPv2'), self.classes()))[name] + + def test_parsing_through_the_base_gives_the_member(self) -> None: + HTTP, _, _ = self.classes() + for label, (octets, name) in OCTETS.items(): + for source in ('bytes', 'stream'): + with self.subTest(octets=label, source=source): + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + layer = (HTTP(octets) if source == 'bytes' + else HTTP(io.BytesIO(octets), len(octets))) + self.assertIs(type(layer), self.member(name)) + self.assertEqual(layer.info_name, name.lower()) + self.assertEqual(layer.data, octets) + + def test_an_explicit_version_names_the_member_on_the_read_path(self) -> None: + from pcapkit.utilities.exceptions import ProtocolError + + HTTP, _, HTTPv2 = self.classes() + octets = OCTETS['frame'][0] + self.assertIs(type(HTTP(octets, version=2)), HTTPv2) + with self.assertRaisesRegex(ProtocolError, 'invalid format'): + HTTP(octets, version=1) + with self.assertRaisesRegex(ProtocolError, 'invalid version'): + HTTP(octets, version=3) + + def test_octets_neither_version_takes_raise(self) -> None: + from pcapkit.utilities.exceptions import ProtocolError + + HTTP, _, _ = self.classes() + for label, (octets, message) in UNRECOGNISED.items(): + with self.subTest(octets=label): + with self.assertRaisesRegex(ProtocolError, message): + HTTP(octets) + + def test_building_through_the_base_gives_the_member_the_fields_describe(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), + 'preface': ({'version': 2, 'preface': True, 'type': Frame.SETTINGS}, HTTPv2), + } + for label, (fields, klass) in cases.items(): + with self.subTest(fields=label): + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + layer = HTTP(**fields) + direct = klass(**{key: val for key, val in fields.items() if key != 'version'}) + self.assertIs(type(layer), klass) + 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. + """ + 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) + 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: + 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}, + 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: + from pcapkit.utilities.exceptions import ProtocolError + + HTTP, _, _ = self.classes() + for label, (octets, name) in OCTETS.items(): + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + parsed = HTTP(octets) + for form in ('info', 'dict'): + with self.subTest(octets=label, form=form): + data = parsed.info if form == 'info' else parsed.info.to_dict() + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + rebuilt = HTTP.from_data(data) + self.assertIs(type(rebuilt), self.member(name)) + self.assertEqual(rebuilt.data, octets) + with self.assertRaisesRegex(ProtocolError, 'invalid data'): + HTTP.from_data({'neither': 'version'}) + + def test_built_packets_parse_back_through_the_base(self) -> None: + """make -> parse through the base gives the same member and octets.""" + from pcapkit.const.http.frame import Frame + + HTTP, _, _ = self.classes() + for fields in ({'method': 'GET', 'uri': '/'}, {'type': Frame.DATA, 'sid': 1}, + {'version': 2, 'preface': True, 'type': Frame.SETTINGS}): + with self.subTest(fields=fields): + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + made = HTTP(**fields) + parsed = HTTP(made.data) + self.assertIs(type(parsed), type(made)) + self.assertEqual(parsed.data, made.data) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/protocols/application/test_http_dispatch_2524_unit.py b/tests/protocols/application/test_http_dispatch_2524_unit.py new file mode 100644 index 0000000000..de85bb8ddb --- /dev/null +++ b/tests/protocols/application/test_http_dispatch_2524_unit.py @@ -0,0 +1,257 @@ +# -*- coding: utf-8 -*- +"""A dispatched HTTP payload is parsed by its version's class. C.f. #2524. + +The HTTP ports bind the family base +:class:`~pcapkit.protocols.application.http.HTTP`, and ``HTTP._dispatch_version`` picks +``HTTPv1`` or ``HTTPv2`` from the payload, as ``IGMP._dispatch`` picks an IGMP +message's class. So a segment's HTTP layer is never the base: its data sits under +``httpv1`` or ``httpv2``, a payload neither version identifies is kept as +:class:`~pcapkit.protocols.misc.raw.Raw`, and every round trip still reproduces +its octets. + +This module is unit-tier: the segments are built in memory. + +""" + +from __future__ import annotations + +import importlib.util +import io +import unittest +import warnings +from typing import TYPE_CHECKING + +from tests._support import reimport_once_per_class + +if TYPE_CHECKING: + from typing import Any + +RUNTIME_DEPS = ('tbtrim', 'aenum', 'chardet', 'dictdumper') +HAS_RUNTIME = all(importlib.util.find_spec(name) is not None for name in RUNTIME_DEPS) + +#: A 20-octet TCP header, data offset 5, ACK set, from port 50000 to port 80. +_TCP_TO_80 = bytes.fromhex('c350005000000001000000015010ffff00000000') + +#: The HTTP/2 connection preface (RFC 9113, section 3.4). +_PREFACE = b'PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n' + +#: An empty ``SETTINGS`` frame on stream 0, as a client sends after the preface. +_SETTINGS = bytes.fromhex('000000040000000000') + +#: Each payload, with the class name that parses it. +PAYLOADS = { + 'request': (b'GET / HTTP/1.1\r\nHost: example.invalid\r\n\r\n', 'HTTPv1'), + 'response': (b'HTTP/1.0 200 OK\r\nContent-Length: 2\r\n\r\nhi', 'HTTPv1'), + 'frame': (bytes.fromhex('000000000000000001'), 'HTTPv2'), + 'two frames': (bytes.fromhex('000000000000000001') * 2, 'HTTPv2'), + 'preface': (_PREFACE + _SETTINGS, 'HTTPv2'), +} + +#: Payloads the dispatcher gives to ``HTTPv2`` and ``HTTPv2`` refuses, with the +#: fragment of the refusal: a preface followed by fewer octets than a frame +#: header, and a plausible frame header whose frame does not parse. +REFUSED = { + **{f'preface and {count} octet(s)': + (_PREFACE + b'\x00' * count, 'shorter than the 9-octet frame header') + for count in range(1, 9)}, + 'preface and an empty PRIORITY frame': (_PREFACE + bytes.fromhex('000000020000000001'), + r'\[Type 2\] invalid format'), + 'empty PRIORITY frame': (bytes.fromhex('000000020000000001'), r'\[Type 2\] invalid format'), +} + +#: Payloads that neither version identifies. +UNIDENTIFIED = { + 'zeros': b'\x00' * 9, + 'text': b'not an http message', + 'truncated preface': _PREFACE[:18], +} + + +@unittest.skipUnless(HAS_RUNTIME, 'runtime dependencies not installed') +class HTTPDispatchTests(unittest.TestCase): + """``HTTP._dispatch_version``, the transport layers' use of it, the round trips.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + @staticmethod + def member(class_name: str) -> 'Any': + import importlib + + return getattr(importlib.import_module( + f'pcapkit.protocols.application.{class_name.lower()}'), class_name) + + @staticmethod + def tcp(payload: bytes) -> 'Any': + from pcapkit.protocols.transport.tcp import TCP + + data = _TCP_TO_80 + payload + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + return TCP(io.BytesIO(data), len(data)) + + @staticmethod + def udp(payload: bytes) -> 'Any': + from pcapkit.protocols.transport.udp import UDP + + data = bytes.fromhex('c3500050') + (8 + len(payload)).to_bytes(2, 'big') + b'\x00\x00' + payload + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + return UDP(io.BytesIO(data), len(data)) + + def test_dispatch_picks_the_member_the_payload_identifies(self) -> None: + from pcapkit.protocols.application.http import HTTP + from pcapkit.protocols.misc.raw import Raw + + for label, (payload, class_name) in PAYLOADS.items(): + with self.subTest(payload=label): + self.assertIs(HTTP._dispatch_version(payload, len(payload)), + self.member(class_name)) + for label, payload in UNIDENTIFIED.items(): + with self.subTest(payload=label): + self.assertIs(HTTP._dispatch_version(payload, len(payload)), Raw) + with self.subTest(payload='bounded by length'): + request = PAYLOADS['request'][0] + self.assertIs(HTTP._dispatch_version(request + b'\x00' * 9, len(request)), + self.member('HTTPv1')) + + def test_tcp_and_udp_parse_with_the_member_under_its_key(self) -> None: + for transport in (self.tcp, self.udp): + for label, (payload, class_name) in PAYLOADS.items(): + with self.subTest(transport=transport.__name__, payload=label): + outer = transport(payload) + self.assertIs(type(outer.payload), self.member(class_name)) + self.assertIs(outer.info[class_name.lower()], outer.payload.info) + self.assertNotIn('http', outer.info) + + def test_reassembly_analysis_parses_with_the_member(self) -> None: + """``Transport.analyze``, which TCP reassembly reaches, dispatches alike.""" + from pcapkit.protocols.transport.tcp import TCP + + for label, (payload, class_name) in PAYLOADS.items(): + with self.subTest(payload=label): + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + layer = TCP.analyze((50000, 80), payload) + self.assertIs(type(layer), self.member(class_name)) + self.assertEqual(layer.data, payload) + + def test_an_unidentified_payload_is_kept_raw(self) -> None: + """An unrecognised payload is Raw with no ``error``, as IGMP's is (#2544).""" + from pcapkit.protocols.misc.raw import Raw + + for label, payload in UNIDENTIFIED.items(): + with self.subTest(payload=label): + tcp = self.tcp(payload) + self.assertIs(type(tcp.payload), Raw) + self.assertEqual(tcp.payload.data, payload) + self.assertIsNone(tcp.payload.info.error) + + def test_a_preface_alone_is_raw_to_the_dispatcher_and_the_parse(self) -> None: + """A preface with no frame after it is kept as Raw, byte for byte. + + It is HTTP/2, and a client may send it in a segment of its own, but the + HTTP/2 data model holds one frame per packet and has no form for it, so + ``HTTPv2`` refuses it and the dispatcher does not pick that class. + """ + from pcapkit.protocols.application.http import HTTP + from pcapkit.protocols.misc.raw import Raw + from pcapkit.protocols.transport.tcp import TCP + from pcapkit.utilities.exceptions import ProtocolError + + self.assertIs(HTTP._dispatch_version(_PREFACE, len(_PREFACE)), Raw) + with self.assertRaisesRegex(ProtocolError, 'connection preface with no frame'): + self.member('HTTPv2')(io.BytesIO(_PREFACE), len(_PREFACE)) + + tcp = self.tcp(_PREFACE) + self.assertIs(type(tcp.payload), Raw) + self.assertEqual(str(tcp.protochain), 'TCP:Raw') + self.assertEqual(tcp.payload.data, _PREFACE) + self.assertIsNone(tcp.payload.info.error) + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + self.assertEqual(TCP.from_data(tcp.info).data, tcp.data) + + def test_a_picked_member_parses_these_payloads_or_refuses_those(self) -> None: + """The member the dispatcher picks parses each payload this module lists + as HTTP, or refuses each it lists as refused. + + The dispatcher identifies without parsing, so it can pick ``HTTPv2`` for a + payload ``HTTPv2`` then refuses: a preface followed by fewer octets than a + frame header, or a plausible frame header whose frame does not parse. That + segment is kept as ``Raw`` with the parser's own error. A preface alone is + the one such payload it keeps ``Raw`` itself. + """ + from pcapkit.protocols.application.http import HTTP + from pcapkit.protocols.misc.raw import Raw + from pcapkit.utilities.exceptions import ProtocolError + + payloads = {**{label: payload for label, (payload, _) in PAYLOADS.items()}, + **UNIDENTIFIED, 'preface alone': _PREFACE} + for label, payload in payloads.items(): + klass = HTTP._dispatch_version(payload, len(payload)) + if klass is Raw: + continue + with self.subTest(payload=label, klass=klass.__name__): + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + self.assertEqual(klass(io.BytesIO(payload), len(payload)).data, payload) + + HTTPv2 = self.member('HTTPv2') + for label, (payload, fragment) in REFUSED.items(): + with self.subTest(payload=label, refused=True): + self.assertIs(HTTP._dispatch_version(payload, len(payload)), HTTPv2) + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + with self.assertRaisesRegex(ProtocolError, fragment): + HTTPv2(io.BytesIO(payload), len(payload)) + tcp = self.tcp(payload) + self.assertIs(type(tcp.payload), Raw) + self.assertEqual(tcp.payload.data, payload) + self.assertRegex(tcp.payload.info.error, fragment) + + def test_a_preface_counts_as_header_and_is_kept(self) -> None: + """The preface and SETTINGS frame parse as one HTTPv2 layer, the preface + counted in its header and kept in its data model.""" + tcp = self.tcp(_PREFACE + _SETTINGS) + layer = tcp.payload + self.assertIs(type(layer), self.member('HTTPv2')) + self.assertIs(tcp.info['httpv2'], layer.info) + self.assertEqual(layer.length, len(_PREFACE) + 9) + self.assertEqual(layer.info.preface, _PREFACE) + self.assertEqual(int(layer.info.type), 0x4) + self.assertEqual(layer.data, _PREFACE + _SETTINGS) + + def test_parse_then_rebuild_reproduces_the_octets(self) -> None: + for label, (payload, class_name) in PAYLOADS.items(): + with self.subTest(payload=label): + tcp = self.tcp(payload) + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + self.assertEqual(type(tcp).from_data(tcp.info).data, tcp.data) + self.assertEqual(self.member(class_name).from_data(tcp.payload.info).data, + payload) + + def test_make_then_parse_reproduces_the_octets(self) -> None: + from pcapkit.const.http.frame import Frame + + HTTPv1, HTTPv2 = self.member('HTTPv1'), self.member('HTTPv2') + made = { + 'HTTPv1': HTTPv1(method='GET', uri='/', http_version='1.1'), + 'HTTPv2': HTTPv2(type=Frame.DATA, sid=1, frame={'data': b'x'}), + 'HTTPv2 preface': HTTPv2(type=Frame.SETTINGS, sid=0, frame={}, preface=True), + } + for label, layer in made.items(): + klass = type(layer) + with self.subTest(made=label): + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + parsed = klass(io.BytesIO(layer.data), len(layer.data)) + self.assertEqual(parsed.data, layer.data) + self.assertEqual(klass.from_data(parsed.info).data, layer.data) + self.assertIs(type(self.tcp(layer.data).payload), klass) + self.assertTrue(made['HTTPv2 preface'].data.startswith(_PREFACE)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/protocols/application/test_http_from_data_1154_unit.py b/tests/protocols/application/test_http_from_data_1154_unit.py index 8b4b8cf6c9..e37787095e 100644 --- a/tests/protocols/application/test_http_from_data_1154_unit.py +++ b/tests/protocols/application/test_http_from_data_1154_unit.py @@ -9,8 +9,8 @@ class from a ``version`` key that neither HTTP/1.* nor HTTP/2 data carries, ``version`` that :meth:`HTTP.make ` dispatches on. -Behind that, :meth:`httpv2.HTTP._make_data -` must not return the +Behind that, :meth:`httpv2.HTTPv2._make_data +` must not return the frame's ``length``, which ``ProtocolBase.__init__`` takes as the number of octets to parse back -- the payload alone, nine short of the frame -- so every HTTP/2 ``from_data`` would raise as well. ``make`` computes the Length itself, @@ -61,7 +61,7 @@ class from a ``version`` key that neither HTTP/1.* nor HTTP/2 data carries, @unittest.skipUnless(HAS_RUNTIME, 'runtime dependencies not installed') class HTTPFromDataTests(unittest.TestCase): - """``from_data`` on the dispatcher and on ``httpv2.HTTP`` round-trips.""" + """``from_data`` on the dispatcher and on ``httpv2.HTTPv2`` round-trips.""" def setUp(self) -> None: reimport_once_per_class(self) @@ -94,8 +94,8 @@ def test_dispatcher_rebuilds_http2_frames(self) -> None: self._round_trip(HTTP, bytes.fromhex(octets)) def test_httpv2_rebuilds_its_own_frames(self) -> None: - """``httpv2.HTTP.from_data`` parses back the whole frame, not only Length octets.""" - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + """``httpv2.HTTPv2.from_data`` parses back the whole frame, not only Length octets.""" + from pcapkit.protocols.application.httpv2 import HTTPv2 for name, octets in HTTP2_FRAMES: with self.subTest(frame=name): diff --git a/tests/protocols/application/test_http_id_2524_unit.py b/tests/protocols/application/test_http_id_2524_unit.py new file mode 100644 index 0000000000..37054030f8 --- /dev/null +++ b/tests/protocols/application/test_http_id_2524_unit.py @@ -0,0 +1,175 @@ +# -*- coding: utf-8 -*- +"""HTTP's ``id()`` carries the RFC names of its versions. C.f. #2524. + +:meth:`HTTP.id() ` lists its members +alone, ``('HTTP/1', 'HTTP/2')``, as ``IP.id()`` does, and each versioned class +narrows to its own name: ``HTTP/2`` is RFC 9113's, and ``HTTP/1`` covers RFC 9112's +HTTP/1.1 with the versions before it. No ``id()`` carries ``'HTTP'``, ``'HTTPv1'`` +or ``'HTTPv2'``, so those select through their registry keys alone. + +A dispatched layer is its version's class, as an IGMP message's is, so each +version's name and key select only its own layer, and the family's key or class +selects either. The segments are built in memory: a TCP header to port 80, then +an HTTP/1.1 request or a nine-octet HTTP/2 ``DATA`` frame on stream 1. + +""" + +from __future__ import annotations + +import importlib.util +import io +import unittest +import warnings +from typing import TYPE_CHECKING + +from tests._support import reimport_once_per_class + +if TYPE_CHECKING: + from typing import Any + +RUNTIME_DEPS = ('tbtrim', 'aenum', 'chardet', 'dictdumper') +HAS_RUNTIME = all(importlib.util.find_spec(name) is not None for name in RUNTIME_DEPS) + +#: A 20-octet TCP header, data offset 5, ACK set, from port 50000 to port 80. +_TCP_TO_80 = bytes.fromhex('c350005000000001000000015010ffff00000000') + +#: Each payload, keyed by the ``id()`` name of the version it is. +PAYLOADS = { + 'HTTP/1': b'GET / HTTP/1.1\r\nHost: example.invalid\r\n\r\n', + 'HTTP/2': bytes.fromhex('000000000000000001'), +} + +#: Each version's registry key, keyed by its ``id()`` name. +KEYS = {'HTTP/1': 'HTTPv1', 'HTTP/2': 'HTTPv2'} + +#: Every HTTP selector: the family's registry key, each version's key, and each +#: version's ``id()`` name. +SELECTORS = ('HTTP', 'HTTPv1', 'HTTPv2', 'HTTP/1', 'HTTP/2') + + +def selects(selector: str, version: str) -> bool: + """Whether ``selector`` reaches a layer of ``version``: the family, or that version.""" + return selector in ('HTTP', version, KEYS[version]) + + +@unittest.skipUnless(HAS_RUNTIME, 'runtime dependencies not installed') +class HTTPIdTests(unittest.TestCase): + """The ``id()`` tuples, and what they make the selectors reach.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + @staticmethod + def classes() -> 'tuple[Any, Any, Any]': + from pcapkit.protocols.application.http import HTTP + from pcapkit.protocols.application.httpv1 import HTTPv1 + from pcapkit.protocols.application.httpv2 import HTTPv2 + + return HTTP, HTTPv1, HTTPv2 + + @staticmethod + def frame(payload: bytes, **kwargs: 'Any') -> 'Any': + """Return the TCP segment carrying ``payload`` to port 80, parsed.""" + from pcapkit.protocols.transport.tcp import TCP + + data = _TCP_TO_80 + payload + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + return TCP(io.BytesIO(data), len(data), **kwargs) + + def test_id_lists_the_members_and_each_member_narrows_to_itself(self) -> None: + HTTP, HTTPv1, HTTPv2 = self.classes() + + self.assertEqual(HTTP.id(), ('HTTP/1', 'HTTP/2')) + self.assertEqual(HTTPv1.id(), ('HTTP/1',)) + self.assertEqual(HTTPv2.id(), ('HTTP/2',)) + self.assertEqual(HTTP.id(), HTTPv1.id() + HTTPv2.id()) + for klass in (HTTP, HTTPv1, HTTPv2): + with self.subTest(klass=klass.__module__): + self.assertFalse({'HTTP', 'HTTPv1', 'HTTPv2'} & set(klass.id())) + + def test_in_and_getitem_select_the_family_and_the_layers_own_version(self) -> None: + """``in``, ``[]`` and the protochain reach a layer by the family's key or + class and by its own version's name or key, and miss it by the other + version's. An exact version such as ``'HTTP/1.1'`` is in no ``id()``, so + only the protochain's alias match finds it.""" + from pcapkit.protocols import __proto__ as registry + from pcapkit.utilities.exceptions import ProtocolNotFound + + _, HTTPv1, HTTPv2 = self.classes() + members = {'HTTP/1': HTTPv1, 'HTTP/2': HTTPv2} + for version, payload in PAYLOADS.items(): + frame = self.frame(payload) + layer = frame.payload + self.assertIs(type(layer), members[version]) + for selector in SELECTORS: + keys = [selector, selector.lower()] + if selector.upper() in registry: + keys.append(registry[selector.upper()]) + for key in keys: + with self.subTest(version=version, selector=key): + if selects(selector, version): + self.assertIn(key, layer) + self.assertIn(key, frame) + self.assertIn(key, frame.protochain) + self.assertIs(frame[key], layer) + else: + self.assertNotIn(key, layer) + self.assertNotIn(key, frame) + self.assertNotIn(key, frame.protochain) + with self.assertRaises(ProtocolNotFound): + frame[key] # pylint: disable=pointless-statement + + with self.subTest(version=version, selector='HTTP/1.1'): + self.assertNotIn('HTTP/1.1', frame) + with self.assertRaises(ProtocolNotFound): + frame['HTTP/1.1'] # pylint: disable=pointless-statement + self.assertIs('HTTP/1.1' in frame.protochain, version == 'HTTP/1') + + def test_string_eq_compares_the_layers_own_id(self) -> None: + """String ``==`` is not expanded, so a layer equals its own version's + name alone, and neither the family nor a version key.""" + HTTP, HTTPv1, HTTPv2 = self.classes() + members = {'HTTP/1': HTTPv1, 'HTTP/2': HTTPv2} + for version, payload in PAYLOADS.items(): + layer = self.frame(payload).payload + for selector in SELECTORS: + with self.subTest(version=version, selector=selector): + self.assertIs(layer == selector, selector == version) + with self.subTest(version=version, selector='classes'): + for klass in (HTTP, HTTPv1, HTTPv2): + self.assertIs(layer == klass, klass is members[version]) + + def test_a_versioned_layer_built_directly_selects_alike(self) -> None: + """A versioned class parsed directly is selected as a dispatched one is.""" + _, HTTPv1, HTTPv2 = self.classes() + layers = { + 'HTTP/1': HTTPv1(io.BytesIO(PAYLOADS['HTTP/1']), len(PAYLOADS['HTTP/1'])), + 'HTTP/2': HTTPv2(io.BytesIO(PAYLOADS['HTTP/2']), len(PAYLOADS['HTTP/2'])), + } + for version, layer in layers.items(): + for selector in SELECTORS: + with self.subTest(layer=version, selector=selector): + self.assertIs(selector in layer, selects(selector, version)) + self.assertIs(layer == selector, selector == version) + + def test_protocol_stop_matches_the_family_and_its_own_version(self) -> None: + """``protocol=`` stops at a layer for the family's selectors and its own + version's, and not for the other version or an exact version, which + ``_check_term_threshold`` compares against ``id()`` alone.""" + HTTP, HTTPv1, HTTPv2 = self.classes() + classes = {'HTTP': HTTP, 'HTTPv1': HTTPv1, 'HTTPv2': HTTPv2} + for version, payload in PAYLOADS.items(): + for selector in SELECTORS: + for key in (selector, classes.get(selector)): + if key is None: + continue + with self.subTest(version=version, selector=key): + self.assertIs(self.frame(payload, protocol=key).payload._sigterm, + selects(selector, version)) + with self.subTest(version=version, selector='HTTP/1.1'): + self.assertIs(self.frame(payload, protocol='HTTP/1.1').payload._sigterm, False) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/protocols/application/test_http_member_names_2529_unit.py b/tests/protocols/application/test_http_member_names_2529_unit.py new file mode 100644 index 0000000000..fca0a59287 --- /dev/null +++ b/tests/protocols/application/test_http_member_names_2529_unit.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +"""HTTP's member classes take their modules' names. C.f. #2529. + +A protocol class is named after its module, and a module's top-level schema and data +classes follow: :mod:`~pcapkit.protocols.application.httpv1`, +:mod:`~pcapkit.protocols.schema.application.httpv1` and +:mod:`~pcapkit.protocols.data.application.httpv1` each hold ``HTTPv1``, and the +``httpv2`` modules ``HTTPv2``. The old module-level name ``HTTP`` keeps no alias +(#1715, #1744), while the package-level names are unchanged. + +A dispatched layer is its version's class (#2524), so the key its enclosing layer +stores it under follows that class's name, ``httpv1`` or ``httpv2``, as a member +built directly does. Since the members no longer share the base's name, +registering one keeps its own key rather than displacing the base under +``'HTTP'``. + +This module is unit-tier: the segments are built in memory. + +""" + +from __future__ import annotations + +import importlib +import importlib.util +import io +import unittest +import warnings + +from tests._support import reimport_once_per_class + +RUNTIME_DEPS = ('tbtrim', 'aenum', 'chardet', 'dictdumper') +HAS_RUNTIME = all(importlib.util.find_spec(name) is not None for name in RUNTIME_DEPS) + +#: Each renamed module, with the class it holds. +MODULES = { + 'pcapkit.protocols.application.httpv1': 'HTTPv1', + 'pcapkit.protocols.application.httpv2': 'HTTPv2', + 'pcapkit.protocols.schema.application.httpv1': 'HTTPv1', + 'pcapkit.protocols.schema.application.httpv2': 'HTTPv2', + 'pcapkit.protocols.data.application.httpv1': 'HTTPv1', + 'pcapkit.protocols.data.application.httpv2': 'HTTPv2', +} + +#: Each package that exports the classes, with the module it exports them from. +PACKAGES = { + 'pcapkit': 'pcapkit.protocols.application', + 'pcapkit.protocols': 'pcapkit.protocols.application', + 'pcapkit.protocols.application': 'pcapkit.protocols.application', + 'pcapkit.protocols.schema': 'pcapkit.protocols.schema.application', + 'pcapkit.protocols.schema.application': 'pcapkit.protocols.schema.application', + 'pcapkit.protocols.data': 'pcapkit.protocols.data.application', + 'pcapkit.protocols.data.application': 'pcapkit.protocols.data.application', +} + +#: A 20-octet TCP header, data offset 5, ACK set, from port 50000 to port 80. +_TCP_TO_80 = bytes.fromhex('c350005000000001000000015010ffff00000000') + +#: An HTTP/1.1 request and a nine-octet HTTP/2 ``DATA`` frame on stream 1. +PAYLOADS = { + 'HTTPv1': b'GET / HTTP/1.1\r\nHost: example.invalid\r\n\r\n', + 'HTTPv2': bytes.fromhex('000000000000000001'), +} + + +@unittest.skipUnless(HAS_RUNTIME, 'runtime dependencies not installed') +class HTTPMemberNameTests(unittest.TestCase): + """The class names, the exports, ``info_name`` and the registry key.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_each_module_holds_the_class_named_after_it(self) -> None: + for module_name, class_name in MODULES.items(): + with self.subTest(module=module_name): + module = importlib.import_module(module_name) + klass = getattr(module, class_name) + self.assertEqual(klass.__name__, class_name) + self.assertEqual(klass.__module__, module_name) + self.assertIn(class_name, module.__all__) + self.assertNotIn('HTTP', module.__all__) + self.assertFalse(hasattr(module, 'HTTP')) + + def test_package_names_are_unchanged(self) -> None: + for package_name, home in PACKAGES.items(): + package = importlib.import_module(package_name) + for class_name in ('HTTPv1', 'HTTPv2'): + module = importlib.import_module(f'{home}.{class_name.lower()}') + with self.subTest(package=package_name, name=class_name): + self.assertIs(getattr(package, class_name), getattr(module, class_name)) + + def test_each_protocol_binds_its_own_modules_schema_and_data(self) -> None: + for class_name in ('HTTPv1', 'HTTPv2'): + suffix = class_name.lower() + klass = getattr(importlib.import_module(f'pcapkit.protocols.application.{suffix}'), + class_name) + schema = importlib.import_module(f'pcapkit.protocols.schema.application.{suffix}') + data = importlib.import_module(f'pcapkit.protocols.data.application.{suffix}') + with self.subTest(klass=class_name): + self.assertIs(klass.__schema__, getattr(schema, class_name)) + self.assertIs(klass.__data__, getattr(data, class_name)) + + def test_a_dispatched_layer_sits_under_its_versions_key(self) -> None: + """The dispatched layer is the member, so its key is the member's name.""" + from pcapkit.protocols.transport.tcp import TCP + + for class_name, payload in PAYLOADS.items(): + klass = getattr(importlib.import_module( + f'pcapkit.protocols.application.{class_name.lower()}'), class_name) + data = _TCP_TO_80 + payload + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + tcp = TCP(io.BytesIO(data), len(data)) + with self.subTest(payload=class_name): + self.assertIs(type(tcp.payload), klass) + self.assertEqual(tcp.payload.info_name, class_name.lower()) + self.assertIs(tcp.info[class_name.lower()], tcp.payload.info) + self.assertNotIn('http', tcp.info) + + def test_a_member_built_directly_reports_its_own_name(self) -> None: + for class_name, payload in PAYLOADS.items(): + klass = getattr(importlib.import_module( + f'pcapkit.protocols.application.{class_name.lower()}'), class_name) + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + layer = klass(io.BytesIO(payload), len(payload)) + with self.subTest(klass=class_name): + self.assertEqual(layer.info_name, class_name.lower()) + + def test_registering_a_member_keeps_the_base_under_http(self) -> None: + """Each member's own key holds it, and ``'HTTP'`` keeps the base.""" + from pcapkit.foundation.registry import protocols as registry + from pcapkit.protocols.application.http import HTTP + from pcapkit.utilities.warnings import RegistryWarning + + names = registry.protocol_registry + before = dict(names) + + def restore() -> None: + names.clear() + names.update(before) + + self.addCleanup(restore) + for class_name in ('HTTPv2', 'HTTPv1'): + klass = getattr(importlib.import_module( + f'pcapkit.protocols.application.{class_name.lower()}'), class_name) + with self.subTest(klass=class_name): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + registry.register_protocol(klass) + self.assertEqual([str(item.message) for item in caught + if issubclass(item.category, RegistryWarning)], []) + self.assertIs(names['HTTP'], HTTP) + self.assertIs(names[class_name.upper()], klass) + + +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 186fb52052..00d657529c 100644 --- a/tests/protocols/application/test_http_message_prefix_2298_unit.py +++ b/tests/protocols/application/test_http_message_prefix_2298_unit.py @@ -7,12 +7,12 @@ ``unknown HTTP version`` from :class:`~pcapkit.protocols.application.http.HTTP`, and ``HTTP request must have URI.`` and ``HTTP packet must be either request or response.`` from -:meth:`httpv1.HTTP._make_start_line -`, capitalised, with +:meth:`httpv1.HTTPv1._make_start_line +`, capitalised, with a closing full stop and without the values that were rejected. The ``SETTINGS`` one read ``HTTP/2 : [Type 4] invalid settings`` from -:meth:`httpv2.HTTP._make_http_settings -`, a space before +:meth:`httpv2.HTTPv2._make_http_settings +`, a space before the colon and no rejected value either. Every case builds its own octets in memory and reads no capture. Classes are @@ -71,21 +71,21 @@ def test_guess_version_has_the_prefix(self) -> None: class TestHTTPv1StartLineMessages(unittest.TestCase): - """Pin ``httpv1.HTTP._make_start_line``'s messages.""" + """Pin ``httpv1.HTTPv1._make_start_line``'s messages.""" def setUp(self) -> None: reimport_once_per_class(self) def test_request_without_uri(self) -> None: - from pcapkit.protocols.application.httpv1 import HTTP + from pcapkit.protocols.application.httpv1 import HTTPv1 from pcapkit.utilities.exceptions import ProtocolError with self.assertRaises(ProtocolError) as ctx: - HTTP(method='GET', http_version='1.1') + HTTPv1(method='GET', http_version='1.1') self.assertEqual(str(ctx.exception), 'HTTP: invalid start line: a request needs a URI') def test_neither_or_both_of_method_and_status(self) -> None: - from pcapkit.protocols.application.httpv1 import HTTP + from pcapkit.protocols.application.httpv1 import HTTPv1 from pcapkit.utilities.exceptions import ProtocolError cases = ( @@ -95,21 +95,21 @@ def test_neither_or_both_of_method_and_status(self) -> None: for kwargs, tail in cases: with self.subTest(**kwargs): with self.assertRaises(ProtocolError) as ctx: - HTTP.__new__(HTTP)._make_start_line(http_version='1.1', **kwargs) + HTTPv1.__new__(HTTPv1)._make_start_line(http_version='1.1', **kwargs) self.assertEqual(str(ctx.exception), 'HTTP: invalid start line: must have either a method or a ' f'status, {tail}') class TestHTTPv2SettingsMessage(unittest.TestCase): - """Pin ``httpv2.HTTP._make_http_settings``'s rejection message.""" + """Pin ``httpv2.HTTPv2._make_http_settings``'s rejection message.""" def setUp(self) -> None: reimport_once_per_class(self) def test_settings_names_the_rejected_value(self) -> None: from pcapkit.const.http.frame import Frame - from pcapkit.protocols.application.httpv2 import HTTP + from pcapkit.protocols.application.httpv2 import HTTPv2 from pcapkit.utilities.exceptions import ProtocolError # ``None`` is no longer rejected: it builds a parameterless ``SETTINGS`` @@ -117,7 +117,7 @@ def test_settings_names_the_rejected_value(self) -> None: for settings, tail in ((42, '42'), ('x', "'x'"), (4.5, '4.5')): with self.subTest(settings=settings): with self.assertRaises(ProtocolError) as ctx: - bytes(HTTP(type=Frame.SETTINGS, frame={'settings': settings})) + bytes(HTTPv2(type=Frame.SETTINGS, frame={'settings': settings})) self.assertEqual(str(ctx.exception), f'HTTP/2: [Type 4] invalid settings: {tail}') diff --git a/tests/protocols/application/test_http_unit.py b/tests/protocols/application/test_http_unit.py index 0df1fe6aca..fb3c00c3d4 100644 --- a/tests/protocols/application/test_http_unit.py +++ b/tests/protocols/application/test_http_unit.py @@ -52,6 +52,20 @@ def http2_frame_bytes(type_: int, flags: int, sid: int, payload: bytes) -> bytes ) +def base_read(raw: bytes) -> object: + """Parse ``raw`` with the base's own ``read``, on a bare instance. + + ``HTTP(...)`` gives the member and never runs the base's ``read`` (#2544), so + ``_guess_version`` is reached this way. + """ + from pcapkit.protocols.application.http import HTTP + + http = object.__new__(HTTP) + http._data = raw + http.__cached__ = {} + return http.read(len(raw)) + + @unittest.skipUnless(HAS_RUNTIME, 'runtime dependencies not installed') class HTTPUnitTests(unittest.TestCase): def setUp(self) -> None: @@ -89,7 +103,7 @@ def test_http_base_id_and_cached_properties_are_stable(self) -> None: http._version = '1.1' http._length = 42 - self.assertEqual(HTTP.id(), ('HTTP', 'HTTPv1', 'HTTPv2')) + self.assertEqual(HTTP.id(), ('HTTP/1', 'HTTP/2')) self.assertEqual(http.name, 'Hypertext Transfer Protocol') self.assertEqual(http.alias, 'HTTP/1.1') self.assertEqual(http.version, '1.1') @@ -132,12 +146,12 @@ def make(**kwargs): http._file = object() http.__cached__ = {} - with mock.patch('pcapkit.protocols.application.httpv1.HTTP', FakeHTTPv1): + with mock.patch('pcapkit.protocols.application.httpv1.HTTPv1', FakeHTTPv1): self.assertEqual(http.read(version=1, marker=True), 'v1-info') self.assertEqual(http.version, '1.1') self.assertEqual(http.length, 11) - with mock.patch('pcapkit.protocols.application.httpv2.HTTP', FakeHTTPv2): + with mock.patch('pcapkit.protocols.application.httpv2.HTTPv2', FakeHTTPv2): self.assertEqual(http.read(length=9, version=2), 'v2-info') self.assertEqual(http.version, '2') self.assertEqual(http.length, 22) @@ -147,9 +161,9 @@ def make(**kwargs): self.assertEqual(http.read(), 'v1-info') guess.assert_called_once_with(len(http._data)) - with mock.patch('pcapkit.protocols.application.httpv1.HTTP', FakeHTTPv1): + with mock.patch('pcapkit.protocols.application.httpv1.HTTPv1', FakeHTTPv1): self.assertEqual(http.make(version=1, path='/'), ('v1-made', {'path': '/'})) - with mock.patch('pcapkit.protocols.application.httpv2.HTTP', FakeHTTPv2): + with mock.patch('pcapkit.protocols.application.httpv2.HTTPv2', FakeHTTPv2): self.assertEqual(http.make(version=2, sid=1), ('v2-made', {'sid': 1})) # The HTTP/2 arm runs only on a frame header a sender could write @@ -160,15 +174,15 @@ def make(**kwargs): frame._file = object() frame.__cached__ = {} - with mock.patch('pcapkit.protocols.application.httpv1.HTTP', + with mock.patch('pcapkit.protocols.application.httpv1.HTTPv1', mock.Mock(side_effect=ProtocolError('bad v1'))), \ - mock.patch('pcapkit.protocols.application.httpv2.HTTP', FakeHTTPv2): + mock.patch('pcapkit.protocols.application.httpv2.HTTPv2', FakeHTTPv2): self.assertIsInstance(HTTP._guess_version(frame, 9), FakeHTTPv2) - with mock.patch('pcapkit.protocols.application.httpv1.HTTP', FakeHTTPv1): + with mock.patch('pcapkit.protocols.application.httpv1.HTTPv1', FakeHTTPv1): self.assertIsInstance(HTTP._guess_version(http, 9), FakeHTTPv1) - with mock.patch('pcapkit.protocols.application.httpv1.HTTP', + with mock.patch('pcapkit.protocols.application.httpv1.HTTPv1', mock.Mock(side_effect=ProtocolError('bad v1'))), \ - mock.patch('pcapkit.protocols.application.httpv2.HTTP', + mock.patch('pcapkit.protocols.application.httpv2.HTTPv2', mock.Mock(side_effect=ProtocolError('bad v2'))): with self.assertRaises(ProtocolError): HTTP._guess_version(frame, 9) @@ -193,8 +207,8 @@ def test_http_make_dispatches_to_real_versioned_classes(self) -> None: threaded through. """ from pcapkit.protocols.application.http import HTTP - from pcapkit.protocols.schema.application.httpv1 import HTTP as Schema_HTTPv1 - from pcapkit.protocols.schema.application.httpv2 import HTTP as Schema_HTTPv2 + from pcapkit.protocols.schema.application.httpv1 import HTTPv1 as Schema_HTTPv1 + from pcapkit.protocols.schema.application.httpv2 import HTTPv2 as Schema_HTTPv2 http = object.__new__(HTTP) @@ -269,11 +283,11 @@ def __init__(self, file: object, length: int, **kwargs: object) -> None: http._file = object() http.__cached__ = {} - with mock.patch('pcapkit.protocols.application.httpv1.HTTP', RecordingHTTP): + with mock.patch('pcapkit.protocols.application.httpv1.HTTPv1', RecordingHTTP): http.read(version=1) self.assertIs(http._http.received, http._data) - with mock.patch('pcapkit.protocols.application.httpv2.HTTP', RecordingHTTP): + with mock.patch('pcapkit.protocols.application.httpv2.HTTPv2', RecordingHTTP): http.read(version=2) self.assertIs(http._http.received, http._data) @@ -312,9 +326,9 @@ def test_http_read_explicit_version_wraps_malformed_payload(self) -> None: def test_http_read_explicit_version_still_wraps_a_bare_value_error(self) -> None: """``HTTP.read``'s ``except ValueError`` net is kept, and is pinned here. - ``httpv1.HTTP`` does not raise a bare :class:`ValueError` (#787), so no + ``httpv1.HTTPv1`` does not raise a bare :class:`ValueError` (#787), so no real payload reaches that net, and ``..._wraps_malformed_payload`` above - gets its chained ``__cause__`` from ``httpv1.HTTP``. The net is kept + gets its chained ``__cause__`` from ``httpv1.HTTPv1``. The net is kept because the guarantee is the explicit path's and not one delegate's: a :class:`ValueError` out of *either* versioned class must reach the caller as a :class:`~pcapkit.utilities.exceptions.ProtocolError`. Pinned @@ -332,7 +346,7 @@ def test_http_read_explicit_version_still_wraps_a_bare_value_error(self) -> None for version, module in ((1, 'httpv1'), (2, 'httpv2')): with self.subTest(version=version): - with mock.patch(f'pcapkit.protocols.application.{module}.HTTP', + with mock.patch(f'pcapkit.protocols.application.{module}.HTTPv{version}', mock.Mock(side_effect=ValueError('boom'))): with self.assertRaises(ProtocolError) as ctx: http.read(version=version) @@ -342,7 +356,7 @@ def test_http_read_explicit_version_still_wraps_a_bare_value_error(self) -> None # A ``ProtocolError`` from the delegate is re-raised as it is, not # re-labelled with the version it was dispatched on. - with mock.patch('pcapkit.protocols.application.httpv1.HTTP', + with mock.patch('pcapkit.protocols.application.httpv1.HTTPv1', mock.Mock(side_effect=ProtocolError('HTTP: invalid format'))): with self.assertRaises(ProtocolError) as ctx: http.read(version=1) @@ -360,7 +374,7 @@ def test_http_make_data_delegates_to_httpv1(self) -> None: """ from pcapkit.const.http.method import Method from pcapkit.protocols.application.http import HTTP - from pcapkit.protocols.application.httpv1 import HTTP as HTTPv1 + from pcapkit.protocols.application.httpv1 import HTTPv1 wire = b'GET /index.html HTTP/1.1\r\nHost: example.test\r\n\r\nbody' data = HTTPv1(wire, len(wire)).info @@ -377,7 +391,7 @@ def test_http_make_data_delegates_to_httpv2(self) -> None: """As for HTTP/1.*, from parsed data, and with no ``length`` (#1154).""" from pcapkit.const.http.frame import Frame from pcapkit.protocols.application.http import HTTP - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv2 import HTTPv2 wire = bytes.fromhex('00000400010000000162796520') data = HTTPv2(wire, len(wire)).info @@ -393,7 +407,7 @@ def test_http_make_data_delegates_to_httpv2(self) -> None: def test_http_guess_version_falls_through_to_the_http2_arm(self) -> None: """``_guess_version`` must reach its second arm (#787). - Guards against ``httpv1.HTTP`` failing with a bare :class:`ValueError`, + Guards against ``httpv1.HTTPv1`` failing with a bare :class:`ValueError`, which ``contextlib.suppress(ProtocolError)`` does not catch: the exception would leave ``_guess_version`` from the *first* arm, and the HTTP/2 arm below it would be dead code. ``assertRaises(ValueError)`` @@ -421,8 +435,8 @@ def test_http_guess_version_falls_through_to_the_http2_arm(self) -> None: http._data = bytes.fromhex('0000ff000000000001') + b'x' * 6 entered = [] # type: list[str] - real_v1 = importlib.import_module('pcapkit.protocols.application.httpv1').HTTP - real_v2 = importlib.import_module('pcapkit.protocols.application.httpv2').HTTP + real_v1 = importlib.import_module('pcapkit.protocols.application.httpv1').HTTPv1 + real_v2 = importlib.import_module('pcapkit.protocols.application.httpv2').HTTPv2 def record(name: str, real: object) -> object: def spy(*args: object, **kwargs: object) -> object: @@ -430,9 +444,9 @@ def spy(*args: object, **kwargs: object) -> object: return real(*args, **kwargs) # type: ignore[operator] return spy - with mock.patch('pcapkit.protocols.application.httpv1.HTTP', + with mock.patch('pcapkit.protocols.application.httpv1.HTTPv1', record('httpv1', real_v1)), \ - mock.patch('pcapkit.protocols.application.httpv2.HTTP', + mock.patch('pcapkit.protocols.application.httpv2.HTTPv2', record('httpv2', real_v2)): with self.assertRaises(ProtocolError): HTTP._guess_version(http, len(http._data)) @@ -446,10 +460,10 @@ def test_guess_version_reaches_http2_on_the_connection_preface(self) -> None: """The literal #787 reproduction: the HTTP/2 connection preface. ``b'PRI * HTTP/2.0\\r\\n\\r\\nSM\\r\\n\\r\\n'`` plus a SETTINGS frame is - what an HTTP/2 connection opens with, and ``httpv2.HTTP`` parses the + what an HTTP/2 connection opens with, and ``httpv2.HTTPv2`` parses the *frame* (on its own) and reports ``version='2'``. Guards against the guess raising ``ValueError: not enough values to unpack (expected 2, got - 1)`` from ``httpv1.HTTP`` instead, which keeps it from the answer its own + 1)`` from ``httpv1.HTTPv1`` instead, which keeps it from the answer its own second arm has. Asserted against the explicit ``version=2`` path rather than on its own, @@ -706,7 +720,7 @@ def test_guess_version_commits_to_http1_once_the_start_line_says_so(self) -> Non self.assertEqual(str(ctx.exception), 'HTTP: invalid format') def test_start_line_predicate_agrees_with_the_httpv1_parser(self) -> None: - """``test_start_line`` must accept exactly what ``httpv1.HTTP`` accepts. + """``test_start_line`` must accept exactly what ``httpv1.HTTPv1`` accepts. The predicate classifies and the parser parses, and they are two statements of the same rule -- which is why the two share one copy of @@ -717,14 +731,14 @@ def test_start_line_predicate_agrees_with_the_httpv1_parser(self) -> None: and #800 are both about. Pinned by construction rather than by inspection -- each payload is run - through ``httpv1.HTTP`` as well as through the predicate, so the two + through ``httpv1.HTTPv1`` as well as through the predicate, so the two cannot be edited apart without this failing. """ import io from pcapkit.protocols.application.http import test_start_line - from pcapkit.protocols.application.httpv1 import HTTP as HTTPv1 + from pcapkit.protocols.application.httpv1 import HTTPv1 from pcapkit.utilities.exceptions import ProtocolError accepted = ( @@ -875,7 +889,7 @@ def test_guess_version_still_reports_an_unknown_http_version(self) -> None: ``_guess_version``'s closing ``raise`` is only reachable when *both* arms fail with :class:`~pcapkit.utilities.exceptions.ProtocolError`, so - it needs a payload that ``httpv2.HTTP`` rejects too -- a frame whose + it needs a payload that ``httpv2.HTTPv2`` rejects too -- a frame whose declared length contradicts its type. A PING frame carries eight octets of opaque data and so must declare 8 [:rfc:`9113#section-6.7`]; 0 is refused by ``_read_http_ping``. @@ -898,14 +912,14 @@ def test_guess_version_still_reports_an_unknown_http_version(self) -> None: raw = http2_frame_bytes(0x06, 0x00, 0, b'') # PING, declared length 0 with self.assertRaises(ProtocolError) as ctx: - HTTP(io.BytesIO(raw), len(raw)) + base_read(raw) self.assertEqual(str(ctx.exception), 'HTTP: unknown version') def test_guess_version_reports_unknown_version_for_a_short_payload(self) -> None: """A payload too short for an HTTP/2 frame header must not leak :exc:`struct.error`. With the HTTP/2 arm reachable (#787), a payload the HTTP/1 arm declines - goes on into ``httpv2.HTTP``. Guards against a payload under nine octets + goes on into ``httpv2.HTTPv2``. Guards against a payload under nine octets failing *inside* the schema machinery rather than in ``read``: :func:`struct.calcsize` handed a template built from a negative length raises :exc:`struct.error`, which derives straight from @@ -920,10 +934,10 @@ def test_guess_version_reports_unknown_version_for_a_short_payload(self) -> None the property at stake; "a caller who catches protocol errors catches this" is. - ``httpv2.HTTP.unpack`` refuses a buffer under nine octets with + ``httpv2.HTTPv2.unpack`` refuses a buffer under nine octets with ``ProtocolError`` before the schema layer runs (#799), so that :exc:`struct.error` is what the guard prevents rather than what such a - payload meets. Both routes into ``httpv2.HTTP`` -- the guess and + payload meets. Both routes into ``httpv2.HTTPv2`` -- the guess and ``HTTP(..., version=2)`` -- reach the same short-buffer path, so both are pinned here. @@ -957,15 +971,15 @@ def test_guess_version_reports_unknown_version_for_a_short_payload(self) -> None HTTP(io.BytesIO(short), len(short)) def test_httpv1_read_raises_protocol_error_for_a_malformed_message(self) -> None: - """The chosen #787 fix, at its source: ``httpv1.HTTP`` raises ``ProtocolError``. + """The chosen #787 fix, at its source: ``httpv1.HTTPv1`` raises ``ProtocolError``. Both of the unpackings that a non-HTTP/1 payload lands short on are covered -- the header/body separator in ``read``, and the start line in ``_read_http_header`` -- since either alone leaves the other leaking a bare :class:`ValueError`, and the reproduction only reaches the second. - Asserted on ``httpv1.HTTP`` directly rather than through the proxy - because the contract holds for every caller of ``httpv1.HTTP``, not only + Asserted on ``httpv1.HTTPv1`` directly rather than through the proxy + because the contract holds for every caller of ``httpv1.HTTPv1``, not only for ``_guess_version``'s first arm. ``__cause__`` is asserted because the chain is what keeps the underlying @@ -976,7 +990,7 @@ def test_httpv1_read_raises_protocol_error_for_a_malformed_message(self) -> None """ import io - from pcapkit.protocols.application.httpv1 import HTTP as HTTPv1 + from pcapkit.protocols.application.httpv1 import HTTPv1 from pcapkit.utilities.exceptions import ProtocolError cases = ( @@ -1011,7 +1025,7 @@ def test_httpv1_reads_a_request_with_no_header_fields(self) -> None: import io from pcapkit.protocols.application.http import HTTP, test_start_line - from pcapkit.protocols.application.httpv1 import HTTP as HTTPv1 + from pcapkit.protocols.application.httpv1 import HTTPv1 from pcapkit.protocols.application.httpv1 import Type raw = b'GET / HTTP/1.0\r\n\r\n' @@ -1033,7 +1047,7 @@ def test_httpv1_reads_a_response_with_no_header_fields_and_a_body(self) -> None: import io from pcapkit.protocols.application.http import HTTP, test_start_line - from pcapkit.protocols.application.httpv1 import HTTP as HTTPv1 + from pcapkit.protocols.application.httpv1 import HTTPv1 from pcapkit.protocols.application.httpv1 import Type raw = b'HTTP/1.0 200 OK\r\n\r\nbody' @@ -1063,7 +1077,7 @@ def test_httpv1_make_read_round_trips_a_message_with_no_header_fields(self) -> N """ import io - from pcapkit.protocols.application.httpv1 import HTTP as HTTPv1 + from pcapkit.protocols.application.httpv1 import HTTPv1 request = HTTPv1(method='GET', uri='/', http_version='1.1') self.assertEqual(request.data, b'GET / HTTP/1.1\r\n\r\n') @@ -1093,7 +1107,7 @@ def test_httpv1_from_data_round_trips_a_message_with_an_empty_body(self) -> None """ import io - from pcapkit.protocols.application.httpv1 import HTTP as HTTPv1 + from pcapkit.protocols.application.httpv1 import HTTPv1 cases = { 'request with headers': (b'GET / HTTP/1.1\r\nHost: a\r\n\r\n', True), @@ -1124,7 +1138,7 @@ def test_httpv1_still_refuses_the_http2_connection_preface(self) -> None: import io from pcapkit.protocols.application.http import HTTP, test_start_line - from pcapkit.protocols.application.httpv1 import HTTP as HTTPv1 + from pcapkit.protocols.application.httpv1 import HTTPv1 from pcapkit.utilities.exceptions import ProtocolError cases = ( @@ -1163,7 +1177,7 @@ class of input as well. """ import io - from pcapkit.protocols.application.httpv1 import HTTP as HTTPv1 + from pcapkit.protocols.application.httpv1 import HTTPv1 from pcapkit.utilities.exceptions import ProtocolError cases = ( @@ -1206,7 +1220,7 @@ def test_httpv1_unfolds_an_obs_fold_continuation_line(self) -> None: """ import io - from pcapkit.protocols.application.httpv1 import HTTP as HTTPv1 + from pcapkit.protocols.application.httpv1 import HTTPv1 cases = ( ('space continuation', b'GET / HTTP/1.1\r\nX-Long: a\r\n b\r\nHost: e\r\n\r\n'), @@ -1250,7 +1264,7 @@ def test_httpv1_unfold_replaces_the_whole_obs_fold_including_the_ows(self) -> No """ import io - from pcapkit.protocols.application.httpv1 import HTTP as HTTPv1 + from pcapkit.protocols.application.httpv1 import HTTPv1 def fields(raw: bytes) -> object: return list(HTTPv1(io.BytesIO(raw), len(raw)).info.header.items()) @@ -1296,7 +1310,7 @@ def fields(raw: bytes) -> object: self.assertEqual([v for v in values if '\t' in v], []) def test_httpv1_never_lets_a_bare_exception_escape(self) -> None: - """The invariant behind #787: ``httpv1.HTTP`` fails only as ``ProtocolError``. + """The invariant behind #787: ``httpv1.HTTPv1`` fails only as ``ProtocolError``. A bare exception out of ``httpv1`` escapes ``_guess_version``'s suppression, so every site that raises one adds a class of payload the @@ -1315,7 +1329,7 @@ def test_httpv1_never_lets_a_bare_exception_escape(self) -> None: """ import io - from pcapkit.protocols.application.httpv1 import HTTP as HTTPv1 + from pcapkit.protocols.application.httpv1 import HTTPv1 from pcapkit.utilities.exceptions import ProtocolError payloads = ( @@ -1354,7 +1368,7 @@ def test_httpv1_never_lets_a_bare_exception_escape(self) -> None: pass # the only permitted failure except BaseException as exc: # noqa: B036 # pragma: no cover self.fail(f'{label}: bare {type(exc).__module__}.' - f'{type(exc).__qualname__} escaped httpv1.HTTP: {exc}') + f'{type(exc).__qualname__} escaped httpv1.HTTPv1: {exc}') def test_httpv1_passes_maxsplit_to_re_split_by_keyword(self) -> None: """``re.split``'s ``maxsplit`` must be a keyword, or #787 recurs verbatim. @@ -1374,7 +1388,7 @@ def test_httpv1_passes_maxsplit_to_re_split_by_keyword(self) -> None: import io import warnings - from pcapkit.protocols.application.httpv1 import HTTP as HTTPv1 + from pcapkit.protocols.application.httpv1 import HTTPv1 raw = b'GET /index.html HTTP/1.1\r\nHost: example.com\r\nX-A: b\r\n\r\nbody' @@ -1402,7 +1416,7 @@ def test_guess_version_does_not_suppress_struct_error_on_the_http1_arm(self) -> best case, needing arm 2 to decline as well. The *outer*-header slice of the class this suppression exists for is - refused by ``httpv2.HTTP.unpack``, which rejects a buffer under nine + refused by ``httpv2.HTTPv2.unpack``, which rejects a buffer under nine octets before the schema layer runs (#799), and the *inner* slice by ``FieldBase.length``, where a resolved field length going negative raises ``ProtocolError`` rather than a bare :exc:`struct.error` (#805). @@ -1415,7 +1429,7 @@ def test_guess_version_does_not_suppress_struct_error_on_the_http1_arm(self) -> below for the inner one. It is also unnecessary: nothing reaches a :exc:`struct.error` through - ``httpv1.HTTP``. Nine byte patterns over lengths 0-24, on both the direct + ``httpv1.HTTPv1``. Nine byte patterns over lengths 0-24, on both the direct route and ``read(version=1)``, answered ``ProtocolError`` 450 times out of 450. @@ -1446,22 +1460,22 @@ def test_guess_version_does_not_suppress_struct_error_on_the_http1_arm(self) -> for label, error in faults: with self.subTest(fault=label): - with mock.patch('pcapkit.protocols.application.httpv1.HTTP', + with mock.patch('pcapkit.protocols.application.httpv1.HTTPv1', mock.Mock(side_effect=error)): # It must propagate, not be swallowed into an HTTP/2 answer. with self.assertRaises(struct.error): - HTTP(io.BytesIO(raw), len(raw)) + base_read(raw) def test_guess_version_does_not_leak_a_bare_struct_error_for_an_inner_field_shortfall(self) -> None: """A buffer that clears nine octets must not crash one field further in. - #799's ``httpv2.HTTP.unpack`` guard only protects the fixed nine-octet + #799's ``httpv2.HTTPv2.unpack`` guard only protects the fixed nine-octet *outer* header. A ``GOAWAY`` frame's own fixed ``stream`` (4 octets) and ``error`` (4 octets) fields consume eight more octets before ``debug`` is even reached, so a sixteen-octet buffer -- nine for the header, seven for the rest -- drives ``pkt['__length__']`` to ``-1`` at ``debug``. Guards against that raising a bare :exc:`struct.error` straight through - ``httpv2.HTTP``. The root is generic ``FieldBase.length`` + ``httpv2.HTTPv2``. The root is generic ``FieldBase.length`` (``struct.calcsize`` on a negative-count template), shared by many schema modules, not something httpv2-specific, and it is handled there (#805) -- so direct construction raises :exc:`ProtocolError` too. @@ -1478,7 +1492,7 @@ def test_guess_version_does_not_leak_a_bare_struct_error_for_an_inner_field_shor import io from pcapkit.protocols.application.http import HTTP - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv2 import HTTPv2 from pcapkit.utilities.exceptions import BaseError, ProtocolError # GOAWAY (type 0x07), sid 0, declared length 0x15 (21 octets of @@ -1531,8 +1545,8 @@ def test_guess_version_keeps_http1_for_a_folded_http1_request(self) -> None: def test_httpv1_id_make_data_and_request_construction(self) -> None: from pcapkit.const.http.method import Method from pcapkit.corekit.multidict import OrderedMultiDict - from pcapkit.protocols.application.httpv1 import HTTP as HTTPv1 - from pcapkit.protocols.schema.application.httpv1 import HTTP as Schema_HTTP + from pcapkit.protocols.application.httpv1 import HTTPv1 + from pcapkit.protocols.schema.application.httpv1 import HTTPv1 as Schema_HTTP from pcapkit.utilities.exceptions import ProtocolError proto = object.__new__(HTTPv1) @@ -1549,7 +1563,7 @@ def test_httpv1_id_make_data_and_request_construction(self) -> None: raw_header=(), ) - self.assertEqual(HTTPv1.id(), ('HTTP', 'HTTPv1')) + self.assertEqual(HTTPv1.id(), ('HTTP/1',)) self.assertEqual(proto.version, '1.0') self.assertEqual(proto.alias, 'HTTP/1.0') self.assertIn(b'GET /index.html HTTP/1.1\r\n', schema.data) @@ -1674,7 +1688,7 @@ def test_httpv1_read_header_uses_the_captured_method(self) -> None: would parse it as a method named ``G``. """ from pcapkit.const.http.method import Method - from pcapkit.protocols.application.httpv1 import HTTP as HTTPv1 + from pcapkit.protocols.application.httpv1 import HTTPv1 from pcapkit.utilities.exceptions import ProtocolError proto = object.__new__(HTTPv1) @@ -1721,7 +1735,7 @@ def test_httpv1_status_regex_is_anchored(self) -> None: base 10: b'200x'``, and ``b'HTTP/1.1 2000 OK'`` -> ``ValueError: 2000 is not a valid StatusCode``. """ - from pcapkit.protocols.application.httpv1 import HTTP as HTTPv1 + from pcapkit.protocols.application.httpv1 import HTTPv1 from pcapkit.utilities.exceptions import ProtocolError proto = object.__new__(HTTPv1) @@ -1740,7 +1754,7 @@ def test_httpv1_status_regex_is_anchored(self) -> None: def test_httpv1_response_construction_and_missing_request_uri_error(self) -> None: from pcapkit.const.http.status_code import StatusCode - from pcapkit.protocols.application.httpv1 import HTTP as HTTPv1 + from pcapkit.protocols.application.httpv1 import HTTPv1 from pcapkit.utilities.exceptions import ProtocolError proto = object.__new__(HTTPv1) @@ -1758,7 +1772,7 @@ def test_httpv1_response_construction_and_missing_request_uri_error(self) -> Non def test_httpv2_id_length_make_bytes_and_register_frame_warning(self) -> None: from pcapkit.const.http.frame import Frame - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv2 import HTTPv2 proto = object.__new__(HTTPv2) original = HTTPv2.__dict__['__frame__'][Frame.DATA] @@ -1767,7 +1781,7 @@ def test_httpv2_id_length_make_bytes_and_register_frame_warning(self) -> None: with mock.patch('pcapkit.protocols.application.httpv2.warn') as warn: HTTPv2.register_frame(Frame.DATA, 'headers') - self.assertEqual(HTTPv2.id(), ('HTTP', 'HTTPv2')) + self.assertEqual(HTTPv2.id(), ('HTTP/2',)) self.assertEqual(proto.alias, 'HTTP/2') self.assertEqual(proto.version, '2') self.assertEqual(proto.length, 9) @@ -1786,7 +1800,7 @@ def test_unregistered_frame_type_does_not_mutate_the_class_registry(self) -> Non #425's defect on HTTP/2 frame dispatch. ``__frame__`` is a :class:`collections.defaultdict` on a class attribute shared by every - :class:`~pcapkit.protocols.application.httpv2.HTTP` instance in the + :class:`~pcapkit.protocols.application.httpv2.HTTPv2` instance in the process, so ``__frame__[type]`` inserted every frame type it missed -- and the value it inserted was ``'none'``, which the default factory returns anyway. @@ -1799,7 +1813,7 @@ def test_unregistered_frame_type_does_not_mutate_the_class_registry(self) -> Non import io from pcapkit.const.http.frame import Frame - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv2 import HTTPv2 # Length 4, type 0xF0, no flags, stream 1, then four octets of payload. # The length counts the payload only [:rfc:`9113#section-4.1`]. @@ -1828,7 +1842,7 @@ def test_httpv2_frame_readers_cover_successful_frames(self) -> None: from pcapkit.const.http.error_code import ErrorCode from pcapkit.const.http.frame import Frame from pcapkit.const.http.setting import Setting - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv2 import HTTPv2 proto = object.__new__(HTTPv2) @@ -1943,7 +1957,7 @@ def test_httpv2_frame_readers_cover_successful_frames(self) -> None: def test_httpv2_frame_readers_reject_malformed_lengths(self) -> None: from pcapkit.const.http.frame import Frame - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv2 import HTTPv2 from pcapkit.utilities.exceptions import ProtocolError proto = object.__new__(HTTPv2) @@ -1972,7 +1986,7 @@ def test_httpv2_frame_readers_reject_malformed_lengths(self) -> None: def test_httpv2_read_validates_common_frame_header_rules(self) -> None: from pcapkit.const.http.frame import Frame - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv2 import HTTPv2 from pcapkit.utilities.exceptions import ProtocolError proto = object.__new__(HTTPv2) @@ -2097,7 +2111,7 @@ def test_httpv2_declared_length_exceeding_the_buffer_is_rejected(self) -> None: """ import io - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv2 import HTTPv2 from pcapkit.utilities.exceptions import ProtocolError def frame(declared: int, buflen: int, type_: int = 0x00) -> bytes: @@ -2134,7 +2148,7 @@ def test_httpv2_boundary_buffer_exactly_nine_octets(self) -> None: """ import io - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv2 import HTTPv2 from pcapkit.utilities.exceptions import ProtocolError exactly_nine = http2_frame_bytes(0x00, 0x00, 1, b'') @@ -2157,7 +2171,7 @@ def test_httpv2_well_formed_frame_still_parses(self) -> None: """ import io - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv2 import HTTPv2 payload = b'{"ok":true}\n' raw = http2_frame_bytes(0x00, 0x00, 1, payload) @@ -2182,7 +2196,7 @@ def test_httpv2_unpack_rejects_a_short_buffer_before_the_schema_layer_crashes(se """ import io - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv2 import HTTPv2 from pcapkit.utilities.exceptions import ProtocolError for size in range(0, 9): @@ -2207,7 +2221,7 @@ def test_httpv2_unpack_rejects_an_implicit_short_buffer_but_not_an_empty_one(sel """ import io - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv2 import HTTPv2 from pcapkit.utilities.exceptions import ProtocolError, StreamEOFError for size in range(1, 9): @@ -2224,7 +2238,7 @@ def test_httpv2_frame_constructors_cover_all_frame_types_and_branches(self) -> N from pcapkit.const.http.frame import Frame from pcapkit.const.http.setting import Setting from pcapkit.corekit.multidict import OrderedMultiDict - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv2 import HTTPv2 from pcapkit.protocols.data.application.httpv2 import PingFrame, PingFrameFlags from pcapkit.protocols.schema.application import httpv2 as schema_httpv2 from pcapkit.protocols.schema.application.httpv2 import DataFrame, SettingPair @@ -2486,7 +2500,7 @@ def test_settings_frame_settings_field_wraps_item_schema(self) -> None: def test_httpv2_callable_frame_registry_paths(self) -> None: from pcapkit.const.http.frame import Frame - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv2 import HTTPv2 from pcapkit.protocols.schema.application import httpv2 as schema_httpv2 custom = Frame.get(250) @@ -2575,7 +2589,7 @@ def test_make_http_data_restores_end_stream_from_the_frame(self) -> None: """ import io - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv2 import HTTPv2 from pcapkit.protocols.schema.application.httpv2 import DataFrame for end_stream, flags_octet in ((False, 0x00), (True, 0x01)): @@ -2611,7 +2625,7 @@ def test_a_flagless_frame_seeds_its_flags_as_an_enum(self) -> None: """ import io - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv2 import HTTPv2 from pcapkit.protocols.schema.application.httpv2 import (DataFrame, FrameType, UnassignedFrame) diff --git a/tests/protocols/application/test_httpv2_error_default_2311_unit.py b/tests/protocols/application/test_httpv2_error_default_2311_unit.py index c3b51044bd..a88f7a4ac1 100644 --- a/tests/protocols/application/test_httpv2_error_default_2311_unit.py +++ b/tests/protocols/application/test_httpv2_error_default_2311_unit.py @@ -37,9 +37,9 @@ def setUp(self) -> None: def build(self, name: 'str', sid: 'int', frame: 'dict[str, object]') -> 'Any': """Build the HTTP/2 frame ``name`` from the ``frame`` mapping.""" from pcapkit.const.http.frame import Frame - from pcapkit.protocols.application.httpv2 import HTTP + from pcapkit.protocols.application.httpv2 import HTTPv2 - return HTTP(type=Frame[name], sid=sid, frame=frame) + return HTTPv2(type=Frame[name], sid=sid, frame=frame) def test_rst_stream_defaults_to_no_error(self) -> None: from pcapkit.const.http.error_code import ErrorCode 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 687d81537d..06115417c6 100644 --- a/tests/protocols/application/test_httpv2_frame_default_2274_unit.py +++ b/tests/protocols/application/test_httpv2_frame_default_2274_unit.py @@ -56,9 +56,9 @@ def setUp(self) -> None: def build(self, type: 'Any', sid: 'int', **kwargs: 'Any') -> 'Any': """Build the HTTP/2 frame of ``type`` on stream ``sid``.""" - from pcapkit.protocols.application.httpv2 import HTTP + from pcapkit.protocols.application.httpv2 import HTTPv2 - return HTTP(type=type, sid=sid, **kwargs) + return HTTPv2(type=type, sid=sid, **kwargs) def test_default_builds_every_frame_type(self) -> None: from pcapkit.const.http.frame import Frame @@ -77,15 +77,15 @@ def test_default_agrees_with_an_empty_mapping(self) -> None: def test_default_frames_round_trip(self) -> None: from pcapkit.const.http.frame import Frame - from pcapkit.protocols.application.httpv2 import HTTP + from pcapkit.protocols.application.httpv2 import HTTPv2 for name, (sid, wire) in DEFAULT_FRAMES.items(): with self.subTest(frame=name): made = bytes(self.build(Frame[name], sid)) - parsed = HTTP(made, len(made)) + parsed = HTTPv2(made, len(made)) self.assertEqual(parsed.info.type, Frame[name]) self.assertEqual(parsed.info.sid, sid) - self.assertEqual(bytes(HTTP.from_data(parsed.info)).hex(), wire.hex()) + self.assertEqual(bytes(HTTPv2.from_data(parsed.info)).hex(), wire.hex()) def test_an_unassigned_type_is_unchanged(self) -> None: wire = bytes.fromhex('000000fa0000000001') @@ -115,14 +115,14 @@ def test_empty_bytes_still_build_a_zero_length_frame(self) -> None: def test_explicit_empty_bytes_stay_raw_on_a_fixed_size_type(self) -> None: from pcapkit.const.http.frame import Frame - from pcapkit.protocols.application.httpv2 import HTTP + from pcapkit.protocols.application.httpv2 import HTTPv2 # An explicit ``b''`` is still packed raw, even on a frame type whose # default (``None``) dispatches to a constructor with a non-empty # payload -- it must not be swept into that dispatch. Built via # ``make`` directly, bypassing the round-trip parse that a length-0 # PING would legitimately fail (PING's payload is a fixed 8 octets). - schema = HTTP.__new__(HTTP).make(type=Frame.PING, frame=b'') + schema = HTTPv2.__new__(HTTPv2).make(type=Frame.PING, frame=b'') self.assertEqual(int(schema.length), 0) self.assertEqual(schema.frame, b'') @@ -135,37 +135,37 @@ def setUp(self) -> None: def test_none_builds_a_parameterless_settings_frame(self) -> None: from pcapkit.const.http.frame import Frame - from pcapkit.protocols.application.httpv2 import HTTP + from pcapkit.protocols.application.httpv2 import HTTPv2 for label, frame in (('default', {}), ('explicit', {'settings': None})): with self.subTest(settings=label): - made = bytes(HTTP(type=Frame.SETTINGS, sid=0, frame=frame)) + made = bytes(HTTPv2(type=Frame.SETTINGS, sid=0, frame=frame)) self.assertEqual(made.hex(), DEFAULT_FRAMES['SETTINGS'][1].hex()) - self.assertEqual(HTTP(made, len(made)).info.length, 0) + self.assertEqual(HTTPv2(made, len(made)).info.length, 0) def test_the_schema_carries_no_entries(self) -> None: - from pcapkit.protocols.application.httpv2 import HTTP + from pcapkit.protocols.application.httpv2 import HTTPv2 - schema, flags = HTTP.__new__(HTTP)._make_http_settings() + schema, flags = HTTPv2.__new__(HTTPv2)._make_http_settings() self.assertEqual(list(schema.settings), []) self.assertEqual(int(flags), 0) def test_an_empty_settings_frame_still_takes_the_ack_flag(self) -> None: from pcapkit.const.http.frame import Frame - from pcapkit.protocols.application.httpv2 import HTTP + from pcapkit.protocols.application.httpv2 import HTTPv2 - made = bytes(HTTP(type=Frame.SETTINGS, sid=0, frame={'ack': True})) + made = bytes(HTTPv2(type=Frame.SETTINGS, sid=0, frame={'ack': True})) self.assertEqual(made.hex(), '000000040100000000') def test_other_settings_types_are_still_rejected(self) -> None: from pcapkit.const.http.frame import Frame - from pcapkit.protocols.application.httpv2 import HTTP + from pcapkit.protocols.application.httpv2 import HTTPv2 from pcapkit.utilities.exceptions import ProtocolError for settings, tail in ((42, '42'), ('x', "'x'"), (4.5, '4.5')): with self.subTest(settings=settings): with self.assertRaises(ProtocolError) as ctx: - bytes(HTTP(type=Frame.SETTINGS, frame={'settings': settings})) + bytes(HTTPv2(type=Frame.SETTINGS, frame={'settings': settings})) self.assertEqual(str(ctx.exception), f'HTTP/2: [Type 4] invalid settings: {tail}') diff --git a/tests/protocols/application/test_httpv2_frame_length_1121_unit.py b/tests/protocols/application/test_httpv2_frame_length_1121_unit.py index d76a8331d2..123fc4dded 100644 --- a/tests/protocols/application/test_httpv2_frame_length_1121_unit.py +++ b/tests/protocols/application/test_httpv2_frame_length_1121_unit.py @@ -69,7 +69,7 @@ class HTTPv2FrameLengthUnitTests(unittest.TestCase): def test_rfc_frames_parse_and_report_the_payload_length(self) -> None: """Each RFC frame parses, and ``info.length`` is its payload length.""" - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv2 import HTTPv2 for name, (raw, _) in RFC_FRAMES.items(): with self.subTest(frame=name): @@ -78,7 +78,7 @@ def test_rfc_frames_parse_and_report_the_payload_length(self) -> None: def test_make_writes_the_rfc_octets(self) -> None: """``make`` packs exactly the RFC octets, Length included.""" - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv2 import HTTPv2 for name, (raw, kwargs) in RFC_FRAMES.items(): with self.subTest(frame=name): @@ -86,7 +86,7 @@ def test_make_writes_the_rfc_octets(self) -> None: def test_parsed_frames_rebuild_to_the_same_octets(self) -> None: """parse -> ``make`` reproduces the wire octets.""" - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv2 import HTTPv2 for name, (raw, _) in RFC_FRAMES.items(): with self.subTest(frame=name): @@ -95,7 +95,7 @@ def test_parsed_frames_rebuild_to_the_same_octets(self) -> None: def test_a_header_inclusive_length_is_rejected(self) -> None: """A fixed-size frame declaring payload + 9 is a size error.""" - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv2 import HTTPv2 from pcapkit.utilities.exceptions import ProtocolError for name in ('PRIORITY', 'RST_STREAM', 'PING', 'WINDOW_UPDATE'): @@ -108,7 +108,7 @@ def test_a_header_inclusive_length_is_rejected(self) -> None: def test_declared_payload_must_fit_after_the_header(self) -> None: """The buffer has to back the 9-octet header plus the declared payload.""" - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv2 import HTTPv2 from pcapkit.utilities.exceptions import ProtocolError raw, _ = RFC_FRAMES['DATA'] diff --git a/tests/protocols/application/test_httpv2_negative_length_unit.py b/tests/protocols/application/test_httpv2_negative_length_unit.py index dcd9a7a861..f2c4b2741f 100644 --- a/tests/protocols/application/test_httpv2_negative_length_unit.py +++ b/tests/protocols/application/test_httpv2_negative_length_unit.py @@ -75,7 +75,7 @@ def test_goaway_at_sixteen_octets_raises_protocolerror_not_structerror(self) -> drives ``debug``'s ``length=lambda pkt: pkt['__length__']`` to -1. Unguarded: ``struct.error: bad char in struct format``. """ - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv2 import HTTPv2 from pcapkit.utilities.exceptions import ProtocolError data = b'\x00\x00\x15\x07\x00\x00\x00\x00\x00' + b'\xff' * 7 @@ -92,7 +92,7 @@ def test_goaway_buflens_nine_through_sixteen_all_raise_protocolerror(self) -> No need eight payload octets, so every one of these drives ``debug`` negative. """ - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv2 import HTTPv2 from pcapkit.utilities.exceptions import ProtocolError for buflen in range(9, 17): @@ -109,7 +109,7 @@ def test_push_promise_buflens_nine_through_twelve_all_raise_protocolerror(self) The promised stream identifier alone is four octets, so buffers 9 through 12 leave ``fragment`` negative. """ - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv2 import HTTPv2 from pcapkit.utilities.exceptions import ProtocolError for buflen in range(9, 13): @@ -122,7 +122,7 @@ def test_push_promise_buflens_nine_through_twelve_all_raise_protocolerror(self) def test_an_over_padded_data_frame_raises_protocolerror(self) -> None: """``pad_len`` exceeding the payload area, for a ``DATA`` frame.""" - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv2 import HTTPv2 from pcapkit.utilities.exceptions import ProtocolError payload = bytes([20]) # PADDED, pad_len=20, no data and no padding octets follow @@ -140,7 +140,7 @@ def test_a_well_formed_goaway_frame_still_parses_cleanly(self) -> None: negative must parse as it would unguarded -- no exception, no warning. """ - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv2 import HTTPv2 debug = b'ok' payload = (5).to_bytes(4, 'big') + (0).to_bytes(4, 'big') + debug diff --git a/tests/protocols/application/test_httpv2_payload_length_unit.py b/tests/protocols/application/test_httpv2_payload_length_unit.py index 693cf7331a..42d23352f8 100644 --- a/tests/protocols/application/test_httpv2_payload_length_unit.py +++ b/tests/protocols/application/test_httpv2_payload_length_unit.py @@ -218,7 +218,7 @@ def parse(self, raw: 'bytes') -> 'Any': The parsed data object, i.e. ``HTTP(...).info``. """ - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv2 import HTTPv2 with warnings.catch_warnings(record=True) as caught: warnings.simplefilter('always') @@ -520,7 +520,7 @@ def test_unassigned_frame_carries_its_data(self) -> None: raw = http2_frame_bytes(0xF0, 0x00, 1, DATA_PAYLOAD) from pcapkit.const.http.frame import Frame - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv2 import HTTPv2 registry = HTTPv2.__dict__['__frame__'] try: @@ -702,13 +702,13 @@ def build(self, **kwargs: 'Any') -> 'bytes': """Construct one frame through the public API and pack it. Args: - **kwargs: Forwarded to :class:`~pcapkit.protocols.application.httpv2.HTTP`. + **kwargs: Forwarded to :class:`~pcapkit.protocols.application.httpv2.HTTPv2`. Returns: The packed frame. """ - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv2 import HTTPv2 with warnings.catch_warnings(record=True) as caught: warnings.simplefilter('always') diff --git a/tests/protocols/application/test_httpv2_settings_dict_2223_unit.py b/tests/protocols/application/test_httpv2_settings_dict_2223_unit.py index a3fac16706..09a0744450 100644 --- a/tests/protocols/application/test_httpv2_settings_dict_2223_unit.py +++ b/tests/protocols/application/test_httpv2_settings_dict_2223_unit.py @@ -32,9 +32,9 @@ def setUp(self) -> None: def build(self, settings: 'object') -> 'bytes': """Build a ``SETTINGS`` frame from ``settings``.""" from pcapkit.const.http.frame import Frame - from pcapkit.protocols.application.httpv2 import HTTP + from pcapkit.protocols.application.httpv2 import HTTPv2 - return bytes(HTTP(type=Frame.SETTINGS, frame={'settings': settings})) + return bytes(HTTPv2(type=Frame.SETTINGS, frame={'settings': settings})) def test_plain_dict_builds_the_list_octets(self) -> None: from pcapkit.const.http.setting import Setting @@ -52,10 +52,10 @@ def test_plain_dict_keeps_insertion_order(self) -> None: def test_plain_dict_frame_parses_back(self) -> None: import io - from pcapkit.protocols.application.httpv2 import HTTP + from pcapkit.protocols.application.httpv2 import HTTPv2 raw = self.build({1: 4096, 3: 100}) - info = HTTP(io.BytesIO(raw), len(raw)).info + info = HTTPv2(io.BytesIO(raw), len(raw)).info self.assertEqual([(int(k), v) for k, v in info.settings.items(multi=True)], [(1, 4096), (3, 100)]) diff --git a/tests/protocols/application/test_httpv2_settings_list_1140_unit.py b/tests/protocols/application/test_httpv2_settings_list_1140_unit.py index 0d042dd29d..f585b6b3cd 100644 --- a/tests/protocols/application/test_httpv2_settings_list_1140_unit.py +++ b/tests/protocols/application/test_httpv2_settings_list_1140_unit.py @@ -45,7 +45,7 @@ class HTTPv2SettingsListUnitTests(unittest.TestCase): def test_settings_parse_without_warnings(self) -> None: """No warning of any kind is emitted, and every pair is read.""" - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv2 import HTTPv2 for name, (pairs, ack) in CASES.items(): raw = scapy_frame(pairs, ack) @@ -59,7 +59,7 @@ def test_settings_parse_without_warnings(self) -> None: def test_settings_round_trip_to_the_scapy_octets(self) -> None: """parse -> ``make`` reproduces scapy's octets exactly.""" - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv2 import HTTPv2 for name, (pairs, ack) in CASES.items(): raw = scapy_frame(pairs, ack) diff --git a/tests/protocols/link/test_link_unit.py b/tests/protocols/link/test_link_unit.py index 4154760984..fa3b8be2ec 100644 --- a/tests/protocols/link/test_link_unit.py +++ b/tests/protocols/link/test_link_unit.py @@ -336,8 +336,8 @@ def test_l2tp_base_is_abstract_and_carries_no_header(self) -> None: # its own, so this must be stated on the subclass, not inherited. self.assertIs(L2TPv2.__schema__, SchemaL2TP) - # Canonical family name first, then this version's label -- the shape - # ``httpv1.HTTP.id`` uses, so ``protocol='L2TP'`` still selects v2. + # Canonical family name first, then this version's label, so + # ``protocol='L2TP'`` still selects v2. self.assertEqual(L2TP.id(), ('L2TP', 'L2TPv2')) self.assertEqual(L2TPv2.id(), ('L2TP', 'L2TPv2')) self.assertEqual(L2TPv2.id()[0], 'L2TP') diff --git a/tests/protocols/test_construction_keyword_check_unit.py b/tests/protocols/test_construction_keyword_check_unit.py index c8d980076e..d9bc64afe9 100644 --- a/tests/protocols/test_construction_keyword_check_unit.py +++ b/tests/protocols/test_construction_keyword_check_unit.py @@ -610,8 +610,8 @@ def test_a_dispatcher_may_decline_the_check(self) -> None: :meth:`HTTP.make ` declares only ``version`` and forwards every other keyword to - :class:`HTTPv1 ` or - :class:`HTTPv2 ` according to + :class:`HTTPv1 ` or + :class:`HTTPv2 ` according to that value, so no fixed set of names is correct for it. Measured without the opt-out:: @@ -620,7 +620,7 @@ def test_a_dispatcher_may_decline_the_check(self) -> None: Asserted through the real class rather than a dummy, because the point is that this one protocol declines and the rest do not. The construction succeeds, and the packet it builds -- ``b'GET / HTTP/1.1\\r\\n\\r\\n'``, - which only :class:`HTTPv1 `'s + which only :class:`HTTPv1 `'s ``make`` produces from these keywords -- is the proof that they reached the delegate rather than being refused on the way. @@ -631,8 +631,8 @@ def test_a_dispatcher_may_decline_the_check(self) -> None: """ from pcapkit.protocols.application.http import HTTP - from pcapkit.protocols.application.httpv1 import HTTP as HTTPv1 - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv1 import HTTPv1 + from pcapkit.protocols.application.httpv2 import HTTPv2 from pcapkit.protocols.protocol import _declared_keywords from pcapkit.protocols.transport.tcp import TCP diff --git a/tests/protocols/test_dispatch_bindings_unit.py b/tests/protocols/test_dispatch_bindings_unit.py index ea6302f2ae..768667644d 100644 --- a/tests/protocols/test_dispatch_bindings_unit.py +++ b/tests/protocols/test_dispatch_bindings_unit.py @@ -269,10 +269,10 @@ def test_http_dispatches_on_the_alternate_port_over_tcp_and_udp(self) -> None: ) self.assertEqual(str(frames[0].protochain), 'Ethernet:IPv4:TCP:HTTP/1.1') - self.assertIn('http', frames[0].info.to_dict()['ethernet']['ipv4']['tcp']) + self.assertIn('httpv1', frames[0].info.to_dict()['ethernet']['ipv4']['tcp']) self.assertEqual(str(frames[1].protochain), 'Ethernet:IPv4:UDP:HTTP/1.1') - self.assertIn('http', frames[1].info.to_dict()['ethernet']['ipv4']['udp']) + self.assertIn('httpv1', frames[1].info.to_dict()['ethernet']['ipv4']['udp']) def test_registered_ports_are_exactly_those_intended(self) -> None: from pcapkit.protocols.transport.tcp import TCP diff --git a/tests/protocols/test_make_index_str_unit.py b/tests/protocols/test_make_index_str_unit.py index 5b21b48063..e05cd41234 100644 --- a/tests/protocols/test_make_index_str_unit.py +++ b/tests/protocols/test_make_index_str_unit.py @@ -151,7 +151,7 @@ def test_http_status_member_name_resolves(self) -> None: member's integer, and a start line built from the integer carries no reason phrase; only the member itself brings one along. """ - http = _attr('pcapkit.protocols.application.httpv1.HTTP') + http = _attr('pcapkit.protocols.application.httpv1.HTTPv1') status_code = _attr('pcapkit.const.http.status_code.StatusCode') self.assertEqual(http(status='CODE_404').data, http(status=404).data) @@ -166,7 +166,7 @@ def test_unknown_name_still_raises(self) -> None: # a named namespace resolves a member name, not an arbitrary string: an # HTTP status code member is spelled ``CODE_404``, so ``'OK'`` is none - http = _attr('pcapkit.protocols.application.httpv1.HTTP') + http = _attr('pcapkit.protocols.application.httpv1.HTTPv1') with self.assertRaises(exc): http(status='OK') diff --git a/tests/protocols/test_maker_invalid_format_messages_2406_unit.py b/tests/protocols/test_maker_invalid_format_messages_2406_unit.py index 45661d2d70..4fe45e4b9f 100644 --- a/tests/protocols/test_maker_invalid_format_messages_2406_unit.py +++ b/tests/protocols/test_maker_invalid_format_messages_2406_unit.py @@ -12,7 +12,7 @@ :meth:`IPv6_Opts._make_opt_pad `, the twins of the ``Pad1`` length warning on :class:`~pcapkit.protocols.internet.mh.MH` (#2296); -* :meth:`HTTP.make ` and +* :meth:`HTTPv2.make ` and :meth:`PCAPNG.make `, the dispatchers whose unsupported-``frame``/``block`` guard is the same shape as ``MH.make``'s. @@ -134,10 +134,10 @@ class HTTPFrameMessage(MakerMessageTestCase): def test_unreadable_frame_names_the_argument(self) -> None: from pcapkit.const.http.frame import Frame - from pcapkit.protocols.application.httpv2 import HTTP + from pcapkit.protocols.application.httpv2 import HTTPv2 from pcapkit.utilities.exceptions import ProtocolError - http = self.protocol(HTTP) + http = self.protocol(HTTPv2) code = Frame.DATA for frame in (5, 1.5, 'frame', []): with self.subTest(frame=frame): @@ -150,20 +150,20 @@ def test_unreadable_frame_names_the_argument(self) -> None: def test_raw_frame_still_builds(self) -> None: from pcapkit.const.http.frame import Frame - from pcapkit.protocols.application.httpv2 import HTTP + from pcapkit.protocols.application.httpv2 import HTTPv2 - http = self.protocol(HTTP) + http = self.protocol(HTTPv2) schema = http.make(type=Frame.DATA, frame=b'\x01\x02') self.assertEqual(schema.frame, b'\x01\x02') self.assertEqual(schema.length, 2) def test_an_omitted_frame_is_not_rejected(self) -> None: from pcapkit.const.http.frame import Frame - from pcapkit.protocols.application.httpv2 import HTTP + from pcapkit.protocols.application.httpv2 import HTTPv2 # ``frame=None`` dispatches to the frame type's own constructor (#2274) # rather than reaching the guard, so the guard does not cover it. - http = self.protocol(HTTP) + http = self.protocol(HTTPv2) self.assertEqual(http.make(type=Frame.DATA, frame=None).type, Frame.DATA) diff --git a/tests/protocols/test_pack_keeps_instance_state_1769_unit.py b/tests/protocols/test_pack_keeps_instance_state_1769_unit.py index d95471b250..9f5b26dbb1 100644 --- a/tests/protocols/test_pack_keeps_instance_state_1769_unit.py +++ b/tests/protocols/test_pack_keeps_instance_state_1769_unit.py @@ -158,7 +158,7 @@ def test_pcapng(self) -> 'None': self.assertEqual(interface.block, BlockType.Interface_Description_Block) def test_httpv2(self) -> 'None': - from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 + from pcapkit.protocols.application.httpv2 import HTTPv2 kwargs = {'type': 0x00, 'sid': 1, 'frame': {'data': b'abc'}} frame = HTTPv2(**kwargs) diff --git a/tests/protocols/test_padding_kept_as_captured_unit.py b/tests/protocols/test_padding_kept_as_captured_unit.py index def760bb9f..7d9cbf0bcf 100644 --- a/tests/protocols/test_padding_kept_as_captured_unit.py +++ b/tests/protocols/test_padding_kept_as_captured_unit.py @@ -39,9 +39,9 @@ # CmprI 15, CmprE 15, Pad 7: one 1-octet address then 7 pad octets 'IPv6-Route RPL padding': ('internet.ipv6_route', 'IPv6_Route', True, '3b010301ff700000' '01eeeeeeeeeeeeee'), - 'HTTP/2 DATA padding': ('application.httpv2', 'HTTP', False, '00000600080000000102616263eeff'), - 'HTTP/2 HEADERS padding': ('application.httpv2', 'HTTP', False, '0000040108000000010282eeff'), - 'HTTP/2 PUSH_PROMISE padding': ('application.httpv2', 'HTTP', False, + 'HTTP/2 DATA padding': ('application.httpv2', 'HTTPv2', False, '00000600080000000102616263eeff'), + 'HTTP/2 HEADERS padding': ('application.httpv2', 'HTTPv2', False, '0000040108000000010282eeff'), + 'HTTP/2 PUSH_PROMISE padding': ('application.httpv2', 'HTTPv2', False, '000008050800000001020000000282eeff'), 'OSPF cryptographic authentication reserved': ( 'application.ospf', 'OSPF', False, @@ -112,7 +112,7 @@ class Padded(Schema): def test_wrong_width_padding_is_zero_filled_or_truncated(self) -> None: """A ``ConditionalField``-wrapped padding zero-fills or truncates as ``struct`` does.""" from pcapkit.const.http.frame import Frame - from pcapkit.protocols.application.httpv2 import HTTP + from pcapkit.protocols.application.httpv2 import HTTPv2 from pcapkit.protocols.link.l2tpv2 import L2TPv2 # the L2TPv2 ``padding`` contract: zero-filled or truncated to ``offset`` @@ -128,7 +128,7 @@ def test_wrong_width_padding_is_zero_filled_or_truncated(self) -> None: (b'', '0000050008000000010378000000')): with self.subTest(http2=padding): frame = {'pad_len': 3, 'data': b'x', 'padding': padding} - self.assertEqual(HTTP(type=Frame.DATA, sid=1, frame=frame).data.hex(), wire) + self.assertEqual(HTTPv2(type=Frame.DATA, sid=1, frame=frame).data.hex(), wire) def test_makers_tolerate_a_data_model_without_the_new_fields(self) -> None: """A stand-in lacking the padding attributes rebuilds with zero padding.""" diff --git a/tests/protocols/test_parser_registrar_identity_unit.py b/tests/protocols/test_parser_registrar_identity_unit.py index e5dc3f4f2c..bd4d5cc777 100644 --- a/tests/protocols/test_parser_registrar_identity_unit.py +++ b/tests/protocols/test_parser_registrar_identity_unit.py @@ -37,7 +37,7 @@ ('pcapkit.protocols.transport.sctp', 'SCTP', 'register_chunk', '__chunk__'), ('pcapkit.protocols.transport.sctp', 'SCTP', 'register_parameter', '__parameter__'), ('pcapkit.protocols.transport.sctp', 'SCTP', 'register_cause', '__cause__'), - ('pcapkit.protocols.application.httpv2', 'HTTP', 'register_frame', '__frame__'), + ('pcapkit.protocols.application.httpv2', 'HTTPv2', 'register_frame', '__frame__'), ) diff --git a/tests/protocols/test_short_read_roundtrip_unit.py b/tests/protocols/test_short_read_roundtrip_unit.py index 0fb0291e8f..81e204f4ba 100644 --- a/tests/protocols/test_short_read_roundtrip_unit.py +++ b/tests/protocols/test_short_read_roundtrip_unit.py @@ -89,7 +89,7 @@ def _ipv6(code: int, body: bytes) -> bytes: ('sctp', 'transport.sctp:SCTP', {}, bytes.fromhex( '13881389deadbeef000000000003001400000001000100020000000061626364')), ('ftp', 'application.ftp:FTP', {}, b'XYZW arg\r\n'), - ('http1', 'application.httpv1:HTTP', {}, b'GET / HTTP/1.1\r\n\r\n'), + ('http1', 'application.httpv1:HTTPv1', {}, b'GET / HTTP/1.1\r\n\r\n'), ) 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 0be8ef6e6f..df4d34c125 100644 --- a/tests/protocols/test_to_dict_payload_roundtrip_1447_unit.py +++ b/tests/protocols/test_to_dict_payload_roundtrip_1447_unit.py @@ -76,9 +76,10 @@ def udp(payload: bytes, port: int = 40000) -> bytes: #: Little-endian global header: v2.4, Ethernet. GLOBAL = bytes.fromhex('d4c3b2a1' '0200' '0400' '00000000' '00000000' 'ffff0000' '01000000') -#: Every layer type #1447 names, each of which these frames must exercise, and -#: L2TPv2, whose ``info_name`` is not its lowercased class name. -LAYERS = {'Frame', 'Ethernet', 'ARP', 'IPv4', 'IPv6', 'TCP', 'UDP', 'HTTP', 'L2TPv2'} +#: Every layer type #1447 names, each of which these frames must exercise -- HTTP +#: as the version classes it dispatches to (#2524) -- and L2TPv2, whose +#: ``info_name`` is not its lowercased class name. +LAYERS = {'Frame', 'Ethernet', 'ARP', 'IPv4', 'IPv6', 'TCP', 'UDP', 'HTTPv1', 'HTTPv2', 'L2TPv2'} class TestToDictPayloadRoundTrip(unittest.TestCase): diff --git a/tests/protocols/transport/test_tcp_http_dispatch_unit.py b/tests/protocols/transport/test_tcp_http_dispatch_unit.py index 1e30c49e30..46380c1eca 100644 --- a/tests/protocols/transport/test_tcp_http_dispatch_unit.py +++ b/tests/protocols/transport/test_tcp_http_dispatch_unit.py @@ -2,18 +2,19 @@ """TCP's HTTP ports dispatch through the version-identifying proxy. C.f. #682. Guards against :attr:`TCP.__proto__ ` -binding :class:`pcapkit.protocols.application.httpv1.HTTP` directly for ports 80 +binding :class:`pcapkit.protocols.application.httpv1.HTTPv1` directly for ports 80 and 8080, which makes a segment on either port HTTP/1 by assertion of the port number: an HTTP/2 payload there would be refused by the HTTP/1 parser and reported as :class:`~pcapkit.protocols.misc.raw.Raw`. Both versions share those ports on the wire -- :rfc:`9113#section-3` gives HTTP/2 the ``http`` scheme and HTTP/1.1's default port numbers -- so the port cannot decide the version and the payload has to, which is what -:class:`pcapkit.protocols.application.http.HTTP` does. +:class:`pcapkit.protocols.application.http.HTTP` does: its ``_dispatch_version`` picks +``HTTPv1`` or ``HTTPv2``, and the layer is that class (#2524). Nothing is parsed from :file:`examples/captures/`, so this is unit tier. The segments below are built in memory, and the HTTP/2 payload is the nine octets -this library's own ``httpv2.HTTP`` writes for an empty ``DATA`` frame on stream +this library's own ``httpv2.HTTPv2`` writes for an empty ``DATA`` frame on stream 1, rather than a hand-rolled frame, so the frame is whatever the library itself writes and cannot fail for a reason that has nothing to do with dispatch. @@ -37,7 +38,7 @@ #: A nine-octet HTTP/2 ``DATA`` frame: declared length 0 (the payload, the #: header excluded), type 0, no flags, stream 1, as -#: ``httpv2.HTTP(type=Frame.DATA, sid=1, frame={'data': b''})`` writes it. A +#: ``httpv2.HTTPv2(type=Frame.DATA, sid=1, frame={'data': b''})`` writes it. A #: ``DATA`` frame needs a non-zero stream (:rfc:`9113#section-6.1`), so the #: version guess does not take a stream-0 one for HTTP/2. _HTTP2_FRAME = bytes.fromhex('000000000000000001') @@ -87,26 +88,26 @@ def test_tcp_and_udp_agree_on_the_http_ports(self) -> None: def test_http2_on_the_http_ports_decodes_as_http2_and_http1_still_as_http1(self) -> None: """Both versions reach their own parser from a port-80/8080 segment. - The HTTP/2 half pins the binding to the proxy: bound to ``httpv1.HTTP``, - these nine octets decode as ``TCP:Raw``, because ``httpv1.HTTP`` refuses + The HTTP/2 half pins the binding to the proxy: bound to ``httpv1.HTTPv1``, + these nine octets decode as ``TCP:Raw``, because ``httpv1.HTTPv1`` refuses them and :func:`~pcapkit.corekit.decorators.beholder` turns the refusal into ``Raw``. The HTTP/1.1 half guards the HTTP/1.1 frames in the fixture corpus, which these ports route through - :meth:`HTTP._guess_version - `: they have to - come back HTTP/1.1, and both halves are asserted here so neither can be - satisfied at the other's expense. + :meth:`HTTP._dispatch_version + `: they have to + come back HTTP/1.1, each as its own version's class, and both halves are + asserted here so neither can be satisfied at the other's expense. """ from pcapkit.protocols.transport.tcp import TCP cases = ( - (_TCP_TO_80, _HTTP2_FRAME, 'TCP:HTTP/2'), - (_TCP_TO_8080, _HTTP2_FRAME, 'TCP:HTTP/2'), - (_TCP_TO_80, _HTTP1_REQUEST, 'TCP:HTTP/1.1'), - (_TCP_TO_8080, _HTTP1_REQUEST, 'TCP:HTTP/1.1'), + (_TCP_TO_80, _HTTP2_FRAME, 'TCP:HTTP/2', 'httpv2'), + (_TCP_TO_8080, _HTTP2_FRAME, 'TCP:HTTP/2', 'httpv2'), + (_TCP_TO_80, _HTTP1_REQUEST, 'TCP:HTTP/1.1', 'httpv1'), + (_TCP_TO_8080, _HTTP1_REQUEST, 'TCP:HTTP/1.1', 'httpv1'), ) - for header, payload, expected in cases: + for header, payload, expected, module in cases: with self.subTest(port=int.from_bytes(header[2:4], 'big'), chain=expected): raw = header + payload with warnings.catch_warnings(): @@ -119,7 +120,7 @@ def test_http2_on_the_http_ports_decodes_as_http2_and_http1_still_as_http1(self) self.assertEqual(str(proto.protochain), expected) self.assertEqual(type(proto.payload).__module__, - 'pcapkit.protocols.application.http') + f'pcapkit.protocols.application.{module}') if __name__ == '__main__': diff --git a/tests/test_docstring_contract.py b/tests/test_docstring_contract.py index 53ee25dff8..6dbcd7bdb4 100644 --- a/tests/test_docstring_contract.py +++ b/tests/test_docstring_contract.py @@ -619,7 +619,7 @@ def test_commented_out_raise_leaves_no_clause_behind(self) -> 'None': """A commented-out ``raise`` never keeps its ``Raises:`` clause. :meth:`HTTP._read_http_none - ` is where + ` is where this went wrong: the ``raise ProtocolError(...)`` was commented out and replaced by a :class:`~pcapkit.utilities.warnings.ProtocolWarning`, and the ``Raises:`` clause stayed. Downgrading a raise to a warning is