diff --git a/docs/source/contributing/conventions/protocol-family-grouping.rst b/docs/source/contributing/conventions/protocol-family-grouping.rst index d9efa64218..76bdbc98c3 100644 --- a/docs/source/contributing/conventions/protocol-family-grouping.rst +++ b/docs/source/contributing/conventions/protocol-family-grouping.rst @@ -66,7 +66,8 @@ Member Shapes - the member's own name only - :class:`~pcapkit.protocols.internet.ah.AH` ``('AH',)``, :class:`~pcapkit.protocols.internet.esp.ESP` ``('ESP',)``; - :class:`IGMPv2 ` ``('IGMPv2',)`` -- + :class:`IGMPv2 ` ``('IGMPv2',)`` and + :class:`IGMPv3 ` ``('IGMPv3',)`` -- :class:`IGMPv1 ` narrows the same way, but to the RFC canonical name rather than a versioned one (:ref:`protocol-family-canonical-names`) @@ -122,11 +123,13 @@ and its ``name`` carries no "version 4". ``ICMPv6`` keeps its own version, since :rfc:`1112` names IGMPv1's protocol plain ``IGMP``, so :meth:`IGMPv1.id() ` is ``('IGMP',)`` and its ``alias`` is ``'IGMP'``, while -:meth:`IGMPv2.id() ` keeps its own version, -``('IGMPv2',)``, since :rfc:`2236` names it with one. The family base's ``id()`` -follows: :meth:`IGMP.id() ` is -``('IGMP', 'IGMPv2')``, the family-inclusive shape, because IGMPv1's canonical name -and the family's own name are the same string -- the same shape +:meth:`IGMPv2.id() ` and +:meth:`IGMPv3.id() ` keep their own +versions, ``('IGMPv2',)`` and ``('IGMPv3',)``, since :rfc:`2236` and :rfc:`3376` name +them with one. The family base's ``id()`` follows: +:meth:`IGMP.id() ` is +``('IGMP', 'IGMPv2', 'IGMPv3')``, the family-inclusive shape, because IGMPv1's +canonical name and the family's own name are the same string -- the same shape :meth:`ICMP.id() ` takes, ``('ICMP', 'ICMPv6')``. .. _protocol-family-selectors: diff --git a/pcapkit/protocols/internet/igmp.py b/pcapkit/protocols/internet/igmp.py index 3e48e11dc6..a4b5668b66 100644 --- a/pcapkit/protocols/internet/igmp.py +++ b/pcapkit/protocols/internet/igmp.py @@ -89,21 +89,22 @@ def length(self) -> 'Literal[8]': ########################################################################## @classmethod - def id(cls) -> 'tuple[Literal["IGMP"], Literal["IGMPv1"], Literal["IGMPv2"], Literal["IGMPv3"]]': + def id(cls) -> 'tuple[Literal["IGMP"], Literal["IGMPv2"], Literal["IGMPv3"]]': """Index ID of the protocol. :rfc:`1112` names the protocol ``IGMP``, without a version, the same shape :class:`~pcapkit.protocols.internet.icmp.ICMP` takes for :rfc:`792` -- so the family base's own name doubles as IGMPv1's - canonical name. :rfc:`2236` names IGMPv2 with its own version. + canonical name. :rfc:`2236` and :rfc:`3376` name IGMPv2 and IGMPv3 + with their own versions. Returns: - 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. + Index ID of the protocol, the family name and the IGMPv2 and + IGMPv3 names; the concrete IGMPv1, IGMPv2 and IGMPv3 classes + narrow it to their own canonical name. """ - return ('IGMP', 'IGMPv1', 'IGMPv2', 'IGMPv3') + return ('IGMP', 'IGMPv2', 'IGMPv3') def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> '_PT': """Read Internet Group Management Protocol. diff --git a/pcapkit/protocols/internet/igmpv3.py b/pcapkit/protocols/internet/igmpv3.py index f0eb254ff9..d00dd44311 100644 --- a/pcapkit/protocols/internet/igmpv3.py +++ b/pcapkit/protocols/internet/igmpv3.py @@ -182,14 +182,16 @@ def length(self) -> 'int': ########################################################################## @classmethod - def id(cls) -> 'tuple[Literal["IGMP"], Literal["IGMPv3"]]': # type: ignore[override] + def id(cls) -> 'tuple[Literal["IGMPv3"]]': # type: ignore[override] """Index ID of the protocol. + :rfc:`3376` names the protocol ``IGMPv3``, carrying its own version. + Returns: - Index ID of the protocol, the family name and the version. + Index ID of the protocol. """ - return ('IGMP', 'IGMPv3') + return ('IGMPv3',) def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_IGMP': """Read Internet Group Management Protocol version 3. diff --git a/tests/protocols/internet/test_igmp_unit.py b/tests/protocols/internet/test_igmp_unit.py index f9d411a383..f0f8d82e06 100644 --- a/tests/protocols/internet/test_igmp_unit.py +++ b/tests/protocols/internet/test_igmp_unit.py @@ -424,26 +424,33 @@ def test_id_and_version(self) -> None: self.assertEqual(IGMPv1.id(), ('IGMP',)) self.assertEqual(IGMPv2.id(), ('IGMPv2',)) - self.assertEqual(IGMPv3.id(), ('IGMP', 'IGMPv3')) + self.assertEqual(IGMPv3.id(), ('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_all_three_versions(self) -> None: + """The base lists each version's canonical name, and only those: IGMPv1's + is the family name itself, so no ``id()`` carries ``'IGMPv1'``.""" from pcapkit.protocols.internet.igmp import IGMP - self.assertEqual(IGMP.id(), ('IGMP', 'IGMPv1', 'IGMPv2', 'IGMPv3')) + self.assertEqual(IGMP.id(), ('IGMP', 'IGMPv2', 'IGMPv3')) + members = (igmp_class('IGMPv1'), igmp_class('IGMPv2'), igmp_class('IGMPv3')) + self.assertEqual(IGMP.id(), tuple(name for member in members for name in member.id())) + for klass in (IGMP, *members): + with self.subTest(klass=klass.__module__): + self.assertNotIn('IGMPv1', klass.id()) def test_each_version_narrows_the_base_id(self) -> None: v1, v2, v3 = igmp_class('IGMPv1'), igmp_class('IGMPv2'), igmp_class('IGMPv3') self.assertEqual(v1.id(), ('IGMP',)) self.assertEqual(v2.id(), ('IGMPv2',)) - self.assertEqual(v3.id(), ('IGMP', 'IGMPv3')) + self.assertEqual(v3.id(), ('IGMPv3',)) self.assertNotIn('IGMPv2', v1.id()) self.assertNotIn('IGMPv3', v1.id()) - self.assertNotIn('IGMPv1', v2.id()) + self.assertNotIn('IGMP', v2.id()) self.assertNotIn('IGMPv3', v2.id()) - self.assertNotIn('IGMPv1', v3.id()) + self.assertNotIn('IGMP', v3.id()) self.assertNotIn('IGMPv2', v3.id()) def test_igmpv1_parses_its_own_messages_directly(self) -> None: @@ -1139,5 +1146,62 @@ def test_ipv4_dispatches_igmp_to_each_version(self) -> None: self.assertEqual(parsed.payload.alias, expected_alias(version)) +class IGMPSelectorTests(unittest.TestCase): + """Selectors on parsed IGMP layers, per the *Selector Matching* section of + ``protocol-family-grouping.rst``: the family name selects every version, and + each version name selects its own (#2512).""" + + #: Every IGMP selector: the family name, then each version's registry key. + SELECTORS = ('IGMP', 'IGMPv1', 'IGMPv2', 'IGMPv3') + + def setUp(self) -> None: + reimport_once_per_class(self) + + @staticmethod + def parse(packet: 'bytes'): + """Return ``(frame, layer)``: ``packet`` carried by IPv4, and the IGMP + layer IP protocol 2 dispatched it to.""" + from pcapkit.protocols.internet.ipv4 import IPv4 + + outer = IPv4(protocol=2, src='10.0.0.1', dst='224.0.0.1', payload=packet).data + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + frame = IPv4(io.BytesIO(outer), len(outer)) + return frame, frame.payload + + def test_in_and_getitem_select_the_family_and_the_own_version(self) -> None: + """``in`` and ``[]`` expand the selector, a registry key or its class + alike: ``'IGMP'`` reaches every version, a version key only its own -- + ``'IGMPv1'`` through its registry key, since no ``id()`` carries it. + The protochain also compares each alias, which here is the same name.""" + from pcapkit.protocols import __proto__ as registry + from pcapkit.utilities.exceptions import ProtocolNotFound + + for name, (packet, version) in {**IGMP_PACKETS, **IGMPV3_PACKETS}.items(): + frame, layer = self.parse(packet) + self.assertIsInstance(layer, igmp_class(version)) + for selector in self.SELECTORS: + expected = selector in ('IGMP', version) + for key in (selector, registry[selector.upper()]): + with self.subTest(packet=name, selector=key): + self.assertIs(key in layer, expected) + self.assertIs(key in frame, expected) + self.assertIs(key in frame.protochain, expected) + if expected: + self.assertIs(frame[key], layer) + else: + with self.assertRaises(ProtocolNotFound): + frame[key] # pylint: disable=pointless-statement + + def test_string_eq_matches_the_layer_s_own_name_only(self) -> None: + """String ``==`` is not expanded, so a layer equals its own canonical + name alone: ``'IGMP'`` only on IGMPv1, ``'IGMPv3'`` only on IGMPv3.""" + for name, (packet, version) in {**IGMP_PACKETS, **IGMPV3_PACKETS}.items(): + _, layer = self.parse(packet) + for selector in self.SELECTORS: + with self.subTest(packet=name, selector=selector): + self.assertIs(layer == selector, selector == expected_alias(version)) + + if __name__ == '__main__': unittest.main()