diff --git a/docs/source/contributing/pep.rst b/docs/source/contributing/pep.rst index f97e882953..d00da2c40a 100644 --- a/docs/source/contributing/pep.rst +++ b/docs/source/contributing/pep.rst @@ -41,7 +41,7 @@ one. Grouped by each TCP/IP layer and ordered by protocol name alphabetically, the ones outstanding are, * Link Layer: DSL, EAPOL, FDDI, ISDN, NDP, PPP -* Internet Layer: ECN, ICMP, ICMPv6, IGMP, Shim6 +* Internet Layer: ECN, ICMP, ICMPv6, Shim6 * Transport Layer: DCCP, RSVP * Application Layer: BGP, DHCP, DHCPv6, DNS, IMAP, LDAP, MQTT, NNTP, NTP, ONC/RPC, POP, QUIC, RIP, RTP, SIP, SMTP, SNMP, SSH, Telnet, @@ -361,8 +361,8 @@ Follow-ups to the bindings above: port number. That depends on the proxy identifying the version positively instead of guessing, since a guess path is wrong on non-HTTP input. -Beyond those, the gaps most likely to be met in a real capture are ICMP (1), -ICMPv6 (58) and IGMP (2) on the internet layer, each of which has a stub. +Beyond those, the gaps most likely to be met in a real capture are ICMP (1) +and ICMPv6 (58) on the internet layer, each of which has a stub. PCAPNG Support -------------- @@ -1087,6 +1087,12 @@ question is the design work and doing either half of a pair alone would answer i twice. **DCCP** is the transport-layer implementation, tracked by :issue:`2489`, and blocked on :issue:`2479` for its pseudo-header checksum. +**IGMP v1/v2 — done within wave 2** (:issue:`2439`). +The versioned classes :class:`IGMPv1 ` and +:class:`IGMPv2 ` share one read path on the +abstract :class:`~pcapkit.protocols.internet.igmp.IGMP` base. +:rfc:`3376` IGMPv3 is :issue:`2448`. + **Done within wave 2 — TLS/SSL with DTLS.** The `DTLS`_ section above lists what is decoded (the shared :class:`~pcapkit.protocols.application.ssl.SSL` base, TLS, DTLS, and handshake message framing) and what is not (DTLS @@ -1125,9 +1131,10 @@ above, which describes the payloads in question and why a registry of their own is wanted instead of :meth:`~pcapkit.protocols.protocol.Protocol._decode_next_layer`. It is sequenced *after* wave 2 rather than by preference: the multicast options embed -:rfc:`3810` MLD, which rides in **ICMPv6**, and :rfc:`3376` **IGMP** records — -both wave 2 deliverables. Landing the registry first would give a dispatch table -with nothing but :class:`~pcapkit.protocols.misc.raw.Raw` to register. The +:rfc:`3810` MLD, which rides in **ICMPv6**, and :rfc:`3376` **IGMPv3** group +records — ICMPv6 a wave 2 deliverable, IGMPv3 :issue:`2448`. Landing the +registry first would give a dispatch table with nothing but +:class:`~pcapkit.protocols.misc.raw.Raw` to register. The counter-argument is real though, and worth weighing rather than dismissing: changing the parsed shape from :obj:`bytes` to :class:`~pcapkit.protocols.misc.raw.Raw` is the disruptive half, since it changes what existing captures dump to, so there is a case for diff --git a/docs/source/ext.rst b/docs/source/ext.rst index a9d385ca47..9697b9394f 100644 --- a/docs/source/ext.rst +++ b/docs/source/ext.rst @@ -71,6 +71,12 @@ The protocol classes :mod:`pcapkit` ships: | | :class:`pcapkit.protocols.internet.ipx.IPX` | + +----------------+-----------------------+-------------------------------------------------------------+ | | :class:`pcapkit.protocols.internet.hip.HIP` | ++ +----------------+-----------------------+-------------------------------------------------------------+ +| | | :class:`pcapkit.protocols.internet.igmp.IGMP` | ++ + +-----------------------+-------------------------------------------------------------+ +| | IGMP Family | :class:`pcapkit.protocols.internet.igmpv1.IGMP` | ++ + +-----------------------+-------------------------------------------------------------+ +| | | :class:`pcapkit.protocols.internet.igmpv2.IGMP` | +------------------------------------------------------------------+----------------+-----------------------+-------------------------------------------------------------+ | Transport Layer | :class:`pcapkit.protocols.transport.tcp.TCP` | + (:class:`~pcapkit.protocols.transport.transport.Transport` +----------------+-----------------------+-------------------------------------------------------------+ diff --git a/docs/source/pcapkit/const/igmp.rst b/docs/source/pcapkit/const/igmp.rst new file mode 100644 index 0000000000..536f3880c7 --- /dev/null +++ b/docs/source/pcapkit/const/igmp.rst @@ -0,0 +1,31 @@ +==================================================================== +:class:`~pcapkit.protocols.internet.igmp.IGMP` Constant Enumerations +==================================================================== + +.. module:: pcapkit.const.igmp + +This module contains all constant enumerations of +:class:`~pcapkit.protocols.internet.igmp.IGMP` implementations. Available +enumerations include: + +.. list-table:: + + * - :class:`IGMP_Type ` + - IGMP Type Numbers [*]_ + +IGMP Type Numbers +================= + +.. module:: pcapkit.const.igmp.type + +This module contains the constant enumeration for **IGMP Type Numbers**, +which is automatically generated from :class:`pcapkit.vendor.igmp.type.Type`. + +.. autoclass:: pcapkit.const.igmp.type.Type + :members: + :undoc-members: + :show-inheritance: + +.. rubric:: Footnotes + +.. [*] https://www.iana.org/assignments/igmp-type-numbers/igmp-type-numbers.xhtml diff --git a/docs/source/pcapkit/const/index.rst b/docs/source/pcapkit/const/index.rst index 5d006a9825..b23be70340 100644 --- a/docs/source/pcapkit/const/index.rst +++ b/docs/source/pcapkit/const/index.rst @@ -80,6 +80,7 @@ Internet Layer esp hip + igmp ipv4 ipv6 ipx diff --git a/docs/source/pcapkit/protocols/index.rst b/docs/source/pcapkit/protocols/index.rst index 4c4fdbb691..3178abb223 100644 --- a/docs/source/pcapkit/protocols/index.rst +++ b/docs/source/pcapkit/protocols/index.rst @@ -83,7 +83,7 @@ from given information. The class hierarchy of :mod:`pcapkit.protocols`: end subgraph internet [Internet Layer] - Internet --> IPX & IP & IPsec & IPv6_Ext + Internet --> IPX & IP & IPsec & IPv6_Ext & IGMP subgraph ip [IP Family] IP --> IPv4 & IPv6 @@ -96,6 +96,10 @@ from given information. The class hierarchy of :mod:`pcapkit.protocols`: subgraph ipv6ext [IPv6 Extension Header] IPv6_Ext --> IPv6-Frag & IPv6-Opts & IPv6-Route & HOPOPT & MH & HIP end + + subgraph igmp [IGMP Family] + IGMP --> IGMPv1 & IGMPv2 + end end subgraph transport [Transport Layer] @@ -135,6 +139,9 @@ from given information. The class hierarchy of :mod:`pcapkit.protocols`: click AH "internet/ah.html#pcapkit.protocols.internet.ah.AH" click ESP "internet/esp.html#pcapkit.protocols.internet.esp.ESP" click HIP "internet/hip.html#pcapkit.protocols.internet.hip.HIP" + click IGMP "internet/igmp.html#pcapkit.protocols.internet.igmp.IGMP" + click IGMPv1 "internet/igmpv1.html#pcapkit.protocols.internet.igmpv1.IGMP" + click IGMPv2 "internet/igmpv2.html#pcapkit.protocols.internet.igmpv2.IGMP" click HOPOPT "internet/hopopt.html#pcapkit.protocols.internet.hopopt.HOPOPT" click IP "internet/ip.html#pcapkit.protocols.internet.ip.IP" click IPsec "internet/ipsec.html#pcapkit.protocols.internet.ipsec.IPsec" diff --git a/docs/source/pcapkit/protocols/internet/igmp.rst b/docs/source/pcapkit/protocols/internet/igmp.rst new file mode 100644 index 0000000000..a72b1d538b --- /dev/null +++ b/docs/source/pcapkit/protocols/internet/igmp.rst @@ -0,0 +1,59 @@ +IGMP - Internet Group Management Protocol +========================================== + +.. module:: pcapkit.protocols.internet.igmp + +:mod:`pcapkit.protocols.internet.igmp` contains +:class:`~pcapkit.protocols.internet.igmp.IGMP`, the abstract base of the +Internet Group Management Protocol (IGMP) [*]_ versions 1 and 2. Both share +the 8-octet structure below. :class:`IGMPv1 ` +and :class:`IGMPv2 ` set :attr:`version`, +and :attr:`alias` names the version of each message. + +======= ========= ========================= =================================== +Octets Bits Name Description +======= ========= ========================= =================================== + 0 0 ``igmp.type`` Type + 1 8 ``igmp.max_resp_time`` Max Response Time + 2 16 ``igmp.checksum`` Checksum + 4 32 ``igmp.group`` Group Address +======= ========= ========================= =================================== + +.. autoclass:: pcapkit.protocols.internet.igmp.IGMP + :no-members: + :show-inheritance: + + .. autoproperty:: name + .. autoproperty:: version + .. autoproperty:: alias + .. autoproperty:: length + + .. automethod:: read + .. automethod:: make + + .. automethod:: __index__ + + .. automethod:: _classify + .. automethod:: _make_data + +Header Schemas +-------------- + +.. module:: pcapkit.protocols.schema.internet.igmp + +.. autoclass:: pcapkit.protocols.schema.internet.igmp.IGMP + :members: + :show-inheritance: + +Data Models +----------- + +.. module:: pcapkit.protocols.data.internet.igmp + +.. autoclass:: pcapkit.protocols.data.internet.igmp.IGMP + :members: + :show-inheritance: + +.. rubric:: Footnotes + +.. [*] https://en.wikipedia.org/wiki/Internet_Group_Management_Protocol diff --git a/docs/source/pcapkit/protocols/internet/igmpv1.rst b/docs/source/pcapkit/protocols/internet/igmpv1.rst new file mode 100644 index 0000000000..3734fceb2e --- /dev/null +++ b/docs/source/pcapkit/protocols/internet/igmpv1.rst @@ -0,0 +1,22 @@ +IGMPv1 - Internet Group Management Protocol version 1 +============================================================= + +.. module:: pcapkit.protocols.internet.igmpv1 + +:mod:`pcapkit.protocols.internet.igmpv1` contains +:class:`~pcapkit.protocols.internet.igmpv1.IGMP`, which implements an +extractor for Internet Group Management Protocol version 1 (IGMPv1) +[:rfc:`1112`]. It shares its header schema and data model with +:class:`IGMPv2 ` -- see +:mod:`~pcapkit.protocols.internet.igmp` for both. + +.. autoclass:: pcapkit.protocols.internet.igmpv1.IGMP + :no-members: + :show-inheritance: + + .. autoproperty:: version + .. autoproperty:: alias + + .. automethod:: id + + .. automethod:: read diff --git a/docs/source/pcapkit/protocols/internet/igmpv2.rst b/docs/source/pcapkit/protocols/internet/igmpv2.rst new file mode 100644 index 0000000000..3ccee3c919 --- /dev/null +++ b/docs/source/pcapkit/protocols/internet/igmpv2.rst @@ -0,0 +1,22 @@ +IGMPv2 - Internet Group Management Protocol version 2 +============================================================= + +.. module:: pcapkit.protocols.internet.igmpv2 + +:mod:`pcapkit.protocols.internet.igmpv2` contains +:class:`~pcapkit.protocols.internet.igmpv2.IGMP`, which implements an +extractor for Internet Group Management Protocol version 2 (IGMPv2) +[:rfc:`2236`]. It shares its header schema and data model with +:class:`IGMPv1 ` -- see +:mod:`~pcapkit.protocols.internet.igmp` for both. + +.. autoclass:: pcapkit.protocols.internet.igmpv2.IGMP + :no-members: + :show-inheritance: + + .. autoproperty:: version + .. autoproperty:: alias + + .. automethod:: id + + .. automethod:: read diff --git a/docs/source/pcapkit/protocols/internet/index.rst b/docs/source/pcapkit/protocols/internet/index.rst index 39f32a0cc5..20641c94dc 100644 --- a/docs/source/pcapkit/protocols/internet/index.rst +++ b/docs/source/pcapkit/protocols/internet/index.rst @@ -26,11 +26,14 @@ internet layer, with detailed implementation and methods. ah esp hip + igmp + igmpv1 + igmpv2 ipx .. todo:: - Implement ECN, ICMP, ICMPv6, IGMP, Shim6. + Implement ECN, ICMP, ICMPv6, Shim6. Protocol Registry ----------------- diff --git a/docs/source/pcapkit/vendor/igmp.rst b/docs/source/pcapkit/vendor/igmp.rst new file mode 100644 index 0000000000..4557a11257 --- /dev/null +++ b/docs/source/pcapkit/vendor/igmp.rst @@ -0,0 +1,30 @@ +============================================================== +:class:`~pcapkit.protocols.internet.igmp.IGMP` Vendor Crawlers +============================================================== + +.. module:: pcapkit.vendor.igmp + +This module contains all vendor crawlers of +:class:`~pcapkit.protocols.internet.igmp.IGMP` implementations. Available +vendor crawlers include: + +.. list-table:: + + * - :class:`IGMP_Type ` + - IGMP Type Numbers [*]_ + +IGMP Type Numbers +================= + +.. module:: pcapkit.vendor.igmp.type + +This module contains the vendor crawler for **IGMP Type Numbers**, +which is automatically generating :class:`pcapkit.const.igmp.type.Type`. + +.. autoclass:: pcapkit.vendor.igmp.type.Type + :members: FLAG, LINK + :show-inheritance: + +.. rubric:: Footnotes + +.. [*] https://www.iana.org/assignments/igmp-type-numbers/igmp-type-numbers.xhtml diff --git a/docs/source/pcapkit/vendor/index.rst b/docs/source/pcapkit/vendor/index.rst index af878e121c..d33eb24bc1 100644 --- a/docs/source/pcapkit/vendor/index.rst +++ b/docs/source/pcapkit/vendor/index.rst @@ -55,6 +55,7 @@ Internet Layer esp hip + igmp ipv4 ipv6 ipx diff --git a/examples/generators/dispatch.py b/examples/generators/dispatch.py index 13f8856900..08ee4cd2e7 100644 --- a/examples/generators/dispatch.py +++ b/examples/generators/dispatch.py @@ -71,6 +71,7 @@ """ from __future__ import annotations +import inspect import os import pathlib import struct @@ -341,8 +342,10 @@ def _link_build(code: 'int') -> 'Any': # Internet -- TransType. ############################################################################### -#: Codes dispatched over an IPv4 outer header. -_INTERNET_OVER_IPV4 = frozenset({4, 6, 17, 50, 51, 89, 111, 132}) +#: Codes dispatched over an IPv4 outer header. IGMP (2) is canonically +#: IPv4-only -- multicast's IPv6 equivalent is MLD, which rides in ICMPv6 +#: rather than as its own IP protocol number (#2439). +_INTERNET_OVER_IPV4 = frozenset({2, 4, 6, 17, 50, 51, 89, 111, 132}) def _internet_registry() -> 'Any': @@ -380,6 +383,14 @@ def _internet_payload(code: 'int') -> 'bytes': from pcapkit.protocols.internet.ah import AH return bytes(AH(next=TransType.TCP, spi=0x1234, seq=1, icv=b'\x00' * 12, payload=_tcp(9999))) + if code == TransType.IGMP: + from pcapkit.const.igmp.type import Type + from pcapkit.protocols.internet.igmpv2 import IGMP + + # A General Query: an IGMPv1/v2 message is the 8-octet header and + # nothing else, so there is no further payload to wrap. A nonzero + # max_resp_time makes it IGMPv2, the class IGMP dispatches it to. + return bytes(IGMP(type=Type.IGMP_Membership_Query, max_resp_time=100, group='0.0.0.0')) if code == TransType.IPv6_NoNxt: # The registered target for this code *is* Raw -- there is no # dissector to hand it, "no next header" is the whole point -- so any @@ -674,6 +685,7 @@ def _link_enum() -> 'Any': 'internet/IPv6_Frag': ('pcapkit.protocols.internet.ipv6_frag', 'IPv6_Frag'), 'internet/ESP': ('pcapkit.protocols.internet.esp', 'ESP'), 'internet/AH': ('pcapkit.protocols.internet.ah', 'AH'), + 'internet/IGMP': ('pcapkit.protocols.internet.igmp', 'IGMP'), 'internet/IPv6_NoNxt': ('pcapkit.protocols.misc.raw', 'Raw'), 'internet/IPv6_Opts': ('pcapkit.protocols.internet.ipv6_opts', 'IPv6_Opts'), 'internet/IPX_in_IP': ('pcapkit.protocols.internet.ipx', 'IPX'), @@ -908,7 +920,11 @@ 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. - reached = case.target in frame.protochain.protocols + # 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)) + 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/pcapkit/__init__.py b/pcapkit/__init__.py index b60c1e8e61..19841fab0c 100644 --- a/pcapkit/__init__.py +++ b/pcapkit/__init__.py @@ -114,7 +114,8 @@ 'ARP', 'C_Tag', 'Ethernet', 'InARP', 'L2TP', # Link Layer 'L2TPv2', 'LinuxSLL', 'LinuxSLL1', 'LinuxSLL2', 'Loopback', 'S_Tag', 'VLAN', - 'AH', 'ESP', 'IP', 'IPsec', 'IPv4', 'IPv6', 'IPX', # Internet Layer + 'AH', 'ESP', 'IGMP', 'IGMPv1', 'IGMPv2', # Internet Layer + 'IP', 'IPsec', 'IPv4', 'IPv6', 'IPX', 'HIP', 'HOPOPT', 'IPv6_Frag', 'IPv6_Ext', 'IPv6_Opts', 'IPv6_Route', 'MH', # IPv6 Extension Header diff --git a/pcapkit/all.py b/pcapkit/all.py index a5a782c8b3..8ccda64095 100644 --- a/pcapkit/all.py +++ b/pcapkit/all.py @@ -121,7 +121,8 @@ 'Raw', # Raw Packet 'ARP', 'C_Tag', 'Ethernet', 'InARP', 'L2TP', 'L2TPv2', 'LinuxSLL', 'LinuxSLL1', 'LinuxSLL2', 'Loopback', 'S_Tag', 'VLAN', # Link Layer - 'AH', 'ESP', 'IP', 'IPsec', 'IPv4', 'IPv6', 'IPX', # Internet Layer + 'AH', 'ESP', 'IGMP', 'IGMPv1', 'IGMPv2', # Internet Layer + 'IP', 'IPsec', 'IPv4', 'IPv6', 'IPX', 'HIP', 'HOPOPT', 'IPv6_Ext', 'IPv6_Frag', 'IPv6_Opts', 'IPv6_Route', 'MH', # IPv6 Extension Header 'TCP', 'UDP', 'SCTP', # Transport Layer diff --git a/pcapkit/const/__init__.py b/pcapkit/const/__init__.py index 0cb93ffda2..06dae60c17 100644 --- a/pcapkit/const/__init__.py +++ b/pcapkit/const/__init__.py @@ -20,6 +20,7 @@ from pcapkit.const.ftp import * from pcapkit.const.hip import * from pcapkit.const.http import * +from pcapkit.const.igmp import * from pcapkit.const.ipv4 import * from pcapkit.const.ipv6 import * from pcapkit.const.ipx import * @@ -48,6 +49,8 @@ 'HIP_Suite', 'HIP_Transport', 'HIP_EdDSACurve', # HTTP 'HTTP_ErrorCode', 'HTTP_Frame', 'HTTP_Method', 'HTTP_Setting', 'HTTP_Status', + # IGMP + 'IGMP_Type', # IPv4 'IPv4_ClassificationLevel', 'IPv4_OptionClass', 'IPv4_OptionNumber', 'IPv4_ProtectionAuthority', 'IPv4_QSFunction', 'IPv4_RouterAlert', 'IPv4_ToSDelay', 'IPv4_ToSECN', 'IPv4_ToSPrecedence', diff --git a/pcapkit/const/igmp/__init__.py b/pcapkit/const/igmp/__init__.py new file mode 100644 index 0000000000..ddeaa53948 --- /dev/null +++ b/pcapkit/const/igmp/__init__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# pylint: disable=unused-import +""":class:`~pcapkit.protocols.internet.igmp.IGMP` Constant Enumerations +========================================================================== + +.. module:: pcapkit.const.igmp + +This module contains all constant enumerations of +:class:`~pcapkit.protocols.internet.igmp.IGMP` implementations. Available +enumerations include: + +.. list-table:: + + * - :class:`IGMP_Type ` + - IGMP Type Numbers [*]_ + +.. [*] https://www.iana.org/assignments/igmp-type-numbers/igmp-type-numbers.xhtml + +""" + +from pcapkit.const.igmp.type import Type as IGMP_Type + +__all__ = ['IGMP_Type'] diff --git a/pcapkit/const/igmp/type.py b/pcapkit/const/igmp/type.py new file mode 100644 index 0000000000..0d7459b69c --- /dev/null +++ b/pcapkit/const/igmp/type.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +# pylint: disable=line-too-long,consider-using-f-string +"""IGMP Type Numbers +======================= + +.. module:: pcapkit.const.igmp.type + +This module contains the constant enumeration for **IGMP Type Numbers**, +which is automatically generated from :class:`pcapkit.vendor.igmp.type.Type`. + +""" + +from typing import TYPE_CHECKING + +from aenum import IntEnum + +from pcapkit.corekit.enum import EnumRegistry +from pcapkit.utilities.exceptions import EnumValueError + +if TYPE_CHECKING: + from typing import Any + +__all__ = ['Type'] + + +class Type(EnumRegistry, IntEnum): + """[Type] IGMP Type Numbers""" + + #: Reserved + Reserved_0 = 0 + + #: IGMP Membership Query [:rfc:`1112`] + IGMP_Membership_Query = 17 + + #: IGMPv1 Membership Report [:rfc:`1112`] + IGMPv1_Membership_Report = 18 + + #: DVMRP [draft-ietf-idmr-dvmrp-v3-11] + DVMRP = 19 + + #: PIM version 1 [draft-ietf-idmr-pim-spec-02] + PIM_version_1 = 20 + + #: Cisco Trace Messages + Cisco_Trace_Messages = 21 + + #: IGMPv2 Membership Report [:rfc:`2236`] + IGMPv2_Membership_Report = 22 + + #: IGMPv2 Leave Group [:rfc:`2236`] + IGMPv2_Leave_Group = 23 + + #: Multicast Traceroute Response [Bill Fenner] + Multicast_Traceroute_Response = 30 + + #: Multicast Traceroute [Bill Fenner] + Multicast_Traceroute = 31 + + #: IGMPv3 Membership Report [:rfc:`9776`] + IGMPv3_Membership_Report = 34 + + #: Multicast Router Advertisement [:rfc:`4286`] + Multicast_Router_Advertisement = 48 + + #: Multicast Router Solicitation [:rfc:`4286`] + Multicast_Router_Solicitation = 49 + + #: Multicast Router Termination [:rfc:`4286`] + Multicast_Router_Termination = 50 + + @classmethod + def _validate_value(cls, value: 'Any') -> 'None': + """Reject ``value`` unless it passes the range check :meth:`_missing_` makes. + + Overrides :meth:`~pcapkit.corekit.enum.EnumLookup._validate_value` + with that check and the message it raises, so + :meth:`~pcapkit.corekit.enum.EnumRegistry.register` refuses exactly the + values :meth:`~pcapkit.corekit.enum.EnumLookup.get` refuses, in the + same words (:issue:`1816`). + + Args: + value: Value about to be looked up or minted. + + Raises: + EnumValueError: If ``value`` fails that check. A + :exc:`ValueError`, so an ``except ValueError`` caller is + unaffected. + + """ + if not (isinstance(value, int) and 0 <= value <= 255): + raise EnumValueError('%r is not a valid %s' % (value, cls.__name__)) + + @classmethod + def _missing_(cls, value: 'int') -> 'Type': + """Lookup function used when value is not found. + + Args: + value: Value to get enum item. + + """ + if not (isinstance(value, int) and 0 <= value <= 255): + raise ValueError('%r is not a valid %s' % (value, cls.__name__)) + if 1 <= value <= 8: + #: Reserved (Obsolete) [:rfc:`988`] + return cls._unregistered_member(value, 'Reserved') + if 9 <= value <= 16: + #: Unassigned + return cls._unregistered_member(value, 'Unassigned') + if 240 <= value <= 255: + #: Reserved for experimentation [:rfc:`9778`] + return cls._unregistered_member(value, 'Reserved_for_experimentation') + return super()._missing_(value) diff --git a/pcapkit/protocols/__init__.py b/pcapkit/protocols/__init__.py index 225b11131b..5aab10622d 100644 --- a/pcapkit/protocols/__init__.py +++ b/pcapkit/protocols/__init__.py @@ -54,7 +54,7 @@ 'LinuxSLL2', 'Loopback', 'S_Tag', 'VLAN', # Internet Layer - 'AH', 'ESP', 'IP', 'IPsec', 'IPv4', 'IPv6', 'IPX', + 'AH', 'ESP', 'IGMP', 'IGMPv1', 'IGMPv2', 'IP', 'IPsec', 'IPv4', 'IPv6', 'IPX', # IPv6 Extension Header 'HIP', 'HOPOPT', 'IPv6_Frag', 'IPv6_Ext', 'IPv6_Opts', diff --git a/pcapkit/protocols/data/__init__.py b/pcapkit/protocols/data/__init__.py index 4b3b0f06fa..4fee25a195 100644 --- a/pcapkit/protocols/data/__init__.py +++ b/pcapkit/protocols/data/__init__.py @@ -144,6 +144,9 @@ 'IPv6_Route', 'IPv6_Route_UnknownType', 'IPv6_Route_SourceRoute', 'IPv6_Route_Type2', 'IPv6_Route_RPL', + # Internet Group Management Protocol + 'IGMP', + # Internet Protocol version 6 'IPv6', diff --git a/pcapkit/protocols/data/internet/__init__.py b/pcapkit/protocols/data/internet/__init__.py index 65c8195776..bd7568364d 100644 --- a/pcapkit/protocols/data/internet/__init__.py +++ b/pcapkit/protocols/data/internet/__init__.py @@ -104,6 +104,9 @@ TunnelEncapsulationLimitOption as HOPOPT_TunnelEncapsulationLimitOption from pcapkit.protocols.data.internet.hopopt import UnassignedOption as HOPOPT_UnassignedOption +# Internet Group Management Protocol +from pcapkit.protocols.data.internet.igmp import IGMP + # Internet Protocol version 4 from pcapkit.protocols.data.internet.ipv4 import EOOLOption as IPv4_EOOLOption from pcapkit.protocols.data.internet.ipv4 import ESECOption as IPv4_ESECOption @@ -413,6 +416,9 @@ 'HOPOPT_LineIdentificationOption', 'HOPOPT_JumboPayloadOption', 'HOPOPT_HomeAddressOption', 'HOPOPT_IPDFFOption', + # Internet Group Management Protocol + 'IGMP', + # Internet Protocol version 4 'IPv4', 'IPv4_ToSField', 'IPv4_Flags', diff --git a/pcapkit/protocols/data/internet/igmp.py b/pcapkit/protocols/data/internet/igmp.py new file mode 100644 index 0000000000..485a8090a8 --- /dev/null +++ b/pcapkit/protocols/data/internet/igmp.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +"""data model for internet group management protocol""" + +from typing import TYPE_CHECKING + +from pcapkit.corekit.infoclass import info_final +from pcapkit.protocols.data.protocol import Protocol + +if TYPE_CHECKING: + from ipaddress import IPv4Address + + from pcapkit.const.igmp.type import Type as Enum_Type + +__all__ = ['IGMP'] + + +@info_final +class IGMP(Protocol): + """Data model for Internet Group Management Protocol. + + This single schema covers both IGMPv1 (:rfc:`1112#page-11`) and IGMPv2 + (:rfc:`2236#section-2`) messages: v1's 8-octet header is the same shape as + v2's, with ``max_resp_time`` reserved at zero and fewer :attr:`type` + values defined (:issue:`2439`). IGMPv3 (:rfc:`3376`) adds a + group-record-list body of a materially different shape and is out of + scope here. + + """ + + #: Message type. + type: 'Enum_Type' + #: Max response time, in units of 1/10 second. Meaningful only for a + #: Membership Query under IGMPv2 (:rfc:`2236#section-2.2`); reserved and + #: zero on every other message, including every IGMPv1 one + #: (:rfc:`1112#page-11`). + max_resp_time: 'int' + #: Checksum. + checksum: 'bytes' + #: Group address: the IPv4 multicast group being queried, reported or + #: left, or ``0.0.0.0`` on a General Query. + group: 'IPv4Address' + + if TYPE_CHECKING: + def __init__(self, type: 'Enum_Type', max_resp_time: 'int', checksum: 'bytes', # pylint: disable=unused-argument,super-init-not-called,multiple-statements,redefined-builtin + group: 'IPv4Address') -> 'None': ... diff --git a/pcapkit/protocols/internet/NotImplemented/igmp.py b/pcapkit/protocols/internet/NotImplemented/igmp.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/pcapkit/protocols/internet/__init__.py b/pcapkit/protocols/internet/__init__.py index 032acb6ab8..c09576918f 100644 --- a/pcapkit/protocols/internet/__init__.py +++ b/pcapkit/protocols/internet/__init__.py @@ -9,7 +9,7 @@ in the internet layer, with detailed implementation and methods. """ -# TODO: Implement ECN, ICMP, ICMPv6, IGMP, Shim6. +# TODO: Implement ECN, ICMP, ICMPv6, Shim6. # Base Class for Internet Layer from pcapkit.protocols.internet.internet import Internet @@ -17,6 +17,8 @@ # Utility Classes for Protocols from pcapkit.protocols.internet.ah import AH from pcapkit.protocols.internet.esp import ESP +from pcapkit.protocols.internet.igmpv1 import IGMP as IGMPv1 +from pcapkit.protocols.internet.igmpv2 import IGMP as IGMPv2 from pcapkit.protocols.internet.ipv4 import IPv4 from pcapkit.protocols.internet.ipv6 import IPv6 from pcapkit.protocols.internet.ipx import IPX @@ -34,12 +36,14 @@ from pcapkit.const.reg.ethertype import EtherType as ETHERTYPE # Base Classes +from pcapkit.protocols.internet.igmp import IGMP from pcapkit.protocols.internet.ip import IP from pcapkit.protocols.internet.ipsec import IPsec __all__ = [ 'ETHERTYPE', # Protocol Numbers - 'AH', 'ESP', 'IP', 'IPsec', 'IPv4', 'IPv6', 'IPX', # Internet Layer + 'AH', 'ESP', 'IGMP', 'IGMPv1', 'IGMPv2', # Internet Layer + 'IP', 'IPsec', 'IPv4', 'IPv6', 'IPX', 'HIP', 'HOPOPT', 'IPv6_Frag', 'IPv6_Ext', 'IPv6_Opts', 'IPv6_Route', 'MH', # IPv6 Extension Header ] diff --git a/pcapkit/protocols/internet/igmp.py b/pcapkit/protocols/internet/igmp.py new file mode 100644 index 0000000000..06ff670af5 --- /dev/null +++ b/pcapkit/protocols/internet/igmp.py @@ -0,0 +1,275 @@ +# -*- coding: utf-8 -*- +"""IGMP - Internet Group Management Protocol +================================================ + +.. module:: pcapkit.protocols.internet.igmp + +:mod:`pcapkit.protocols.internet.igmp` contains +:class:`~pcapkit.protocols.internet.igmp.IGMP`, the abstract base of +IGMPv1 (:rfc:`1112`) and IGMPv2 (:rfc:`2236`) messages. Both versions share +one 8-octet layout, so the base holds the schema, the data model and the read +path. :class:`~pcapkit.protocols.internet.igmpv1.IGMP` and +:class:`~pcapkit.protocols.internet.igmpv2.IGMP` set :attr:`version`. The +registry entry for IP protocol 2 is this base, and :meth:`IGMP._dispatch +` picks the version class. + +.. versionadded:: 1.6.0 + + IGMPv1 (:rfc:`1112`) and IGMPv2 (:rfc:`2236`) (:issue:`2439`). + +""" + +import abc +from typing import TYPE_CHECKING, Generic, cast + +from pcapkit.const.igmp.type import Type as Enum_Type +from pcapkit.const.reg.transtype import TransType as Enum_TransType +from pcapkit.protocols.data.internet.igmp import IGMP as Data_IGMP +from pcapkit.protocols.internet.internet import Internet +from pcapkit.protocols.protocol import _PT, _ST +from pcapkit.protocols.schema.internet.igmp import IGMP as Schema_IGMP +from pcapkit.utilities.exceptions import ProtocolError + +if TYPE_CHECKING: + from enum import IntEnum as StdlibEnum + from ipaddress import IPv4Address + from typing import Any, Literal, Optional, Type + + from aenum import IntEnum as AenumEnum + + from pcapkit.protocols.protocol import ProtocolBase + from pcapkit.protocols.schema.schema import Schema + +__all__ = ['IGMP'] + + +class IGMP(Internet[_PT, _ST], Generic[_PT, _ST], + schema=Schema_IGMP, data=Data_IGMP): + """This class implements Internet Group Management Protocol. + + The class is abstract: :attr:`version` is set by the concrete IGMPv1 and + IGMPv2 classes. :meth:`read` refuses a message that :meth:`_classify` does + not place in :attr:`version`. + + """ + + ########################################################################## + # Properties. + ########################################################################## + + @property + def name(self) -> 'Literal["Internet Group Management Protocol"]': + """Name of corresponding protocol.""" + return 'Internet Group Management Protocol' + + @property + @abc.abstractmethod + def version(self) -> 'Literal[1, 2]': + """IGMP version this class reads: ``1`` or ``2``.""" + + @property + def alias(self) -> 'str': + """Acronym of corresponding protocol, ``'IGMPv1'`` or ``'IGMPv2'``.""" + return f'IGMPv{self.version}' + + @property + def length(self) -> 'Literal[8]': + """Header length of current protocol.""" + return 8 + + ########################################################################## + # Methods. + ########################################################################## + + @classmethod + def id(cls) -> 'tuple[Literal["IGMP"], Literal["IGMPv1"], Literal["IGMPv2"]]': + """Index ID of the protocol. + + Returns: + Index ID of the protocol, the family name and both versions; the + concrete IGMPv1 and IGMPv2 classes narrow it to their own version. + + """ + return ('IGMP', 'IGMPv1', 'IGMPv2') + + def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> '_PT': + """Read Internet Group Management Protocol. + + Args: + length: Length of packet data. + **kwargs: Arbitrary keyword arguments. + + Returns: + Parsed packet data. + + Raises: + ProtocolError: If :meth:`_classify` does not place the message in + :attr:`version`. + + """ + if length is None: + length = len(self) + schema = cast('Schema_IGMP', self.__header__) + + if self._classify(schema.type, schema.max_resp_time, length) != self.version: + raise ProtocolError(f'{self.alias}: {schema.type!r} is not an {self.alias} ' + f'message ({length} octets)') + + igmp = Data_IGMP( + type=schema.type, + max_resp_time=schema.max_resp_time, + checksum=schema.checksum, + group=schema.group, + ) + # NOTE: IGMPv1/v2 has no next-protocol field, so trailing octets stay + # as ``Raw``. + return self._decode_next_layer(cast('_PT', igmp), None, length - self.length) + + def make(self, + type: 'Enum_Type | StdlibEnum | AenumEnum | str | int' = Enum_Type.IGMP_Membership_Query, + type_default: 'Optional[int]' = None, + type_namespace: 'Optional[dict[str, int] | dict[int, str] | Type[StdlibEnum] | Type[AenumEnum]]' = None, # pylint: disable=line-too-long + type_reversed: 'bool' = False, + max_resp_time: 'int' = 0, + checksum: 'bytes' = b'\x00\x00', + group: 'IPv4Address | str | bytes | int' = '0.0.0.0', # nosec: B104 + payload: 'bytes | ProtocolBase | Schema' = b'', + **kwargs: 'Any') -> '_ST': + """Make (construct) packet data. + + The version of a constructed message is decided by its ``type``, + ``max_resp_time`` and length (see :meth:`_classify`), not by the class + that built it. + + Args: + type: Message type. + type_default: Default value of message type. + type_namespace: Namespace of message type. + type_reversed: If the namespace is reversed. + max_resp_time: Max response time, in tenths of a second; zero for + an IGMPv1 message. + checksum: Checksum over the message with this field zeroed, no + pseudo-header (:rfc:`2236#section-2.3`). + group: Group address; ``0.0.0.0`` for a General Query. + payload: Payload of current instance. + **kwargs: Arbitrary keyword arguments. + + Returns: + Constructed packet data. + + Raises: + ProtocolError: If ``type`` is outside 0 to 255, or ``max_resp_time`` + is not a non-:obj:`bool` integer from 0 to 255. + + """ + type_value = self._make_index(type, type_default, namespace=type_namespace, + reversed=type_reversed, pack=False) + for name, value in (('type', type_value), ('max_resp_time', max_resp_time)): + if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= 255: + raise ProtocolError(f'{self.alias}: invalid {name}: {value!r}') + + return Schema_IGMP( + type=type_value, # type: ignore[arg-type] + max_resp_time=max_resp_time, + checksum=checksum, + group=group, + payload=payload, + ) # type: ignore[return-value] + + ########################################################################## + # Data models. + ########################################################################## + + @classmethod + def __index__(cls) -> 'Enum_TransType': # pylint: disable=invalid-index-returned + """Numeral registry index of the protocol. + + Returns: + Numeral registry index of the protocol in `IANA`_. + + .. _IANA: https://www.iana.org/assignments/protocol-numbers/protocol-numbers.xhtml + + """ + return Enum_TransType.IGMP # type: ignore[return-value] + + def __length_hint__(self) -> 'Literal[8]': + """Return an estimated length for the object.""" + return 8 + + ########################################################################## + # Utilities. + ########################################################################## + + @classmethod + def _make_data(cls, data: 'Data_IGMP') -> 'dict[str, Any]': # type: ignore[override] + """Create key-value pairs from ``data`` for protocol construction. + + Args: + data: protocol data + + Returns: + Key-value pairs for protocol construction. + + """ + return { + 'type': data.type, + 'max_resp_time': data.max_resp_time, + 'checksum': data.checksum, + 'group': data.group, + 'payload': cls._make_payload(data), + } + + @classmethod + def _classify(cls, type: 'Enum_Type | int', max_resp_time: 'int', + length: 'int') -> 'Literal[0, 1, 2]': + """Classify a parsed IGMP header as IGMPv1 or IGMPv2. + + A Membership Query is IGMPv1/v2 only at exactly 8 octets: IGMPv1 if + ``max_resp_time`` is zero, IGMPv2 otherwise. Longer queries are IGMPv3 + and other lengths are ignored (:rfc:`9776#section-7.1`). + + Args: + type: The header's message type. + max_resp_time: The header's max response time. + length: The whole IGMP message's length, in octets. + + Returns: + ``1`` or ``2`` for that version, ``0`` for neither. + + """ + if type == Enum_Type.IGMPv1_Membership_Report: + return 1 + if type in (Enum_Type.IGMPv2_Membership_Report, Enum_Type.IGMPv2_Leave_Group): + return 2 + if type == Enum_Type.IGMP_Membership_Query: + if length != 8: + return 0 + return 2 if max_resp_time else 1 + return 0 + + @classmethod + def _dispatch(cls, data: 'bytes', length: 'int') -> 'Type[ProtocolBase] | Type[Raw]': + """Pick the class that parses the IGMP message at the start of ``data``. + + The type and max response time are the first two octets, read here + before any class is built, and :meth:`_classify` decides the version. + + Args: + data: Payload of the IP layer, starting at the IGMP message. + length: The IGMP message's length, in octets. + + Returns: + :class:`~pcapkit.protocols.internet.igmpv1.IGMP` or + :class:`~pcapkit.protocols.internet.igmpv2.IGMP` for the version + the message classifies as; :class:`~pcapkit.protocols.misc.raw.Raw` + for any other message, which then keeps its octets undissected. + + """ + from pcapkit.protocols.internet.igmpv1 import IGMP as IGMPv1 # isort: skip # pylint: disable=import-outside-toplevel + from pcapkit.protocols.internet.igmpv2 import IGMP as IGMPv2 # isort: skip # pylint: disable=import-outside-toplevel + from pcapkit.protocols.misc.raw import Raw # isort: skip # pylint: disable=import-outside-toplevel + + if len(data) < 2: + return Raw + version = cls._classify(data[0], data[1], length) + return {1: IGMPv1, 2: IGMPv2}.get(version, Raw) diff --git a/pcapkit/protocols/internet/igmpv1.py b/pcapkit/protocols/internet/igmpv1.py new file mode 100644 index 0000000000..0ed7630223 --- /dev/null +++ b/pcapkit/protocols/internet/igmpv1.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +"""IGMPv1 - Internet Group Management Protocol version 1 +============================================================= + +.. module:: pcapkit.protocols.internet.igmpv1 + +:mod:`pcapkit.protocols.internet.igmpv1` contains +:class:`~pcapkit.protocols.internet.igmpv1.IGMP`, which implements an +extractor for Internet Group Management Protocol version 1 +(IGMPv1) [:rfc:`1112`]. It refuses a message of any other version, and a +Membership Query with a non-zero ``max_resp_time`` is IGMPv2, not IGMPv1. + +.. versionadded:: 1.6.0 + +""" +from typing import TYPE_CHECKING + +from pcapkit.protocols.data.internet.igmp import IGMP as Data_IGMP +from pcapkit.protocols.internet.igmp import IGMP as IGMPBase +from pcapkit.protocols.schema.internet.igmp import IGMP as Schema_IGMP + +if TYPE_CHECKING: + from typing import Literal + +__all__ = ['IGMP'] + + +class IGMP(IGMPBase[Data_IGMP, Schema_IGMP], + schema=Schema_IGMP, data=Data_IGMP): + """This class implements Internet Group Management Protocol version 1.""" + + ########################################################################## + # Properties. + ########################################################################## + + @property + def version(self) -> 'Literal[1]': + """IGMP version this class reads: ``1``.""" + return 1 + + ########################################################################## + # Methods. + ########################################################################## + + @classmethod + def id(cls) -> 'tuple[Literal["IGMP"], Literal["IGMPv1"]]': # type: ignore[override] + """Index ID of the protocol. + + Returns: + Index ID of the protocol, the family name and the version. + + """ + return ('IGMP', 'IGMPv1') diff --git a/pcapkit/protocols/internet/igmpv2.py b/pcapkit/protocols/internet/igmpv2.py new file mode 100644 index 0000000000..5a07e5cfdb --- /dev/null +++ b/pcapkit/protocols/internet/igmpv2.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +"""IGMPv2 - Internet Group Management Protocol version 2 +============================================================= + +.. module:: pcapkit.protocols.internet.igmpv2 + +:mod:`pcapkit.protocols.internet.igmpv2` contains +:class:`~pcapkit.protocols.internet.igmpv2.IGMP`, which implements an +extractor for Internet Group Management Protocol version 2 +(IGMPv2) [:rfc:`2236`]. It refuses a message of any other version; a +Membership Query with a zero ``max_resp_time`` is IGMPv1, not IGMPv2. + +.. versionadded:: 1.6.0 + +""" +from typing import TYPE_CHECKING + +from pcapkit.protocols.data.internet.igmp import IGMP as Data_IGMP +from pcapkit.protocols.internet.igmp import IGMP as IGMPBase +from pcapkit.protocols.schema.internet.igmp import IGMP as Schema_IGMP + +if TYPE_CHECKING: + from typing import Literal + +__all__ = ['IGMP'] + + +class IGMP(IGMPBase[Data_IGMP, Schema_IGMP], + schema=Schema_IGMP, data=Data_IGMP): + """This class implements Internet Group Management Protocol version 2.""" + + ########################################################################## + # Properties. + ########################################################################## + + @property + def version(self) -> 'Literal[2]': + """IGMP version this class reads: ``2``.""" + return 2 + + ########################################################################## + # Methods. + ########################################################################## + + @classmethod + def id(cls) -> 'tuple[Literal["IGMP"], Literal["IGMPv2"]]': # type: ignore[override] + """Index ID of the protocol. + + Returns: + Index ID of the protocol, the family name and the version. + + """ + return ('IGMP', 'IGMPv2') diff --git a/pcapkit/protocols/internet/internet.py b/pcapkit/protocols/internet/internet.py index 27061b0ca9..aac12d60ca 100644 --- a/pcapkit/protocols/internet/internet.py +++ b/pcapkit/protocols/internet/internet.py @@ -74,6 +74,10 @@ class Internet(ProtocolBase[_PT, _ST], Generic[_PT, _ST]): # pylint: disable=ab - :class:`pcapkit.protocols.internet.esp.ESP` * - :attr:`~pcapkit.const.reg.transtype.TransType.AH` - :class:`pcapkit.protocols.internet.ah.AH` + * - :attr:`~pcapkit.const.reg.transtype.TransType.IGMP` + - :class:`pcapkit.protocols.internet.igmp.IGMP`, which dispatches to + :class:`pcapkit.protocols.internet.igmpv1.IGMP` or + :class:`pcapkit.protocols.internet.igmpv2.IGMP` * - :attr:`~pcapkit.const.reg.transtype.TransType.IPv6_NoNxt` - :class:`pcapkit.protocols.misc.raw.Raw` * - :attr:`~pcapkit.const.reg.transtype.TransType.IPv6_Opts` @@ -121,6 +125,7 @@ class Internet(ProtocolBase[_PT, _ST], Generic[_PT, _ST]): # pylint: disable=ab Enum_TransType.IPv6_Frag: ModuleDescriptor('pcapkit.protocols.internet.ipv6_frag', 'IPv6_Frag'), Enum_TransType.ESP: ModuleDescriptor('pcapkit.protocols.internet.esp', 'ESP'), Enum_TransType.AH: ModuleDescriptor('pcapkit.protocols.internet.ah', 'AH'), + Enum_TransType.IGMP: ModuleDescriptor('pcapkit.protocols.internet.igmp', 'IGMP'), Enum_TransType.IPv6_NoNxt: ModuleDescriptor('pcapkit.protocols.misc.raw', 'Raw'), Enum_TransType.IPv6_Opts: ModuleDescriptor('pcapkit.protocols.internet.ipv6_opts', 'IPv6_Opts'), Enum_TransType.IPX_in_IP: ModuleDescriptor('pcapkit.protocols.internet.ipx', 'IPX'), @@ -416,7 +421,11 @@ class registered for ``proto``. Paired with it, the keywords that warn(message, ProtocolWarning, stacklevel=stacklevel()) return Raw, {'error': message} + from pcapkit.protocols.internet.igmp import IGMP as IGMPBase # isort: skip # pylint: disable=import-outside-toplevel + protocol = self._lookup_next_layer(self.__proto__, cast('int', proto)) + if protocol is IGMPBase: + protocol = IGMPBase._dispatch(file_, length) if depth and issubclass(protocol, Internet): return protocol, {'_exthdr_depth': depth} return protocol, {} diff --git a/pcapkit/protocols/schema/__init__.py b/pcapkit/protocols/schema/__init__.py index f10675e583..cf7cca6790 100644 --- a/pcapkit/protocols/schema/__init__.py +++ b/pcapkit/protocols/schema/__init__.py @@ -84,6 +84,7 @@ 'IPv6_Route', 'IPv6_Route_RoutingType', 'IPv6_Route_UnknownType', 'IPv6_Route_SourceRoute', 'IPv6_Route_Type2', 'IPv6_Route_RPL', + 'IGMP', 'IPv6', 'IPX', 'MH', diff --git a/pcapkit/protocols/schema/internet/__init__.py b/pcapkit/protocols/schema/internet/__init__.py index dbc944333d..f1e95b4a74 100644 --- a/pcapkit/protocols/schema/internet/__init__.py +++ b/pcapkit/protocols/schema/internet/__init__.py @@ -107,6 +107,9 @@ TunnelEncapsulationLimitOption as HOPOPT_TunnelEncapsulationLimitOption from pcapkit.protocols.schema.internet.hopopt import UnassignedOption as HOPOPT_UnassignedOption +# Internet Group Management Protocol +from pcapkit.protocols.schema.internet.igmp import IGMP + # Internet Protocol version 4 from pcapkit.protocols.schema.internet.ipv4 import EOOLOption as IPv4_EOOLOption from pcapkit.protocols.schema.internet.ipv4 import ESECOption as IPv4_ESECOption @@ -421,6 +424,9 @@ 'HOPOPT_LineIdentificationOption', 'HOPOPT_JumboPayloadOption', 'HOPOPT_HomeAddressOption', 'HOPOPT_IPDFFOption', + # Internet Group Management Protocol + 'IGMP', + # Internet Protocol version 4 'IPv4', 'IPv4_Option', diff --git a/pcapkit/protocols/schema/internet/igmp.py b/pcapkit/protocols/schema/internet/igmp.py new file mode 100644 index 0000000000..4313171458 --- /dev/null +++ b/pcapkit/protocols/schema/internet/igmp.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# mypy: disable-error-code=assignment +"""header schema for internet group management protocol""" + +from typing import TYPE_CHECKING + +from pcapkit.const.igmp.type import Type as Enum_Type +from pcapkit.corekit.fields.ipaddress import IPv4AddressField +from pcapkit.corekit.fields.misc import PayloadField +from pcapkit.corekit.fields.numbers import EnumField, UInt8Field +from pcapkit.corekit.fields.strings import BytesField +from pcapkit.protocols.schema.schema import Schema, schema_final + +__all__ = ['IGMP'] + +if TYPE_CHECKING: + from ipaddress import IPv4Address + + from pcapkit.protocols.protocol import ProtocolBase + + +@schema_final +class IGMP(Schema): + """Header schema for IGMP packet. + + The same 8-octet layout serves IGMPv1 (:rfc:`1112#page-11`) and IGMPv2 + (:rfc:`2236#section-2`): v1 leaves :attr:`max_resp_time` reserved at zero + rather than omitting the octet (:issue:`2439`). + + """ + + #: Message type. + type: 'Enum_Type' = EnumField(length=1, namespace=Enum_Type) + #: Max response time, in units of 1/10 second (zero/reserved outside an + #: IGMPv2 Membership Query). + max_resp_time: 'int' = UInt8Field() + #: Checksum. The 16-bit one's complement of the one's complement sum of + #: the message, with the checksum field itself taken as zero and no + #: pseudo-header involved -- the 8-octet message itself for IGMPv1 + #: (:rfc:`1112#page-11`), or the whole IP payload for IGMPv2 + #: (:rfc:`2236#section-2.3`). + checksum: 'bytes' = BytesField(length=2) + #: Group address. + group: 'IPv4Address' = IPv4AddressField() + #: Payload, empty for a standards-conformant IGMPv1/v2 message, since the + #: 8-octet header is the whole message. + payload: 'bytes' = PayloadField() + + if TYPE_CHECKING: + def __init__(self, type: 'Enum_Type', max_resp_time: 'int', checksum: 'bytes', # pylint: disable=unused-argument,super-init-not-called,multiple-statements,redefined-builtin + group: 'IPv4Address | str | bytes | int', + payload: 'bytes | ProtocolBase | Schema') -> 'None': ... diff --git a/pcapkit/vendor/__init__.py b/pcapkit/vendor/__init__.py index 0e2d4b698f..6cbaaf2628 100644 --- a/pcapkit/vendor/__init__.py +++ b/pcapkit/vendor/__init__.py @@ -110,6 +110,7 @@ def __getattr__(name: 'str') -> 'typing.NoReturn': from pcapkit.vendor.ftp import * from pcapkit.vendor.hip import * from pcapkit.vendor.http import * + from pcapkit.vendor.igmp import * from pcapkit.vendor.ipv4 import * from pcapkit.vendor.ipv6 import * from pcapkit.vendor.ipx import * @@ -142,6 +143,8 @@ def __getattr__(name: 'str') -> 'typing.NoReturn': 'HIP_Suite', 'HIP_Transport', 'HIP_EdDSACurve', # HTTP 'HTTP_ErrorCode', 'HTTP_Frame', 'HTTP_Method', 'HTTP_Setting', 'HTTP_StatusCode', + # IGMP + 'IGMP_Type', # IPv4 'IPv4_ClassificationLevel', 'IPv4_OptionClass', 'IPv4_OptionNumber', 'IPv4_ProtectionAuthority', 'IPv4_QSFunction', 'IPv4_RouterAlert', 'IPv4_ToSDelay', 'IPv4_ToSECN', 'IPv4_ToSPrecedence', diff --git a/pcapkit/vendor/igmp/__init__.py b/pcapkit/vendor/igmp/__init__.py new file mode 100644 index 0000000000..8e795032c5 --- /dev/null +++ b/pcapkit/vendor/igmp/__init__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# pylint: disable=unused-import, wrong-import-position +""":class:`~pcapkit.protocols.internet.igmp.IGMP` Vendor Crawlers +==================================================================== + +.. module:: pcapkit.vendor.igmp + +This module contains all vendor crawlers of +:class:`~pcapkit.protocols.internet.igmp.IGMP` implementations. Available +crawlers include: + +.. list-table:: + + * - :class:`IGMP_Type ` + - IGMP Type Numbers [*]_ + +.. [*] https://www.iana.org/assignments/igmp-type-numbers/igmp-type-numbers.xhtml + +""" + +from pcapkit.vendor.igmp.type import Type as IGMP_Type + +__all__ = ['IGMP_Type'] diff --git a/pcapkit/vendor/igmp/type.py b/pcapkit/vendor/igmp/type.py new file mode 100644 index 0000000000..e20b6c5baa --- /dev/null +++ b/pcapkit/vendor/igmp/type.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +"""IGMP Type Numbers +======================= + +.. module:: pcapkit.vendor.igmp.type + +This module contains the vendor crawler for **IGMP Type Numbers**, +which is automatically generating :class:`pcapkit.const.igmp.type.Type`. + +""" + +import csv +import sys + +from pcapkit.vendor.default import Vendor + +__all__ = ['Type'] + + +class Type(Vendor): + """IGMP Type Numbers""" + + #: Value limit checker. + FLAG = 'isinstance(value, int) and 0 <= value <= 255' + #: Link to registry. + LINK = 'https://www.iana.org/assignments/igmp-type-numbers/igmp-type-numbers-1.csv' + + def process(self, data: 'list[str]') -> 'tuple[list[str], list[str]]': + """Process CSV data. + + The registry's ``Type`` column is hexadecimal (``0x11``, or a range + such as ``0x01-0x08``), unlike the decimal columns + :meth:`~pcapkit.vendor.default.Vendor.process` parses by default, so + this overrides it to read every code and range endpoint with + :func:`int` in base 16. + + Args: + data: CSV data. + + Returns: + Enumeration fields and missing fields. + + """ + reader = csv.reader(data) + next(reader) # header + + enum = [] # type: list[str] + miss = [] # type: list[str] + for item in reader: + name = item[1] + rfcs = item[2] + + temp = [] # type: list[str] + for ref in self.split_references(rfcs): + if ref.startswith('RFC') and ref[3:].isdigit(): + temp.append(f'[:rfc:`{ref[3:]}`]') + else: + temp.append(f'[{ref}]'.replace('_', ' ')) + desc = self.wrap_comment('%s %s' % ( # pylint: disable=consider-using-f-string + name, ''.join(temp) if rfcs else '', + )) + + if '-' in item[0]: + start, stop = item[0].split('-') + miss.append(f'if {int(start, 16)} <= value <= {int(stop, 16)}:') + miss.append(f' #: {desc}') + miss.append(f" return cls._unregistered_member(value, '{self.safe_name(name)}')") + continue + + code = str(int(item[0], 16)) + renm = self.rename(name, code) + enum.append(f'#: {desc}\n {renm} = {code}') + return enum, miss + + +if __name__ == '__main__': + sys.exit(Type()) # type: ignore[arg-type] diff --git a/tests/const/test_const_enum_builtin_parity.py b/tests/const/test_const_enum_builtin_parity.py index a3940913ee..912ed8a9a5 100644 --- a/tests/const/test_const_enum_builtin_parity.py +++ b/tests/const/test_const_enum_builtin_parity.py @@ -377,11 +377,11 @@ def test_the_sweep_size_is_pinned(self) -> None: # tests.const.test_const_enum_get's narrower (IntEnum, IntFlag)-only # sweep. The int count includes GitHub issue #880's ngap.ProcedureCode # and ngap.ProtocolIE, which are on EnumRegistry. - self.assertEqual(len(ints), 117) + self.assertEqual(len(ints), 118) self.assertEqual(len(flags), 6) self.assertEqual(len(strs), 10) - self.assertEqual(len(self.enums), 133) - self.assertEqual(len({obj.__module__ for obj in self.enums}), 127) + self.assertEqual(len(self.enums), 134) + self.assertEqual(len({obj.__module__ for obj in self.enums}), 128) def test_every_registry_rejects_a_negative_value(self) -> None: """The registry-wide form of GitHub issue #647. diff --git a/tests/const/test_const_enum_get.py b/tests/const/test_const_enum_get.py index 836475a478..e274b547c2 100644 --- a/tests/const/test_const_enum_get.py +++ b/tests/const/test_const_enum_get.py @@ -297,7 +297,7 @@ def test_the_sweep_size_is_pinned(self) -> None: # pcapkit.protocols.application.ngap; neither needs an exception-set # entry, since both take the base's own get(key, default). names = {_qualname(obj) for obj in self.enums} - self.assertEqual(len(self.enums), 123) + self.assertEqual(len(self.enums), 124) for expected in (EXPECTED_TO_RESOLVE_ANYTHING, EXPECTED_WITHOUT_AN_INTEGER_DEFAULT, EXPECTED_WITHOUT_A_CACHEABLE_FALLBACK): self.assertTrue(expected.issubset(names), @@ -337,7 +337,7 @@ def test_every_integer_path_consults_the_default(self) -> None: # registry excused above: ``EXPECTED_TO_RESOLVE_ANYTHING`` and # ``EXPECTED_WITHOUT_A_CACHEABLE_FALLBACK``, with # ``EXPECTED_WITHOUT_AN_INTEGER_DEFAULT`` empty. - self.assertEqual(covered, 120) + self.assertEqual(covered, 121) def test_the_always_resolving_registries_have_nothing_to_fall_back_to(self) -> None: """The two registries excused from the sweep, and why. diff --git a/tests/const/test_const_enum_lookup.py b/tests/const/test_const_enum_lookup.py index 8b4094b51f..d9b3e732b3 100644 --- a/tests/const/test_const_enum_lookup.py +++ b/tests/const/test_const_enum_lookup.py @@ -200,7 +200,7 @@ def test_every_registry_enum_was_discovered(self) -> None: # ``_iter_const_int_flags``'s, and so are GitHub issue #880's # ngap.ProcedureCode and ngap.ProtocolIE, which are on EnumRegistry. names = {f'{obj.__module__}.{obj.__qualname__}' for obj in self.enums} - self.assertEqual(len(self.enums), 117) + self.assertEqual(len(self.enums), 118) self.assertTrue(EXPECTED_TO_REJECT_ZERO.issubset(names), f'expected reject-list entries missing from the sweep: ' f'{EXPECTED_TO_REJECT_ZERO - names}') diff --git a/tests/const/test_const_registry_protocol.py b/tests/const/test_const_registry_protocol.py index 6ee574f08f..9acc1ecf0b 100644 --- a/tests/const/test_const_registry_protocol.py +++ b/tests/const/test_const_registry_protocol.py @@ -846,8 +846,8 @@ def test_every_generated_const_module_is_accounted_for(self) -> None: generated = [path for path in all_files if path not in converted_relpaths and path not in EXCLUDED_FROM_INTENUM_CENSUS] - self.assertEqual(len(all_files), 127) - self.assertEqual(len(generated), 110) + self.assertEqual(len(all_files), 128) + self.assertEqual(len(generated), 111) for relpath in generated: with self.subTest(module=relpath): diff --git a/tests/protocols/internet/test_igmp_unit.py b/tests/protocols/internet/test_igmp_unit.py new file mode 100644 index 0000000000..391a59d98d --- /dev/null +++ b/tests/protocols/internet/test_igmp_unit.py @@ -0,0 +1,614 @@ +# -*- coding: utf-8 -*- +"""IGMPv1 and IGMPv2 messages parse and rebuild byte for byte (#2439). + +Each case parses its octets with the concrete class for the version it names, +:class:`~pcapkit.protocols.internet.igmpv1.IGMP` or +:class:`~pcapkit.protocols.internet.igmpv2.IGMP`; the abstract +:class:`~pcapkit.protocols.internet.igmp.IGMP` base cannot be built. A type +outside v1/v2, IGMPv3 (:rfc:`3376`) included, falls back to +:class:`~pcapkit.protocols.misc.raw.Raw`. + +""" + +from __future__ import annotations + +import io +import ipaddress +import unittest +import warnings + +from tests._support import reimport_once_per_class + +#: Test tier (#1958): hand-picked round-trip cases, not a sweep. +__tier__ = 'ALL' + + +def internet_checksum(data: 'bytes') -> 'bytes': + """Compute the IP-style ones'-complement checksum (:rfc:`1071#section-4.1`). + + Fixture builder only: pcapkit does not compute or validate IGMP checksums. + + Args: + data: Message octets, with the checksum field zeroed. + + Returns: + The 2-octet checksum. + + """ + if len(data) % 2: + data += b'\x00' + total = 0 + for i in range(0, len(data), 2): + total += (data[i] << 8) | data[i + 1] + while total >> 16: + total = (total & 0xffff) + (total >> 16) + return (~total & 0xffff).to_bytes(2, 'big') + + +def igmp_packet(type_: 'int', max_resp_time: 'int', group: 'str') -> 'bytes': + """Build a correctly-checksummed 8-octet IGMPv1/v2 message.""" + group_bytes = ipaddress.ip_address(group).packed + zeroed = bytes([type_, max_resp_time, 0, 0]) + group_bytes + checksum = internet_checksum(zeroed) + return bytes([type_, max_resp_time]) + checksum + group_bytes + + +def igmp_class(version: 'str'): + """Return the concrete class for ``version``, ``'IGMPv1'`` or ``'IGMPv2'``.""" + from pcapkit.protocols.internet import ( # pylint: disable=import-outside-toplevel + igmpv1, igmpv2) + return igmpv1.IGMP if version == 'IGMPv1' else igmpv2.IGMP + + +#: One fixture per message type IGMPv1 and IGMPv2 define, each a correctly +#: checksummed 8-octet message, with the version it is read as. Type +#: octets per :rfc:`1112#page-11` (``0x11``/``0x12``, v1) and +#: :rfc:`2236#section-2.1` (``0x11``/``0x16``/``0x17``, v2's own report and +#: leave, plus v1's report kept for backwards compatibility), cross-checked +#: against the IANA *IGMP Type Numbers* registry (``0x11``, ``0x12``, +#: ``0x16``, ``0x17``). +IGMP_PACKETS = { + # IGMPv1 Membership Query (General Query only -- v1 has no group-specific + # query): max_resp_time is reserved at zero (:rfc:`1112#page-11`). + 'v1-query': (igmp_packet(0x11, 0, '0.0.0.0'), 'IGMPv1'), + # IGMPv1 Membership Report. + 'v1-report': (igmp_packet(0x12, 0, '224.0.0.1'), 'IGMPv1'), + # IGMPv2 General Query: max_resp_time nonzero, in tenths of a second + # (:rfc:`2236#section-2.2`). + 'v2-query-general': (igmp_packet(0x11, 100, '0.0.0.0'), 'IGMPv2'), + # IGMPv2 Group-Specific Query. + 'v2-query-group': (igmp_packet(0x11, 100, '239.1.2.3'), 'IGMPv2'), + # IGMPv2 Version 2 Membership Report. + 'v2-report': (igmp_packet(0x16, 0, '239.1.2.3'), 'IGMPv2'), + # IGMPv2 Leave Group. + 'v2-leave': (igmp_packet(0x17, 0, '239.1.2.3'), 'IGMPv2'), +} + +#: An IGMPv3 Membership Report (:rfc:`3376#section-4.2`, type ``0x22``): +#: checksum and reserved fields zeroed, one group record declared, and one +#: (unparsed) group record's worth of octets. Read through the v1/v2 layout, +#: its "reserved" and "number of group records" octets would form a group +#: address of ``0.0.0.1``. +IGMPV3_REPORT = bytes.fromhex('2200000000000001') + bytes(8) + + +def igmp_query(max_resp_time: 'int', tail: 'bytes') -> 'bytes': + """Build a correctly-checksummed Membership Query (type ``0x11``) of + General Query shape, followed by ``tail``, with the checksum covering + the whole message as :rfc:`9776#section-4.1.2` specifies.""" + zeroed = bytes([0x11, max_resp_time, 0, 0]) + bytes(4) + tail + checksum = internet_checksum(zeroed) + return zeroed[:2] + checksum + zeroed[4:] + + +#: An IGMPv3 General Query (:rfc:`9776#section-4.1`): the 8 octets an +#: IGMPv2 Query also carries, then Resv/S/QRV (``0x02``), QQIC (``125``) and +#: a zero Number of Sources -- 12 octets in all. The type octet and the first +#: 8 octets alone cannot tell it from an IGMPv2 General Query; only the +#: length does (:rfc:`9776#section-7.1`), so it must not be labelled IGMPv2. +IGMPV3_QUERY = igmp_query(100, bytes([0x02, 125, 0, 0])) + +#: A Membership Query of neither 8 nor 12-or-more octets, which +#: :rfc:`9776#section-7.1` says must be silently ignored: neither an +#: IGMPv1/v2 nor an IGMPv3 message. +IGMP_QUERY_10_OCTETS = igmp_query(100, b'\x00\x00') + + +class IGMPRoundTripTests(unittest.TestCase): + """Pin IGMP parsing and rebuild for every IGMPv1/v2 message type.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_from_data_rebuilds_byte_for_byte(self) -> None: + for name, (packet, version) in IGMP_PACKETS.items(): + with self.subTest(packet=name): + cls = igmp_class(version) + parsed = cls(io.BytesIO(packet), len(packet)) + self.assertEqual(cls.from_data(parsed.info).data, packet) + + def test_checksum_is_parsed_correctly(self) -> None: + """The fixture's checksum is the RFC-correct ones'-complement checksum + of the 8-octet message, computed independently by + :func:`internet_checksum`, and the parser reports exactly that value. + """ + for name, (packet, version) in IGMP_PACKETS.items(): + with self.subTest(packet=name): + header, checksum, rest = packet[:2], packet[2:4], packet[4:] + expected = internet_checksum(header + b'\x00\x00' + rest) + self.assertEqual(checksum, expected) + + cls = igmp_class(version) + parsed = cls(io.BytesIO(packet), len(packet)) + self.assertEqual(parsed.info.checksum, expected) + + def test_type_and_group_are_parsed(self) -> None: + from pcapkit.const.igmp.type import Type + from pcapkit.protocols.internet.igmpv2 import IGMP + + parsed = IGMP(io.BytesIO(IGMP_PACKETS['v2-report'][0]), 8) + self.assertEqual(parsed.info.type, Type.IGMPv2_Membership_Report) + self.assertEqual(parsed.info.max_resp_time, 0) + self.assertEqual(str(parsed.info.group), '239.1.2.3') + + query = IGMP(io.BytesIO(IGMP_PACKETS['v2-query-general'][0]), 8) + self.assertEqual(query.info.type, Type.IGMP_Membership_Query) + self.assertEqual(query.info.max_resp_time, 100) + self.assertEqual(str(query.info.group), '0.0.0.0') + + def test_trailing_octets_are_kept_as_raw_and_rebuilt(self) -> None: + """Octets past the 8-octet header have no next-protocol field to + dispatch on, so they are kept as + :class:`~pcapkit.protocols.misc.raw.Raw`, as :meth:`ESP.read + ` keeps its own trailer. + """ + from pcapkit.protocols.internet.igmpv2 import IGMP + from pcapkit.protocols.misc.raw import Raw + + packet = IGMP_PACKETS['v2-report'][0] + b'XY' + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + parsed = IGMP(io.BytesIO(packet), len(packet)) + + self.assertIsInstance(parsed.payload, Raw) + self.assertEqual(parsed.payload.info.packet, b'XY') + self.assertEqual(IGMP.from_data(parsed.info).data, packet) + + def test_no_trailing_octets_have_no_payload(self) -> None: + from pcapkit.protocols.internet.igmpv1 import IGMP + from pcapkit.protocols.misc.null import NoPayload + + parsed = IGMP(io.BytesIO(IGMP_PACKETS['v1-query'][0]), 8) + self.assertIsInstance(parsed.payload, NoPayload) + + def test_non_v1_v2_type_raises_protocol_error(self) -> None: + """Reading a type outside the IGMPv1/v2 values, such as an IGMPv3 + Membership Report (``0x22``), must be refused rather than decoded + through the v1/v2 8-octet layout, which would misread the "reserved" + and "number of group records" octets as a group address (#2439). + """ + from pcapkit.protocols.internet.igmpv1 import IGMP as IGMPv1 + from pcapkit.protocols.internet.igmpv2 import IGMP as IGMPv2 + from pcapkit.utilities.exceptions import ProtocolError + + for cls in (IGMPv1, IGMPv2): + with self.subTest(cls=cls.__module__), self.assertRaises(ProtocolError): + cls(io.BytesIO(IGMPV3_REPORT), len(IGMPV3_REPORT)) + + def test_non_v1_v2_type_falls_back_to_raw_on_dispatch(self) -> None: + """Dispatched as an IPv4 payload, the same IGMPv3 Membership Report + must fall back to :class:`~pcapkit.protocols.misc.raw.Raw` via + :func:`~pcapkit.corekit.decorators.beholder` -- not be mis-decoded as + IGMP type ``0x22`` with ``group=0.0.0.1`` (#2439). + """ + from pcapkit.protocols.internet.ipv4 import IPv4 + from pcapkit.protocols.misc.raw import Raw + + made = IPv4(protocol=2, src='10.0.0.1', dst='224.0.0.1', payload=IGMPV3_REPORT) + packet = made.data + + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + parsed = IPv4(io.BytesIO(packet), len(packet)) + + self.assertIsInstance(parsed.payload, Raw) + self.assertEqual(parsed.payload.info.packet, IGMPV3_REPORT) + + def test_non_8_octet_query_raises_protocol_error(self) -> None: + """A Membership Query is IGMPv1/v2 only at exactly 8 octets + (:rfc:`9776#section-7.1`): a 12-octet IGMPv3 General Query and a + 10-octet Query must be refused, not labelled ``IGMPv2`` on the strength + of a nonzero Max Resp Code (#2439). + """ + from pcapkit.protocols.internet.igmpv1 import IGMP as IGMPv1 + from pcapkit.protocols.internet.igmpv2 import IGMP as IGMPv2 + from pcapkit.utilities.exceptions import ProtocolError + + for cls in (IGMPv1, IGMPv2): + for name, packet in (('v3-query', IGMPV3_QUERY), ('10-octet-query', IGMP_QUERY_10_OCTETS)): + with self.subTest(cls=cls.__module__, packet=name), self.assertRaises(ProtocolError): + cls(io.BytesIO(packet), len(packet)) + + def test_non_8_octet_query_falls_back_to_raw_on_dispatch(self) -> None: + """Dispatched as an IPv4 payload, a non-8-octet Membership Query + falls back to :class:`~pcapkit.protocols.misc.raw.Raw`, exactly as an + IGMPv3 Membership Report does, rather than reading as + ``IPv4:IGMPv2:Raw`` (#2439). + """ + from pcapkit.protocols.internet.ipv4 import IPv4 + from pcapkit.protocols.misc.raw import Raw + + for name, payload in (('v3-query', IGMPV3_QUERY), ('10-octet-query', IGMP_QUERY_10_OCTETS)): + with self.subTest(packet=name): + packet = IPv4(protocol=2, src='10.0.0.1', dst='224.0.0.1', payload=payload).data + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + parsed = IPv4(io.BytesIO(packet), len(packet)) + + self.assertIsInstance(parsed.payload, Raw) + self.assertEqual(parsed.payload.info.packet, payload) + self.assertNotIn('IGMPv2', str(parsed.protochain)) + + +class IGMPReadTests(unittest.TestCase): + """Pin the version identification of :meth:`read` and its one refusal + path, including the one ambiguous message type (#2439). + """ + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_alias_names_the_version_read(self) -> None: + """``alias`` follows the concrete class, and each concrete class is an + instance of the :class:`~pcapkit.protocols.internet.igmp.IGMP` base. + """ + from pcapkit.protocols.internet.igmp import IGMP as IGMPBase + + for name, (packet, version) in IGMP_PACKETS.items(): + with self.subTest(packet=name): + parsed = igmp_class(version)(io.BytesIO(packet), len(packet)) + self.assertIsInstance(parsed, IGMPBase) + self.assertEqual(parsed.alias, version) + + def test_refusals_name_the_version_refused(self) -> None: + from pcapkit.protocols.internet.igmpv1 import IGMP as IGMPv1 + from pcapkit.protocols.internet.igmpv2 import IGMP as IGMPv2 + from pcapkit.utilities.exceptions import ProtocolError + + v1_report, v2_report = IGMP_PACKETS['v1-report'][0], IGMP_PACKETS['v2-report'][0] + with self.assertRaisesRegex(ProtocolError, r'^IGMPv1: .+ is not an IGMPv1 message \(8 octets\)'): + IGMPv1(io.BytesIO(v2_report), len(v2_report)) + with self.assertRaisesRegex(ProtocolError, r'^IGMPv2: .+ is not an IGMPv2 message \(8 octets\)'): + IGMPv2(io.BytesIO(v1_report), len(v1_report)) + + def test_membership_query_max_resp_time_zero_is_v1(self) -> None: + from pcapkit.protocols.internet.igmpv1 import IGMP + + packet = igmp_packet(0x11, 0, '0.0.0.0') + parsed = IGMP(io.BytesIO(packet), len(packet)) + self.assertEqual(parsed.alias, 'IGMPv1') + + def test_membership_query_max_resp_time_nonzero_is_v2(self) -> None: + from pcapkit.protocols.internet.igmpv2 import IGMP + + packet = igmp_packet(0x11, 1, '0.0.0.0') + parsed = IGMP(io.BytesIO(packet), len(packet)) + self.assertEqual(parsed.alias, 'IGMPv2') + + def test_classify_rejects_a_non_v1_v2_type(self) -> None: + from pcapkit.const.igmp.type import Type + from pcapkit.protocols.internet.igmp import IGMP + + self.assertEqual(IGMP._classify(Type.IGMPv3_Membership_Report, 0, 8), 0) + + def test_classify_reads_a_query_s_version_from_its_length(self) -> None: + """Only an 8-octet Membership Query is IGMPv1/v2 + (:rfc:`9776#section-7.1`); a Report or Leave keeps its version + whatever trails it (:rfc:`2236#section-2.5`). + """ + from pcapkit.const.igmp.type import Type + from pcapkit.protocols.internet.igmp import IGMP + + query = Type.IGMP_Membership_Query + self.assertEqual(IGMP._classify(query, 0, 8), 1) + self.assertEqual(IGMP._classify(query, 100, 8), 2) + for length in (9, 10, 11, 12, 16): + for max_resp_time in (0, 100): + with self.subTest(length=length, max_resp_time=max_resp_time): + self.assertEqual(IGMP._classify(query, max_resp_time, length), 0) + self.assertEqual(IGMP._classify(Type.IGMPv1_Membership_Report, 0, 10), 1) + self.assertEqual(IGMP._classify(Type.IGMPv2_Membership_Report, 0, 10), 2) + self.assertEqual(IGMP._classify(Type.IGMPv2_Leave_Group, 0, 10), 2) + + +class IGMPVersionedClassTests(unittest.TestCase): + """The concrete IGMPv1 and IGMPv2 classes, and the abstract base they + share.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_base_is_abstract(self) -> None: + from pcapkit.protocols.internet.igmp import IGMP + + self.assertIn('version', IGMP.__abstractmethods__) + packet = IGMP_PACKETS['v2-report'][0] + with self.assertRaises(TypeError): + IGMP(io.BytesIO(packet), len(packet)) # type: ignore[abstract] + + def test_id_and_version(self) -> None: + from pcapkit.protocols.internet.igmpv1 import IGMP as IGMPv1 + from pcapkit.protocols.internet.igmpv2 import IGMP as IGMPv2 + + self.assertEqual(IGMPv1.id(), ('IGMP', 'IGMPv1')) + self.assertEqual(IGMPv2.id(), ('IGMP', 'IGMPv2')) + self.assertEqual(object.__new__(IGMPv1).version, 1) + self.assertEqual(object.__new__(IGMPv2).version, 2) + + def test_base_id_covers_both_versions(self) -> None: + from pcapkit.protocols.internet.igmp import IGMP + + self.assertEqual(IGMP.id(), ('IGMP', 'IGMPv1', 'IGMPv2')) + + def test_each_version_narrows_the_base_id(self) -> None: + v1, v2 = igmp_class('IGMPv1'), igmp_class('IGMPv2') + self.assertEqual(v1.id(), ('IGMP', 'IGMPv1')) + self.assertEqual(v2.id(), ('IGMP', 'IGMPv2')) + self.assertNotIn('IGMPv2', v1.id()) + self.assertNotIn('IGMPv1', v2.id()) + + def test_igmpv1_parses_its_own_messages_directly(self) -> None: + from pcapkit.protocols.internet.igmpv1 import IGMP as IGMPv1 + + for name in ('v1-query', 'v1-report'): + packet, _version = IGMP_PACKETS[name] + with self.subTest(packet=name): + parsed = IGMPv1(io.BytesIO(packet), len(packet)) + self.assertEqual(parsed.alias, 'IGMPv1') + + def test_igmpv1_rejects_a_v2_only_message(self) -> None: + from pcapkit.protocols.internet.igmpv1 import IGMP as IGMPv1 + from pcapkit.utilities.exceptions import ProtocolError + + packet, _version = IGMP_PACKETS['v2-report'] + with self.assertRaises(ProtocolError): + IGMPv1(io.BytesIO(packet), len(packet)) + + def test_igmpv1_rejects_a_query_with_nonzero_max_resp_time(self) -> None: + from pcapkit.protocols.internet.igmpv1 import IGMP as IGMPv1 + from pcapkit.utilities.exceptions import ProtocolError + + packet, _version = IGMP_PACKETS['v2-query-general'] + with self.assertRaises(ProtocolError): + IGMPv1(io.BytesIO(packet), len(packet)) + + def test_igmpv2_parses_its_own_messages_directly(self) -> None: + from pcapkit.protocols.internet.igmpv2 import IGMP as IGMPv2 + + for name in ('v2-query-general', 'v2-query-group', 'v2-report', 'v2-leave'): + packet, _version = IGMP_PACKETS[name] + with self.subTest(packet=name): + parsed = IGMPv2(io.BytesIO(packet), len(packet)) + self.assertEqual(parsed.alias, 'IGMPv2') + + def test_igmpv2_rejects_a_v1_only_message(self) -> None: + from pcapkit.protocols.internet.igmpv2 import IGMP as IGMPv2 + from pcapkit.utilities.exceptions import ProtocolError + + packet, _version = IGMP_PACKETS['v1-report'] + with self.assertRaises(ProtocolError): + IGMPv2(io.BytesIO(packet), len(packet)) + + def test_igmpv2_rejects_a_query_with_zero_max_resp_time(self) -> None: + from pcapkit.protocols.internet.igmpv2 import IGMP as IGMPv2 + from pcapkit.utilities.exceptions import ProtocolError + + packet, _version = IGMP_PACKETS['v1-query'] + with self.assertRaises(ProtocolError): + IGMPv2(io.BytesIO(packet), len(packet)) + + def test_igmpv2_rejects_a_non_8_octet_query(self) -> None: + """A 12-octet IGMPv3 General Query or a 10-octet Query is refused by + the IGMPv2 class, as a v1 message is (:rfc:`9776#section-7.1`). + """ + from pcapkit.protocols.internet.igmpv2 import IGMP as IGMPv2 + from pcapkit.utilities.exceptions import ProtocolError + + for name, packet in (('v3-query', IGMPV3_QUERY), ('10-octet-query', IGMP_QUERY_10_OCTETS)): + with self.subTest(packet=name), self.assertRaises(ProtocolError): + IGMPv2(io.BytesIO(packet), len(packet)) + + def test_igmpv1_rejects_a_non_8_octet_query(self) -> None: + """Likewise for the IGMPv1 class, with a zero Max Resp Code so the + length is the only thing refusing it (:rfc:`9776#section-7.1`). + """ + from pcapkit.protocols.internet.igmpv1 import IGMP as IGMPv1 + from pcapkit.utilities.exceptions import ProtocolError + + for tail in (bytes(4), bytes(2)): + packet = igmp_query(0, tail) + with self.subTest(length=len(packet)), self.assertRaises(ProtocolError): + IGMPv1(io.BytesIO(packet), len(packet)) + + def test_versions_take_the_length_from_the_data_when_none_is_given(self) -> None: + """Without a ``length``, each version reads the whole of ``bytes`` + data: trailing octets become :class:`~pcapkit.protocols.misc.raw.Raw`, + and a Query longer than 8 octets is refused. + """ + from pcapkit.protocols.internet.igmpv1 import IGMP as IGMPv1 + from pcapkit.protocols.internet.igmpv2 import IGMP as IGMPv2 + from pcapkit.protocols.misc.raw import Raw + from pcapkit.utilities.exceptions import ProtocolError + + cases = ( + (IGMPv1, 'v1-report', igmp_query(0, bytes(4))), + (IGMPv2, 'v2-report', IGMPV3_QUERY), + ) + for cls, name, long_query in cases: + packet, version = IGMP_PACKETS[name] + with self.subTest(version=version): + parsed = cls(packet) + self.assertEqual(parsed.alias, version) + self.assertEqual(len(parsed), 8) + + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + trailed = cls(packet + b'XY') + self.assertIsInstance(trailed.payload, Raw) + self.assertEqual(trailed.payload.info.packet, b'XY') + + with self.assertRaisesRegex(ProtocolError, rf'\({len(long_query)} octets\)'): + cls(long_query) + + +class IGMPUnitTests(unittest.TestCase): + """Lower-level unit coverage, mirroring the AH/IPX unit tests.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_igmp_index_length_and_name(self) -> None: + from pcapkit.const.reg.transtype import TransType + from pcapkit.protocols.internet.igmp import IGMP + from pcapkit.protocols.internet.igmpv2 import IGMP as IGMPv2 + + proto = object.__new__(IGMPv2) + + self.assertEqual(IGMP.__index__(), TransType.IGMP) + self.assertEqual(proto.__length_hint__(), 8) + self.assertEqual(proto.length, 8) + self.assertEqual(proto.name, 'Internet Group Management Protocol') + self.assertEqual(proto.alias, 'IGMPv2') + + def test_make_data_round_trip(self) -> None: + from pcapkit.const.igmp.type import Type + from pcapkit.protocols.internet.igmp import IGMP + + class DummyDict(dict): + __getattr__ = dict.__getitem__ + + data = DummyDict( + type=Type.IGMPv2_Leave_Group, + max_resp_time=0, + checksum=b'\xab\xcd', + group=ipaddress.ip_address('239.8.8.8'), + __next_type__=None, + ) + values = IGMP._make_data(data) + self.assertEqual(values['type'], Type.IGMPv2_Leave_Group) + self.assertEqual(values['max_resp_time'], 0) + self.assertEqual(values['checksum'], b'\xab\xcd') + self.assertEqual(values['group'], ipaddress.ip_address('239.8.8.8')) + self.assertIn('payload', values) + + def test_make_builds_schema(self) -> None: + from pcapkit.const.igmp.type import Type + from pcapkit.protocols.internet.igmpv1 import IGMP + + proto = object.__new__(IGMP) + schema = proto.make( + type=Type.IGMPv1_Membership_Report, + group='224.0.0.1', + ) + + self.assertEqual(schema.type, Type.IGMPv1_Membership_Report) + self.assertEqual(schema.max_resp_time, 0) + self.assertEqual(schema.checksum, b'\x00\x00') + self.assertEqual(bytes(schema)[4:8], ipaddress.ip_address('224.0.0.1').packed) + + def test_make_rejects_out_of_range_octets(self) -> None: + from pcapkit.protocols.internet.igmpv1 import IGMP + from pcapkit.utilities.exceptions import ProtocolError + + proto = object.__new__(IGMP) + for field in ('type', 'max_resp_time'): + for value in (256, -1): + with self.subTest(field=field, value=value), \ + self.assertRaisesRegex(ProtocolError, rf'invalid {field}: {value}'): + proto.make(**{field: value}) + + def test_make_rejects_non_int_max_resp_time(self) -> None: + from pcapkit.protocols.internet.igmpv1 import IGMP + from pcapkit.utilities.exceptions import ProtocolError + + proto = object.__new__(IGMP) + for value in (1.5, '3', None, True, False): + with self.subTest(value=value), \ + self.assertRaisesRegex(ProtocolError, 'invalid max_resp_time'): + proto.make(max_resp_time=value) # type: ignore[arg-type] + + def test_make_rejects_bool_type(self) -> None: + from pcapkit.protocols.internet.igmpv1 import IGMP + from pcapkit.utilities.exceptions import ProtocolError + + proto = object.__new__(IGMP) + for value in (True, False): + with self.subTest(value=value), \ + self.assertRaisesRegex(ProtocolError, 'invalid type'): + proto.make(type=value) # type: ignore[arg-type] + + def test_max_resp_time_bounds_round_trip(self) -> None: + for value, version in ((0, 'IGMPv1'), (255, 'IGMPv2')): + with self.subTest(max_resp_time=value): + cls = igmp_class(version) + built = cls(type=0x11, max_resp_time=value) + packet = built.data + parsed = cls(io.BytesIO(packet), len(packet)) + self.assertEqual(packet[1], value) + self.assertEqual(parsed.info.max_resp_time, value) + self.assertEqual(parsed.alias, version) + self.assertEqual(cls.from_data(parsed.info).data, packet) + + +class IGMPDispatchTests(unittest.TestCase): + """The IP protocol 2 registry entry is the abstract base, which picks the + version class per message (#2439).""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_registry_entry_is_the_abstract_base(self) -> None: + from pcapkit.const.reg.transtype import TransType + from pcapkit.protocols.internet.igmp import IGMP + from pcapkit.protocols.internet.internet import Internet + + self.assertIs(Internet.__proto__[TransType.IGMP].klass, IGMP) + + def test_dispatch_selects_version_per_message(self) -> None: + from pcapkit.protocols.internet.igmp import IGMP + from pcapkit.protocols.misc.raw import Raw + + for name, (packet, version) in IGMP_PACKETS.items(): + with self.subTest(packet=name): + self.assertIs(IGMP._dispatch(packet, len(packet)), igmp_class(version)) + for name, packet in (('v3-report', IGMPV3_REPORT), ('v3-query', IGMPV3_QUERY), + ('10-octet-query', IGMP_QUERY_10_OCTETS)): + with self.subTest(packet=name): + self.assertIs(IGMP._dispatch(packet, len(packet)), Raw) + self.assertIs(IGMP._dispatch(b'', 0), Raw) + + def test_base_refuses_instantiation(self) -> None: + from pcapkit.protocols.internet.igmp import IGMP + + self.assertIn('version', IGMP.__abstractmethods__) + with self.assertRaises(TypeError): + IGMP(io.BytesIO(IGMP_PACKETS['v2-report'][0]), 8) # type: ignore[abstract] + + def test_ipv4_dispatches_igmp_to_each_version(self) -> None: + from pcapkit.protocols.internet.igmpv1 import IGMP as IGMPv1 + from pcapkit.protocols.internet.igmpv2 import IGMP as IGMPv2 + from pcapkit.protocols.internet.ipv4 import IPv4 + + for name, (packet, version) in IGMP_PACKETS.items(): + with self.subTest(packet=name): + outer = IPv4(protocol=2, src='10.0.0.1', dst='224.0.0.1', payload=packet).data + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + parsed = IPv4(io.BytesIO(outer), len(outer)) + expected = IGMPv1 if version == 'IGMPv1' else IGMPv2 + self.assertIsInstance(parsed.payload, expected) + self.assertEqual(parsed.payload.alias, version) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/protocols/test_dispatch_registry_unit.py b/tests/protocols/test_dispatch_registry_unit.py index 43ca51e9f7..152a441229 100644 --- a/tests/protocols/test_dispatch_registry_unit.py +++ b/tests/protocols/test_dispatch_registry_unit.py @@ -226,11 +226,12 @@ def test_cases_cover_every_table_named_in_the_issue(self) -> None: # The tables of #496, including #904's Internet.__proto__[TransType.Shim6] # registered at IPv6_Ext rather than defaulting to Raw, plus #1574's # LinkType.NULL and LinkType.LOOP in both link-type tables, the four - # address families of Loopback.__proto__, and #2438's + # address families of Loopback.__proto__, #2438's # Frame.__proto__[LinkType.LINUX_SLL]/[LinkType.LINUX_SLL2] in both the - # pcap and pcapng frame tables. - self.assertEqual(len(cases), 53, - 'expected exactly 53 entries across the seven __proto__ ' + # pcap and pcapng frame tables, and #2439's IGMP. + self.assertEqual(len(cases), 54, + 'expected exactly 54 entries across the seven __proto__ ' + 'tables named in GitHub issue #496 and the loopback table ' 'of #1574; a different count means a registry changed ' 'shape and this module was not updated to match') @@ -239,7 +240,7 @@ def test_cases_cover_every_table_named_in_the_issue(self) -> None: for case in cases: by_family[case.family] = by_family.get(case.family, 0) + 1 self.assertEqual(by_family, { - 'link': 7, 'internet': 17, 'tcp': 5, 'udp': 4, 'sctp': 2, + 'link': 7, 'internet': 18, 'tcp': 5, 'udp': 4, 'sctp': 2, 'pcap-frame': 7, 'pcapng-frame': 7, 'loopback': 4, })