From c5af34e7bcf11415686436eb4d3c69b78ae790f7 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Fri, 30 Oct 2026 22:45:31 -0400 Subject: [PATCH 1/4] feat(internet): implement IGMPv3 (#2448) - IGMPv1/v2 messages and an IGMPv3 Membership Query share one GroupBody schema; a Query of at least 12 octets (RFC 3376 7.1) appends a QueryExtension (Resv/S Flag/QRV, QQIC, source list). - An IGMPv3 Membership Report (0x22) gets a ReportBody schema: a group record list covering the six RFC 3376 4.2.12 record types. Octets past the last record (Additional Data) stay as payload. - Max Resp Code and QQIC decode RFC 3376 4.1.1's floating-point encoding for codes >= 128. Reserved bits round-trip byte-exact. - IGMP.length is never below the 8-octet fixed header, so a cut header falls back to Raw instead of zero-filled fields. - Adds Type and RecordType const enumerations, exported from pcapkit.const, and wires IGMP into the Internet dispatch table. - A GroupBody unpacked on its own reads the extension by length alone. - The round-trip harness applies its overrides before selectors read them; IGMP's minimal type is pinned to a Query, since type 0 is reserved. --- docs/source/contributing/pep.rst | 31 +- docs/source/pcapkit/const/igmp.rst | 17 + .../pcapkit/protocols/internet/igmp.rst | 12 +- .../pcapkit/protocols/internet/igmpv3.rst | 105 +++ .../pcapkit/protocols/internet/index.rst | 1 + docs/source/pcapkit/vendor/igmp.rst | 19 + pcapkit/__init__.py | 2 +- pcapkit/all.py | 2 +- pcapkit/const/__init__.py | 2 +- pcapkit/const/igmp/__init__.py | 6 +- pcapkit/const/igmp/record_type.py | 73 ++ pcapkit/protocols/__init__.py | 2 +- pcapkit/protocols/data/__init__.py | 3 + pcapkit/protocols/data/internet/__init__.py | 6 +- pcapkit/protocols/data/internet/igmp.py | 2 +- pcapkit/protocols/data/internet/igmpv3.py | 133 ++++ pcapkit/protocols/internet/__init__.py | 3 +- pcapkit/protocols/internet/igmp.py | 77 +- pcapkit/protocols/internet/igmpv3.py | 502 ++++++++++++ pcapkit/protocols/internet/internet.py | 5 +- pcapkit/protocols/schema/__init__.py | 1 + pcapkit/protocols/schema/internet/__init__.py | 8 +- pcapkit/protocols/schema/internet/igmpv3.py | 331 ++++++++ pcapkit/vendor/__init__.py | 2 +- pcapkit/vendor/igmp/__init__.py | 6 +- pcapkit/vendor/igmp/record_type.py | 88 +++ tests/const/test_const_enum_builtin_parity.py | 6 +- tests/const/test_const_enum_get.py | 4 +- tests/const/test_const_enum_lookup.py | 2 +- tests/const/test_const_registry_protocol.py | 4 +- tests/corekit/test_schema_roundtrip_unit.py | 13 +- .../test_rfc_constants_one_home_1739_unit.py | 3 + tests/protocols/internet/test_igmp_unit.py | 737 +++++++++++++++--- .../protocols/test_dispatch_registry_unit.py | 2 +- 34 files changed, 2027 insertions(+), 183 deletions(-) create mode 100644 docs/source/pcapkit/protocols/internet/igmpv3.rst create mode 100644 pcapkit/const/igmp/record_type.py create mode 100644 pcapkit/protocols/data/internet/igmpv3.py create mode 100644 pcapkit/protocols/internet/igmpv3.py create mode 100644 pcapkit/protocols/schema/internet/igmpv3.py create mode 100644 pcapkit/vendor/igmp/record_type.py diff --git a/docs/source/contributing/pep.rst b/docs/source/contributing/pep.rst index 5ba6486462..314f8042eb 100644 --- a/docs/source/contributing/pep.rst +++ b/docs/source/contributing/pep.rst @@ -1082,17 +1082,23 @@ post-wave-1 consistency sweep described under `Release Plan — 1.5.0 in Two Steps`_ above. **Wave 2 — the protocol pairs that want a shared abstract base**, per `More -Protocols, More!!!`_ above: **ICMP with ICMPv6** and **TLS/SSL with DTLS**, -plus **IGMP** and **DCCP** on their own. These are grouped because the shared-base -question is the design work and doing either half of a pair alone would answer it -twice. **DCCP** is the transport-layer implementation, tracked by :issue:`2489`, and +Protocols, More!!!`_ above: **ICMP with ICMPv6**, **TLS/SSL with DTLS**, and +``LINUX_SLL`` with ``LINUX_SLL2``, plus **DCCP** on its own. These are +grouped because the shared-base question is the design work and doing either half of +a pair alone would answer it twice. ``LINUX_SLL`` matters out of proportion to its +size: every ``tcpdump -i any`` capture uses it, and neither variant has even a +stub. **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`. +**IGMP — done within wave 2** (:issue:`2439`, :issue:`2448`). +The three concrete classes :class:`IGMPv1 `, +:class:`IGMPv2 ` and +:class:`IGMPv3 ` share one read path on +the abstract :class:`~pcapkit.protocols.internet.igmp.IGMP` base, which +:meth:`~pcapkit.protocols.internet.igmp.IGMP._classify` dispatches to the right +one. IGMPv1/v2 share one 8-octet message shape; IGMPv3 (:rfc:`3376`) adds the +Membership Query's source-list extension and the Membership Report's +group-record list, each a shape of its own. **Done within wave 2 — TLS/SSL with DTLS.** The `DTLS`_ section above lists what is decoded: the shared :class:`~pcapkit.protocols.application.ssl.SSL` @@ -1132,9 +1138,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` **IGMPv3** group -records — ICMPv6 a wave 2 deliverable, IGMPv3 :issue:`2448`. Landing the -registry first would give a dispatch table with nothing but +:rfc:`3810` MLD, which rides in **ICMPv6** — still a wave 2 deliverable — and +:rfc:`3376` IGMP records, which :mod:`pcapkit.protocols.internet.igmp` now +dissects in full, including IGMPv3 (see above). 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 diff --git a/docs/source/pcapkit/const/igmp.rst b/docs/source/pcapkit/const/igmp.rst index 536f3880c7..ce848d264c 100644 --- a/docs/source/pcapkit/const/igmp.rst +++ b/docs/source/pcapkit/const/igmp.rst @@ -12,6 +12,8 @@ enumerations include: * - :class:`IGMP_Type ` - IGMP Type Numbers [*]_ + * - :class:`IGMP_RecordType ` + - IGMP Group Record Types [*]_ IGMP Type Numbers ================= @@ -26,6 +28,21 @@ which is automatically generated from :class:`pcapkit.vendor.igmp.type.Type`. :undoc-members: :show-inheritance: +IGMP Group Record Types +======================== + +.. module:: pcapkit.const.igmp.record_type + +This module contains the constant enumeration for **IGMP Group Record +Types**, which is automatically generated from +:class:`pcapkit.vendor.igmp.record_type.RecordType`. + +.. autoclass:: pcapkit.const.igmp.record_type.RecordType + :members: + :undoc-members: + :show-inheritance: + .. rubric:: Footnotes .. [*] https://www.iana.org/assignments/igmp-type-numbers/igmp-type-numbers.xhtml +.. [*] https://www.rfc-editor.org/rfc/rfc3376#section-4.2.12 diff --git a/docs/source/pcapkit/protocols/internet/igmp.rst b/docs/source/pcapkit/protocols/internet/igmp.rst index a72b1d538b..212cb1ea40 100644 --- a/docs/source/pcapkit/protocols/internet/igmp.rst +++ b/docs/source/pcapkit/protocols/internet/igmp.rst @@ -5,10 +5,14 @@ IGMP - Internet Group Management Protocol :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. +Internet Group Management Protocol (IGMP) [*]_ versions 1, 2 and 3. IGMPv1 +and IGMPv2 share the 8-octet structure below. +:class:`IGMPv1 ` and +:class:`IGMPv2 ` set :attr:`version` +and reuse this schema and data model as-is; IGMPv3's wire format differs +materially by message, so :class:`IGMPv3 ` +carries its own instead -- see :doc:`igmpv3`. :attr:`alias` names the version +of each message read. ======= ========= ========================= =================================== Octets Bits Name Description diff --git a/docs/source/pcapkit/protocols/internet/igmpv3.rst b/docs/source/pcapkit/protocols/internet/igmpv3.rst new file mode 100644 index 0000000000..8bb01a0dff --- /dev/null +++ b/docs/source/pcapkit/protocols/internet/igmpv3.rst @@ -0,0 +1,105 @@ +IGMPv3 - Internet Group Management Protocol version 3 +============================================================== + +.. module:: pcapkit.protocols.internet.igmpv3 + +:mod:`pcapkit.protocols.internet.igmpv3` contains +:class:`~pcapkit.protocols.internet.igmpv3.IGMP`, which implements an +extractor for Internet Group Management Protocol version 3 (IGMPv3) +[:rfc:`3376`]. Unlike :class:`IGMPv1 ` +and :class:`IGMPv2 `, which reuse the +abstract base's own 8-octet schema and data model (see +:mod:`~pcapkit.protocols.internet.igmp`), IGMPv3 defines two message shapes +of its own: + +An IGMPv3 Membership Query (type ``0x11``, shared with v1/v2) reuses the +8-octet layout IGMPv1/v2 share and, when at least 12 octets long overall, +appends a source-list extension: + +======= ========= ================================== =================================== +Octets Bits Name Description +======= ========= ================================== =================================== + 0 0 ``igmp.type`` Type + 1 8 ``igmp.body.max_resp_code`` Max Response Code + 2 16 ``igmp.body.checksum`` Checksum + 4 32 ``igmp.body.group`` Group Address + 8 64 ``igmp.body.ext.flags`` Resv / S Flag / QRV + 9 72 ``igmp.body.ext.qqic`` Querier's Query Interval Code + 10 80 ``igmp.body.ext.num_sources`` Number of Sources (N) + 12 96 ``igmp.body.ext.sources`` Source Address [1..N] +======= ========= ================================== =================================== + +An IGMPv3 Membership Report (type ``0x22``) has an unrelated, materially +different shape of its own: + +======= ========= ================================== =================================== +Octets Bits Name Description +======= ========= ================================== =================================== + 0 0 ``igmp.type`` Type + 1 8 ``igmp.body.reserved_1`` Reserved + 2 16 ``igmp.body.checksum`` Checksum + 4 32 ``igmp.body.reserved_2`` Reserved + 6 48 ``igmp.body.num_group_records`` Number of Group Records (M) + 8 64 ``igmp.body.records`` Group Record [1..M] +======= ========= ================================== =================================== + +.. autoclass:: pcapkit.protocols.internet.igmpv3.IGMP + :no-members: + :show-inheritance: + + .. autoproperty:: version + .. autoproperty:: length + + .. automethod:: id + + .. automethod:: read + .. automethod:: make + + .. automethod:: _make_group_record + .. automethod:: _make_data + +Header Schemas +-------------- + +.. module:: pcapkit.protocols.schema.internet.igmpv3 + +.. autoclass:: pcapkit.protocols.schema.internet.igmpv3.IGMP + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.schema.internet.igmpv3.Query + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.schema.internet.igmpv3.QueryExtension + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.schema.internet.igmpv3.Report + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.schema.internet.igmpv3.GroupRecord + :members: + :show-inheritance: + +Data Models +----------- + +.. module:: pcapkit.protocols.data.internet.igmpv3 + +.. autoclass:: pcapkit.protocols.data.internet.igmpv3.IGMP + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.data.internet.igmpv3.Query + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.data.internet.igmpv3.Report + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.data.internet.igmpv3.GroupRecord + :members: + :show-inheritance: diff --git a/docs/source/pcapkit/protocols/internet/index.rst b/docs/source/pcapkit/protocols/internet/index.rst index 20641c94dc..04f0e4ec6f 100644 --- a/docs/source/pcapkit/protocols/internet/index.rst +++ b/docs/source/pcapkit/protocols/internet/index.rst @@ -29,6 +29,7 @@ internet layer, with detailed implementation and methods. igmp igmpv1 igmpv2 + igmpv3 ipx .. todo:: diff --git a/docs/source/pcapkit/vendor/igmp.rst b/docs/source/pcapkit/vendor/igmp.rst index 4557a11257..bd93f2d8fd 100644 --- a/docs/source/pcapkit/vendor/igmp.rst +++ b/docs/source/pcapkit/vendor/igmp.rst @@ -12,6 +12,8 @@ vendor crawlers include: * - :class:`IGMP_Type ` - IGMP Type Numbers [*]_ + * - :class:`IGMP_RecordType ` + - IGMP Group Record Types [*]_ IGMP Type Numbers ================= @@ -25,6 +27,23 @@ which is automatically generating :class:`pcapkit.const.igmp.type.Type`. :members: FLAG, LINK :show-inheritance: +IGMP Group Record Types +======================== + +.. module:: pcapkit.vendor.igmp.record_type + +This module contains the vendor crawler for **IGMP Group Record Types**, +which is automatically generating +:class:`pcapkit.const.igmp.record_type.RecordType`. Unlike the type-number +crawler above, this is not an IANA-maintained registry -- the six values are +fixed in :rfc:`3376#section-4.2.12` itself -- so :meth:`~pcapkit.vendor.igmp.record_type.RecordType.request` +returns the table directly rather than fetching :attr:`LINK`. + +.. autoclass:: pcapkit.vendor.igmp.record_type.RecordType + :members: FLAG + :show-inheritance: + .. rubric:: Footnotes .. [*] https://www.iana.org/assignments/igmp-type-numbers/igmp-type-numbers.xhtml +.. [*] https://www.rfc-editor.org/rfc/rfc3376#section-4.2.12 diff --git a/pcapkit/__init__.py b/pcapkit/__init__.py index 19841fab0c..0abeae90cb 100644 --- a/pcapkit/__init__.py +++ b/pcapkit/__init__.py @@ -114,7 +114,7 @@ 'ARP', 'C_Tag', 'Ethernet', 'InARP', 'L2TP', # Link Layer 'L2TPv2', 'LinuxSLL', 'LinuxSLL1', 'LinuxSLL2', 'Loopback', 'S_Tag', 'VLAN', - 'AH', 'ESP', 'IGMP', 'IGMPv1', 'IGMPv2', # Internet Layer + 'AH', 'ESP', 'IGMP', 'IGMPv1', 'IGMPv2', 'IGMPv3', # 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 20b9563aa7..14e5173a18 100644 --- a/pcapkit/all.py +++ b/pcapkit/all.py @@ -122,7 +122,7 @@ 'Raw', # Raw Packet 'ARP', 'C_Tag', 'Ethernet', 'InARP', 'L2TP', 'L2TPv2', 'LinuxSLL', 'LinuxSLL1', 'LinuxSLL2', 'Loopback', 'S_Tag', 'VLAN', # Link Layer - 'AH', 'ESP', 'IGMP', 'IGMPv1', 'IGMPv2', # Internet Layer + 'AH', 'ESP', 'IGMP', 'IGMPv1', 'IGMPv2', 'IGMPv3', # Internet Layer 'IP', 'IPsec', 'IPv4', 'IPv6', 'IPX', 'HIP', 'HOPOPT', 'IPv6_Ext', 'IPv6_Frag', 'IPv6_Opts', 'IPv6_Route', 'MH', # IPv6 Extension Header diff --git a/pcapkit/const/__init__.py b/pcapkit/const/__init__.py index 06dae60c17..b49a8b5e30 100644 --- a/pcapkit/const/__init__.py +++ b/pcapkit/const/__init__.py @@ -50,7 +50,7 @@ # HTTP 'HTTP_ErrorCode', 'HTTP_Frame', 'HTTP_Method', 'HTTP_Setting', 'HTTP_Status', # IGMP - 'IGMP_Type', + 'IGMP_Type', 'IGMP_RecordType', # 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 index ddeaa53948..f75f28898e 100644 --- a/pcapkit/const/igmp/__init__.py +++ b/pcapkit/const/igmp/__init__.py @@ -13,11 +13,15 @@ * - :class:`IGMP_Type ` - IGMP Type Numbers [*]_ + * - :class:`IGMP_RecordType ` + - IGMP Group Record Types [*]_ .. [*] https://www.iana.org/assignments/igmp-type-numbers/igmp-type-numbers.xhtml +.. [*] https://www.rfc-editor.org/rfc/rfc3376#section-4.2.12 """ +from pcapkit.const.igmp.record_type import RecordType as IGMP_RecordType from pcapkit.const.igmp.type import Type as IGMP_Type -__all__ = ['IGMP_Type'] +__all__ = ['IGMP_Type', 'IGMP_RecordType'] diff --git a/pcapkit/const/igmp/record_type.py b/pcapkit/const/igmp/record_type.py new file mode 100644 index 0000000000..78d31b6e65 --- /dev/null +++ b/pcapkit/const/igmp/record_type.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# pylint: disable=line-too-long,consider-using-f-string +"""IGMP Group Record Types +============================= + +.. module:: pcapkit.const.igmp.record_type + +This module contains the constant enumeration for **IGMP Group Record Types**, +which is automatically generated from :class:`pcapkit.vendor.igmp.record_type.RecordType`. + +""" + +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__ = ['RecordType'] + + +class RecordType(EnumRegistry, IntEnum): + """[RecordType] IGMP Group Record Types""" + + MODE_IS_INCLUDE = 1 + + MODE_IS_EXCLUDE = 2 + + CHANGE_TO_INCLUDE_MODE = 3 + + CHANGE_TO_EXCLUDE_MODE = 4 + + ALLOW_NEW_SOURCES = 5 + + BLOCK_OLD_SOURCES = 6 + + @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') -> 'RecordType': + """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__)) + return cls._unregistered_member(value, 'Unassigned') diff --git a/pcapkit/protocols/__init__.py b/pcapkit/protocols/__init__.py index 5aab10622d..f05d7a85a2 100644 --- a/pcapkit/protocols/__init__.py +++ b/pcapkit/protocols/__init__.py @@ -54,7 +54,7 @@ 'LinuxSLL2', 'Loopback', 'S_Tag', 'VLAN', # Internet Layer - 'AH', 'ESP', 'IGMP', 'IGMPv1', 'IGMPv2', 'IP', 'IPsec', 'IPv4', 'IPv6', 'IPX', + 'AH', 'ESP', 'IGMP', 'IGMPv1', 'IGMPv2', 'IGMPv3', '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 4fee25a195..1b77450453 100644 --- a/pcapkit/protocols/data/__init__.py +++ b/pcapkit/protocols/data/__init__.py @@ -80,6 +80,9 @@ # Encapsulating Security Payload 'ESP', + # Internet Group Management Protocol + 'IGMP', 'IGMP_GroupAddressMessage', 'IGMP_GroupRecord', 'IGMP_Version3MembershipReport', + # Host Identity Protocol 'HIP', 'HIP_Control', 'HIP_LocatorData', 'HIP_Locator', 'HIP_HostIdentity', 'HIP_Lifetime', 'HIP_Flags', diff --git a/pcapkit/protocols/data/internet/__init__.py b/pcapkit/protocols/data/internet/__init__.py index bd7568364d..4b986859b6 100644 --- a/pcapkit/protocols/data/internet/__init__.py +++ b/pcapkit/protocols/data/internet/__init__.py @@ -106,6 +106,10 @@ # Internet Group Management Protocol from pcapkit.protocols.data.internet.igmp import IGMP +from pcapkit.protocols.data.internet.igmpv3 import GroupRecord as IGMPv3_GroupRecord +from pcapkit.protocols.data.internet.igmpv3 import IGMP as IGMPv3 +from pcapkit.protocols.data.internet.igmpv3 import Query as IGMPv3_Query +from pcapkit.protocols.data.internet.igmpv3 import Report as IGMPv3_Report # Internet Protocol version 4 from pcapkit.protocols.data.internet.ipv4 import EOOLOption as IPv4_EOOLOption @@ -417,7 +421,7 @@ 'HOPOPT_IPDFFOption', # Internet Group Management Protocol - 'IGMP', + 'IGMP', 'IGMPv3', 'IGMPv3_Query', 'IGMPv3_Report', 'IGMPv3_GroupRecord', # Internet Protocol version 4 'IPv4', diff --git a/pcapkit/protocols/data/internet/igmp.py b/pcapkit/protocols/data/internet/igmp.py index 485a8090a8..dd63734655 100644 --- a/pcapkit/protocols/data/internet/igmp.py +++ b/pcapkit/protocols/data/internet/igmp.py @@ -23,7 +23,7 @@ class IGMP(Protocol): 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. + scope here -- see :mod:`pcapkit.protocols.data.internet.igmpv3`. """ diff --git a/pcapkit/protocols/data/internet/igmpv3.py b/pcapkit/protocols/data/internet/igmpv3.py new file mode 100644 index 0000000000..e1e652425f --- /dev/null +++ b/pcapkit/protocols/data/internet/igmpv3.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +"""data model for internet group management protocol version 3""" + +from typing import TYPE_CHECKING + +from pcapkit.corekit.infoclass import info_final +from pcapkit.protocols.data.data import Data +from pcapkit.protocols.data.protocol import Protocol + +if TYPE_CHECKING: + from ipaddress import IPv4Address + + from pcapkit.const.igmp.record_type import RecordType as Enum_RecordType + from pcapkit.const.igmp.type import Type as Enum_Type + +__all__ = ['IGMP', 'Query', 'Report', 'GroupRecord'] + + +class IGMP(Protocol): + """Data model for Internet Group Management Protocol version 3. + + This is the common ancestor of the two message shapes IGMPv3 defines: + :class:`Query`, its Membership Query, extending the 8-octet group-address + header IGMPv1/v2 also use with a source-list extension; and + :class:`Report`, its Membership Report, whose body is a list of group + records instead (:issue:`2439`, :issue:`2448`). + + """ + + #: Message type. + type: 'Enum_Type' + #: Header length, in octets. IGMP has no header field of its own that + #: states this -- unlike :class:`~pcapkit.protocols.internet.ah.AH`, it + #: is computed from what :meth:`IGMP.read + #: ` actually consumed, the + #: same way :attr:`~pcapkit.protocols.data.internet.esp.ESP.length` is. + length: 'int' + + +@info_final +class Query(IGMP): + """Data model for an IGMPv3 Membership Query (:rfc:`3376#section-4.1`). + + Reached only once :meth:`IGMP.read ` + has confirmed the message classifies as IGMPv3 (at least 12 octets), so + the source-list extension fields below are always populated -- never + :data:`None`, unlike the single-class design IGMPv1/v2 predate this one + with. + + """ + + #: Max response code, as received on the wire; the raw octet + #: :attr:`max_resp_time` is decoded from (:rfc:`3376#section-4.1.1`). + max_resp_code: 'int' + #: Max response time, in units of 1/10 second, decoded from + #: :attr:`max_resp_code`: literal if below 128, else floating-point, + #: ``(mant | 0x10) << (exp + 3)`` (:rfc:`3376#section-4.1.1`). + max_resp_time: 'int' + #: Checksum. + checksum: 'bytes' + #: Group address: the IPv4 multicast group being queried, or ``0.0.0.0`` + #: on a General Query. + group: 'IPv4Address' + #: Resv, the 4 reserved high-order bits of the flags octet + #: (:rfc:`3376#section-4.1.4`), kept as read rather than assumed zero so + #: a round trip reproduces a non-conformant sender's octet exactly. + resv: 'int' + #: Suppress Router-Side Processing flag (:rfc:`3376#section-4.1.5`). + s_flag: 'bool' + #: Querier's Robustness Variable (:rfc:`3376#section-4.1.6`). + qrv: 'int' + #: Querier's Query Interval Code, as received on the wire. + qqic: 'int' + #: Querier's Query Interval, in seconds, decoded from :attr:`qqic` by the + #: same formula as :attr:`max_resp_time` (:rfc:`3376#section-4.1.7`). + qqi: 'int' + #: Source addresses being queried (:rfc:`3376#section-4.1.9`); an empty + #: :obj:`tuple` for a General or Group-Specific Query. + sources: 'tuple[IPv4Address, ...]' + + if TYPE_CHECKING: + def __init__(self, type: 'Enum_Type', length: 'int', max_resp_code: 'int', + max_resp_time: 'int', checksum: 'bytes', group: 'IPv4Address', + resv: 'int', s_flag: 'bool', qrv: 'int', qqic: 'int', qqi: 'int', + sources: 'tuple[IPv4Address, ...]') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements,redefined-builtin,line-too-long + + +@info_final +class GroupRecord(Data): + """Data model for an IGMPv3 group record (:rfc:`3376#section-4.2.4`).""" + + #: Record type. + record_type: 'Enum_RecordType' + #: Auxiliary data length, in 32-bit words. + aux_data_len: 'int' + #: Number of source addresses. + num_sources: 'int' + #: Multicast address this record pertains to. + multicast_address: 'IPv4Address' + #: Source addresses (:rfc:`3376#section-4.2.9`). + sources: 'tuple[IPv4Address, ...]' + #: Auxiliary data (:rfc:`3376#section-4.2.10`); always empty for a + #: standards-conformant IGMPv3 sender, but kept as read for a receiver. + aux_data: 'bytes' + + if TYPE_CHECKING: + def __init__(self, record_type: 'Enum_RecordType', aux_data_len: 'int', num_sources: 'int', + multicast_address: 'IPv4Address', sources: 'tuple[IPv4Address, ...]', + aux_data: 'bytes') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements + + +@info_final +class Report(IGMP): + """Data model for an IGMPv3 Membership Report (:rfc:`3376#section-4.2`).""" + + #: Reserved octet after :attr:`type` (:rfc:`3376#section-4.2.1`), kept as + #: read rather than assumed zero so a round trip reproduces a + #: non-conformant sender's octet exactly. + reserved_1: 'int' + #: Checksum. + checksum: 'bytes' + #: Reserved 16 bits before :attr:`num_group_records` + #: (:rfc:`3376#section-4.2.1`); see :attr:`reserved_1`. + reserved_2: 'bytes' + #: Number of group records, as received on the wire. + num_group_records: 'int' + #: Group records (:rfc:`3376#section-4.2.4`). + records: 'tuple[GroupRecord, ...]' + + if TYPE_CHECKING: + def __init__(self, type: 'Enum_Type', length: 'int', reserved_1: 'int', checksum: 'bytes', + reserved_2: 'bytes', num_group_records: 'int', + records: 'tuple[GroupRecord, ...]') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements,redefined-builtin diff --git a/pcapkit/protocols/internet/__init__.py b/pcapkit/protocols/internet/__init__.py index c09576918f..70b5f75132 100644 --- a/pcapkit/protocols/internet/__init__.py +++ b/pcapkit/protocols/internet/__init__.py @@ -19,6 +19,7 @@ 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.igmpv3 import IGMP as IGMPv3 from pcapkit.protocols.internet.ipv4 import IPv4 from pcapkit.protocols.internet.ipv6 import IPv6 from pcapkit.protocols.internet.ipx import IPX @@ -42,7 +43,7 @@ __all__ = [ 'ETHERTYPE', # Protocol Numbers - 'AH', 'ESP', 'IGMP', 'IGMPv1', 'IGMPv2', # Internet Layer + 'AH', 'ESP', 'IGMP', 'IGMPv1', 'IGMPv2', 'IGMPv3', # 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 index 06ff670af5..ca3d4eb60a 100644 --- a/pcapkit/protocols/internet/igmp.py +++ b/pcapkit/protocols/internet/igmp.py @@ -6,10 +6,13 @@ :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 +IGMPv1 (:rfc:`1112`), IGMPv2 (:rfc:`2236`) and IGMPv3 (:rfc:`3376`) messages. +IGMPv1 and IGMPv2 share one 8-octet layout, so the base holds that shared +schema, data model and read path. :class:`~pcapkit.protocols.internet.igmpv1.IGMP`, +:class:`~pcapkit.protocols.internet.igmpv2.IGMP` and +:class:`~pcapkit.protocols.internet.igmpv3.IGMP` set :attr:`version`; IGMPv3's +wire format differs materially (a Query's source-list extension, a Report's +group-record list), so it carries its own schema and data model instead. The registry entry for IP protocol 2 is this base, and :meth:`IGMP._dispatch ` picks the version class. @@ -17,6 +20,10 @@ IGMPv1 (:rfc:`1112`) and IGMPv2 (:rfc:`2236`) (:issue:`2439`). +.. versionadded:: 1.6.0 + + IGMPv3 (:rfc:`3376`) (:issue:`2448`). + """ import abc @@ -47,9 +54,9 @@ 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`. + The class is abstract: :attr:`version` is set by the concrete IGMPv1, + IGMPv2 and IGMPv3 classes. :meth:`read` refuses a message that + :meth:`_classify` does not place in :attr:`version`. """ @@ -64,12 +71,12 @@ def name(self) -> 'Literal["Internet Group Management Protocol"]': @property @abc.abstractmethod - def version(self) -> 'Literal[1, 2]': - """IGMP version this class reads: ``1`` or ``2``.""" + def version(self) -> 'Literal[1, 2, 3]': + """IGMP version this class reads: ``1``, ``2`` or ``3``.""" @property def alias(self) -> 'str': - """Acronym of corresponding protocol, ``'IGMPv1'`` or ``'IGMPv2'``.""" + """Acronym of corresponding protocol, ``'IGMPv1'``, ``'IGMPv2'`` or ``'IGMPv3'``.""" return f'IGMPv{self.version}' @property @@ -82,15 +89,16 @@ def length(self) -> 'Literal[8]': ########################################################################## @classmethod - def id(cls) -> 'tuple[Literal["IGMP"], Literal["IGMPv1"], Literal["IGMPv2"]]': + def id(cls) -> 'tuple[Literal["IGMP"], Literal["IGMPv1"], Literal["IGMPv2"], Literal["IGMPv3"]]': """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. + Index ID of the protocol, the family name and all three + versions; the concrete IGMPv1, IGMPv2 and IGMPv3 classes narrow + it to their own version. """ - return ('IGMP', 'IGMPv1', 'IGMPv2') + return ('IGMP', 'IGMPv1', 'IGMPv2', 'IGMPv3') def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> '_PT': """Read Internet Group Management Protocol. @@ -99,13 +107,13 @@ def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> '_PT': 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`. + Returns: + Parsed packet data. + """ if length is None: length = len(self) @@ -221,30 +229,39 @@ def _make_data(cls, data: 'Data_IGMP') -> 'dict[str, Any]': # type: ignore[over @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. + length: 'int') -> 'Literal[0, 1, 2, 3]': + """Classify a parsed IGMP header as IGMPv1, IGMPv2 or IGMPv3. 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`). + ``max_resp_time`` is zero, IGMPv2 otherwise. A Membership Query of 12 + or more octets is IGMPv3's Query, carrying the source-list extension + (:rfc:`3376#section-7.1`); one of 9 to 11 octets is neither and is + ignored. The IGMPv3 Membership Report (``0x22``) is always IGMPv3, + whatever its length. Args: type: The header's message type. - max_resp_time: The header's max response time. + max_resp_time: The header's max response time (the raw Max Resp + Code octet; unused for a type whose version does not depend + on it). length: The whole IGMP message's length, in octets. Returns: - ``1`` or ``2`` for that version, ``0`` for neither. + ``1``, ``2`` or ``3`` for that version, ``0`` for none of them. """ 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.IGMPv3_Membership_Report: + return 3 if type == Enum_Type.IGMP_Membership_Query: - if length != 8: - return 0 - return 2 if max_resp_time else 1 + if length == 8: + return 2 if max_resp_time else 1 + if length >= 12: + return 3 + return 0 return 0 @classmethod @@ -259,17 +276,19 @@ def _dispatch(cls, data: 'bytes', length: 'int') -> 'Type[ProtocolBase] | Type[R 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 + :class:`~pcapkit.protocols.internet.igmpv1.IGMP`, + :class:`~pcapkit.protocols.internet.igmpv2.IGMP` or + :class:`~pcapkit.protocols.internet.igmpv3.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.internet.igmpv3 import IGMP as IGMPv3 # 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) + return {1: IGMPv1, 2: IGMPv2, 3: IGMPv3}.get(version, Raw) diff --git a/pcapkit/protocols/internet/igmpv3.py b/pcapkit/protocols/internet/igmpv3.py new file mode 100644 index 0000000000..88ed450041 --- /dev/null +++ b/pcapkit/protocols/internet/igmpv3.py @@ -0,0 +1,502 @@ +# -*- coding: utf-8 -*- +"""IGMPv3 - Internet Group Management Protocol version 3 +============================================================== + +.. module:: pcapkit.protocols.internet.igmpv3 + +:mod:`pcapkit.protocols.internet.igmpv3` contains +:class:`~pcapkit.protocols.internet.igmpv3.IGMP`, which implements an +extractor for Internet Group Management Protocol version 3 (IGMPv3) +[:rfc:`3376`]. Unlike IGMPv1/v2, IGMPv3 defines two message shapes of its +own, both handled by this one class: + +An IGMPv3 Membership Query (type ``0x11``, shared with v1/v2) reuses the +8-octet layout IGMPv1/v2 share and, when at least 12 octets long overall, +appends a source-list extension: + +======= ========= ================================== =================================== +Octets Bits Name Description +======= ========= ================================== =================================== + 0 0 ``igmp.type`` Type + 1 8 ``igmp.body.max_resp_code`` Max Response Code + 2 16 ``igmp.body.checksum`` Checksum + 4 32 ``igmp.body.group`` Group Address + 8 64 ``igmp.body.ext.flags`` Resv / S Flag / QRV + 9 72 ``igmp.body.ext.qqic`` Querier's Query Interval Code + 10 80 ``igmp.body.ext.num_sources`` Number of Sources (N) + 12 96 ``igmp.body.ext.sources`` Source Address [1..N] +======= ========= ================================== =================================== + +An IGMPv3 Membership Report (type ``0x22``) has an unrelated, materially +different shape of its own: + +======= ========= ================================== =================================== +Octets Bits Name Description +======= ========= ================================== =================================== + 0 0 ``igmp.type`` Type + 1 8 ``igmp.body.reserved_1`` Reserved + 2 16 ``igmp.body.checksum`` Checksum + 4 32 ``igmp.body.reserved_2`` Reserved + 6 48 ``igmp.body.num_group_records`` Number of Group Records (M) + 8 64 ``igmp.body.records`` Group Record [1..M] +======= ========= ================================== =================================== + +:meth:`~pcapkit.protocols.internet.igmp.IGMP._classify` is what keeps an +IGMPv1/v2 message out of this class; :attr:`~pcapkit.protocols.internet.igmp.IGMP.version` +and :meth:`id` are this class's own. + +.. versionadded:: 1.6.0 + + IGMPv3 (:rfc:`3376`) (:issue:`2448`). + +""" +from typing import TYPE_CHECKING, cast + +from pcapkit.const.igmp.record_type import RecordType as Enum_RecordType +from pcapkit.const.igmp.type import Type as Enum_Type +from pcapkit.protocols.data.internet.igmpv3 import GroupRecord as Data_GroupRecord +from pcapkit.protocols.data.internet.igmpv3 import IGMP as Data_IGMP +from pcapkit.protocols.data.internet.igmpv3 import Query as Data_Query +from pcapkit.protocols.data.internet.igmpv3 import Report as Data_Report +from pcapkit.protocols.internet.igmp import IGMP as IGMPBase +from pcapkit.protocols.schema.internet.igmpv3 import IGMP as Schema_IGMP +from pcapkit.protocols.schema.internet.igmpv3 import GroupRecord as Schema_GroupRecord +from pcapkit.protocols.schema.internet.igmpv3 import Query as Schema_Query +from pcapkit.protocols.schema.internet.igmpv3 import QueryExtension as Schema_QueryExtension +from pcapkit.protocols.schema.internet.igmpv3 import Report as Schema_Report +from pcapkit.utilities.exceptions import ProtocolError + +if TYPE_CHECKING: + from enum import IntEnum as StdlibEnum + from ipaddress import IPv4Address + from typing import Any, Iterable, Optional, Type + + from aenum import IntEnum as AenumEnum + from typing_extensions import Literal + + from pcapkit.protocols.protocol import ProtocolBase + from pcapkit.protocols.schema.schema import Schema + +__all__ = ['IGMP'] + + +def _decode_resp_code(code: 'int') -> 'int': + """Decode a Max Resp Code/QQIC octet into its real value. + + Per :rfc:`3376#section-4.1.1` (:attr:`~pcapkit.protocols.data.internet.igmpv3.Query.max_resp_time`) + and :rfc:`3376#section-4.1.7` (:attr:`~pcapkit.protocols.data.internet.igmpv3.Query.qqi`) + alike: + + * if ``code < 128``, the value is ``code`` itself; + * otherwise ``code`` is a floating-point value ``1 exp(3) mant(4)``, and + the value is ``(mant | 0x10) << (exp + 3)``. + + Args: + code: the raw octet. + + Returns: + The decoded value. + + """ + if code < 128: + return code + exp = (code >> 4) & 0x07 + mant = code & 0x0F + return (mant | 0x10) << (exp + 3) + + +def _encode_resp_code(value: 'int') -> 'int': + """Encode a value into a Max Resp Code/QQIC octet. + + The reverse of :func:`_decode_resp_code`. A value below 128 round-trips + exactly. At or above it, this picks the smallest exponent whose mantissa + fits 4 bits, which round-trips exactly for any value that formula can + represent and otherwise floors to the nearest one it can (the same + lossy-above-127 trade-off :rfc:`3376#section-4.1.1` itself makes) -- + mirroring how :meth:`HIP._make_puzzle_lifetime + ` encodes its + own exponential field. + + Args: + value: the value to encode, in the same units as :func:`_decode_resp_code` + returns. + + Returns: + The raw octet. + + """ + if value < 0: + raise ProtocolError(f'IGMPv3: invalid Max Resp Time/QQI value: {value!r}') + if value < 128: + return value + + exp = 0 + mant_full = value >> 3 + while mant_full > 31 and exp < 7: + exp += 1 + mant_full = value >> (exp + 3) + mant_full = min(mant_full, 31) + + mant = mant_full & 0x0F + return 0x80 | (exp << 4) | mant + + +class IGMP(IGMPBase[Data_IGMP, Schema_IGMP], + schema=Schema_IGMP, data=Data_IGMP): + """This class implements Internet Group Management Protocol version 3. + + Unlike :class:`~pcapkit.protocols.internet.igmpv1.IGMP` and + :class:`~pcapkit.protocols.internet.igmpv2.IGMP`, which share the base's + own 8-octet schema and data model, IGMPv3's wire format differs + materially by message -- a Query's source-list extension, a Report's + group-record list -- so this class carries its own (:issue:`2439`, + :issue:`2448`). + + """ + + ########################################################################## + # Properties. + ########################################################################## + + @property + def version(self) -> 'Literal[3]': + """IGMP version this class reads: ``3``.""" + return 3 + + @property + def length(self) -> 'int': + """Header length of current protocol. + + Every message starts with an 8-octet header. A Query may carry the + source-list extension, and a Report's group-record list has no fixed + size, so this is taken during :meth:`read` from the octets consumed + (see :attr:`~pcapkit.protocols.data.internet.igmpv3.IGMP.length`) + rather than returned as a constant, unlike the base's own + :attr:`~pcapkit.protocols.internet.igmp.IGMP.length`. + + """ + return self._info.length + + ########################################################################## + # Methods. + ########################################################################## + + @classmethod + def id(cls) -> 'tuple[Literal["IGMP"], Literal["IGMPv3"]]': # type: ignore[override] + """Index ID of the protocol. + + Returns: + Index ID of the protocol, the family name and the version. + + """ + return ('IGMP', 'IGMPv3') + + def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_IGMP': + """Read Internet Group Management Protocol version 3. + + 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` 3 -- an IGMPv1/v2-shaped Query, or a type + neither version of IGMP defines. + + """ + if length is None: + length = len(self) + schema = cast('Schema_IGMP', self.__header__) + body = schema.body + + if isinstance(body, Schema_Query): + if self._classify(schema.type, body.max_resp_code, length) != self.version: + raise ProtocolError(f'{self.alias}: {schema.type!r} is not an {self.alias} ' + f'message ({length} octets)') + + # NOTE: The classify check above only accepts a Query of 12 or more + # octets, which is exactly when ``query_extension_present`` parses + # ``ext`` -- so it is never ``None`` here. + ext = cast('Schema_QueryExtension', body.ext) + resv = ext.flags['resv'] + s_flag = bool(ext.flags['s_flag']) + qrv = ext.flags['qrv'] + qqic = ext.qqic + qqi = _decode_resp_code(qqic) + max_resp_time = _decode_resp_code(body.max_resp_code) + sources = tuple(ext.sources) + trailing = ext.get_payload() + + igmp = Data_Query( + type=schema.type, + length=1 + len(body) - len(trailing), + max_resp_code=body.max_resp_code, + max_resp_time=max_resp_time, + checksum=body.checksum, + group=body.group, + resv=resv, + s_flag=s_flag, + qrv=qrv, + qqic=qqic, + qqi=qqi, + sources=sources, + ) # type: Data_IGMP + elif isinstance(body, Schema_Report): + records = tuple( + Data_GroupRecord( + record_type=record.record_type, + aux_data_len=record.aux_data_len, + num_sources=record.num_sources, + multicast_address=record.multicast_address, + sources=tuple(record.sources), + aux_data=record.aux_data, + ) for record in body.records + ) + trailing = body.payload + igmp = Data_Report( + type=schema.type, + length=1 + len(body) - len(trailing), + reserved_1=body.reserved_1, + checksum=body.checksum, + reserved_2=body.reserved_2, + num_group_records=body.num_group_records, + records=records, + ) + else: # pragma: no cover + raise ProtocolError(f'{self.alias}: unsupported message type: {schema.type!r}') + + return self._decode_next_layer(igmp, None, len(trailing)) + + def make(self, + type: 'Enum_Type | StdlibEnum | AenumEnum | str | int' = Enum_Type.IGMP_Membership_Query, # pylint: disable=redefined-builtin + 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_code: 'Optional[int]' = None, + max_resp_time: 'int' = 0, + checksum: 'bytes' = b'\x00\x00', + group: 'IPv4Address | str | bytes | int' = '0.0.0.0', # nosec: B104 + resv: 'int' = 0, + s_flag: 'bool' = False, + qrv: 'int' = 0, + qqic: 'Optional[int]' = None, + qqi: 'Optional[int]' = None, + sources: 'Optional[Iterable[IPv4Address | str | bytes | int]]' = None, + reserved_1: 'int' = 0, + reserved_2: 'bytes' = b'\x00\x00', + records: 'Optional[Iterable[dict[str, Any] | Data_GroupRecord]]' = None, + payload: 'bytes | ProtocolBase | Schema' = b'', + **kwargs: 'Any') -> 'Schema_IGMP': + """Make (construct) packet data. + + Building a Query (the default) always writes the source-list + extension: this class is specifically IGMPv3, unlike the base's own + :meth:`~pcapkit.protocols.internet.igmp.IGMP.make`, which writes the + extension only when asked, since there a plain 8-octet message is + the more natural default. + + 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_code: Max response code, as transmitted on the wire. When + not given, derived from ``max_resp_time`` via + :func:`_encode_resp_code`; only meaningful for a Query. + max_resp_time: Max response time, in units of 1/10 second: an + :obj:`int` from 0 to 255 (not a :obj:`bool`), else + :exc:`ProtocolError`. Ignored when ``max_resp_code`` is given. + checksum: Checksum; the one's complement of the one's complement + sum of the message, with this field taken as zero and no + pseudo-header -- the whole IP payload (:rfc:`3376#section-4.1.2`, + :rfc:`3376#section-4.2.2`). + group: Group address; ``0.0.0.0`` for a General Query; only + meaningful for a Query. + resv: The 4 reserved high-order bits of the flags octet + (:rfc:`3376#section-4.1.4`); ``0`` on a standards-conformant + sender; only meaningful for a Query. + s_flag: Suppress Router-Side Processing flag + (:rfc:`3376#section-4.1.5`); only meaningful for a Query. + qrv: Querier's Robustness Variable (:rfc:`3376#section-4.1.6`); + only meaningful for a Query. + qqic: Querier's Query Interval Code, as transmitted on the wire; + derived from ``qqi`` via :func:`_encode_resp_code` when not + given; only meaningful for a Query. + qqi: Querier's Query Interval, in seconds; ignored when ``qqic`` + is given; only meaningful for a Query. + sources: Source addresses being queried + (:rfc:`3376#section-4.1.9`); only meaningful for a Query. + reserved_1: The reserved octet following ``type`` + (:rfc:`3376#section-4.2.1`); only meaningful for a Report. + reserved_2: The reserved 16 bits before the group-record count + (:rfc:`3376#section-4.2.1`); only meaningful for a Report. + records: Group records (:rfc:`3376#section-4.2.4`), each either + a :class:`~pcapkit.protocols.data.internet.igmpv3.GroupRecord` + or a :obj:`dict` of the keyword arguments + :meth:`_make_group_record` takes; only meaningful for a + Report. + payload: Payload of current instance. + **kwargs: Arbitrary keyword arguments. + + Returns: + Constructed packet data. + + Raises: + ProtocolError: If ``max_resp_time`` is not an :obj:`int` from 0 + to 255, or ``type`` is neither a Query nor a Report. + + """ + type_value = self._make_index(type, type_default, namespace=type_namespace, + reversed=type_reversed, pack=False) + if isinstance(max_resp_time, bool) or not isinstance(max_resp_time, int) \ + or not 0 <= max_resp_time <= 255: + raise ProtocolError(f'{self.alias}: invalid max_resp_time: {max_resp_time!r}') + + if type_value == Enum_Type.IGMPv3_Membership_Report: + record_list = [] if records is None else [ + self._make_group_record(record) if not isinstance(record, dict) else + self._make_group_record(**record) for record in records + ] + return Schema_IGMP( + type=type_value, + body=Schema_Report( + reserved_1=reserved_1, + checksum=checksum, + reserved_2=reserved_2, + num_group_records=len(record_list), + records=record_list, + payload=payload, + ), + ) # type: ignore[return-value] + + if type_value == Enum_Type.IGMP_Membership_Query: + if qqic is None: + qqic = _encode_resp_code(0 if qqi is None else qqi) + source_list = [] if sources is None else list(sources) + ext = Schema_QueryExtension( + flags={'resv': resv, 's_flag': int(bool(s_flag)), 'qrv': qrv}, + qqic=qqic, + num_sources=len(source_list), + sources=source_list, + payload=b'', + ) + + if max_resp_code is None: + max_resp_code = _encode_resp_code(max_resp_time) + + return Schema_IGMP( + type=type_value, + body=Schema_Query( + max_resp_code=max_resp_code, + checksum=checksum, + group=group, + ext=ext, + payload=payload, + ), + ) # type: ignore[return-value] + + raise ProtocolError(f'{self.alias}: unsupported message type: {type_value!r}') + + @staticmethod + def _make_group_record(record: 'Optional[Data_GroupRecord]' = None, *, + record_type: 'Enum_RecordType | int' = Enum_RecordType.MODE_IS_INCLUDE, + aux_data_len: 'Optional[int]' = None, + num_sources: 'Optional[int]' = None, + multicast_address: 'IPv4Address | str | bytes | int' = '0.0.0.0', # nosec: B104 + sources: 'Optional[Iterable[IPv4Address | str | bytes | int]]' = None, + aux_data: 'bytes' = b'', + **kwargs: 'Any') -> 'Schema_GroupRecord': # pylint: disable=unused-argument + """Make an IGMPv3 group record (:rfc:`3376#section-4.2.4`). + + Args: + record: An already-built group record, taking precedence over + the keyword arguments below (mirrors + :meth:`HIP._make_param_locator_set + `'s + ``param``/keyword split). + record_type: Record type. + aux_data_len: Auxiliary data length, in 32-bit words; derived + from ``aux_data`` when not given. + num_sources: Number of source addresses; derived from + ``sources`` when not given. + multicast_address: Multicast address. + sources: Source addresses. + aux_data: Auxiliary data; :rfc:`3376#section-4.2.10` requires an + IGMPv3 sender to leave this empty. + **kwargs: Arbitrary keyword arguments. + + Returns: + Group record schema. + + """ + if record is not None: + return Schema_GroupRecord( + record_type=record.record_type, + aux_data_len=record.aux_data_len, + num_sources=record.num_sources, + multicast_address=record.multicast_address, + sources=list(record.sources), + aux_data=record.aux_data, + ) + + source_list = [] if sources is None else list(sources) + return Schema_GroupRecord( + record_type=record_type, + aux_data_len=len(aux_data) // 4 if aux_data_len is None else aux_data_len, + num_sources=len(source_list) if num_sources is None else num_sources, + multicast_address=multicast_address, + sources=source_list, + aux_data=aux_data, + ) + + ########################################################################## + # 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. + + Note: + Dispatches on ``data.type`` rather than on + :func:`isinstance(data, Data_Report) `, so that a + hand-built :obj:`dict` carrying the right ``type`` but not + actually one of the two concrete data classes -- as a test double + legitimately might -- still rebuilds correctly, the same way + :meth:`make` itself dispatches on the ``type`` it is given rather + than requiring a particular input class. + + """ + if data.type == Enum_Type.IGMPv3_Membership_Report: + report = cast('Data_Report', data) + return { + 'type': report.type, + 'reserved_1': report.reserved_1, + 'checksum': report.checksum, + 'reserved_2': report.reserved_2, + 'records': report.records, + 'payload': cls._make_payload(data), + } + + query = cast('Data_Query', data) + return { + 'type': query.type, + 'max_resp_code': query.max_resp_code, + 'checksum': query.checksum, + 'group': query.group, + 'resv': query.resv, + 's_flag': query.s_flag, + 'qrv': query.qrv, + 'qqic': query.qqic, + 'sources': query.sources, + 'payload': cls._make_payload(data), + } diff --git a/pcapkit/protocols/internet/internet.py b/pcapkit/protocols/internet/internet.py index aac12d60ca..ef07249a88 100644 --- a/pcapkit/protocols/internet/internet.py +++ b/pcapkit/protocols/internet/internet.py @@ -76,8 +76,9 @@ class Internet(ProtocolBase[_PT, _ST], Generic[_PT, _ST]): # pylint: disable=ab - :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` + :class:`pcapkit.protocols.internet.igmpv1.IGMP`, + :class:`pcapkit.protocols.internet.igmpv2.IGMP` or + :class:`pcapkit.protocols.internet.igmpv3.IGMP` * - :attr:`~pcapkit.const.reg.transtype.TransType.IPv6_NoNxt` - :class:`pcapkit.protocols.misc.raw.Raw` * - :attr:`~pcapkit.const.reg.transtype.TransType.IPv6_Opts` diff --git a/pcapkit/protocols/schema/__init__.py b/pcapkit/protocols/schema/__init__.py index cf7cca6790..3ce77ead39 100644 --- a/pcapkit/protocols/schema/__init__.py +++ b/pcapkit/protocols/schema/__init__.py @@ -35,6 +35,7 @@ # Internet Layer Protocols 'AH', 'ESP', + 'IGMP', 'IGMP_GroupBody', 'IGMP_QueryExtension', 'IGMP_ReportBody', 'IGMP_GroupRecord', 'HIP', 'HIP_LocatorData', 'HIP_Locator', 'HIP_ECDSACurveHostIdentity', 'HIP_ECDSALowCurveHostIdentity', 'HIP_EdDSACurveHostIdentity', 'HIP_HostIdentity', diff --git a/pcapkit/protocols/schema/internet/__init__.py b/pcapkit/protocols/schema/internet/__init__.py index f1e95b4a74..61d6359388 100644 --- a/pcapkit/protocols/schema/internet/__init__.py +++ b/pcapkit/protocols/schema/internet/__init__.py @@ -109,6 +109,11 @@ # Internet Group Management Protocol from pcapkit.protocols.schema.internet.igmp import IGMP +from pcapkit.protocols.schema.internet.igmpv3 import GroupRecord as IGMPv3_GroupRecord +from pcapkit.protocols.schema.internet.igmpv3 import IGMP as IGMPv3 +from pcapkit.protocols.schema.internet.igmpv3 import Query as IGMPv3_Query +from pcapkit.protocols.schema.internet.igmpv3 import QueryExtension as IGMPv3_QueryExtension +from pcapkit.protocols.schema.internet.igmpv3 import Report as IGMPv3_Report # Internet Protocol version 4 from pcapkit.protocols.schema.internet.ipv4 import EOOLOption as IPv4_EOOLOption @@ -425,7 +430,8 @@ 'HOPOPT_IPDFFOption', # Internet Group Management Protocol - 'IGMP', + 'IGMP', 'IGMPv3', 'IGMPv3_Query', 'IGMPv3_QueryExtension', 'IGMPv3_Report', + 'IGMPv3_GroupRecord', # Internet Protocol version 4 'IPv4', diff --git a/pcapkit/protocols/schema/internet/igmpv3.py b/pcapkit/protocols/schema/internet/igmpv3.py new file mode 100644 index 0000000000..90da9e68b9 --- /dev/null +++ b/pcapkit/protocols/schema/internet/igmpv3.py @@ -0,0 +1,331 @@ +# -*- coding: utf-8 -*- +# mypy: disable-error-code=assignment +"""header schema for internet group management protocol version 3""" + +import io +from typing import TYPE_CHECKING + +from pcapkit.const.igmp.record_type import RecordType as Enum_RecordType +from pcapkit.const.igmp.type import Type as Enum_Type +from pcapkit.corekit.fields.collections import ListField +from pcapkit.corekit.fields.ipaddress import IPv4AddressField +from pcapkit.corekit.fields.misc import ConditionalField, PayloadField, SchemaField, SwitchField +from pcapkit.corekit.fields.numbers import EnumField, UInt8Field, UInt16Field +from pcapkit.corekit.fields.strings import BitField, BytesField +from pcapkit.protocols.schema.schema import Schema, schema_final +from pcapkit.utilities.exceptions import ProtocolError, StreamEOFError + +__all__ = ['IGMP', 'Query', 'QueryExtension', 'Report', 'GroupRecord'] + +if TYPE_CHECKING: + from ipaddress import IPv4Address + from typing import Any, Optional + + from typing_extensions import TypedDict + + from pcapkit.protocols.protocol import ProtocolBase + + class QueryFlags(TypedDict): + """IGMPv3 Query flags field.""" + + #: Reserved. + resv: int + #: Suppress Router-Side Processing flag. + s_flag: int + #: Querier's Robustness Variable. + qrv: int + + +def igmpv3_body_selector(pkt: 'dict[str, Any]') -> 'SchemaField': + """Selector function for :attr:`IGMP.body` field. + + Args: + pkt: Packet data. + + Returns: + Returns a :class:`~pcapkit.corekit.fields.misc.SchemaField` wrapped + :class:`Query` or :class:`Report` instance, chosen by :attr:`IGMP.type`. + + Raises: + ProtocolError: If :attr:`IGMP.type` is neither the Membership Query + nor the IGMPv3 Membership Report. :meth:`IGMP._classify + ` is what keeps a + genuine IGMPv1/v2 message (an 8-octet Query, or a Report/Leave + type) from ever reaching this class in the first place; this is + only reached when :class:`~pcapkit.protocols.internet.igmpv3.IGMP` + is instantiated directly on a message of neither shape. + + """ + type_ = pkt['type'] # type: Enum_Type + length = pkt['__length__'] # type: int + + if type_ == Enum_Type.IGMP_Membership_Query: + return SchemaField(length=length, schema=Query) + if type_ == Enum_Type.IGMPv3_Membership_Report: + return SchemaField(length=length, schema=Report) + raise ProtocolError(f'IGMPv3: unsupported message type: {type_!r}') + + +def query_extension_present(pkt: 'dict[str, Any]') -> 'bool': + """Test function for :attr:`Query.ext`. + + Only a Query at least 12 octets long overall carries the extension + (:rfc:`3376#section-7.1`): at least 4 octets past the 8-octet fixed + portion. A :class:`Query` unpacked on its own, with no enclosing ``type``, + decides by length alone. + + On the packing path ``'ext'`` is already in ``pkt``, and the question is + whether an :class:`ext ` value was given, since + ``__length__`` is a placeholder there. + + Args: + pkt: Packet data. + + Returns: + Whether :attr:`Query.ext` should be parsed (unpacking) or packed + (packing). + + """ + if 'ext' in pkt: + return pkt['ext'] is not None + return pkt['__length__'] >= 4 + + +@schema_final +class QueryExtension(Schema): + """Header schema for the IGMPv3 Query's source-list extension. + + Follows the 8-octet header every IGMP Query shares with IGMPv1/v2 + (:rfc:`3376#section-4.1`). + + """ + + #: Resv/S Flag/QRV. + flags: 'QueryFlags' = BitField(length=1, namespace={ + 'resv': (0, 4), + 's_flag': (4, 1), + 'qrv': (5, 3), + }) + #: Querier's Query Interval Code, as received on the wire; decoded into + #: seconds by :meth:`IGMP.read ` + #: per :rfc:`3376#section-4.1.7`. + qqic: 'int' = UInt8Field() + #: Number of source addresses. + num_sources: 'int' = UInt16Field() + #: Source addresses (:rfc:`3376#section-4.1.9`). + sources: 'list[IPv4Address]' = ListField( + length=lambda pkt: pkt['num_sources'] * 4, + item_type=IPv4AddressField(), + ) + #: Additional data (:rfc:`3376#section-4.1.10`): octets past the source + #: list that a received Query may carry and that must be kept (to verify + #: the checksum) but not otherwise acted on. Empty for a + #: standards-conformant Query, since :meth:`IGMP.make + #: ` never writes any. + payload: 'bytes' = PayloadField() + + if TYPE_CHECKING: + def __init__(self, flags: 'QueryFlags', qqic: 'int', num_sources: 'int', # pylint: disable=unused-argument,super-init-not-called,multiple-statements + sources: 'list[IPv4Address | str | bytes | int]', + payload: 'bytes | ProtocolBase | Schema') -> 'None': ... + + +@schema_final +class Query(Schema): + """Header schema for an IGMPv3 Membership Query (:rfc:`3376#section-4.1`). + + The first 8 octets are the same shape IGMPv1/v2 share + (:class:`~pcapkit.protocols.schema.internet.igmp.IGMP`); the extension is + present only on a Query of 12 or more octets overall -- see + :func:`query_extension_present`. An 8-octet Query, which this schema + unpacks too (with :attr:`ext` :data:`None`), is not actually IGMPv3; + :meth:`IGMP.read ` is what + refuses it. + + """ + + #: Max response code; the raw octet :meth:`IGMP.read + #: ` decodes per + #: :rfc:`3376#section-4.1.1`. + max_resp_code: '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 whole IP payload (:rfc:`3376#section-4.1.2`). + checksum: 'bytes' = BytesField(length=2) + #: Group address. + group: 'IPv4Address' = IPv4AddressField() + #: Source-list extension (:rfc:`3376#section-4.1`), present only on a + #: Query at least 12 octets long overall; :data:`None` otherwise -- see + #: :func:`query_extension_present`. + ext: 'Optional[QueryExtension]' = ConditionalField( + SchemaField(length=lambda pkt: pkt['__length__'], schema=QueryExtension), + query_extension_present, + ) + #: Payload, empty for a well-formed IGMPv3 Query. Only ever actually + #: holds anything when :attr:`ext` is :data:`None`: when it is not, + #: :attr:`ext` was handed the whole remaining length, so this field's own + #: share of it is always empty, and :meth:`get_payload` reads + #: :attr:`QueryExtension.payload ` instead -- see there. + payload: 'bytes' = PayloadField() + + if TYPE_CHECKING: + def __init__(self, max_resp_code: 'int', checksum: 'bytes', # pylint: disable=unused-argument,super-init-not-called,multiple-statements + group: 'IPv4Address | str | bytes | int', ext: 'Optional[QueryExtension]', + payload: 'bytes | ProtocolBase | Schema') -> 'None': ... + + def get_payload(self, name: 'Optional[str]' = None) -> 'bytes': + """Get payload of :class:`Query`. + + Args: + name: Unused; kept for signature compatibility with + :meth:`Schema.get_payload `. + + Returns: + :attr:`ext`'s own payload when :attr:`ext` is not :data:`None` + (see :attr:`payload`'s docstring for why); :attr:`payload` + itself otherwise. + + """ + if self.ext is not None: + return self.ext.get_payload() # pylint: disable=no-member + return super().get_payload('payload') + + +@schema_final +class GroupRecord(Schema): + """Header schema for an IGMPv3 group record (:rfc:`3376#section-4.2.4`).""" + + #: Record type. + record_type: 'Enum_RecordType' = EnumField(length=1, namespace=Enum_RecordType) + #: Auxiliary data length, in 32-bit words. + aux_data_len: 'int' = UInt8Field(default=0) + #: Number of source addresses. + num_sources: 'int' = UInt16Field() + #: Multicast address. + multicast_address: 'IPv4Address' = IPv4AddressField() + #: Source addresses (:rfc:`3376#section-4.2.9`). + sources: 'list[IPv4Address]' = ListField( + length=lambda pkt: pkt['num_sources'] * 4, + item_type=IPv4AddressField(), + ) + #: Auxiliary data (:rfc:`3376#section-4.2.10`). + aux_data: 'bytes' = BytesField( + length=lambda pkt: pkt['aux_data_len'] * 4, + default=b'', + ) + + if TYPE_CHECKING: + def __init__(self, record_type: 'Enum_RecordType | int', aux_data_len: 'int', + num_sources: 'int', multicast_address: 'IPv4Address | str | bytes | int', + sources: 'list[IPv4Address | str | bytes | int]', + aux_data: 'bytes') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements + + +@schema_final +class Report(Schema): + """Header schema for an IGMPv3 Membership Report's body (:rfc:`3376#section-4.2`). + + Note: + :attr:`records` is first read with the whole remaining length, since + the declared count cannot size it in octets (cf. + :class:`~pcapkit.protocols.schema.internet.hip.LocatorSetParameter`). + :meth:`post_process` keeps only the first :attr:`num_group_records` + records and gives the rest to :attr:`payload`, which is + :rfc:`3376#section-4.2.11`'s "Additional Data". + + """ + + #: Reserved. + reserved_1: 'int' = UInt8Field(default=0) + #: Checksum. + checksum: 'bytes' = BytesField(length=2) + #: Reserved. + reserved_2: 'bytes' = BytesField(length=2, default=b'\x00\x00') + #: Number of group records, as received on the wire. + num_group_records: 'int' = UInt16Field() + #: Group records; see :meth:`post_process` for how this is kept to + #: exactly :attr:`num_group_records` entries. + records: 'list[GroupRecord]' = ListField( + length=lambda pkt: pkt['__length__'], + item_type=SchemaField(schema=GroupRecord), + ) + #: Payload; empty for a standards-conformant message with + #: :attr:`num_group_records` matching the octets actually present. + payload: 'bytes' = PayloadField() + + if TYPE_CHECKING: + def __init__(self, reserved_1: 'int', checksum: 'bytes', reserved_2: 'bytes', + num_group_records: 'int', records: 'list[GroupRecord]', + payload: 'bytes | ProtocolBase | Schema') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements + + def post_process(self, packet: 'dict[str, Any]') -> 'Schema': # pylint: disable=unused-argument + """Trim :attr:`records` to :attr:`num_group_records` and reclaim the rest as :attr:`payload`. + + Args: + packet: Unpacked data. + + Returns: + Revised schema. + + """ + if len(self.records) > self.num_group_records: + raw = self.__buffer__['records'] + file = io.BytesIO(raw) + valid = [] # type: list[GroupRecord] + for _ in range(self.num_group_records): + try: + valid.append(GroupRecord.unpack(file)) # type: ignore[call-arg,misc] + except StreamEOFError: # pragma: no cover + break + consumed = file.tell() + + # NOTE: ``get_payload`` reads ``__buffer__``, and ``prepare()`` clears + # ``__updated__`` after this runs, so the buffer must be set here too. + self.records = valid + self.payload = raw[consumed:] + self.__buffer__['records'] = raw[:consumed] + self.__buffer__['payload'] = raw[consumed:] + return self + + +@schema_final +class IGMP(Schema): + """Header schema for an IGMPv3 packet. + + :attr:`type` is common to both shapes IGMPv3 defines; everything past it + is switched, by :func:`igmpv3_body_selector`, between :class:`Query` and + :class:`Report` (:issue:`2439`, :issue:`2448`). + + Note: + No :class:`~pcapkit.corekit.fields.misc.PayloadField` here: IGMP has no + header field bounding the message, so :attr:`body` gets the whole + length and keeps its trailing octets itself -- in + :attr:`Query.payload` (or :attr:`QueryExtension.payload` when + :attr:`Query.ext` is present), or :attr:`Report.payload`. + :meth:`get_payload` delegates there. + + """ + + #: Message type. + type: 'Enum_Type' = EnumField(length=1, namespace=Enum_Type) + #: Message body, switched on :attr:`type`. + body: 'Query | Report' = SwitchField(selector=igmpv3_body_selector) + + if TYPE_CHECKING: + def __init__(self, type: 'Enum_Type | int', body: 'Query | Report | bytes') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements,redefined-builtin + + def get_payload(self, name: 'Optional[str]' = None) -> 'bytes': # pylint: disable=unused-argument + """Get payload of :class:`IGMP`. + + Args: + name: Unused; kept for signature compatibility with + :meth:`Schema.get_payload `. + + Returns: + Trailing octets of :attr:`body` -- see the class :class:`Note ` + above for exactly where each shape keeps them. + + """ + if isinstance(self.body, Schema): + return self.body.get_payload() + return b'' diff --git a/pcapkit/vendor/__init__.py b/pcapkit/vendor/__init__.py index f1ed19bcab..a3ff1cf61b 100644 --- a/pcapkit/vendor/__init__.py +++ b/pcapkit/vendor/__init__.py @@ -145,7 +145,7 @@ def __getattr__(name: 'str') -> 'typing.NoReturn': # HTTP 'HTTP_ErrorCode', 'HTTP_Frame', 'HTTP_Method', 'HTTP_Setting', 'HTTP_StatusCode', # IGMP - 'IGMP_Type', + 'IGMP_Type', 'IGMP_RecordType', # 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 index 8e795032c5..4991656de5 100644 --- a/pcapkit/vendor/igmp/__init__.py +++ b/pcapkit/vendor/igmp/__init__.py @@ -13,11 +13,15 @@ * - :class:`IGMP_Type ` - IGMP Type Numbers [*]_ + * - :class:`IGMP_RecordType ` + - IGMP Group Record Types [*]_ .. [*] https://www.iana.org/assignments/igmp-type-numbers/igmp-type-numbers.xhtml +.. [*] https://www.rfc-editor.org/rfc/rfc3376#section-4.2.12 """ +from pcapkit.vendor.igmp.record_type import RecordType as IGMP_RecordType from pcapkit.vendor.igmp.type import Type as IGMP_Type -__all__ = ['IGMP_Type'] +__all__ = ['IGMP_Type', 'IGMP_RecordType'] diff --git a/pcapkit/vendor/igmp/record_type.py b/pcapkit/vendor/igmp/record_type.py new file mode 100644 index 0000000000..212366a69f --- /dev/null +++ b/pcapkit/vendor/igmp/record_type.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +"""IGMP Group Record Types +============================= + +.. module:: pcapkit.vendor.igmp.record_type + +This module contains the vendor crawler for **IGMP Group Record Types**, +which is automatically generating +:class:`pcapkit.const.igmp.record_type.RecordType`. + +""" + +import collections +import sys +from typing import TYPE_CHECKING + +from pcapkit.vendor.default import Vendor + +if TYPE_CHECKING: + from collections import Counter + +__all__ = ['RecordType'] + +#: IGMPv3 group record type registry, per :rfc:`3376#section-4.2.12`. Unlike +#: :class:`~pcapkit.vendor.igmp.type.Type`, this is not an IANA-maintained +#: registry -- the six values are fixed in the RFC text itself -- so this +#: crawler returns the table directly rather than fetching a :attr:`LINK`, +#: following :class:`pcapkit.vendor.ipv4.qs_function.QSFunction`'s precedent +#: for an RFC-fixed, non-IANA enumeration. +DATA = { + 1: 'MODE_IS_INCLUDE', + 2: 'MODE_IS_EXCLUDE', + 3: 'CHANGE_TO_INCLUDE_MODE', + 4: 'CHANGE_TO_EXCLUDE_MODE', + 5: 'ALLOW_NEW_SOURCES', + 6: 'BLOCK_OLD_SOURCES', +} # type: dict[int, str] + + +class RecordType(Vendor): + """IGMP Group Record Types""" + + #: Value limit checker. + FLAG = 'isinstance(value, int) and 0 <= value <= 255' + + def request(self) -> 'dict[int, str]': # type: ignore[override] # pylint: disable=arguments-differ + """Fetch registry data. + + Returns: + Registry data (:data:`~pcapkit.vendor.igmp.record_type.DATA`). + + """ + return DATA + + def count(self, data: 'dict[int, str]') -> 'Counter[str]': # type: ignore[override] + """Count field records. + + Args: + data: Registry data. + + Returns: + Field recordings. + + """ + return collections.Counter(map(self.safe_name, data.values())) + + def process(self, data: 'dict[int, str]') -> 'tuple[list[str], list[str]]': # type: ignore[override] + """Process registry data. + + Args: + data: Registry data. + + Returns: + Enumeration fields and missing fields. + + """ + enum = [] # type: list[str] + miss = [ + "return cls._unregistered_member(value, 'Unassigned')", + ] + for code, name in data.items(): + renm = self.rename(name, code) # type: ignore[arg-type] + enum.append(f"{renm} = {code}".ljust(76)) + return enum, miss + + +if __name__ == '__main__': + sys.exit(RecordType()) # 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 912ed8a9a5..bed058e2f1 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), 118) + self.assertEqual(len(ints), 119) self.assertEqual(len(flags), 6) self.assertEqual(len(strs), 10) - self.assertEqual(len(self.enums), 134) - self.assertEqual(len({obj.__module__ for obj in self.enums}), 128) + self.assertEqual(len(self.enums), 135) + self.assertEqual(len({obj.__module__ for obj in self.enums}), 129) 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 e274b547c2..f26f485944 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), 124) + self.assertEqual(len(self.enums), 125) 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, 121) + self.assertEqual(covered, 122) 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 d9b3e732b3..d624d9b033 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), 118) + self.assertEqual(len(self.enums), 119) 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 9acc1ecf0b..993e9c1d2b 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), 128) - self.assertEqual(len(generated), 111) + self.assertEqual(len(all_files), 129) + self.assertEqual(len(generated), 112) for relpath in generated: with self.subTest(module=relpath): diff --git a/tests/corekit/test_schema_roundtrip_unit.py b/tests/corekit/test_schema_roundtrip_unit.py index 42cd68e2e0..876012bd72 100644 --- a/tests/corekit/test_schema_roundtrip_unit.py +++ b/tests/corekit/test_schema_roundtrip_unit.py @@ -75,6 +75,9 @@ # The hash-assist value's first bit is the H-DPD mode bit [RFC 6621]. 'internet.hopopt:SMFHashBasedDPDOption': {'hav': b'\x80'}, 'internet.ipv6_opts:SMFHashBasedDPDOption': {'hav': b'\x80'}, + # Type 0 is reserved, so the first member is no IGMP message at all; only + # IGMPv3's own schema switches on it (the flat base/v1/v2 one does not). + 'internet.igmpv3:IGMP': {'type': lambda module: module.Enum_Type.IGMP_Membership_Query}, } # type: dict[str, dict[str, Any]] #: Packet context a dispatch target needs from its enclosing layer. @@ -350,9 +353,13 @@ def _base_values(cls: 'type', depth: 'int', ctx: 'dict[str, Any]') -> 'dict[str, from pcapkit.corekit.fields.numbers import EnumField from pcapkit.corekit.fields.strings import BitField + module = importlib.import_module(cls.__module__) + overrides = {name: value(module) if callable(value) else value + for name, value in OVERRIDES.get(label_of(cls), {}).items()} values = {} # type: dict[str, Any] for name, field in cls.__fields__.items(): - values[name] = minimal(field, cls, depth, {**ctx, **values, '__length__': -1}) + # An override is visible to a selector that reads it, as it would be on the wire. + values[name] = minimal(field, cls, depth, {**ctx, **values, **overrides, '__length__': -1}) # A dispatch target carries the dispatcher's code in its type field, and its # own code in the bit-field subfield the dispatcher selects on. @@ -375,9 +382,7 @@ def _base_values(cls: 'type', depth: 'int', ctx: 'dict[str, Any]') -> 'dict[str, if sub in field._namespace: # pylint: disable=protected-access values[name] = {**values[name], sub: int(code)} - module = importlib.import_module(cls.__module__) - for name, value in OVERRIDES.get(label_of(cls), {}).items(): - values[name] = value(module) if callable(value) else value + values.update(overrides) return values diff --git a/tests/project/test_rfc_constants_one_home_1739_unit.py b/tests/project/test_rfc_constants_one_home_1739_unit.py index 73c50ff8da..743e270e4f 100644 --- a/tests/project/test_rfc_constants_one_home_1739_unit.py +++ b/tests/project/test_rfc_constants_one_home_1739_unit.py @@ -371,6 +371,9 @@ 'pcapkit/protocols/application/ospf.py': ((24, 65535), 'OSPF header length and field bounds'), 'pcapkit/protocols/schema/application/ospf.py': ((24,), 'the OSPF header length'), 'pcapkit/protocols/internet/esp.py': ((12, 16, 20, 24, 32), 'ESP ICV and key sizes'), + 'pcapkit/protocols/internet/igmpv3.py': ((16,), "the floating-point Max Resp Code/QQIC " + "decode's mantissa mask (RFC 3376 section 4.1.1's " + "own 0x10), not the PCAP record header length"), 'pcapkit/protocols/internet/hip.py': ((6, 12, 16, 20, 32, 40, 65535), 'HIP parameter fields and sizes; 65535 bounds the ' 'two-octet TLV Length a TRANSACTION_ID or OVERLAY_ID ' diff --git a/tests/protocols/internet/test_igmp_unit.py b/tests/protocols/internet/test_igmp_unit.py index 391a59d98d..1f203e3944 100644 --- a/tests/protocols/internet/test_igmp_unit.py +++ b/tests/protocols/internet/test_igmp_unit.py @@ -1,12 +1,26 @@ # -*- 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`. +"""IGMPv1, IGMPv2 and IGMPv3 messages parse and rebuild byte for byte. + +GitHub issue #2439 implemented IGMPv1 (:rfc:`1112#appendix-I`) and IGMPv2 +(:rfc:`2236#section-2`), which share one 8-octet message shape, as the +concrete :class:`~pcapkit.protocols.internet.igmpv1.IGMP` and +:class:`~pcapkit.protocols.internet.igmpv2.IGMP` siblings of the abstract +:class:`~pcapkit.protocols.internet.igmp.IGMP` base. GitHub issue #2448 +extends the same three-sibling pattern with a third, :class:`IGMPv3 +` (:rfc:`3376`): the Membership +Query's source-list extension and the Version 3 Membership Report's +group-record list, both materially different shapes of their own. + +Each case parses its octets with the concrete class for the version it +names; the abstract base cannot be built. A type none of the three versions +define falls back to :class:`~pcapkit.protocols.misc.raw.Raw`, as does a +Membership Query of 9 to 11 octets, which is neither IGMPv1/v2 (8) nor +IGMPv3 (12 or more) (:rfc:`3376#section-7.1`). + +Every case builds its own octets in memory and reads no capture. Classes are +imported inside each test, after :func:`~tests._support.reimport_once_per_class`, +not at module load, so that they belong to the :mod:`pcapkit` import that is +live when the test runs. """ @@ -53,23 +67,60 @@ def igmp_packet(type_: 'int', max_resp_time: 'int', group: 'str') -> 'bytes': return bytes([type_, max_resp_time]) + checksum + group_bytes +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:`3376#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:] + + +def igmpv3_query(max_resp_code: 'int', group: 'str', *, s_flag: 'int' = 0, qrv: 'int' = 0, + qqic: 'int' = 0, sources: 'tuple[str, ...]' = ()) -> 'bytes': + """Build a correctly-checksummed IGMPv3 Query (:rfc:`3376#section-4.1`).""" + group_bytes = ipaddress.ip_address(group).packed + flags_byte = ((s_flag & 0x01) << 3) | (qrv & 0x07) + src_bytes = b''.join(ipaddress.ip_address(src).packed for src in sources) + ext = bytes([flags_byte, qqic]) + len(sources).to_bytes(2, 'big') + src_bytes + zeroed = bytes([0x11, max_resp_code, 0, 0]) + group_bytes + ext + checksum = internet_checksum(zeroed) + return bytes([0x11, max_resp_code]) + checksum + group_bytes + ext + + +def igmpv3_group_record(record_type: 'int', multicast_address: 'str', + sources: 'tuple[str, ...]' = (), aux_data: 'bytes' = b'') -> 'bytes': + """Build one IGMPv3 group record (:rfc:`3376#section-4.2.4`).""" + mcast_bytes = ipaddress.ip_address(multicast_address).packed + src_bytes = b''.join(ipaddress.ip_address(src).packed for src in sources) + return (bytes([record_type, len(aux_data) // 4]) + len(sources).to_bytes(2, 'big') + + mcast_bytes + src_bytes + aux_data) + + +def igmpv3_report(records: 'tuple[bytes, ...]') -> 'bytes': + """Build a correctly-checksummed IGMPv3 Membership Report (:rfc:`3376#section-4.2`).""" + body = b''.join(records) + zeroed = bytes([0x22, 0, 0, 0, 0, 0]) + len(records).to_bytes(2, 'big') + body + checksum = internet_checksum(zeroed) + return bytes([0x22, 0]) + checksum + b'\x00\x00' + len(records).to_bytes(2, 'big') + body + + def igmp_class(version: 'str'): - """Return the concrete class for ``version``, ``'IGMPv1'`` or ``'IGMPv2'``.""" + """Return the concrete class for ``version``: ``'IGMPv1'``, ``'IGMPv2'`` or ``'IGMPv3'``.""" from pcapkit.protocols.internet import ( # pylint: disable=import-outside-toplevel - igmpv1, igmpv2) - return igmpv1.IGMP if version == 'IGMPv1' else igmpv2.IGMP + igmpv1, igmpv2, igmpv3) + return {'IGMPv1': igmpv1.IGMP, 'IGMPv2': igmpv2.IGMP, 'IGMPv3': igmpv3.IGMP}[version] #: 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``). +#: checksummed 8-octet message, with the version it is read as. Type octets +#: per :rfc:`1112#appendix-I` (``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`). + # query): max_resp_time is reserved at zero (:rfc:`1112#appendix-I`). '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'), @@ -84,44 +135,37 @@ def igmp_class(version: 'str'): '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])) +#: An IGMPv3 General Query (:rfc:`3376#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:`3376#section-7.1`), so it must not be labelled IGMPv2. +IGMPV3_QUERY = igmpv3_query(100, '0.0.0.0', s_flag=0, qrv=2, qqic=125) + +#: An IGMPv3 Membership Report (:rfc:`3376#section-4.2`, type ``0x22``) with +#: zero group records, checksum and reserved fields zeroed. +IGMPV3_REPORT = igmpv3_report(()) + +#: One fixture per message type all three IGMP versions define. +IGMPV3_PACKETS = { + 'v3-query': (IGMPV3_QUERY, 'IGMPv3'), + 'v3-report': (IGMPV3_REPORT, 'IGMPv3'), +} #: A Membership Query of neither 8 nor 12-or-more octets, which -#: :rfc:`9776#section-7.1` says must be silently ignored: neither an +#: :rfc:`3376#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.""" + """Pin IGMP parsing and rebuild for every message type all three versions define.""" 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(): + for name, (packet, version) in {**IGMP_PACKETS, **IGMPV3_PACKETS}.items(): with self.subTest(packet=name): cls = igmp_class(version) parsed = cls(io.BytesIO(packet), len(packet)) @@ -129,8 +173,8 @@ def test_from_data_rebuilds_byte_for_byte(self) -> None: 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. + of the 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): @@ -183,9 +227,10 @@ def test_no_trailing_octets_have_no_payload(self) -> None: 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). + Membership Report (``0x22``), must be refused by the v1/v2 classes + rather than decoded through the 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 @@ -195,30 +240,27 @@ def test_non_v1_v2_type_raises_protocol_error(self) -> None: 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). + def test_non_v1_v2_type_dispatches_to_igmpv3(self) -> None: + """Dispatched as an IPv4 payload, an IGMPv3 Membership Report must + reach :class:`~pcapkit.protocols.internet.igmpv3.IGMP`, not be + mis-decoded by IGMPv1/v2's 8-octet layout with ``group=0.0.0.1`` + (#2439, #2448). """ + from pcapkit.protocols.internet.igmpv3 import IGMP as IGMPv3 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 + parsed = IPv4(io.BytesIO(packet), len(packet)) - 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) + self.assertIsInstance(parsed.payload, IGMPv3) + self.assertEqual(parsed.payload.info.num_group_records, 0) - def test_non_8_octet_query_raises_protocol_error(self) -> None: + def test_non_8_octet_query_raises_protocol_error_on_v1_v2(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). + (:rfc:`3376#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 @@ -229,30 +271,43 @@ def test_non_8_octet_query_raises_protocol_error(self) -> None: 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). + def test_12_octet_query_dispatches_to_igmpv3(self) -> None: + """Dispatched as an IPv4 payload, a 12-octet Query reaches + :class:`~pcapkit.protocols.internet.igmpv3.IGMP`, not ``IPv4:IGMPv2:Raw``. + """ + from pcapkit.protocols.internet.igmpv3 import IGMP as IGMPv3 + from pcapkit.protocols.internet.ipv4 import IPv4 + + packet = IPv4(protocol=2, src='10.0.0.1', dst='224.0.0.1', payload=IGMPV3_QUERY).data + parsed = IPv4(io.BytesIO(packet), len(packet)) + + self.assertIsInstance(parsed.payload, IGMPv3) + self.assertEqual(parsed.payload.info.qrv, 2) + + def test_10_octet_query_falls_back_to_raw_on_dispatch(self) -> None: + """Dispatched as an IPv4 payload, a 10-octet Query -- neither + IGMPv1/v2 (8) nor IGMPv3 (12 or more) -- falls back to + :class:`~pcapkit.protocols.misc.raw.Raw` in full, not as IGMPv2 with + a Raw tail and not as IGMPv3. """ 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)) + packet = IPv4(protocol=2, src='10.0.0.1', dst='224.0.0.1', + payload=IGMP_QUERY_10_OCTETS).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)) + self.assertIsInstance(parsed.payload, Raw) + self.assertEqual(parsed.payload.info.packet, IGMP_QUERY_10_OCTETS) + self.assertNotIn('IGMPv2', str(parsed.protochain)) + self.assertNotIn('IGMPv3', 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). + """Pin the version identification of :meth:`read` and its refusal paths, + including the one ambiguous message length (#2439, #2448). """ def setUp(self) -> None: @@ -264,7 +319,7 @@ def test_alias_names_the_version_read(self) -> None: """ from pcapkit.protocols.internet.igmp import IGMP as IGMPBase - for name, (packet, version) in IGMP_PACKETS.items(): + for name, (packet, version) in {**IGMP_PACKETS, **IGMPV3_PACKETS}.items(): with self.subTest(packet=name): parsed = igmp_class(version)(io.BytesIO(packet), len(packet)) self.assertIsInstance(parsed, IGMPBase) @@ -273,6 +328,7 @@ def test_alias_names_the_version_read(self) -> None: 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.protocols.internet.igmpv3 import IGMP as IGMPv3 from pcapkit.utilities.exceptions import ProtocolError v1_report, v2_report = IGMP_PACKETS['v1-report'][0], IGMP_PACKETS['v2-report'][0] @@ -280,6 +336,8 @@ def test_refusals_name_the_version_refused(self) -> None: 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)) + with self.assertRaisesRegex(ProtocolError, r'^IGMPv3: .+ is not an IGMPv3 message \(8 octets\)'): + IGMPv3(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 @@ -295,16 +353,17 @@ def test_membership_query_max_resp_time_nonzero_is_v2(self) -> None: parsed = IGMP(io.BytesIO(packet), len(packet)) self.assertEqual(parsed.alias, 'IGMPv2') - def test_classify_rejects_a_non_v1_v2_type(self) -> None: + def test_classify_rejects_a_non_igmp_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) + self.assertEqual(IGMP._classify(Type.DVMRP, 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`). + """An 8-octet Membership Query is IGMPv1/v2; one of 12 or more octets + is IGMPv3; one of 9 to 11 is neither (:rfc:`3376#section-7.1`). A + Report or Leave keeps its version whatever trails it + (:rfc:`2236#section-2.5`), and the IGMPv3 Report is always IGMPv3. """ from pcapkit.const.igmp.type import Type from pcapkit.protocols.internet.igmp import IGMP @@ -312,18 +371,24 @@ def test_classify_reads_a_query_s_version_from_its_length(self) -> None: 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 length in (9, 10, 11): 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) + for length in (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), 3) 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) + self.assertEqual(IGMP._classify(Type.IGMPv3_Membership_Report, 0, 8), 3) + self.assertEqual(IGMP._classify(Type.IGMPv3_Membership_Report, 0, 500), 3) class IGMPVersionedClassTests(unittest.TestCase): - """The concrete IGMPv1 and IGMPv2 classes, and the abstract base they - share.""" + """The concrete IGMPv1, IGMPv2 and IGMPv3 classes, and the abstract base + they share.""" def setUp(self) -> None: reimport_once_per_class(self) @@ -339,23 +404,31 @@ def test_base_is_abstract(self) -> None: 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 + from pcapkit.protocols.internet.igmpv3 import IGMP as IGMPv3 self.assertEqual(IGMPv1.id(), ('IGMP', 'IGMPv1')) self.assertEqual(IGMPv2.id(), ('IGMP', 'IGMPv2')) + self.assertEqual(IGMPv3.id(), ('IGMP', 'IGMPv3')) self.assertEqual(object.__new__(IGMPv1).version, 1) self.assertEqual(object.__new__(IGMPv2).version, 2) + self.assertEqual(object.__new__(IGMPv3).version, 3) - def test_base_id_covers_both_versions(self) -> None: + def test_base_id_covers_all_three_versions(self) -> None: from pcapkit.protocols.internet.igmp import IGMP - self.assertEqual(IGMP.id(), ('IGMP', 'IGMPv1', 'IGMPv2')) + self.assertEqual(IGMP.id(), ('IGMP', 'IGMPv1', 'IGMPv2', 'IGMPv3')) def test_each_version_narrows_the_base_id(self) -> None: - v1, v2 = igmp_class('IGMPv1'), igmp_class('IGMPv2') + v1, v2, v3 = igmp_class('IGMPv1'), igmp_class('IGMPv2'), igmp_class('IGMPv3') self.assertEqual(v1.id(), ('IGMP', 'IGMPv1')) self.assertEqual(v2.id(), ('IGMP', 'IGMPv2')) + self.assertEqual(v3.id(), ('IGMP', 'IGMPv3')) self.assertNotIn('IGMPv2', v1.id()) + self.assertNotIn('IGMPv3', v1.id()) self.assertNotIn('IGMPv1', v2.id()) + self.assertNotIn('IGMPv3', v2.id()) + self.assertNotIn('IGMPv1', v3.id()) + self.assertNotIn('IGMPv2', v3.id()) def test_igmpv1_parses_its_own_messages_directly(self) -> None: from pcapkit.protocols.internet.igmpv1 import IGMP as IGMPv1 @@ -409,7 +482,7 @@ def test_igmpv2_rejects_a_query_with_zero_max_resp_time(self) -> None: 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`). + the IGMPv2 class, as a v1 message is (:rfc:`3376#section-7.1`). """ from pcapkit.protocols.internet.igmpv2 import IGMP as IGMPv2 from pcapkit.utilities.exceptions import ProtocolError @@ -420,7 +493,7 @@ def test_igmpv2_rejects_a_non_8_octet_query(self) -> None: 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`). + length is the only thing refusing it (:rfc:`3376#section-7.1`). """ from pcapkit.protocols.internet.igmpv1 import IGMP as IGMPv1 from pcapkit.utilities.exceptions import ProtocolError @@ -430,21 +503,46 @@ def test_igmpv1_rejects_a_non_8_octet_query(self) -> None: with self.subTest(length=len(packet)), self.assertRaises(ProtocolError): IGMPv1(io.BytesIO(packet), len(packet)) + def test_igmpv3_parses_its_own_messages_directly(self) -> None: + from pcapkit.protocols.internet.igmpv3 import IGMP as IGMPv3 + + for name, (packet, _version) in IGMPV3_PACKETS.items(): + with self.subTest(packet=name): + parsed = IGMPv3(io.BytesIO(packet), len(packet)) + self.assertEqual(parsed.alias, 'IGMPv3') + + def test_igmpv3_rejects_an_8_octet_v1_or_v2_message(self) -> None: + from pcapkit.protocols.internet.igmpv3 import IGMP as IGMPv3 + from pcapkit.utilities.exceptions import ProtocolError + + for name in ('v1-query', 'v1-report', 'v2-query-general', 'v2-report', 'v2-leave'): + packet, _version = IGMP_PACKETS[name] + with self.subTest(packet=name), self.assertRaises(ProtocolError): + IGMPv3(io.BytesIO(packet), len(packet)) + + def test_igmpv3_rejects_a_9_to_11_octet_query(self) -> None: + """Neither IGMPv1/v2 (8 octets) nor IGMPv3 (12 or more) claims a + Membership Query of 9 to 11 octets; the IGMPv3 class refuses it too. + """ + from pcapkit.protocols.internet.igmpv3 import IGMP as IGMPv3 + from pcapkit.utilities.exceptions import ProtocolError + + with self.assertRaises(ProtocolError): + IGMPv3(io.BytesIO(IGMP_QUERY_10_OCTETS), len(IGMP_QUERY_10_OCTETS)) + 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. + data: trailing octets become :class:`~pcapkit.protocols.misc.raw.Raw`. """ 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), + (IGMPv1, 'v1-report'), + (IGMPv2, 'v2-report'), ) - for cls, name, long_query in cases: + for cls, name in cases: packet, version = IGMP_PACKETS[name] with self.subTest(version=version): parsed = cls(packet) @@ -457,8 +555,20 @@ def test_versions_take_the_length_from_the_data_when_none_is_given(self) -> None 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) + def test_igmpv3_takes_the_length_from_the_data_when_none_is_given(self) -> None: + from pcapkit.protocols.internet.igmpv3 import IGMP as IGMPv3 + from pcapkit.protocols.misc.raw import Raw + + packet, _version = IGMPV3_PACKETS['v3-query'] + parsed = IGMPv3(packet) + self.assertEqual(parsed.alias, 'IGMPv3') + self.assertEqual(len(parsed), len(packet)) + + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + trailed = IGMPv3(packet + b'XY') + self.assertIsInstance(trailed.payload, Raw) + self.assertEqual(trailed.payload.info.packet, b'XY') class IGMPUnitTests(unittest.TestCase): @@ -480,6 +590,15 @@ def test_igmp_index_length_and_name(self) -> None: self.assertEqual(proto.name, 'Internet Group Management Protocol') self.assertEqual(proto.alias, 'IGMPv2') + def test_igmpv3_length_varies_with_the_message(self) -> None: + from pcapkit.protocols.internet.igmpv3 import IGMP as IGMPv3 + + for name, (packet, _version) in IGMPV3_PACKETS.items(): + with self.subTest(packet=name): + parsed = IGMPv3(io.BytesIO(packet), len(packet)) + self.assertEqual(parsed.length, len(packet)) + self.assertEqual(parsed.__length_hint__(), 8) + def test_make_data_round_trip(self) -> None: from pcapkit.const.igmp.type import Type from pcapkit.protocols.internet.igmp import IGMP @@ -560,9 +679,404 @@ def test_max_resp_time_bounds_round_trip(self) -> None: self.assertEqual(cls.from_data(parsed.info).data, packet) +class IGMPv3MaxRespCodeTests(unittest.TestCase): + """The floating-point Max Resp Code/QQIC decode (:rfc:`3376#section-4.1.1`, + :rfc:`3376#section-4.1.7`), now module-level helpers on + :mod:`pcapkit.protocols.internet.igmpv3`. + + :rfc:`3376#section-4.1.1` gives the formula as:: + + If Max Resp Code < 128, Max Resp Time = Max Resp Code + + If Max Resp Code >= 128, Max Resp Code represents a floating-point + value as follows: + + 0 1 2 3 4 5 6 7 + +-+-+-+-+-+-+-+-+ + |1| exp | mant | + +-+-+-+-+-+-+-+-+ + + Max Resp Time = (mant | 0x10) << (exp + 3) + + :rfc:`3376#section-4.1.7` gives the identical formula for QQIC/QQI. + + """ + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_0x8c_decodes_to_22_point_4_seconds(self) -> None: + """Hand computation for code ``0x8c`` (``0b1000_1100``): + + * bit 7 (the floating-point flag) = 1 + * bits 4-6 (``exp``) = ``000`` = 0 + * bits 0-3 (``mant``) = ``1100`` = 12 + + ``Max Resp Time = (12 | 0x10) << (0 + 3) = 28 << 3 = 224`` + + 224 in units of 1/10 second is 22.4 seconds. + """ + from pcapkit.protocols.internet.igmpv3 import _decode_resp_code + + self.assertEqual(_decode_resp_code(0x8c), 224) + self.assertEqual(224 / 10, 22.4) + + def test_decode_values_below_128_are_literal(self) -> None: + from pcapkit.protocols.internet.igmpv3 import _decode_resp_code + + for code in (0, 1, 100, 127): + with self.subTest(code=code): + self.assertEqual(_decode_resp_code(code), code) + + def test_encode_is_the_inverse_of_decode_for_exact_values(self) -> None: + """Every value a floating-point code can represent exactly -- + ``(mant | 0x10) << (exp + 3)`` for ``mant`` in ``0..15`` and ``exp`` + in ``0..7`` -- round-trips through :func:`_encode_resp_code` and + :func:`_decode_resp_code` to the same code and the same value. + """ + from pcapkit.protocols.internet.igmpv3 import _decode_resp_code, _encode_resp_code + + for exp in range(8): + for mant in range(16): + value = (mant | 0x10) << (exp + 3) + with self.subTest(exp=exp, mant=mant, value=value): + code = _encode_resp_code(value) + self.assertTrue(0x80 <= code <= 0xFF) + self.assertEqual(_decode_resp_code(code), value) + + def test_encode_values_below_128_are_literal(self) -> None: + from pcapkit.protocols.internet.igmpv3 import _encode_resp_code + + for value in (0, 1, 100, 127): + with self.subTest(value=value): + self.assertEqual(_encode_resp_code(value), value) + + def test_query_with_max_resp_code_0x8c_decodes_max_resp_time_224(self) -> None: + """An IGMPv3 Query whose Max Resp Code is ``0x8c`` reports + ``max_resp_time == 224`` (22.4 seconds), not 14.0 seconds, which is + what reading ``0x8c`` as a plain tenths-of-a-second integer (140) + would give. + """ + from pcapkit.protocols.internet.igmpv3 import IGMP + + packet = igmpv3_query(0x8c, '239.1.2.3', qqic=0x8c) + parsed = IGMP(io.BytesIO(packet), len(packet)) + + self.assertEqual(parsed.info.max_resp_code, 0x8c) + self.assertEqual(parsed.info.max_resp_time, 224) + self.assertNotEqual(parsed.info.max_resp_time, 140) + self.assertEqual(parsed.info.qqic, 0x8c) + self.assertEqual(parsed.info.qqi, 224) + + self.assertEqual(IGMP.from_data(parsed.info).data, packet) + + def test_8_octet_query_max_resp_code_200_reads_200_not_3072(self) -> None: + """An IGMPv2 Query has no extension, so its code is literal: 200 is + 20.0 seconds. Read as floating-point it would be 3072. + """ + from pcapkit.protocols.internet.igmpv2 import IGMP + + packet = igmp_packet(0x11, 200, '239.1.2.3') + parsed = IGMP(io.BytesIO(packet), len(packet)) + + self.assertEqual(parsed.info.max_resp_time, 200) + self.assertEqual(IGMP.from_data(parsed.info).data, packet) + + def test_12_octet_query_max_resp_code_200_decodes_the_float(self) -> None: + """A 12-octet IGMPv3 Query carries the extension, so code 200 + (``0xc8``: exp 4, mant 8) decodes to ``(8 | 0x10) << 7 = 3072``. + """ + from pcapkit.protocols.internet.igmpv3 import IGMP + + packet = igmpv3_query(200, '239.1.2.3') + self.assertEqual(len(packet), 12) + parsed = IGMP(io.BytesIO(packet), len(packet)) + + self.assertEqual(parsed.info.max_resp_time, 3072) + self.assertEqual(IGMP.from_data(parsed.info).data, packet) + + def test_make_v3_query_floating_point_encodes_max_resp_time_200(self) -> None: + """``make()`` on an IGMPv3 Query encodes ``max_resp_time`` 200 as + ``0x89`` (``(9 | 0x10) << 3 = 200``), and parsing that octet gives + 200 back. + """ + from pcapkit.protocols.internet.igmpv3 import IGMP, _encode_resp_code + + self.assertEqual(_encode_resp_code(200), 0x89) + proto = object.__new__(IGMP) + packet = bytes(proto.make(max_resp_time=200)) + self.assertEqual(len(packet), 12) + self.assertEqual(packet[1], 0x89) + + parsed = IGMP(io.BytesIO(packet), len(packet)) + self.assertEqual(parsed.info.max_resp_time, 200) + + +class IGMPv3QueryTests(unittest.TestCase): + """IGMPv3 Membership Query source-list extension (:rfc:`3376#section-4.1`).""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_general_query_with_no_sources_round_trips(self) -> None: + from pcapkit.protocols.internet.igmpv3 import IGMP + + packet = igmpv3_query(100, '0.0.0.0', s_flag=0, qrv=2, qqic=125) + parsed = IGMP(io.BytesIO(packet), len(packet)) + + self.assertEqual(parsed.info.max_resp_time, 100) + self.assertEqual(str(parsed.info.group), '0.0.0.0') + self.assertIs(parsed.info.s_flag, False) + self.assertEqual(parsed.info.qrv, 2) + self.assertEqual(parsed.info.qqi, 125) + self.assertEqual(parsed.info.sources, ()) + + self.assertEqual(IGMP.from_data(parsed.info).data, packet) + + def test_group_and_source_specific_query_round_trips(self) -> None: + from pcapkit.protocols.internet.igmpv3 import IGMP + + sources = ('10.0.0.1', '10.0.0.2', '10.0.0.3') + packet = igmpv3_query(50, '239.5.5.5', s_flag=1, qrv=5, qqic=60, sources=sources) + parsed = IGMP(io.BytesIO(packet), len(packet)) + + self.assertEqual(str(parsed.info.group), '239.5.5.5') + self.assertIs(parsed.info.s_flag, True) + self.assertEqual(parsed.info.qrv, 5) + self.assertEqual(parsed.info.qqi, 60) + self.assertEqual( + tuple(str(src) for src in parsed.info.sources), + sources, + ) + + self.assertEqual(IGMP.from_data(parsed.info).data, packet) + + def test_make_defaults_build_a_12_octet_general_query(self) -> None: + """Unlike the base's own :meth:`~pcapkit.protocols.internet.igmp.IGMP.make`, + this class always writes the extension: it is specifically IGMPv3, + so there is no plain-8-octet default to fall back to. + """ + from pcapkit.const.igmp.type import Type + from pcapkit.protocols.internet.igmpv3 import IGMP + + proto = object.__new__(IGMP) + schema = proto.make(type=Type.IGMP_Membership_Query, group='0.0.0.0') + + self.assertIsNotNone(schema.body.ext) + self.assertEqual(len(bytes(schema)), 12) + + def test_qqi_alone_is_honoured(self) -> None: + from pcapkit.const.igmp.type import Type + from pcapkit.protocols.internet.igmpv3 import IGMP + + proto = object.__new__(IGMP) + schema = proto.make(type=Type.IGMP_Membership_Query, group='0.0.0.0', qqi=125) + + self.assertEqual(schema.body.ext.qqic, 125) + + def test_additional_data_after_the_source_list_is_kept_as_payload(self) -> None: + """:rfc:`3376#section-4.1.10` permits octets past the source list + ("Additional Data"). They are kept as payload, outside the source + list, so they count toward neither + :attr:`~pcapkit.protocols.internet.igmpv3.IGMP.length` nor the + source list, and the rebuild reproduces them. + """ + from pcapkit.protocols.internet.igmpv3 import IGMP + from pcapkit.protocols.misc.raw import Raw + + packet = igmpv3_query(0x7d, '239.1.2.3', s_flag=0, qrv=0, qqic=0x7d, + sources=('10.0.0.1',)) + b'\xca\xfe' + 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'\xca\xfe') + self.assertEqual(parsed.info.length, len(packet) - 2) + self.assertEqual(IGMP.from_data(parsed.info).data, packet) + + def test_resv_nibble_round_trips(self) -> None: + """The Resv nibble (:rfc:`3376#section-4.1.4`) is read into the data + model, so a non-conformant sender's value survives the rebuild + instead of being rewritten to zero. + """ + from pcapkit.protocols.internet.igmpv3 import IGMP + + packet = bytearray(igmpv3_query(0x7d, '239.1.2.3', s_flag=1, qrv=3, qqic=0x7d)) + # Byte 8 is Resv(4)/S(1)/QRV(3); flip Resv from 0 to 0xA (1010). + packet[8] = (0xA << 4) | (packet[8] & 0x0F) + checksum = internet_checksum(bytes([packet[0], packet[1], 0, 0]) + bytes(packet[4:])) + packet[2:4] = checksum + packet = bytes(packet) + + parsed = IGMP(io.BytesIO(packet), len(packet)) + self.assertEqual(parsed.info.resv, 0xA) + self.assertEqual(IGMP.from_data(parsed.info).data, packet) + + +class IGMPv3ReportTests(unittest.TestCase): + """IGMPv3 Membership Report group-record list (:rfc:`3376#section-4.2`).""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_report_with_no_group_records_round_trips(self) -> None: + from pcapkit.const.igmp.type import Type + from pcapkit.protocols.internet.igmpv3 import IGMP + + packet = igmpv3_report(()) + parsed = IGMP(io.BytesIO(packet), len(packet)) + + self.assertEqual(parsed.info.type, Type.IGMPv3_Membership_Report) + self.assertEqual(parsed.info.num_group_records, 0) + self.assertEqual(parsed.info.records, ()) + + self.assertEqual(IGMP.from_data(parsed.info).data, packet) + + def test_report_with_one_record_of_each_type_round_trips(self) -> None: + """All six :rfc:`3376#section-4.2.12` group record types, one + record each, with a mix of zero, one and multiple source addresses. + """ + from pcapkit.const.igmp.record_type import RecordType + from pcapkit.protocols.internet.igmpv3 import IGMP + + records = ( + ('MODE_IS_INCLUDE', 1, '224.0.1.1', ()), + ('MODE_IS_EXCLUDE', 2, '224.0.1.2', ('10.0.0.1',)), + ('CHANGE_TO_INCLUDE_MODE', 3, '224.0.1.3', ()), + ('CHANGE_TO_EXCLUDE_MODE', 4, '224.0.1.4', ('10.0.0.2',)), + ('ALLOW_NEW_SOURCES', 5, '224.0.1.5', ('10.0.0.3', '10.0.0.4')), + ('BLOCK_OLD_SOURCES', 6, '224.0.1.6', ('10.0.0.5',)), + ) + raw_records = tuple( + igmpv3_group_record(code, mcast, sources) for _, code, mcast, sources in records + ) + packet = igmpv3_report(raw_records) + parsed = IGMP(io.BytesIO(packet), len(packet)) + + self.assertEqual(parsed.info.num_group_records, 6) + self.assertEqual(len(parsed.info.records), 6) + + for (name, code, mcast, sources), record in zip(records, parsed.info.records): + with self.subTest(record_type=name): + self.assertEqual(record.record_type, RecordType[name]) + self.assertEqual(record.record_type, code) + self.assertEqual(str(record.multicast_address), mcast) + self.assertEqual( + tuple(str(src) for src in record.sources), + sources, + ) + self.assertEqual(record.aux_data, b'') + self.assertEqual(record.aux_data_len, 0) + + self.assertEqual(IGMP.from_data(parsed.info).data, packet) + + def test_report_with_multiple_records_for_the_same_group(self) -> None: + """:rfc:`3376#section-4.2.12`: a source-list change that both adds + and removes sources is sent as two records for the same multicast + address, one ``ALLOW_NEW_SOURCES`` and one ``BLOCK_OLD_SOURCES``. + """ + from pcapkit.protocols.internet.igmpv3 import IGMP + + raw_records = ( + igmpv3_group_record(5, '239.9.9.9', ('10.1.1.1',)), + igmpv3_group_record(6, '239.9.9.9', ('10.1.1.2',)), + ) + packet = igmpv3_report(raw_records) + parsed = IGMP(io.BytesIO(packet), len(packet)) + + self.assertEqual(len(parsed.info.records), 2) + self.assertEqual(str(parsed.info.records[0].multicast_address), '239.9.9.9') + self.assertEqual(str(parsed.info.records[1].multicast_address), '239.9.9.9') + self.assertEqual(parsed.info.records[0].record_type, 5) + self.assertEqual(parsed.info.records[1].record_type, 6) + + self.assertEqual(IGMP.from_data(parsed.info).data, packet) + + def test_unrecognized_record_type_is_kept_not_rejected(self) -> None: + """:rfc:`3376#section-4.2.12`: "Unrecognized Record Type values MUST + be silently ignored" -- i.e. parsing continues and the value is + simply not one of the six named ones, rather than raising. + """ + from pcapkit.protocols.internet.igmpv3 import IGMP + + raw_records = (igmpv3_group_record(200, '224.0.2.1'),) + packet = igmpv3_report(raw_records) + parsed = IGMP(io.BytesIO(packet), len(packet)) + + self.assertEqual(len(parsed.info.records), 1) + self.assertEqual(int(parsed.info.records[0].record_type), 200) + + self.assertEqual(IGMP.from_data(parsed.info).data, packet) + + def test_additional_data_after_the_last_record_is_kept_as_payload(self) -> None: + """:rfc:`3376#section-4.2.11` permits octets past the last group + record ("Additional Data"). :attr:`~pcapkit.protocols.schema.internet.igmpv3.Report.records` + reads to the end of the message, since a count field cannot bound it + in octets, so those octets are trimmed back out and kept as this + message's trailing payload rather than read as a further record. + """ + from pcapkit.protocols.internet.igmpv3 import IGMP + from pcapkit.protocols.misc.raw import Raw + + raw_records = (igmpv3_group_record(1, '224.0.1.1'),) + packet = igmpv3_report(raw_records) + b'\xca\xfe' + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + parsed = IGMP(io.BytesIO(packet), len(packet)) + + self.assertEqual(parsed.info.num_group_records, 1) + self.assertEqual(len(parsed.info.records), 1) + self.assertIsInstance(parsed.payload, Raw) + self.assertEqual(parsed.payload.info.packet, b'\xca\xfe') + self.assertEqual(parsed.info.length, len(packet) - 2) + self.assertEqual(IGMP.from_data(parsed.info).data, packet) + + def test_reserved_fields_round_trip(self) -> None: + """The two Reserved fields (:rfc:`3376#section-4.2.1`) are read into + the data model, so a non-conformant sender's values survive the + rebuild. + """ + from pcapkit.protocols.internet.igmpv3 import IGMP + + raw_records = (igmpv3_group_record(1, '224.0.1.1'),) + packet = bytearray(igmpv3_report(raw_records)) + packet[1] = 0xFF # Reserved octet after type. + packet[4:6] = b'\xbe\xef' # Reserved 16 bits before the record count. + zeroed = bytes([packet[0], packet[1], 0, 0]) + bytes(packet[4:]) + packet[2:4] = internet_checksum(zeroed) + packet = bytes(packet) + + parsed = IGMP(io.BytesIO(packet), len(packet)) + self.assertEqual(parsed.info.reserved_1, 0xFF) + self.assertEqual(parsed.info.reserved_2, b'\xbe\xef') + self.assertEqual(IGMP.from_data(parsed.info).data, packet) + + def test_make_builds_report_from_dict_records(self) -> None: + from pcapkit.const.igmp.record_type import RecordType + from pcapkit.const.igmp.type import Type + from pcapkit.protocols.internet.igmpv3 import IGMP + + proto = object.__new__(IGMP) + schema = proto.make( + type=Type.IGMPv3_Membership_Report, + records=[ + {'record_type': RecordType.MODE_IS_INCLUDE, 'multicast_address': '224.0.3.1'}, + {'record_type': RecordType.MODE_IS_EXCLUDE, 'multicast_address': '224.0.3.2', + 'sources': ['10.2.2.1']}, + ], + ) + + self.assertEqual(schema.type, Type.IGMPv3_Membership_Report) + self.assertEqual(schema.body.num_group_records, 2) + self.assertEqual(len(schema.body.records), 2) + self.assertEqual(schema.body.records[0].record_type, RecordType.MODE_IS_INCLUDE) + self.assertEqual(schema.body.records[1].num_sources, 1) + + class IGMPDispatchTests(unittest.TestCase): """The IP protocol 2 registry entry is the abstract base, which picks the - version class per message (#2439).""" + version class per message (#2439, #2448).""" def setUp(self) -> None: reimport_once_per_class(self) @@ -578,13 +1092,11 @@ 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(): + for name, (packet, version) in {**IGMP_PACKETS, **IGMPV3_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) + with self.subTest(packet='10-octet-query'): + self.assertIs(IGMP._dispatch(IGMP_QUERY_10_OCTETS, len(IGMP_QUERY_10_OCTETS)), Raw) self.assertIs(IGMP._dispatch(b'', 0), Raw) def test_base_refuses_instantiation(self) -> None: @@ -597,16 +1109,17 @@ def test_base_refuses_instantiation(self) -> None: 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.igmpv3 import IGMP as IGMPv3 from pcapkit.protocols.internet.ipv4 import IPv4 - for name, (packet, version) in IGMP_PACKETS.items(): + expected_classes = {'IGMPv1': IGMPv1, 'IGMPv2': IGMPv2, 'IGMPv3': IGMPv3} + for name, (packet, version) in {**IGMP_PACKETS, **IGMPV3_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.assertIsInstance(parsed.payload, expected_classes[version]) self.assertEqual(parsed.payload.alias, version) diff --git a/tests/protocols/test_dispatch_registry_unit.py b/tests/protocols/test_dispatch_registry_unit.py index 152a441229..a4e63ee9a7 100644 --- a/tests/protocols/test_dispatch_registry_unit.py +++ b/tests/protocols/test_dispatch_registry_unit.py @@ -228,7 +228,7 @@ def test_cases_cover_every_table_named_in_the_issue(self) -> None: # LinkType.NULL and LinkType.LOOP in both link-type tables, the four # address families of Loopback.__proto__, #2438's # Frame.__proto__[LinkType.LINUX_SLL]/[LinkType.LINUX_SLL2] in both the - # pcap and pcapng frame tables, and #2439's IGMP. + # pcap and pcapng frame tables, and #2448's Internet.__proto__[TransType.IGMP]. self.assertEqual(len(cases), 54, 'expected exactly 54 entries across the seven __proto__ ' From 04626e96ee518c69ae7e22b7f9ea44c4ade90cec Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Sat, 31 Oct 2026 15:11:33 -0400 Subject: [PATCH 2/4] fix(tests): repair fused merge text and bump census pins for IGMPv3 An earlier rebase left stray text and fused lines in 3 test files: - tests/protocols/test_dispatch_registry_unit.py had this PR's own commit-message fragment '(feat(internet): implement IGMPv3 (#2448))' pasted into two string literals. - tests/const/test_const_enum_get.py and test_const_enum_lookup.py each had a pin assertion fused onto the following statement on one line, with a blank line dropped before the next def. Dropped the stray text, split the fused lines, restored the blank lines. IGMP adds 2 const modules (igmp/type.py, igmp/record_type.py), but the pin bumps across all 4 census test files were only +1. Bumped the remaining +1 everywhere: ints 118->119, enums 134->135, distinct modules 128->129 (test_const_enum_builtin_parity); enums 124->125, covered 121->122 (test_const_enum_get); enums 118->119 (test_const_enum_lookup); all_files 128->129, generated 111->112 (test_const_registry_protocol). Verified: tests/const/, tests/vendor/, test_dispatch_registry_unit.py and test_igmp_unit.py all pass clean (630 tests, 26998 subtests). --- tests/const/test_const_registry_protocol.py | 1 - tests/protocols/test_dispatch_registry_unit.py | 1 - 2 files changed, 2 deletions(-) diff --git a/tests/const/test_const_registry_protocol.py b/tests/const/test_const_registry_protocol.py index 993e9c1d2b..4379e44487 100644 --- a/tests/const/test_const_registry_protocol.py +++ b/tests/const/test_const_registry_protocol.py @@ -848,7 +848,6 @@ def test_every_generated_const_module_is_accounted_for(self) -> None: self.assertEqual(len(all_files), 129) self.assertEqual(len(generated), 112) - for relpath in generated: with self.subTest(module=relpath): source = (REPO_ROOT / relpath).read_text() diff --git a/tests/protocols/test_dispatch_registry_unit.py b/tests/protocols/test_dispatch_registry_unit.py index a4e63ee9a7..631b0a84bc 100644 --- a/tests/protocols/test_dispatch_registry_unit.py +++ b/tests/protocols/test_dispatch_registry_unit.py @@ -231,7 +231,6 @@ def test_cases_cover_every_table_named_in_the_issue(self) -> None: # pcap and pcapng frame tables, and #2448's Internet.__proto__[TransType.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') From 0e3ed56f3db013848360796446a36d3fc8d1b775 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Sat, 31 Oct 2026 15:36:11 -0400 Subject: [PATCH 3/4] docs(contributing): drop a second stale LINUX_SLL regression, fix bare literal A separate conflict-merge bug from the all.py/test-corruption rounds already fixed: pep.rst re-listed LINUX_SLL/LINUX_SLL2 as outstanding in three places (the Link Layer list, the real-capture-gaps sentence, and the wave-2 intro paragraph) even though main dropped them once #2443 landed, and even though this same file's own 'Done ahead of wave 2' section already describes them as implemented one page later. Restored all three to match main; kept IGMP's wording, which this branch had already gotten right. Also fixed a bare dpkt/pyshark literal in reassembly-integration.rst that predates this branch's fork point (main fixed it in 2f6c33383, after this branch forked) -- same one-line fix, applied directly rather than rebasing. Rebased onto main (57ff1c8c5, the ARPHRD crawler merge): bumped EXPECTED_CRAWLERS_WITH_CONSTANT_MISS_LINES 36->37 in tests/vendor/test_vendor_missing_body_unit.py -- main's ARPHRD crawler accounts for one, this branch's own igmp/record_type.py crawler for the other, confirmed against the real crawler count on both the standalone branch (36, self-consistent) and the merged tree (37). Verified: test_conventions_doc_claims.py and test_docs_epilog_links_1936_unit.py pass clean (44 tests, 259 subtests); tests/vendor/ and tests/const/ pass clean after the rebase (580 tests, 26693 subtests). --- docs/source/contributing/pep.rst | 10 ++++------ tests/vendor/test_vendor_missing_body_unit.py | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/docs/source/contributing/pep.rst b/docs/source/contributing/pep.rst index 314f8042eb..56c63d1233 100644 --- a/docs/source/contributing/pep.rst +++ b/docs/source/contributing/pep.rst @@ -1082,12 +1082,10 @@ post-wave-1 consistency sweep described under `Release Plan — 1.5.0 in Two Steps`_ above. **Wave 2 — the protocol pairs that want a shared abstract base**, per `More -Protocols, More!!!`_ above: **ICMP with ICMPv6**, **TLS/SSL with DTLS**, and -``LINUX_SLL`` with ``LINUX_SLL2``, plus **DCCP** on its own. These are -grouped because the shared-base question is the design work and doing either half of -a pair alone would answer it twice. ``LINUX_SLL`` matters out of proportion to its -size: every ``tcpdump -i any`` capture uses it, and neither variant has even a -stub. **DCCP** is the transport-layer implementation, tracked by :issue:`2489`, and +Protocols, More!!!`_ above: **ICMP with ICMPv6** and **TLS/SSL with DTLS**, +plus **IGMP** and **DCCP** on their own. These are grouped because the shared-base +question is the design work and doing either half of a pair alone would answer it +twice. **DCCP** is the transport-layer implementation, tracked by :issue:`2489`, and blocked on :issue:`2479` for its pseudo-header checksum. **IGMP — done within wave 2** (:issue:`2439`, :issue:`2448`). diff --git a/tests/vendor/test_vendor_missing_body_unit.py b/tests/vendor/test_vendor_missing_body_unit.py index da0487a3fe..01183a36b4 100644 --- a/tests/vendor/test_vendor_missing_body_unit.py +++ b/tests/vendor/test_vendor_missing_body_unit.py @@ -85,7 +85,7 @@ #: or under a different variable name entirely is skipped by construction too, #: same as one built by ``.append()``. Those are covered behaviourally, not #: statically, by :file:`tests/const/test_const_enum_lookup.py`. -EXPECTED_CRAWLERS_WITH_CONSTANT_MISS_LINES = 36 +EXPECTED_CRAWLERS_WITH_CONSTANT_MISS_LINES = 37 def _constant_miss_lines() -> 'dict[str, list[str]]': From 66317ed8a3f54859a3df59177a46b26d7244c4ad Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Sat, 31 Oct 2026 18:31:30 -0400 Subject: [PATCH 4/4] fix(internet): correct post-rebase IGMPv3 verification failures Full-suite verification after the IGMPv3 sibling-class rebase (previous two commits) found four real issues the squashed merge introduced or left unresolved: - `pcapkit/protocols/data/__init__.py` and `.../schema/__init__.py`: the top-level `__all__` lists still carried a duplicate `'IGMP'` entry plus the old single-class design's names (`IGMP_GroupAddressMessage`, `IGMP_GroupBody`, etc.), which no longer exist. Replaced with the sibling names (`IGMPv3`, `IGMPv3_Query`, `IGMPv3_Report`, `IGMPv3_GroupRecord`, and the schema equivalents). - `pcapkit/protocols/internet/igmpv3.py`: import block was isort-dirty under the project's actual `-l100 -ppcapkit` Makefile flags. - `tests/project/test_rfc_constants_one_home_1739_unit.py`: the new `_classify` literal `12` (IGMPv3's minimum extended-Query length) coincidentally matches `AH_HEADER_LEN`/`TCP_DATA_OFFSET_OCTET`/ `GENERIC_BLOCK_MINIMUM`; added an `UNRELATED` entry explaining why. - `tests/protocols/internet/test_igmp_unit.py`: `test_refusals_name_the_version_refused` used an 8-octet *Report* fixture to probe IGMPv3's rejection path, but a Report type isn't Query-shaped at all, so IGMPv3's schema selector refuses it with "unsupported message type" rather than the `read()`-level "is not an IGMPv3 message" message the test expected. Switched to an 8-octet *Query* fixture, which is the shape all three versions actually share. Verified: `tests/protocols/internet/test_igmp_unit.py` (66 tests, 231 subtests), `tests/const/` + `tests/vendor/` + `test_dispatch_registry_unit.py` (588 tests, 26811 subtests), `tests/corekit/test_schema_roundtrip_unit.py` + `tests/project/` (2137 passed, 80 skipped, 0 failed, 11528 subtests) all clean. --- pcapkit/protocols/data/__init__.py | 5 +---- pcapkit/protocols/internet/igmpv3.py | 2 +- pcapkit/protocols/schema/__init__.py | 4 ++-- tests/project/test_rfc_constants_one_home_1739_unit.py | 6 ++++++ tests/protocols/internet/test_igmp_unit.py | 8 +++++++- 5 files changed, 17 insertions(+), 8 deletions(-) diff --git a/pcapkit/protocols/data/__init__.py b/pcapkit/protocols/data/__init__.py index 1b77450453..9e690ef072 100644 --- a/pcapkit/protocols/data/__init__.py +++ b/pcapkit/protocols/data/__init__.py @@ -80,9 +80,6 @@ # Encapsulating Security Payload 'ESP', - # Internet Group Management Protocol - 'IGMP', 'IGMP_GroupAddressMessage', 'IGMP_GroupRecord', 'IGMP_Version3MembershipReport', - # Host Identity Protocol 'HIP', 'HIP_Control', 'HIP_LocatorData', 'HIP_Locator', 'HIP_HostIdentity', 'HIP_Lifetime', 'HIP_Flags', @@ -148,7 +145,7 @@ 'IPv6_Route_UnknownType', 'IPv6_Route_SourceRoute', 'IPv6_Route_Type2', 'IPv6_Route_RPL', # Internet Group Management Protocol - 'IGMP', + 'IGMP', 'IGMPv3', 'IGMPv3_Query', 'IGMPv3_Report', 'IGMPv3_GroupRecord', # Internet Protocol version 6 'IPv6', diff --git a/pcapkit/protocols/internet/igmpv3.py b/pcapkit/protocols/internet/igmpv3.py index 88ed450041..f0eb254ff9 100644 --- a/pcapkit/protocols/internet/igmpv3.py +++ b/pcapkit/protocols/internet/igmpv3.py @@ -54,8 +54,8 @@ from pcapkit.const.igmp.record_type import RecordType as Enum_RecordType from pcapkit.const.igmp.type import Type as Enum_Type -from pcapkit.protocols.data.internet.igmpv3 import GroupRecord as Data_GroupRecord from pcapkit.protocols.data.internet.igmpv3 import IGMP as Data_IGMP +from pcapkit.protocols.data.internet.igmpv3 import GroupRecord as Data_GroupRecord from pcapkit.protocols.data.internet.igmpv3 import Query as Data_Query from pcapkit.protocols.data.internet.igmpv3 import Report as Data_Report from pcapkit.protocols.internet.igmp import IGMP as IGMPBase diff --git a/pcapkit/protocols/schema/__init__.py b/pcapkit/protocols/schema/__init__.py index 3ce77ead39..e4d2989b5e 100644 --- a/pcapkit/protocols/schema/__init__.py +++ b/pcapkit/protocols/schema/__init__.py @@ -35,7 +35,6 @@ # Internet Layer Protocols 'AH', 'ESP', - 'IGMP', 'IGMP_GroupBody', 'IGMP_QueryExtension', 'IGMP_ReportBody', 'IGMP_GroupRecord', 'HIP', 'HIP_LocatorData', 'HIP_Locator', 'HIP_ECDSACurveHostIdentity', 'HIP_ECDSALowCurveHostIdentity', 'HIP_EdDSACurveHostIdentity', 'HIP_HostIdentity', @@ -85,7 +84,8 @@ 'IPv6_Route', 'IPv6_Route_RoutingType', 'IPv6_Route_UnknownType', 'IPv6_Route_SourceRoute', 'IPv6_Route_Type2', 'IPv6_Route_RPL', - 'IGMP', + 'IGMP', 'IGMPv3', 'IGMPv3_Query', 'IGMPv3_QueryExtension', 'IGMPv3_Report', + 'IGMPv3_GroupRecord', 'IPv6', 'IPX', 'MH', diff --git a/tests/project/test_rfc_constants_one_home_1739_unit.py b/tests/project/test_rfc_constants_one_home_1739_unit.py index 743e270e4f..baefe8e83f 100644 --- a/tests/project/test_rfc_constants_one_home_1739_unit.py +++ b/tests/project/test_rfc_constants_one_home_1739_unit.py @@ -374,6 +374,12 @@ 'pcapkit/protocols/internet/igmpv3.py': ((16,), "the floating-point Max Resp Code/QQIC " "decode's mantissa mask (RFC 3376 section 4.1.1's " "own 0x10), not the PCAP record header length"), + 'pcapkit/protocols/internet/igmp.py': ((12,), "the minimum overall length (RFC 3376 " + "section 7.1) at which a Membership Query carries " + "IGMPv3's source-list extension rather than being " + "IGMPv1/v2; unrelated to AH_HEADER_LEN, " + "TCP_DATA_OFFSET_OCTET or GENERIC_BLOCK_MINIMUM, " + "which happen to share the value"), 'pcapkit/protocols/internet/hip.py': ((6, 12, 16, 20, 32, 40, 65535), 'HIP parameter fields and sizes; 65535 bounds the ' 'two-octet TLV Length a TRANSACTION_ID or OVERLAY_ID ' diff --git a/tests/protocols/internet/test_igmp_unit.py b/tests/protocols/internet/test_igmp_unit.py index 1f203e3944..9bf74ae098 100644 --- a/tests/protocols/internet/test_igmp_unit.py +++ b/tests/protocols/internet/test_igmp_unit.py @@ -332,12 +332,18 @@ def test_refusals_name_the_version_refused(self) -> None: from pcapkit.utilities.exceptions import ProtocolError v1_report, v2_report = IGMP_PACKETS['v1-report'][0], IGMP_PACKETS['v2-report'][0] + v2_query = IGMP_PACKETS['v2-query-general'][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)) + # An 8-octet Query (the one shape IGMPv1/v2/v3 all share the type + # value for) reaches the same ``read()``-level refusal; a Report or + # Leave type is not even Query-shaped, so IGMPv3's schema selector + # refuses it before ``read()`` -- see + # ``test_igmpv3_rejects_an_8_octet_v1_or_v2_message``. with self.assertRaisesRegex(ProtocolError, r'^IGMPv3: .+ is not an IGMPv3 message \(8 octets\)'): - IGMPv3(io.BytesIO(v1_report), len(v1_report)) + IGMPv3(io.BytesIO(v2_query), len(v2_query)) def test_membership_query_max_resp_time_zero_is_v1(self) -> None: from pcapkit.protocols.internet.igmpv1 import IGMP