From c1f84400b1249be54ffe2ccfab62f3335f80cb6b Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Thu, 17 Sep 2026 12:06:31 -0400 Subject: [PATCH 1/5] protocols: bind the S-Tag, FTP-DATA, HTTP-alt, OSPF and L2TP to the tables that reach them Split VLAN into an abstract base plus concrete tags, and fill in the dispatch entries whose dissectors already existed but were reachable from no registry. * ``VLAN`` becomes an abstract base holding the tag layout, with ``C_Tag`` (802.1Q, ``0x8100``) and ``S_Tag`` (802.1ad, ``0x88A8``) as concrete subclasses. The two layouts are identical -- the TPID that tells them apart belongs to the encapsulating header -- so the split is not about parsing but about Q-in-Q: both tags appear in one frame, and ``info_name`` is what keeps them distinct in the parsed output. A stacked frame previously collapsed into a single opaque ``Raw`` payload, losing the inner tag and everything under it. * ``VLAN.read`` reported the DEI flag as ``bool(tci['pcp'])`` instead of reading its own bit, so it was wrong whenever priority and drop-eligibility disagreed. The existing test passed because it pinned a case where they did not. * TCP 20 to ``FTP_DATA`` and 8080 to HTTP/1; UDP 8080 to HTTP and 1701 to ``L2TP``. Port numbers, service names and descriptions are IANA service-name-registry assignments. 8443 is deliberately left unbound: IANA registers it as ``pcsync-https``, and pcapkit implements no TLS. * OSPF is bound at ``TransType`` 89. Three defects had kept it from parsing anything at all: ``read`` consulted the schema *class* rather than the parsed header, ``alias`` reached for an ``_info`` that does not exist until ``read`` has returned, and the remaining payload length was dispatched as if it were a protocol code. L2TP shared the last of those; both now dispatch on the ``-1`` sentinel, as ARP does. * ``TransType`` 115 stays unbound: it references RFC 3931, i.e. L2TPv3 over IP, whose session header differs from the RFC 2661 v2 framing this dissector implements. Every registry read goes through ``ProtocolBase._lookup_registry``, so a miss does not grow the shared tables. Suite 891 passed / 18 skipped, against 876 / 18 on main (+15 tests, +17 subtests). All 15 sample captures produce byte-identical tree and json output; none of them contains a VLAN tag, an OSPF or L2TP packet, or traffic on any newly bound port, so the new paths are covered by synthetic frames instead. mypy unchanged at 128 errors in 41 files; pylint adds no message and drops one over-long line. --- docs/source/ext.rst | 6 +- docs/source/pcapkit/protocols/index.rst | 6 + docs/source/pcapkit/protocols/link/vlan.rst | 62 +++- docs/source/pep.rst | 77 ++++- pcapkit/all.py | 4 +- pcapkit/protocols/__init__.py | 4 +- pcapkit/protocols/data/link/vlan.py | 10 +- pcapkit/protocols/internet/internet.py | 11 + pcapkit/protocols/link/__init__.py | 6 +- pcapkit/protocols/link/l2tp.py | 28 +- pcapkit/protocols/link/link.py | 17 +- pcapkit/protocols/link/ospf.py | 46 ++- pcapkit/protocols/link/vlan.py | 170 +++++++-- pcapkit/protocols/schema/link/vlan.py | 16 +- pcapkit/protocols/transport/tcp.py | 23 +- pcapkit/protocols/transport/udp.py | 35 +- tests/protocols/link/test_link_unit.py | 142 +++++++- .../protocols/test_dispatch_bindings_unit.py | 322 ++++++++++++++++++ 18 files changed, 887 insertions(+), 98 deletions(-) create mode 100644 tests/protocols/test_dispatch_bindings_unit.py diff --git a/docs/source/ext.rst b/docs/source/ext.rst index 46b73f34a5..50718f3081 100644 --- a/docs/source/ext.rst +++ b/docs/source/ext.rst @@ -34,7 +34,11 @@ The following table shows all available protocol classes in :mod:`pcapkit`: + +----------------+-----------------------+-------------------------------------------------------------+ | | :class:`pcapkit.protocols.link.ospf.OSPF` | + +----------------+-----------------------+-------------------------------------------------------------+ -| | :class:`pcapkit.protocols.link.vlan.VLAN` | +| | | :class:`pcapkit.protocols.link.vlan.VLAN` | ++ + +-----------------------+-------------------------------------------------------------+ +| | VLAN Family | :class:`pcapkit.protocols.link.vlan.C_Tag` | ++ + +-----------------------+-------------------------------------------------------------+ +| | | :class:`pcapkit.protocols.link.vlan.S_Tag` | +------------------------------------------------------------------+----------------+-----------------------+-------------------------------------------------------------+ | | | :class:`pcapkit.protocols.internet.ip.IP` | + + +-----------------------+-------------------------------------------------------------+ diff --git a/docs/source/pcapkit/protocols/index.rst b/docs/source/pcapkit/protocols/index.rst index 9499763ccd..abea1c0de8 100644 --- a/docs/source/pcapkit/protocols/index.rst +++ b/docs/source/pcapkit/protocols/index.rst @@ -38,6 +38,10 @@ diagram of the class hierarchy of :mod:`pcapkit.protocols`: RARP --> DRARP end end + + subgraph vlan [VLAN Family] + VLAN --> C_Tag & S_Tag + end end subgraph internet [Internet Layer] @@ -102,6 +106,8 @@ diagram of the class hierarchy of :mod:`pcapkit.protocols`: click L2TP "/pcapkit/protocols/link/l2tp.html#pcapkit.protocols.link.l2tp.L2TP" click OSPF "/pcapkit/protocols/link/ospf.html#pcapkit.protocols.link.ospf.OSPF" click VLAN "/pcapkit/protocols/link/vlan.html#pcapkit.protocols.link.vlan.VLAN" + click C_Tag "/pcapkit/protocols/link/vlan.html#pcapkit.protocols.link.vlan.C_Tag" + click S_Tag "/pcapkit/protocols/link/vlan.html#pcapkit.protocols.link.vlan.S_Tag" click ARP "/pcapkit/protocols/link/arp.html#pcapkit.protocols.link.arp.ARP" click InARP "/pcapkit/protocols/link/arp.html#pcapkit.protocols.link.arp.InARP" click RARP "/pcapkit/protocols/link/rarp.html#pcapkit.protocols.link.rarp.RARP" diff --git a/docs/source/pcapkit/protocols/link/vlan.rst b/docs/source/pcapkit/protocols/link/vlan.rst index 130a21218e..34b6032221 100644 --- a/docs/source/pcapkit/protocols/link/vlan.rst +++ b/docs/source/pcapkit/protocols/link/vlan.rst @@ -1,13 +1,14 @@ -VLAN - 802.1Q Customer VLAN Tag Type +VLAN - 802.1Q/802.1ad VLAN Tag Types ==================================== .. module:: pcapkit.protocols.link.vlan :mod:`pcapkit.protocols.link.vlan` contains -:class:`~pcapkit.protocols.link.vlan.VLAN` -only, which implements extractor for 802.1Q -Customer VLAN Tag Type [*]_, whose structure is -described as below: +:class:`~pcapkit.protocols.link.vlan.VLAN`, an abstract base class holding the +tag layout shared by every VLAN tag, and its two concrete subclasses -- +:class:`~pcapkit.protocols.link.vlan.C_Tag` for the 802.1Q customer tag [*]_ and +:class:`~pcapkit.protocols.link.vlan.S_Tag` for the 802.1ad service tag -- whose +structure is described as below: ======= ========= ====================== ============================= Octets Bits Name Description @@ -19,13 +20,36 @@ Octets Bits Name Description 3 24 ``vlan.type`` Protocol (Internet Layer) ======= ========= ====================== ============================= +The two tags carry an identical tag control information layout and are told apart +solely by the tag protocol identifier (TPID) that selected them -- ``0x8100`` for +the customer tag against ``0x88A8`` for the service tag. That TPID is not part of +either tag: it is the EtherType field of whatever encapsulates the tag, so both +classes read the same four octets and share every byte of parsing and +construction code. + +They are nonetheless distinct classes rather than one class bound at two +EtherTypes, because 802.1ad *stacks* them. In a Q-in-Q frame the service tag's +own next-EtherType is ``0x8100``, which selects a customer tag in turn, so both +tags appear in one frame: + +.. code-block:: text + + ethernet.type = 0x88A8 + ethernet.s_tag.tci.vid = 100 <- service tag, 802.1ad + ethernet.s_tag.type = 0x8100 + ethernet.s_tag.c_tag.tci.vid = 200 <- customer tag, 802.1Q + ethernet.s_tag.c_tag.type = 0x0800 + +:attr:`~pcapkit.protocols.protocol.ProtocolBase.info_name` -- ``s_tag`` against +``c_tag`` -- is what keeps the two apart in the parsed +:class:`~pcapkit.corekit.infoclass.Info`. A single class bound at both EtherTypes +would nest one ``c_tag`` inside another, leaving nothing in the output to say +which of the two was the service tag. + .. autoclass:: pcapkit.protocols.link.vlan.VLAN :no-members: :show-inheritance: - .. autoproperty:: name - .. autoproperty:: alias - .. autoproperty:: info_name .. autoproperty:: length .. autoproperty:: protocol @@ -36,11 +60,33 @@ Octets Bits Name Description .. automethod:: __index__ +.. autoclass:: pcapkit.protocols.link.vlan.C_Tag + :no-members: + :show-inheritance: + + .. autoproperty:: name + .. autoproperty:: alias + .. autoproperty:: info_name + + .. automethod:: id + +.. autoclass:: pcapkit.protocols.link.vlan.S_Tag + :no-members: + :show-inheritance: + + .. autoproperty:: name + .. autoproperty:: alias + .. autoproperty:: info_name + + .. automethod:: id + Header Schemas -------------- .. module:: pcapkit.protocols.schema.link.vlan +Both tags share these, since their layouts are identical. + .. autoclass:: pcapkit.protocols.schema.link.vlan.VLAN :members: :show-inheritance: diff --git a/docs/source/pep.rst b/docs/source/pep.rst index 34e2619721..944b4b47f9 100644 --- a/docs/source/pep.rst +++ b/docs/source/pep.rst @@ -211,14 +211,15 @@ complaint and yields nothing useful. ``ETHERNET``, ``IPV4`` and ``IPV6``, declared identically in :class:`~pcapkit.protocols.misc.pcap.frame.Frame` and :class:`~pcapkit.protocols.misc.pcapng.PCAPNG`. -* **15 of the 151** :class:`~pcapkit.const.reg.transtype.TransType` values, in +* **16 of the 151** :class:`~pcapkit.const.reg.transtype.TransType` values, in :attr:`Internet.__proto__ `. -* **6 of the 160** :class:`~pcapkit.const.reg.ethertype.EtherType` values, in +* **7 of the 160** :class:`~pcapkit.const.reg.ethertype.EtherType` values, in :attr:`Link.__proto__ `: ARP, - RARP, IPv4, IPv6, IPX and the customer VLAN tag. -* **2 port numbers, out of 8182** :class:`~pcapkit.const.reg.apptype.AppType` - members -- TCP 21 to FTP, and port 80 to HTTP on both TCP and UDP. + RARP, IPv4, IPv6, IPX and both VLAN tags. +* **6 port numbers, out of 8182** :class:`~pcapkit.const.reg.apptype.AppType` + members -- TCP 20 to FTP-DATA and 21 to FTP, port 80 and 8080 to HTTP on both + TCP and UDP, and UDP 1701 to L2TP. * **none of the 75** :class:`~pcapkit.const.sctp.payload_protocol_identifier.PayloadProtocolIdentifier` values. :attr:`SCTP.__proto__ @@ -227,23 +228,63 @@ complaint and yields nothing useful. :func:`~pcapkit.foundation.registry.protocols.register_sctp`. Most of those want a dissector written and are covered by the stub list above. -A handful want only a table entry, because the dissector is already there: - -* :class:`~pcapkit.protocols.link.ospf.OSPF` and - :class:`~pcapkit.protocols.link.l2tp.L2TP` are implemented but reachable from - no registry at all -- ``TransType`` 89 and 115 are both unbound. Note that - each class deliberately makes ``__index__`` raise, so registering them means - deciding that question first. -* :class:`~pcapkit.protocols.application.ftp.FTP_DATA` is implemented and - exported, but TCP port 20 is unbound. -* HTTP is bound on port 80 only, not on 8080 or 8443. -* The service VLAN tag identifier (S-Tag), ``0x88A8``, is unbound, though - :class:`~pcapkit.protocols.link.vlan.VLAN` already parses that shape and the - customer tag ``0x8100`` is bound to it. +A handful wanted only a table entry, because the dissector was already there, +and those have now been made: + +* **Done.** :class:`~pcapkit.protocols.link.ospf.OSPF` is bound at + ``TransType`` 89 (``OSPFIGP``) and + :class:`~pcapkit.protocols.link.l2tp.L2TP` at UDP port 1701. Binding them + turned up three defects that had kept OSPF from parsing anything at all -- + ``read`` consulted the schema *class* rather than the parsed header, + :attr:`~pcapkit.protocols.link.ospf.OSPF.alias` read an ``_info`` that does + not exist until ``read`` has returned, and both classes dispatched the + remaining payload *length* as if it were a protocol code. ``__index__`` still + raises on both, which is correct: neither is reached through a link-layer + EtherType. +* **Done.** :class:`~pcapkit.protocols.application.ftp.FTP_DATA` is bound at TCP + port 20 (IANA ``ftp-data``). It is a thin + :class:`~pcapkit.protocols.misc.raw.Raw` subclass, so this buys the payload a + *name* rather than a parse -- which is the right answer for a data channel + carrying an arbitrary file. +* **Done.** HTTP is additionally bound on 8080 (IANA ``http-alt``, "HTTP + Alternate (see port 80)") on both TCP and UDP. **8443 is deliberately left + unbound**: IANA registers it as ``pcsync-https``, not as an HTTP alternate, + and de-facto 8443 traffic is TLS-wrapped, which pcapkit cannot parse -- see + the ``tls`` stub above. Binding it would feed a TLS record to an HTTP parser. +* **Done.** The service VLAN tag identifier (S-Tag), ``0x88A8``, is bound to + :class:`~pcapkit.protocols.link.vlan.S_Tag`, and the customer tag ``0x8100`` + to :class:`~pcapkit.protocols.link.vlan.C_Tag`. Both subclass the now-abstract + :class:`~pcapkit.protocols.link.vlan.VLAN`, which carries the shared tag + layout; the split exists so that a Q-in-Q frame's two tags stay distinct in + the parsed output. Fixing the shared ``read`` also fixed the DEI flag, which + had been reported as ``bool(pcp)`` rather than read from its own bit. * ``LinkType`` ``NULL``, ``LOOP`` and ``RAW`` carry bare IPv4 or IPv6, both of which pcapkit dissects. ``NULL`` and ``LOOP`` need their four-octet address family word skipped first, and ``RAW`` needs a version sniff. +Three follow-ups the above deliberately left alone: + +* ``TransType`` 115 (``L2TP``) stays unbound. It references :rfc:`3931`, i.e. + L2TPv3 over IP, whose session header differs from the :rfc:`2661` L2TPv2 + framing :class:`~pcapkit.protocols.link.l2tp.L2TP` implements. Binding it + wants a v3 dissector, not a table entry. +* :class:`~pcapkit.protocols.link.ospf.OSPF` and + :class:`~pcapkit.protocols.link.l2tp.L2TP` both live under + :mod:`pcapkit.protocols.link` and so report ``layer == 'Link'``, although one + is carried inside IP and the other inside UDP. Moving them would change their + public import paths, so the misclassification is documented rather than + fixed. It is inert for layer-limited extraction, since IPv4 and IPv6 terminate + an ``internet`` extraction before either is reached. +* :attr:`UDP.__proto__ ` points + its HTTP ports at the version-guessing + :class:`pcapkit.protocols.application.http.HTTP`, while + :attr:`TCP.__proto__ ` points + the same ports at :class:`pcapkit.protocols.application.httpv1.HTTP`. The + asymmetry predates the 8080 entries. Reconciling it changes what existing + captures parse to, so it wants its own change. Note also that + ``http.HTTP``'s explicit ``version=`` path is broken independently: it passes + an already-drained file object, so only the auto-guess path works. + Beyond those, the gaps most likely to be met in a real capture are ICMP (1), ICMPv6 (58) and IGMP (2) on the internet layer, all three of which have stubs; and ``LINUX_SLL`` and ``LINUX_SLL2`` at the link layer, which every diff --git a/pcapkit/all.py b/pcapkit/all.py index 3db61aa816..b9c4df2894 100644 --- a/pcapkit/all.py +++ b/pcapkit/all.py @@ -109,8 +109,8 @@ 'Header', 'Frame', # PCAP Headers 'NoPayload', # No Payload 'Raw', # Raw Packet - 'ARP', 'DRARP', 'Ethernet', 'InARP', 'L2TP', 'OSPF', 'RARP', 'VLAN', - # Link Layer + 'ARP', 'C_Tag', 'DRARP', 'Ethernet', 'InARP', 'L2TP', 'OSPF', 'RARP', + 'S_Tag', 'VLAN', # Link Layer 'AH', 'IP', 'IPsec', 'IPv4', 'IPv6', 'IPX', # Internet Layer 'HIP', 'HOPOPT', 'IPv6_Frag', 'IPv6_Opts', 'IPv6_Route', 'MH', # IPv6 Extension Header diff --git a/pcapkit/protocols/__init__.py b/pcapkit/protocols/__init__.py index 2e440fbb1b..fe004992e1 100644 --- a/pcapkit/protocols/__init__.py +++ b/pcapkit/protocols/__init__.py @@ -50,8 +50,8 @@ 'Raw', # Link Layer - 'ARP', 'DRARP', 'Ethernet', 'InARP', 'L2TP', - 'OSPF', 'RARP', 'VLAN', + 'ARP', 'C_Tag', 'DRARP', 'Ethernet', 'InARP', 'L2TP', + 'OSPF', 'RARP', 'S_Tag', 'VLAN', # Internet Layer 'AH', 'ESP', 'IP', 'IPsec', 'IPv4', 'IPv6', 'IPX', diff --git a/pcapkit/protocols/data/link/vlan.py b/pcapkit/protocols/data/link/vlan.py index 06bb4b55df..efa660ee77 100644 --- a/pcapkit/protocols/data/link/vlan.py +++ b/pcapkit/protocols/data/link/vlan.py @@ -1,5 +1,11 @@ # -*- coding: utf-8 -*- -"""data models for 802.1Q customer VLAN tag type""" +"""data models for 802.1Q/802.1ad VLAN tag types + +The customer tag (802.1Q) and the service tag (802.1ad) carry an identical +layout, so :class:`~pcapkit.protocols.link.vlan.C_Tag` and +:class:`~pcapkit.protocols.link.vlan.S_Tag` share the data model below. + +""" from typing import TYPE_CHECKING @@ -31,7 +37,7 @@ def __init__(self, pcp: 'PriorityLevel', dei: 'bool', vid: 'int') -> 'None': ... @info_final class VLAN(Protocol): - """Data model for 802.1Q customer VLAN tag type.""" + """Data model for an 802.1Q/802.1ad VLAN tag.""" #: Tag control information. tci: 'TCI' diff --git a/pcapkit/protocols/internet/internet.py b/pcapkit/protocols/internet/internet.py index 734c19c090..6e080b4c3c 100644 --- a/pcapkit/protocols/internet/internet.py +++ b/pcapkit/protocols/internet/internet.py @@ -73,6 +73,8 @@ class Internet(Protocol[_PT, _ST], Generic[_PT, _ST]): # pylint: disable=abstra - :class:`pcapkit.protocols.internet.hip.HIP` * - :attr:`~pcapkit.const.reg.transtype.TransType.SCTP` - :class:`pcapkit.protocols.transport.sctp.SCTP` + * - :attr:`~pcapkit.const.reg.transtype.TransType.OSPFIGP` + - :class:`pcapkit.protocols.link.ospf.OSPF` """ @@ -104,6 +106,15 @@ class Internet(Protocol[_PT, _ST], Generic[_PT, _ST]): # pylint: disable=abstra Enum_TransType.Mobility_Header: ModuleDescriptor('pcapkit.protocols.internet.mh', 'MH'), Enum_TransType.HIP: ModuleDescriptor('pcapkit.protocols.internet.hip', 'HIP'), Enum_TransType.SCTP: ModuleDescriptor('pcapkit.protocols.transport.sctp', 'SCTP'), + + # OSPF rides directly on IP, so IANA protocol number 89 is its only + # dispatch point. The dissector lives under ``protocols.link`` + # despite that, and so reports ``__layer__ = 'Link'``; c.f. the note + # in :mod:`pcapkit.protocols.link.ospf`. The module is left where it + # is here -- moving it would break its import path -- and the + # mislabel is inert for layer-limited extraction, since IPv4/IPv6 + # terminate an ``internet`` extraction before OSPF is reached. + Enum_TransType.OSPFIGP: ModuleDescriptor('pcapkit.protocols.link.ospf', 'OSPF'), }, ) diff --git a/pcapkit/protocols/link/__init__.py b/pcapkit/protocols/link/__init__.py index 4da53a9ec6..64242031d6 100644 --- a/pcapkit/protocols/link/__init__.py +++ b/pcapkit/protocols/link/__init__.py @@ -20,7 +20,7 @@ from pcapkit.protocols.link.l2tp import L2TP from pcapkit.protocols.link.ospf import OSPF from pcapkit.protocols.link.rarp import RARP, DRARP -from pcapkit.protocols.link.vlan import VLAN +from pcapkit.protocols.link.vlan import VLAN, C_Tag, S_Tag # Link-Layer Header Type Values from pcapkit.const.reg.linktype import LinkType as LINKTYPE @@ -30,6 +30,6 @@ 'LINKTYPE', # Link Layer Protocols - 'ARP', 'DRARP', 'Ethernet', 'InARP', 'L2TP', - 'OSPF', 'RARP', 'VLAN', + 'ARP', 'C_Tag', 'DRARP', 'Ethernet', 'InARP', 'L2TP', + 'OSPF', 'RARP', 'S_Tag', 'VLAN', ] diff --git a/pcapkit/protocols/link/l2tp.py b/pcapkit/protocols/link/l2tp.py index be37c12197..b657473546 100644 --- a/pcapkit/protocols/link/l2tp.py +++ b/pcapkit/protocols/link/l2tp.py @@ -75,7 +75,25 @@ class L2TP(Link[Data_L2TP, Schema_L2TP], schema=Schema_L2TP, data=Data_L2TP): - """This class implements Layer Two Tunnelling Protocol.""" + """This class implements Layer Two Tunnelling Protocol. + + The protocol is dispatched from :attr:`UDP.__proto__ + ` at port 1701. + + Note: + This is **L2TPv2** as specified by :rfc:`2661` -- a 16-bit tunnel ID and + a 16-bit session ID, with the version nibble reading 2. IANA protocol + number 115 (``L2TP``) is therefore deliberately left unbound: it + references :rfc:`3931`, i.e. L2TPv3 over IP, whose session header is a + different shape and which this dissector would misparse. + + As with :class:`~pcapkit.protocols.link.ospf.OSPF`, the class subclasses + :class:`~pcapkit.protocols.link.link.Link` and so reports + ``layer == 'Link'`` even though it is carried inside UDP. That is a + pre-existing classification, kept because moving the module would change + its public import path. + + """ ########################################################################## # Properties. @@ -160,7 +178,13 @@ def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_L2TP': # l2tp['padding'] = self._read_fileng(_size) length = schema.length if flags.len else (length or len(self)) - return self._decode_next_layer(l2tp, length - hdr_len) + # L2TP carries no next-protocol field -- the payload is a PPP frame, + # which pcapkit does not dissect -- so dispatch on the -1 sentinel, as + # ARP does, rather than on a code read off the wire. Passing the + # remaining length here (as this did) dispatched on it as if it were an + # EtherType, which resolved to Raw only because a length rarely collides + # with a registered one. + return self._decode_next_layer(l2tp, -1, length - hdr_len) def make(self, version: 'Literal[2]' = 2, diff --git a/pcapkit/protocols/link/link.py b/pcapkit/protocols/link/link.py index e5201c3e6c..cea1e99754 100644 --- a/pcapkit/protocols/link/link.py +++ b/pcapkit/protocols/link/link.py @@ -51,7 +51,9 @@ class Link(Protocol[_PT, _ST], Generic[_PT, _ST]): # pylint: disable=abstract-m * - :attr:`~pcapkit.const.reg.ethertype.EtherType.Reverse_Address_Resolution_Protocol` - :class:`pcapkit.protocols.link.rarp.RARP` * - :attr:`~pcapkit.const.reg.ethertype.EtherType.Customer_VLAN_Tag_Type` - - :class:`pcapkit.protocols.link.vlan.VLAN` + - :class:`pcapkit.protocols.link.vlan.C_Tag` + * - :attr:`~pcapkit.const.reg.ethertype.EtherType.IEEE_Std_802_1Q_Service_VLAN_tag_identifier` + - :class:`pcapkit.protocols.link.vlan.S_Tag` * - :attr:`~pcapkit.const.reg.ethertype.EtherType.Internet_Protocol_version_4` - :class:`pcapkit.protocols.internet.ipv4.IPv4` * - :attr:`~pcapkit.const.reg.ethertype.EtherType.Internet_Protocol_version_6` @@ -76,7 +78,18 @@ class Link(Protocol[_PT, _ST], Generic[_PT, _ST]): # pylint: disable=abstract-m { Enum_EtherType.Address_Resolution_Protocol: ModuleDescriptor('pcapkit.protocols.link.arp', 'ARP'), Enum_EtherType.Reverse_Address_Resolution_Protocol: ModuleDescriptor('pcapkit.protocols.link.rarp', 'RARP'), - Enum_EtherType.Customer_VLAN_Tag_Type: ModuleDescriptor('pcapkit.protocols.link.vlan', 'VLAN'), + # The 802.1Q customer tag and the 802.1ad service tag. Q-in-Q stacks + # them -- the service tag's own next-EtherType is what selects the + # customer tag -- so the two coexist in one frame rather than + # competing. They are separate classes only to keep their + # ``info_name`` apart in the parsed output; the tag layout itself is + # identical, and both share the code in + # :class:`~pcapkit.protocols.link.vlan.VLAN`. + Enum_EtherType.Customer_VLAN_Tag_Type: + ModuleDescriptor('pcapkit.protocols.link.vlan', 'C_Tag'), + Enum_EtherType.IEEE_Std_802_1Q_Service_VLAN_tag_identifier: + ModuleDescriptor('pcapkit.protocols.link.vlan', 'S_Tag'), + Enum_EtherType.Internet_Protocol_version_4: ModuleDescriptor('pcapkit.protocols.internet.ipv4', 'IPv4'), Enum_EtherType.Internet_Protocol_version_6: ModuleDescriptor('pcapkit.protocols.internet.ipv6', 'IPv6'), diff --git a/pcapkit/protocols/link/ospf.py b/pcapkit/protocols/link/ospf.py index b3c1c739aa..91ba9bcdd9 100644 --- a/pcapkit/protocols/link/ospf.py +++ b/pcapkit/protocols/link/ospf.py @@ -69,7 +69,34 @@ class OSPF(Link[Data_OSPF, Schema_OSPF], schema=Schema_OSPF, data=Data_OSPF): - """This class implements Open Shortest Path First.""" + """This class implements Open Shortest Path First. + + The protocol is dispatched from :attr:`Internet.__proto__ + ` at + :attr:`~pcapkit.const.reg.transtype.TransType.OSPFIGP` (IANA protocol number + 89), since OSPF rides directly on IP rather than on a link-layer frame. + + Note: + It nonetheless subclasses :class:`~pcapkit.protocols.link.link.Link` and + so reports ``layer == 'Link'``, which is not where a protocol carried + inside IP belongs. That is a pre-existing classification, kept because + moving the module would change its public import path. It is inert for + layer-limited extraction -- IPv4 and IPv6 terminate an ``internet`` + extraction before OSPF is reached, and a ``link`` extraction stops at + Ethernet -- but :attr:`self.layer ` + does read ``'Link'`` on a parsed OSPF packet. + + """ + #: Version number of corresponding protocol, as read off the header. Held on + #: the instance rather than read back out of :attr:`self._info + #: ` because :attr:`name` and + #: :attr:`alias` are both needed *during* :meth:`read` -- it is + #: :meth:`self._decode_next_layer + #: ` that builds + #: the protocol chain out of :attr:`alias` -- and ``_info`` is not assigned + #: until :meth:`read` has returned. c.f. :attr:`ARP._acnm + #: `, which carries the same constraint. + _version: 'int' ########################################################################## # Properties. @@ -78,12 +105,12 @@ class OSPF(Link[Data_OSPF, Schema_OSPF], @property def name(self) -> 'str': """Name of current protocol.""" - return f'Open Shortest Path First version {self._info.version}' + return f'Open Shortest Path First version {self._version}' @property def alias(self) -> 'str': """Acronym of current protocol.""" - return f'OSPFv{self._info.version}' + return f'OSPFv{self._version}' @property def length(self) -> 'Literal[24]': @@ -130,7 +157,10 @@ def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_OSPF': Parsed packet data. """ - schema = self.__schema__ + schema = self.__header__ + + # Set before _decode_next_layer below, which reads self.alias. + self._version = schema.version ospf = Data_OSPF( version=schema.version, @@ -153,7 +183,13 @@ def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_OSPF': ospf.__update__([ ('auth', cast('bytes', schema.auth_data)), ]) - return self._decode_next_layer(ospf, length - self.length) + # OSPF carries no next-protocol field -- the body is LSAs and packet-type + # specific fields, which pcapkit does not dissect -- so dispatch on the + # -1 sentinel, as ARP does, rather than on a code read off the wire. + # Passing the remaining length here (as this did) dispatched on it as if + # it were an EtherType, which resolved to Raw only because a length + # rarely collides with a registered one. + return self._decode_next_layer(ospf, -1, length - self.length) def make(self, version: 'int' = 2, diff --git a/pcapkit/protocols/link/vlan.py b/pcapkit/protocols/link/vlan.py index c0ebf711e2..3be77e7a89 100644 --- a/pcapkit/protocols/link/vlan.py +++ b/pcapkit/protocols/link/vlan.py @@ -1,14 +1,15 @@ # -*- coding: utf-8 -*- -"""VLAN - 802.1Q Customer VLAN Tag Type -========================================== +"""VLAN - 802.1Q/802.1ad VLAN Tag Types +========================================= .. module:: pcapkit.protocols.link.vlan :mod:`pcapkit.protocols.link.vlan` contains -:class:`~pcapkit.protocols.link.vlan.VLAN` -only, which implements extractor for 802.1Q -Customer VLAN Tag Type [*]_, whose structure is -described as below: +:class:`~pcapkit.protocols.link.vlan.VLAN`, an abstract base class holding the +tag layout shared by every VLAN tag, and its two concrete subclasses -- +:class:`~pcapkit.protocols.link.vlan.C_Tag` for the 802.1Q customer tag [*]_ and +:class:`~pcapkit.protocols.link.vlan.S_Tag` for the 802.1ad service tag -- whose +structure is described as below: ======= ========= ====================== ============================= Octets Bits Name Description @@ -20,6 +21,22 @@ 3 24 ``vlan.type`` Protocol (Internet Layer) ======= ========= ====================== ============================= +The two tags carry an **identical** tag control information layout -- the same +3-bit PCP, 1-bit DEI and 12-bit VID -- and are told apart solely by the tag +protocol identifier (TPID) that selected them, ``0x8100`` for the customer tag +against ``0x88A8`` for the service tag. That TPID is not part of either tag: it +is the EtherType field of whatever encapsulates the tag, so both classes read the +same four octets and share every byte of parsing and construction code. + +They are nonetheless distinct classes rather than one class bound at two +EtherTypes, because 802.1ad *stacks* them: a Q-in-Q frame carries a service tag +whose next EtherType is ``0x8100``, selecting a customer tag in turn. Both tags +therefore appear in one frame, and :attr:`~pcapkit.protocols.protocol.ProtocolBase.info_name` +-- ``s_tag`` against ``c_tag`` -- is what keeps them apart in the parsed +:class:`~pcapkit.corekit.infoclass.Info`. A single class bound at both EtherTypes +would nest one ``c_tag`` inside another, leaving nothing in the output to say +which of the two was the service tag. + .. [*] https://en.wikipedia.org/wiki/IEEE_802.1Q """ @@ -45,31 +62,30 @@ from pcapkit.protocols.schema.link.vlan import TCIType from pcapkit.protocols.schema.schema import Schema -__all__ = ['VLAN'] +__all__ = ['VLAN', 'C_Tag', 'S_Tag'] -class VLAN(Link[Data_VLAN, Schema_VLAN], +class VLAN(Link[Data_VLAN, Schema_VLAN], # pylint: disable=abstract-method schema=Schema_VLAN, data=Data_VLAN): - """This class implements 802.1Q Customer VLAN Tag Type.""" + """Abstract base class for 802.1Q/802.1ad VLAN tag types. - ########################################################################## - # Properties. - ########################################################################## + The class implements the whole of the tag -- both parsing and construction -- + since the customer and service tags are byte-for-byte identical. What it + deliberately leaves to its subclasses is only how the tag *names* itself: + :attr:`name`, :attr:`alias` and + :attr:`~pcapkit.protocols.protocol.ProtocolBase.info_name`. - @property - def name(self) -> 'Literal["802.1Q Customer VLAN Tag Type"]': - """Name of current protocol.""" - return '802.1Q Customer VLAN Tag Type' + It is abstract for the same reason :class:`~pcapkit.protocols.internet.ip.IP` + is: :attr:`~pcapkit.protocols.protocol.ProtocolBase.name` is declared + abstract by :class:`~pcapkit.protocols.protocol.ProtocolBase` and is not + defined here, so the class cannot be instantiated. Bind + :class:`C_Tag` or :class:`S_Tag`, never this class. - @property - def alias(self) -> 'Literal["802.1Q"]': - """Acronym of corresponding protocol.""" - return '802.1Q' + """ - @property - def info_name(self) -> 'Literal["c_tag"]': - """Key name of the :attr:`info` dict.""" - return 'c_tag' + ########################################################################## + # Properties. + ########################################################################## @property def length(self) -> 'Literal[4]': @@ -86,9 +102,9 @@ def protocol(self) -> 'Enum_EtherType': ########################################################################## def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_VLAN': # pylint: disable=unused-argument - """Read 802.1Q Customer VLAN Tag Type. + """Read 802.1Q/802.1ad VLAN tag type. - Structure of 802.1Q Customer VLAN Tag Type [`IEEE 802.1Q `__]: + Structure of 802.1Q/802.1ad VLAN tag type [`IEEE 802.1Q `__]: .. code-block:: text @@ -118,7 +134,7 @@ def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_VLAN': vlan = Data_VLAN( tci=Data_TCI( pcp=Enum_PriorityLevel.get(tci['pcp']), - dei=bool(tci['pcp']), + dei=bool(tci['dei']), vid=int(tci['vid']), ), type=schema.type, @@ -223,3 +239,103 @@ def _make_data(cls, data: 'Data_VLAN') -> 'dict[str, Any]': # type: ignore[over 'type': data.type, 'payload': cls._make_payload(data), } + + +# NOTE: Both concrete tags restate ``schema`` and ``data`` even though +# :class:`VLAN` already declares them. They are not inherited: +# :meth:`ProtocolBase.__init_subclass__ ` +# resolves an omitted schema by looking the *subclass name* up in +# :mod:`pcapkit.protocols.schema`, and assigns unconditionally -- so leaving them +# off would silently bind ``Schema_Raw``/``Data_Raw`` here rather than falling +# back to the base class's pair. + + +class C_Tag(VLAN, schema=Schema_VLAN, data=Data_VLAN): + """This class implements 802.1Q Customer VLAN Tag Type.""" + + ########################################################################## + # Properties. + ########################################################################## + + @property + def name(self) -> 'Literal["802.1Q Customer VLAN Tag Type"]': + """Name of current protocol.""" + return '802.1Q Customer VLAN Tag Type' + + @property + def alias(self) -> 'Literal["802.1Q"]': + """Acronym of corresponding protocol.""" + return '802.1Q' + + #: NOTE: This is what keeps a stacked service tag and customer tag apart in + #: the parsed info dict, so it is spelled out rather than left to the + #: class-name default -- a rename must not silently move the output key. + @property + def info_name(self) -> 'Literal["c_tag"]': + """Key name of the :attr:`info` dict.""" + return 'c_tag' + + ########################################################################## + # Methods. + ########################################################################## + + @classmethod + def id(cls) -> 'tuple[Literal["C_Tag"], Literal["VLAN"]]': + """Index ID of the protocol. + + Returns: + Index ID of the protocol. ``VLAN`` is retained alongside the class's + own name so that selecting the protocol by that name -- as + ``pcapkit.extract(..., protocol='VLAN')`` did when this class *was* + ``VLAN`` -- keeps matching. + + """ + return ('C_Tag', 'VLAN') + + +class S_Tag(VLAN, schema=Schema_VLAN, data=Data_VLAN): + """This class implements 802.1ad Service VLAN Tag Type. + + Note: + 802.1ad was incorporated into IEEE 802.1Q-2011, so the service tag is + specified by 802.1Q today. The ``802.1ad`` name is kept because it is + what the provider-bridging tag is universally called, and because it is + the only thing distinguishing this class from :class:`C_Tag` by name. + + """ + + ########################################################################## + # Properties. + ########################################################################## + + @property + def name(self) -> 'Literal["802.1ad Service VLAN Tag Type"]': + """Name of current protocol.""" + return '802.1ad Service VLAN Tag Type' + + @property + def alias(self) -> 'Literal["802.1ad"]': + """Acronym of corresponding protocol.""" + return '802.1ad' + + #: NOTE: c.f. :attr:`C_Tag.info_name` -- spelled out deliberately. + @property + def info_name(self) -> 'Literal["s_tag"]': + """Key name of the :attr:`info` dict.""" + return 's_tag' + + ########################################################################## + # Methods. + ########################################################################## + + @classmethod + def id(cls) -> 'tuple[Literal["S_Tag"], Literal["VLAN"]]': + """Index ID of the protocol. + + Returns: + Index ID of the protocol. ``VLAN`` is retained alongside the class's + own name so that selecting VLAN tags by that name matches the + service tag as well as the customer tag. + + """ + return ('S_Tag', 'VLAN') diff --git a/pcapkit/protocols/schema/link/vlan.py b/pcapkit/protocols/schema/link/vlan.py index 71ffdaf3c6..e44ebac6ea 100644 --- a/pcapkit/protocols/schema/link/vlan.py +++ b/pcapkit/protocols/schema/link/vlan.py @@ -1,6 +1,14 @@ # -*- coding: utf-8 -*- # mypy: disable-error-code=assignment -"""header schema for 802.1Q Customer VLAN Tag Type protocol""" +"""header schema for 802.1Q/802.1ad VLAN tag type protocols + +The customer tag (802.1Q, TPID ``0x8100``) and the service tag (802.1ad, TPID +``0x88A8``) carry an identical layout, so +:class:`~pcapkit.protocols.link.vlan.C_Tag` and +:class:`~pcapkit.protocols.link.vlan.S_Tag` share the schema below. The TPID that +told them apart belongs to the encapsulating header, not to the tag. + +""" from typing import TYPE_CHECKING @@ -21,7 +29,7 @@ from typing_extensions import TypedDict class TCIType(TypedDict): - """Type of 802.1Q Customer VLAN Tag Type tag control information.""" + """Type of 802.1Q/802.1ad VLAN tag control information.""" #: Priority code point. pcp: int @@ -33,7 +41,7 @@ class TCIType(TypedDict): @schema_final class TCI(Schema): - """Header schema for 802.1Q Customer VLAN Tag Type tag control information.""" + """Header schema for 802.1Q/802.1ad VLAN tag control information.""" #: Priority code point. pcp: 'Enum_PriorityLevel' = EnumField(length=1, bit_length=3, namespace=Enum_PriorityLevel) @@ -48,7 +56,7 @@ def __init__(self, pcp: 'Enum_PriorityLevel', dei: 'int', vid: 'int') -> 'None': @schema_final class VLAN(Schema): - """Header schema for 802.1Q Customer VLAN Tag Type packet.""" + """Header schema for an 802.1Q/802.1ad VLAN tag.""" #: Tag control information. tci: 'TCIType' = BitField( diff --git a/pcapkit/protocols/transport/tcp.py b/pcapkit/protocols/transport/tcp.py index 1f430dfdc1..fdbbcf623d 100644 --- a/pcapkit/protocols/transport/tcp.py +++ b/pcapkit/protocols/transport/tcp.py @@ -180,10 +180,14 @@ class TCP(Transport[Data_TCP, Schema_TCP], * - Port Number - Protocol + * - 20 + - :class:`pcapkit.protocols.application.ftp.FTP_DATA` * - 21 - :class:`pcapkit.protocols.application.ftp.FTP` * - 80 - - :class:`pcapkit.protocols.application.http.HTTP` + - :class:`pcapkit.protocols.application.httpv1.HTTP` + * - 8080 + - :class:`pcapkit.protocols.application.httpv1.HTTP` This class currently supports parsing of the following TCP options, which are directly mapped to the :class:`pcapkit.const.tcp.option.Option` @@ -310,8 +314,21 @@ class TCP(Transport[Data_TCP, Schema_TCP], __proto__ = collections.defaultdict( lambda: ModuleDescriptor('pcapkit.protocols.misc.raw', 'Raw'), { - 21: ModuleDescriptor('pcapkit.protocols.application.ftp', 'FTP'), # FTP - 80: ModuleDescriptor('pcapkit.protocols.application.httpv1', 'HTTP'), # HTTP/1.* + # Ports are IANA service-name registry assignments, quoting that + # registry's own service name and description: + # + # 20 ftp-data File Transfer [Default Data] + # 21 ftp File Transfer Protocol [Control] + # 80 http World Wide Web HTTP + # 8080 http-alt HTTP Alternate (see port 80) + # + # 8443 is deliberately absent: IANA registers it as ``pcsync-https`` + # rather than as an HTTP alternate, and traffic there is TLS-wrapped, + # which pcapkit does not parse. + 20: ModuleDescriptor('pcapkit.protocols.application.ftp', 'FTP_DATA'), + 21: ModuleDescriptor('pcapkit.protocols.application.ftp', 'FTP'), + 80: ModuleDescriptor('pcapkit.protocols.application.httpv1', 'HTTP'), + 8080: ModuleDescriptor('pcapkit.protocols.application.httpv1', 'HTTP'), }, ) diff --git a/pcapkit/protocols/transport/udp.py b/pcapkit/protocols/transport/udp.py index e442e98a88..8cd7963ea7 100644 --- a/pcapkit/protocols/transport/udp.py +++ b/pcapkit/protocols/transport/udp.py @@ -59,6 +59,21 @@ class UDP(Transport[Data_UDP, Schema_UDP], - Protocol * - 80 - :class:`pcapkit.protocols.application.http.HTTP` + * - 1701 + - :class:`pcapkit.protocols.link.l2tp.L2TP` + * - 8080 + - :class:`pcapkit.protocols.application.http.HTTP` + + Note: + Both HTTP ports here resolve to + :class:`pcapkit.protocols.application.http.HTTP`, which sniffs HTTP/1 + against HTTP/2 and delegates, whereas + :attr:`TCP.__proto__ ` + binds :class:`pcapkit.protocols.application.httpv1.HTTP` directly for + the same ports. The asymmetry predates the 8080 entries -- port 80 was + already split this way -- and each table is left internally consistent + rather than repointing port 80 and changing what existing captures + parse to. Reconciling the two is left as its own change. """ @@ -73,7 +88,25 @@ class UDP(Transport[Data_UDP, Schema_UDP], __proto__ = collections.defaultdict( lambda: ModuleDescriptor('pcapkit.protocols.misc.raw', 'Raw'), { - 80: ModuleDescriptor('pcapkit.protocols.application.http', 'HTTP'), # HTTP + # Ports are IANA service-name registry assignments, quoting that + # registry's own service name and description: + # + # 80 http World Wide Web HTTP + # 1701 l2tp l2tp + # 8080 http-alt HTTP Alternate (see port 80) + # + # Both HTTP entries keep pointing at the version-guessing + # :class:`pcapkit.protocols.application.http.HTTP`, which is what + # port 80 already used here -- unlike TCP, which binds HTTP/1 + # directly. c.f. the note in the class docstring. + 80: ModuleDescriptor('pcapkit.protocols.application.http', 'HTTP'), + 8080: ModuleDescriptor('pcapkit.protocols.application.http', 'HTTP'), + + # L2TPv2 (RFC 2661) is UDP-borne, and v2 is what the dissector + # implements. IANA protocol number 115 is deliberately *not* bound + # to it: that assignment references RFC 3931, i.e. L2TPv3 over IP, + # whose session header is a different shape. + 1701: ModuleDescriptor('pcapkit.protocols.link.l2tp', 'L2TP'), }, ) diff --git a/tests/protocols/link/test_link_unit.py b/tests/protocols/link/test_link_unit.py index 0ae776cf73..ab6f7ec639 100644 --- a/tests/protocols/link/test_link_unit.py +++ b/tests/protocols/link/test_link_unit.py @@ -22,11 +22,72 @@ def setUp(self) -> None: purge_modules(['pcapkit']) def test_vlan_index_is_unsupported(self) -> None: - from pcapkit.protocols.link.vlan import VLAN + from pcapkit.protocols.link.vlan import VLAN, C_Tag, S_Tag from pcapkit.utilities.exceptions import UnsupportedCall - with self.assertRaises(UnsupportedCall): - VLAN.__index__() + for klass in (VLAN, C_Tag, S_Tag): + with self.subTest(protocol=klass.__name__): + with self.assertRaises(UnsupportedCall): + klass.__index__() + + def test_vlan_base_is_abstract_and_tags_are_concrete(self) -> None: + from pcapkit.protocols.data.link.vlan import VLAN as DataVLAN + from pcapkit.protocols.link.vlan import VLAN, C_Tag, S_Tag + from pcapkit.protocols.schema.link.vlan import VLAN as SchemaVLAN + + # The base leaves ``name`` to its subclasses, which is what makes it + # abstract -- the same reason ``internet.ip.IP`` is. + self.assertEqual(VLAN.__abstractmethods__, frozenset({'name'})) + with self.assertRaises(TypeError): + object.__new__(VLAN) + + for klass in (C_Tag, S_Tag): + with self.subTest(protocol=klass.__name__): + self.assertEqual(klass.__abstractmethods__, frozenset()) + self.assertTrue(issubclass(klass, VLAN)) + # Restated on each subclass rather than inherited: an omitted + # ``schema=``/``data=`` is resolved by *class name* lookup and + # would silently fall back to the Raw pair. + self.assertIs(klass.__schema__, SchemaVLAN) + self.assertIs(klass.__data__, DataVLAN) + + def test_vlan_tags_keep_distinct_names_and_share_vlan_id(self) -> None: + from pcapkit.protocols.link.vlan import C_Tag, S_Tag + + c_tag = object.__new__(C_Tag) + s_tag = object.__new__(S_Tag) + + self.assertEqual(c_tag.name, '802.1Q Customer VLAN Tag Type') + self.assertEqual(c_tag.alias, '802.1Q') + self.assertEqual(c_tag.info_name, 'c_tag') + + self.assertEqual(s_tag.name, '802.1ad Service VLAN Tag Type') + self.assertEqual(s_tag.alias, '802.1ad') + self.assertEqual(s_tag.info_name, 's_tag') + + # Distinct info_name is the whole point: it is what keeps a stacked + # service tag and customer tag apart in the parsed info dict. + self.assertNotEqual(c_tag.info_name, s_tag.info_name) + + # Both still answer to 'VLAN' for protocol selection. + self.assertEqual(C_Tag.id(), ('C_Tag', 'VLAN')) + self.assertEqual(S_Tag.id(), ('S_Tag', 'VLAN')) + + def test_vlan_tags_are_registered_at_their_own_ethertypes(self) -> None: + from pcapkit.const.reg.ethertype import EtherType + from pcapkit.protocols.link.link import Link + from pcapkit.protocols.protocol import ProtocolBase + + size = len(Link.__proto__) + for code, name in ( + (EtherType.Customer_VLAN_Tag_Type, 'C_Tag'), + (EtherType.IEEE_Std_802_1Q_Service_VLAN_tag_identifier, 'S_Tag'), + ): + with self.subTest(ethertype=code): + self.assertIn(code, Link.__proto__) + entry = ProtocolBase._lookup_registry(Link.__proto__, code) + self.assertEqual(getattr(entry, 'name', None) or entry.__name__, name) + self.assertEqual(len(Link.__proto__), size) def test_ethernet_index_and_length_hint_are_stable(self) -> None: from pcapkit.const.reg.linktype import LinkType @@ -57,11 +118,11 @@ def test_rarp_ids_and_index_are_stable(self) -> None: self.assertEqual(RARP.__index__(), EtherType.Reverse_Address_Resolution_Protocol) def test_vlan_length_hint_is_stable(self) -> None: - from pcapkit.protocols.link.vlan import VLAN + from pcapkit.protocols.link.vlan import C_Tag, S_Tag - proto = object.__new__(VLAN) - - self.assertEqual(proto.__length_hint__(), 4) + for klass in (C_Tag, S_Tag): + with self.subTest(protocol=klass.__name__): + self.assertEqual(object.__new__(klass).__length_hint__(), 4) def test_arp_cached_properties_expose_grouped_addresses_and_types(self) -> None: from pcapkit.const.arp.hardware import Hardware @@ -167,6 +228,8 @@ def test_vlan_make_data_preserves_tci_and_type(self) -> None: from pcapkit.const.vlan.priority_level import PriorityLevel from pcapkit.protocols.link.vlan import VLAN + # ``_make_data`` is shared plumbing on the abstract base, so it is + # reachable from the base itself as well as from either concrete tag. class DummyTCI(dict): __getattr__ = dict.__getitem__ @@ -484,7 +547,7 @@ def test_l2tp_properties_read_and_make_variants(self) -> None: payload=b'xxpayload', ) reader._read_fileng = mock.Mock(return_value=b'\x00\x00') - reader._decode_next_layer = mock.Mock(side_effect=lambda data, length: data) + reader._decode_next_layer = mock.Mock(side_effect=lambda data, proto, length: data) data = reader.read() self.assertTrue(data.flags.len) self.assertTrue(data.flags.seq) @@ -506,7 +569,7 @@ def test_l2tp_properties_read_and_make_variants(self) -> None: offset=None, payload=b'payload', ) - reader_no_flags._decode_next_layer = mock.Mock(side_effect=lambda data, length: data) + reader_no_flags._decode_next_layer = mock.Mock(side_effect=lambda data, proto, length: data) data_no_flags = reader_no_flags.read() self.assertIsNone(data_no_flags.length) self.assertIsNone(data_no_flags.ns) @@ -534,11 +597,11 @@ def test_l2tp_properties_read_and_make_variants(self) -> None: def test_vlan_properties_read_and_make_variants(self) -> None: from pcapkit.const.reg.ethertype import EtherType from pcapkit.const.vlan.priority_level import PriorityLevel - from pcapkit.protocols.link.vlan import VLAN + from pcapkit.protocols.link.vlan import C_Tag from pcapkit.protocols.schema.link.vlan import TCI as SchemaTCI from pcapkit.protocols.schema.link.vlan import VLAN as SchemaVLAN - vlan = object.__new__(VLAN) + vlan = object.__new__(C_Tag) vlan._info = types.SimpleNamespace(type=EtherType.Internet_Protocol_version_4) self.assertEqual(vlan.name, '802.1Q Customer VLAN Tag Type') @@ -547,7 +610,7 @@ def test_vlan_properties_read_and_make_variants(self) -> None: self.assertEqual(vlan.length, 4) self.assertEqual(vlan.protocol, EtherType.Internet_Protocol_version_4) - reader = object.__new__(VLAN) + reader = object.__new__(C_Tag) reader.__cached__ = {} reader._data = b'\x00' * 18 reader.__header__ = SchemaVLAN( @@ -562,13 +625,13 @@ def test_vlan_properties_read_and_make_variants(self) -> None: self.assertEqual(data.tci.vid, 4094) self.assertEqual(data.type, EtherType.Internet_Protocol_version_6) - explicit_length = object.__new__(VLAN) + explicit_length = object.__new__(C_Tag) explicit_length.__header__ = reader.__header__ explicit_length._decode_next_layer = mock.Mock(side_effect=lambda data, proto, length: data) self.assertEqual(explicit_length.read(64).type, EtherType.Internet_Protocol_version_6) explicit_length._decode_next_layer.assert_called_once() - maker = object.__new__(VLAN) + maker = object.__new__(C_Tag) schema = maker.make( pcp=PriorityLevel.CA, dei=True, @@ -588,6 +651,44 @@ def test_vlan_properties_read_and_make_variants(self) -> None: self.assertFalse(explicit_schema.tci['dei']) self.assertEqual(explicit_schema.type, EtherType.Address_Resolution_Protocol) + def test_vlan_dei_is_read_from_the_dei_bit_not_the_pcp(self) -> None: + """``read`` used to report ``bool(tci['pcp'])`` as the DEI flag. + + That is wrong in both directions, and invisible whenever the two happen + to agree -- which is why the case below pins them in *dis*agreement. + + """ + from pcapkit.const.reg.ethertype import EtherType + from pcapkit.const.vlan.priority_level import PriorityLevel + from pcapkit.protocols.link.vlan import C_Tag, S_Tag + from pcapkit.protocols.schema.link.vlan import VLAN as SchemaVLAN + + cases = ( + # priority set, drop-eligible clear -- the old code said True here + (PriorityLevel.CA, False, False), + # priority clear, drop-eligible set -- the old code said False here + (PriorityLevel.BE, True, True), + (PriorityLevel.BE, False, False), + (PriorityLevel.CA, True, True), + ) + for klass in (C_Tag, S_Tag): + for pcp, dei, expected in cases: + with self.subTest(protocol=klass.__name__, pcp=pcp, dei=dei): + reader = object.__new__(klass) + reader.__cached__ = {} + reader._data = b'\x00' * 18 + reader.__header__ = SchemaVLAN( + tci={'pcp': pcp, 'dei': dei, 'vid': 42}, + type=EtherType.Internet_Protocol_version_4, + payload=b'payload', + ) + reader._decode_next_layer = mock.Mock( + side_effect=lambda data, proto, length: data) + data = reader.read() + self.assertIs(data.tci.dei, expected) + self.assertEqual(data.tci.pcp, pcp) + self.assertEqual(data.tci.vid, 42) + def test_ospf_properties_read_make_and_auth_helpers(self) -> None: from pcapkit.const.ospf.authentication import Authentication from pcapkit.const.ospf.packet import Packet @@ -600,6 +701,9 @@ def test_ospf_properties_read_make_and_auth_helpers(self) -> None: from pcapkit.utilities.exceptions import ProtocolError ospf = object.__new__(OSPF) + # ``name``/``alias`` read ``_version``, not ``_info``: both are needed + # while ``read`` is still running, before ``_info`` exists. + ospf._version = 2 ospf._info = types.SimpleNamespace(version=2, type=Packet.Hello) self.assertEqual(ospf.name, 'Open Shortest Path First version 2') @@ -608,7 +712,7 @@ def test_ospf_properties_read_make_and_auth_helpers(self) -> None: self.assertEqual(ospf.type, Packet.Hello) reader = object.__new__(OSPF) - reader.__schema__ = SchemaOSPF( + reader.__header__ = SchemaOSPF( version=2, type=Packet.Database_Description, length=24, @@ -619,14 +723,16 @@ def test_ospf_properties_read_make_and_auth_helpers(self) -> None: auth_data=b'\x00' * 8, payload=b'', ) - reader._decode_next_layer = mock.Mock(side_effect=lambda data, length: data) + reader._decode_next_layer = mock.Mock(side_effect=lambda data, proto, length: data) data = reader.read() self.assertEqual(data.auth, b'\x00' * 8) self.assertEqual(str(data.router_id), '192.0.2.1') + # No next-protocol field on the wire, so the -1 sentinel is dispatched. + self.assertEqual(reader._decode_next_layer.call_args.args[1], -1) crypto_schema = SchemaCryptoAuth(key_id=1, len=16, seq=99) crypto_reader = object.__new__(OSPF) - crypto_reader.__schema__ = SchemaOSPF( + crypto_reader.__header__ = SchemaOSPF( version=2, type=Packet.Link_State_Request, length=0, @@ -639,7 +745,7 @@ def test_ospf_properties_read_make_and_auth_helpers(self) -> None: ) crypto_reader.__cached__ = {} crypto_reader._data = b'\x00' * 32 - crypto_reader._decode_next_layer = mock.Mock(side_effect=lambda data, length: data) + crypto_reader._decode_next_layer = mock.Mock(side_effect=lambda data, proto, length: data) crypto_data = crypto_reader.read() self.assertEqual(crypto_data.auth.key_id, 1) self.assertEqual(crypto_data.auth.seq, 99) diff --git a/tests/protocols/test_dispatch_bindings_unit.py b/tests/protocols/test_dispatch_bindings_unit.py new file mode 100644 index 0000000000..7845f239b7 --- /dev/null +++ b/tests/protocols/test_dispatch_bindings_unit.py @@ -0,0 +1,322 @@ +# -*- coding: utf-8 -*- +"""End-to-end checks on the protocol dispatch tables. + +Every fixture here is synthesised in memory, so the module belongs to the unit +tier and reads none of the generated captures under :file:`examples/captures/`. + +What it pins is that a *registered* code actually reaches a working dissector. +The tables and the dissectors were previously able to disagree without anything +noticing -- :class:`~pcapkit.protocols.link.ospf.OSPF` was reachable from no +table at all and could not have parsed a packet if it had been -- so each case +asserts on the parsed protocol chain rather than on the table entry alone. + +""" +from __future__ import annotations + +import importlib.util +import os +import struct +import tempfile +import unittest + +from tests._support import close_extractor, purge_modules + +RUNTIME_DEPS = ('tbtrim', 'aenum', 'chardet', 'dictdumper') +HAS_RUNTIME = all(importlib.util.find_spec(name) is not None for name in RUNTIME_DEPS) + +ETH_DST = bytes((0x00, 0x11, 0x22, 0x33, 0x44, 0x55)) +ETH_SRC = bytes((0x00, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE)) + + +def tci(pcp: int, dei: int, vid: int) -> bytes: + """Pack a VLAN tag control information word.""" + return struct.pack('!H', (pcp & 0x7) << 13 | (dei & 0x1) << 12 | (vid & 0xFFF)) + + +def ethernet(ethertype: int, payload: bytes) -> bytes: + """Wrap ``payload`` in an Ethernet II frame.""" + return ETH_DST + ETH_SRC + struct.pack('!H', ethertype) + payload + + +def ipv4(proto: int, payload: bytes) -> bytes: + """Wrap ``payload`` in a minimal option-less IPv4 header.""" + return struct.pack('!BBHHHBBH4s4s', + 0x45, 0x00, 20 + len(payload), 0x1234, 0x0000, + 64, proto, 0x0000, + bytes((10, 0, 0, 1)), bytes((10, 0, 0, 2))) + payload + + +def tcp(sport: int, dport: int, payload: bytes = b'') -> bytes: + """Wrap ``payload`` in a minimal 20-octet TCP header.""" + return struct.pack('!HHIIBBHHH', sport, dport, 0, 0, + 0x50, 0x18, 8192, 0x0000, 0x0000) + payload + + +def udp(sport: int, dport: int, payload: bytes = b'') -> bytes: + """Wrap ``payload`` in a UDP header.""" + return struct.pack('!HHHH', sport, dport, 8 + len(payload), 0x0000) + payload + + +def ospf_hello() -> bytes: + """A well-formed OSPFv2 Hello: 24-octet header plus a 20-octet body.""" + body = struct.pack('!4sHBBIII', + bytes((255, 255, 255, 0)), 10, 0x02, 1, 40, 0, 0) + return struct.pack('!BBH4s4s2sH8s', + 2, # version + 1, # type = Hello + 24 + len(body), # packet length + bytes((10, 0, 0, 1)), # router id + bytes((0, 0, 0, 0)), # area id + b'\xAB\xCD', # checksum + 0, # autype = none + b'\x00' * 8) + body # authentication + + +def l2tp_data() -> bytes: + """An L2TPv2 data message with every optional field absent.""" + # bit0=type, bit1=len, bit4=seq, bit6=offset, bit7=prio, bits12-15=version + return struct.pack('!HHH', 0x0002, 0x1234, 0x5678) + b'\xff\x03\x00\x21PPP' + + +def make_pcap(*frames: bytes) -> str: + """Write ``frames`` to a little-endian LINKTYPE_ETHERNET PCAP file.""" + path = os.path.join(tempfile.mkdtemp(prefix='pcapkit-dispatch-'), 'dispatch.pcap') + with open(path, 'wb') as file: + # little endian, v2.4, LINKTYPE_ETHERNET + file.write(struct.pack(' None: + purge_modules(['pcapkit']) + + def extract(self, *frames: bytes): + """Extract synthesised ``frames`` and return the frame list.""" + import pcapkit + + extraction = pcapkit.extract(fin=make_pcap(*frames), nofile=True, + store=True, ip=True, tcp=True, + reassembly=True) + self.addCleanup(close_extractor, extraction) + return extraction.frame + + ########################################################################## + # 802.1ad Q-in-Q. + ########################################################################## + + def test_stacked_service_and_customer_tags_both_parse_and_stay_distinct(self) -> None: + """A Q-in-Q frame yields both tags, under their own info names. + + This is the acceptance test for the S-Tag work: before the service tag + was bound, the whole of ``S-Tag + C-Tag + IPv4 + TCP`` collapsed into a + single opaque ``Raw`` payload hanging off the Ethernet header. + + """ + from pcapkit.const.reg.ethertype import EtherType + + inner = ipv4(6, tcp(12345, 80, b'hello-qinq')) + frame = self.extract(ethernet( + 0x88A8, # eth.type -> S-Tag + tci(pcp=5, dei=1, vid=100) + struct.pack('!H', 0x8100) + + tci(pcp=3, dei=0, vid=200) + struct.pack('!H', 0x0800) + + inner, + ))[0] + + self.assertEqual(str(frame.protochain), 'Ethernet:802.1ad:802.1Q:IPv4:TCP:Raw') + + eth = frame.info.to_dict()['ethernet'] + self.assertEqual(eth['type'], + EtherType.IEEE_Std_802_1Q_Service_VLAN_tag_identifier) + + # The service tag is the outer one, and is named as such. + self.assertIn('s_tag', eth) + self.assertNotIn('c_tag', eth) + s_tag = eth['s_tag'] + self.assertEqual(s_tag['tci']['vid'], 100) + self.assertEqual(int(s_tag['tci']['pcp']), 5) + self.assertIs(s_tag['tci']['dei'], True) + self.assertEqual(s_tag['type'], EtherType.Customer_VLAN_Tag_Type) + + # ... and the customer tag is nested inside it, under its own name. + self.assertIn('c_tag', s_tag) + c_tag = s_tag['c_tag'] + self.assertEqual(c_tag['tci']['vid'], 200) + self.assertEqual(int(c_tag['tci']['pcp']), 3) + self.assertIs(c_tag['tci']['dei'], False) + self.assertEqual(c_tag['type'], EtherType.Internet_Protocol_version_4) + + # The payload survived both tags. + self.assertIn('ipv4', c_tag) + self.assertIn('tcp', c_tag['ipv4']) + + def test_single_customer_tag_is_unchanged_by_the_service_tag_binding(self) -> None: + from pcapkit.const.reg.ethertype import EtherType + + frame = self.extract(ethernet( + 0x8100, + tci(pcp=3, dei=0, vid=200) + struct.pack('!H', 0x0800) + + ipv4(6, tcp(12345, 80, b'plain')), + ))[0] + + self.assertEqual(str(frame.protochain), 'Ethernet:802.1Q:IPv4:TCP:Raw') + eth = frame.info.to_dict()['ethernet'] + self.assertIn('c_tag', eth) + self.assertNotIn('s_tag', eth) + self.assertEqual(eth['c_tag']['tci']['vid'], 200) + self.assertEqual(eth['type'], EtherType.Customer_VLAN_Tag_Type) + + ########################################################################## + # OSPF and L2TP. + ########################################################################## + + def test_ospf_parses_when_dispatched_from_ip_protocol_89(self) -> None: + """OSPF is reachable from ``TransType.OSPFIGP`` and parses its header. + + The dissector could not parse anything at all before: ``read`` consulted + the schema *class* rather than the parsed header, and ``alias`` -- which + the dispatch reads while ``read`` is still running -- reached for an + ``_info`` that does not exist yet. + + """ + from pcapkit.const.ospf.authentication import Authentication + from pcapkit.const.ospf.packet import Packet + + frame = self.extract(ethernet(0x0800, ipv4(89, ospf_hello())))[0] + + self.assertEqual(str(frame.protochain), 'Ethernet:IPv4:OSPFv2:Raw') + ospf = frame.info.to_dict()['ethernet']['ipv4']['ospf'] + self.assertEqual(ospf['version'], 2) + self.assertEqual(ospf['type'], Packet.Hello) + self.assertEqual(ospf['len'], 44) + self.assertEqual(str(ospf['router_id']), '10.0.0.1') + self.assertEqual(str(ospf['area_id']), '0.0.0.0') + self.assertEqual(ospf['chksum'], b'\xab\xcd') + self.assertEqual(ospf['autype'], Authentication.No_Authentication) + self.assertEqual(ospf['auth'], b'\x00' * 8) + + # The 20-octet Hello body is not dissected, and is dispatched on the + # -1 "no next protocol" sentinel rather than on a length. + self.assertEqual(ospf['raw']['protocol'], -1) + self.assertEqual(len(ospf['raw']['packet']), 20) + + def test_l2tp_parses_when_dispatched_from_udp_port_1701(self) -> None: + frame = self.extract(ethernet( + 0x0800, ipv4(17, udp(1701, 1701, l2tp_data())), + ))[0] + + self.assertEqual(str(frame.protochain), 'Ethernet:IPv4:UDP:L2TP:Raw') + l2tp = frame.info.to_dict()['ethernet']['ipv4']['udp']['l2tp'] + self.assertEqual(l2tp['version'], 2) + self.assertEqual(l2tp['tunnelid'], 0x1234) + self.assertEqual(l2tp['sessionid'], 0x5678) + self.assertIs(l2tp['flags']['len'], False) + self.assertIs(l2tp['flags']['seq'], False) + + # PPP is not dissected, so the payload is Raw under the -1 sentinel. + self.assertEqual(l2tp['raw']['protocol'], -1) + + def test_l2tp_over_ip_is_deliberately_not_bound(self) -> None: + """``TransType.L2TP`` (115) stays unbound: it is L2TPv3, not v2. + + :rfc:`3931` gives protocol number 115 a different session header from + the :rfc:`2661` framing this dissector implements, so binding it would + hand the parser the wrong shape. + + """ + from pcapkit.const.reg.transtype import TransType + from pcapkit.protocols.internet.internet import Internet + + self.assertNotIn(TransType.L2TP, Internet.__proto__) + + ########################################################################## + # Port bindings. + ########################################################################## + + def test_ftp_data_and_ftp_dispatch_on_their_own_ports(self) -> None: + frames = self.extract( + ethernet(0x0800, ipv4(6, tcp(50000, 20, b'binary-file-contents'))), + ethernet(0x0800, ipv4(6, tcp(50000, 21, b'USER anonymous\r\n'))), + ) + + self.assertEqual(str(frames[0].protochain), 'Ethernet:IPv4:TCP:FTP_DATA') + data_tcp = frames[0].info.to_dict()['ethernet']['ipv4']['tcp'] + self.assertIn('ftp_data', data_tcp) + self.assertEqual(data_tcp['ftp_data']['packet'], b'binary-file-contents') + + self.assertEqual(str(frames[1].protochain), 'Ethernet:IPv4:TCP:FTP') + ctrl_tcp = frames[1].info.to_dict()['ethernet']['ipv4']['tcp'] + self.assertIn('ftp', ctrl_tcp) + + def test_http_dispatches_on_the_alternate_port_over_tcp_and_udp(self) -> None: + request = b'GET / HTTP/1.1\r\nHost: example.invalid\r\n\r\n' + frames = self.extract( + ethernet(0x0800, ipv4(6, tcp(50000, 8080, request))), + ethernet(0x0800, ipv4(17, udp(50000, 8080, request))), + ) + + self.assertEqual(str(frames[0].protochain), 'Ethernet:IPv4:TCP:HTTP/1.1') + self.assertIn('http', frames[0].info.to_dict()['ethernet']['ipv4']['tcp']) + + self.assertEqual(str(frames[1].protochain), 'Ethernet:IPv4:UDP:HTTP/1.1') + self.assertIn('http', frames[1].info.to_dict()['ethernet']['ipv4']['udp']) + + def test_registered_ports_are_exactly_those_intended(self) -> None: + from pcapkit.protocols.transport.tcp import TCP + from pcapkit.protocols.transport.udp import UDP + + self.assertEqual(sorted(TCP.__proto__), [20, 21, 80, 8080]) + self.assertEqual(sorted(UDP.__proto__), [80, 1701, 8080]) + + def test_port_8443_is_deliberately_not_bound(self) -> None: + """8443 is IANA's ``pcsync-https``, and pcapkit implements no TLS. + + Binding HTTP there would hand a TLS record to an HTTP parser. + + """ + from pcapkit.protocols.transport.tcp import TCP + from pcapkit.protocols.transport.udp import UDP + + self.assertNotIn(8443, TCP.__proto__) + self.assertNotIn(8443, UDP.__proto__) + + def test_dispatch_lookups_do_not_grow_the_shared_registries(self) -> None: + """An unregistered code must not be inserted by being looked up. + + The registries are ``defaultdict`` instances held on class attributes, + so a bare ``registry[code]`` on a miss grows them for the whole process. + + """ + from pcapkit.protocols.internet.internet import Internet + from pcapkit.protocols.link.link import Link + from pcapkit.protocols.protocol import ProtocolBase + from pcapkit.protocols.transport.tcp import TCP + from pcapkit.protocols.transport.udp import UDP + + registries = (Link.__proto__, Internet.__proto__, + TCP.__proto__, UDP.__proto__) + sizes = [len(registry) for registry in registries] + + for registry in registries: + for code in (8443, 115, 0x88A8, 20, 1701, 9999, -1): + ProtocolBase._lookup_registry(registry, code) + + self.assertEqual([len(registry) for registry in registries], sizes) + + def test_parsing_an_unregistered_port_does_not_grow_the_registry(self) -> None: + from pcapkit.protocols.transport.tcp import TCP + + before = dict(TCP.__proto__) + self.extract(ethernet(0x0800, ipv4(6, tcp(50000, 9999, b'whatever')))) + self.assertEqual(dict(TCP.__proto__), before) + + +if __name__ == '__main__': + unittest.main() From cf7f0f275ef19bb9e34ff3144e08a5da12310c06 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Thu, 17 Sep 2026 12:25:16 -0400 Subject: [PATCH 2/5] docs: name VLAN as the shared-ABC precedent for an identical-layout pair --- docs/source/pep.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/source/pep.rst b/docs/source/pep.rst index 068f4224f0..bc7e976d07 100644 --- a/docs/source/pep.rst +++ b/docs/source/pep.rst @@ -47,6 +47,10 @@ Several of these come in pairs that want a shared abstract base rather than two independent implementations, in the way :class:`~pcapkit.protocols.internet.ip.IP` already covers its family: ICMP with ICMPv6, TLS/SSL with DTLS, and ``LINUX_SLL`` with ``LINUX_SLL2``. +:class:`~pcapkit.protocols.link.vlan.VLAN` is the closer precedent for a pair +whose *layout* is identical -- it holds the whole of the tag, and +:class:`~pcapkit.protocols.link.vlan.C_Tag` and +:class:`~pcapkit.protocols.link.vlan.S_Tag` add only how each names itself. .. note:: From 9262afb9e669188094daa81d9e277345f6b5a2f0 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Thu, 17 Sep 2026 12:57:14 -0400 Subject: [PATCH 3/5] docs: count the port bindings and the port numbers separately, since 80 and 8080 are bound twice --- docs/source/pep.rst | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/source/pep.rst b/docs/source/pep.rst index bc7e976d07..a46b413409 100644 --- a/docs/source/pep.rst +++ b/docs/source/pep.rst @@ -224,9 +224,11 @@ complaint and yields nothing useful. * **7 of the 160** :class:`~pcapkit.const.reg.ethertype.EtherType` values, in :attr:`Link.__proto__ `: ARP, RARP, IPv4, IPv6, IPX and both VLAN tags. -* **6 port numbers, out of 8182** :class:`~pcapkit.const.reg.apptype.AppType` - members -- TCP 20 to FTP-DATA and 21 to FTP, port 80 and 8080 to HTTP on both - TCP and UDP, and UDP 1701 to L2TP. +* **7 bindings over 5 port numbers, out of 8182** + :class:`~pcapkit.const.reg.apptype.AppType` members -- TCP 20 to FTP-DATA and + 21 to FTP, port 80 and 8080 to HTTP on both TCP and UDP, and UDP 1701 to L2TP. + The two counts differ because 80 and 8080 are each bound twice, once per + transport. * **none of the 75** :class:`~pcapkit.const.sctp.payload_protocol_identifier.PayloadProtocolIdentifier` values. :attr:`SCTP.__proto__ From 15a91cdfdf8779d218fb2741032b5c88a0e9c67d Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Thu, 17 Sep 2026 15:33:38 -0400 Subject: [PATCH 4/5] protocols: one module per registry index -- split the VLAN tags and the L2TP versions Follows the project's existing convention, which the ARP family already encodes: a protocol with its own ``__index__`` gets its own module, and siblings may share one only when they share an index. ``InARP`` shares :mod:`~pcapkit.protocols.link.arp` because it inherits ``ARP``'s index; ``RARP`` declares a different index and so has :mod:`~pcapkit.protocols.link.rarp`, which ``DRARP`` then shares. * ``C_Tag`` and ``S_Tag`` move to ``link/c_tag.py`` and ``link/s_tag.py``, and each now **declares the EtherType it is reached by** -- ``0x8100`` and ``0x88A8``. That declaration was the missing piece: both were bound in ``Link.__proto__`` as distinct EtherTypes while inheriting a ``__index__`` that raised. The ``VLAN`` base keeps raising, which is correct for an abstract protocol nothing dispatches to, and keeps the shared tag layout. * ``L2TP`` becomes an abstract base and ``L2TPv2`` carries the RFC 2661 implementation, in ``link/l2tpv2.py``. What existed was v2 only, presented as though it were L2TP in general. The base holds no header parsing at all, in the way ``internet.ip.IP`` holds none: the versions genuinely do not share a header, only the version nibble in the first 16-bit word. UDP 1701 now binds the concrete class. * ``OSPF.__index__`` returns ``TransType.OSPFIGP`` instead of raising -- the same gap as the VLAN tags, since it is dispatched from ``Internet.__proto__`` at 89. ``TransType`` 115 stays unbound, and the reason is now structural rather than incidental: it is L2TPv3 (RFC 3931), and there is no ``L2TPv3`` class for it to point at. 115 is also the first index anything in the family would carry, so v3 gets its own module when written. Recorded in the module and in ``pep.rst``. Neither ``L2TP`` nor ``L2TPv2`` declares an index: v2 is reached by a UDP *port*, and a port is not an ``__index__`` value anywhere here -- every non-raising ``__index__`` returns a ``TransType``, ``EtherType`` or ``LinkType``, and ``Application.__index__`` raises for that reason. ``id()`` follows the HTTP family: canonical name first, then the version- or variant-flavoured alias, since callers take element zero as canonical. ``L2TP`` and ``L2TPv2`` both return ``('L2TP', 'L2TPv2')``; the ``VLAN`` base claims ``('VLAN', 'C_Tag', 'S_Tag')`` while each tag keeps its own name canonical, as the tags are distinct protocols rather than versions of one. ``info_name`` is declared on the ``L2TP`` base so a consumer finds the datagram under ``l2tp`` whichever version was on the wire; the version is reported by ``alias`` instead. The follow-up stream implementing L2TPv3 and L2F has what it needs written into the base's docstring, including that ``Ver == 1`` selects **L2F** (RFC 2341), a separate protocol, to be named ``L2F`` with ``L2TPv1`` only as an ``id()`` alias. Suite 915 passed / 18 skipped, against 898 / 18 on main (+17 tests, +16 subtests). All 15 sample captures produce byte-identical tree, json and reassembly output; ``make_samples.py`` regenerates byte-identically. mypy unchanged at 128 errors in 41 files; pylint adds no message and still drops one over-long line. --- docs/source/ext.rst | 8 +- docs/source/pcapkit/protocols/index.rst | 9 +- docs/source/pcapkit/protocols/link/c_tag.rst | 39 ++ docs/source/pcapkit/protocols/link/index.rst | 3 + docs/source/pcapkit/protocols/link/l2tp.rst | 138 +++---- docs/source/pcapkit/protocols/link/l2tpv2.rst | 114 ++++++ docs/source/pcapkit/protocols/link/s_tag.rst | 41 +++ docs/source/pcapkit/protocols/link/vlan.rst | 52 +-- docs/source/pep.rst | 28 +- pcapkit/all.py | 4 +- pcapkit/protocols/__init__.py | 2 +- pcapkit/protocols/data/link/vlan.py | 4 +- pcapkit/protocols/link/__init__.py | 13 +- pcapkit/protocols/link/c_tag.py | 102 ++++++ pcapkit/protocols/link/l2tp.py | 332 +++++------------ pcapkit/protocols/link/l2tpv2.py | 344 ++++++++++++++++++ pcapkit/protocols/link/link.py | 8 +- pcapkit/protocols/link/ospf.py | 22 +- pcapkit/protocols/link/s_tag.py | 102 ++++++ pcapkit/protocols/link/vlan.py | 136 ++----- pcapkit/protocols/schema/link/vlan.py | 4 +- pcapkit/protocols/transport/udp.py | 14 +- tests/protocols/link/test_link_unit.py | 131 +++++-- .../protocols/test_dispatch_bindings_unit.py | 18 +- 24 files changed, 1148 insertions(+), 520 deletions(-) create mode 100644 docs/source/pcapkit/protocols/link/c_tag.rst create mode 100644 docs/source/pcapkit/protocols/link/l2tpv2.rst create mode 100644 docs/source/pcapkit/protocols/link/s_tag.rst create mode 100644 pcapkit/protocols/link/c_tag.py create mode 100644 pcapkit/protocols/link/l2tpv2.py create mode 100644 pcapkit/protocols/link/s_tag.py diff --git a/docs/source/ext.rst b/docs/source/ext.rst index 19060bbfb0..1eee18895a 100644 --- a/docs/source/ext.rst +++ b/docs/source/ext.rst @@ -30,15 +30,17 @@ The following table shows all available protocol classes in :mod:`pcapkit`: + Link Layer +----------------+-----------------------+-------------------------------------------------------------+ | (:class:`~pcapkit.protocols.link.link.Link` subclasses) | :class:`pcapkit.protocols.link.ethernet.Ethernet` | + +----------------+-----------------------+-------------------------------------------------------------+ -| | :class:`pcapkit.protocols.link.l2tp.L2TP` | +| | | :class:`pcapkit.protocols.link.l2tp.L2TP` | ++ + +-----------------------+-------------------------------------------------------------+ +| | L2TP Family | :class:`pcapkit.protocols.link.l2tpv2.L2TPv2` | + +----------------+-----------------------+-------------------------------------------------------------+ | | :class:`pcapkit.protocols.link.ospf.OSPF` | + +----------------+-----------------------+-------------------------------------------------------------+ | | | :class:`pcapkit.protocols.link.vlan.VLAN` | + + +-----------------------+-------------------------------------------------------------+ -| | VLAN Family | :class:`pcapkit.protocols.link.vlan.C_Tag` | +| | VLAN Family | :class:`pcapkit.protocols.link.c_tag.C_Tag` | + + +-----------------------+-------------------------------------------------------------+ -| | | :class:`pcapkit.protocols.link.vlan.S_Tag` | +| | | :class:`pcapkit.protocols.link.s_tag.S_Tag` | +------------------------------------------------------------------+----------------+-----------------------+-------------------------------------------------------------+ | | | :class:`pcapkit.protocols.internet.ip.IP` | + + +-----------------------+-------------------------------------------------------------+ diff --git a/docs/source/pcapkit/protocols/index.rst b/docs/source/pcapkit/protocols/index.rst index abea1c0de8..1b212f057e 100644 --- a/docs/source/pcapkit/protocols/index.rst +++ b/docs/source/pcapkit/protocols/index.rst @@ -42,6 +42,10 @@ diagram of the class hierarchy of :mod:`pcapkit.protocols`: subgraph vlan [VLAN Family] VLAN --> C_Tag & S_Tag end + + subgraph l2tp [L2TP Family] + L2TP --> L2TPv2 + end end subgraph internet [Internet Layer] @@ -104,10 +108,11 @@ diagram of the class hierarchy of :mod:`pcapkit.protocols`: click Link "/pcapkit/protocols/link/link.html#pcapkit.protocols.link.Link" click Ethernet "/pcapkit/protocols/link/ethernet.html#pcapkit.protocols.link.ethernet.Ethernet" click L2TP "/pcapkit/protocols/link/l2tp.html#pcapkit.protocols.link.l2tp.L2TP" + click L2TPv2 "/pcapkit/protocols/link/l2tpv2.html#pcapkit.protocols.link.l2tpv2.L2TPv2" click OSPF "/pcapkit/protocols/link/ospf.html#pcapkit.protocols.link.ospf.OSPF" click VLAN "/pcapkit/protocols/link/vlan.html#pcapkit.protocols.link.vlan.VLAN" - click C_Tag "/pcapkit/protocols/link/vlan.html#pcapkit.protocols.link.vlan.C_Tag" - click S_Tag "/pcapkit/protocols/link/vlan.html#pcapkit.protocols.link.vlan.S_Tag" + click C_Tag "/pcapkit/protocols/link/c_tag.html#pcapkit.protocols.link.c_tag.C_Tag" + click S_Tag "/pcapkit/protocols/link/s_tag.html#pcapkit.protocols.link.s_tag.S_Tag" click ARP "/pcapkit/protocols/link/arp.html#pcapkit.protocols.link.arp.ARP" click InARP "/pcapkit/protocols/link/arp.html#pcapkit.protocols.link.arp.InARP" click RARP "/pcapkit/protocols/link/rarp.html#pcapkit.protocols.link.rarp.RARP" diff --git a/docs/source/pcapkit/protocols/link/c_tag.rst b/docs/source/pcapkit/protocols/link/c_tag.rst new file mode 100644 index 0000000000..6293c61b70 --- /dev/null +++ b/docs/source/pcapkit/protocols/link/c_tag.rst @@ -0,0 +1,39 @@ +C_Tag - 802.1Q Customer VLAN Tag Type +===================================== + +.. module:: pcapkit.protocols.link.c_tag + +:mod:`pcapkit.protocols.link.c_tag` contains +:class:`~pcapkit.protocols.link.c_tag.C_Tag` only, which implements extractor for +the 802.1Q Customer VLAN Tag Type (C-Tag, formerly the Q-Tag) [*]_, EtherType +``0x8100``. + +Its structure, and all of its parsing and construction, come from +:class:`~pcapkit.protocols.link.vlan.VLAN`; this class adds only the tag's own +identity -- :attr:`~pcapkit.protocols.link.c_tag.C_Tag.name`, +:attr:`~pcapkit.protocols.link.c_tag.C_Tag.alias`, +:attr:`~pcapkit.protocols.link.c_tag.C_Tag.info_name` -- and its registry index. + +It lives in a module of its own rather than beside +:class:`~pcapkit.protocols.link.s_tag.S_Tag` because the two are reached through +*different* registry indices, ``0x8100`` against ``0x88A8``, which is the +project's rule for when protocols share a module. Contrast +:class:`~pcapkit.protocols.link.arp.InARP`, which shares +:mod:`~pcapkit.protocols.link.arp` with :class:`~pcapkit.protocols.link.arp.ARP` +precisely because it inherits its index. + +.. autoclass:: pcapkit.protocols.link.c_tag.C_Tag + :no-members: + :show-inheritance: + + .. autoproperty:: name + .. autoproperty:: alias + .. autoproperty:: info_name + + .. automethod:: id + + .. automethod:: __index__ + +.. rubric:: Footnotes + +.. [*] https://en.wikipedia.org/wiki/IEEE_802.1Q diff --git a/docs/source/pcapkit/protocols/link/index.rst b/docs/source/pcapkit/protocols/link/index.rst index 00024d706f..71a34eaede 100644 --- a/docs/source/pcapkit/protocols/link/index.rst +++ b/docs/source/pcapkit/protocols/link/index.rst @@ -16,8 +16,11 @@ link layer, with detailed implementation and methods. arp rarp l2tp + l2tpv2 ospf vlan + c_tag + s_tag .. todo:: diff --git a/docs/source/pcapkit/protocols/link/l2tp.rst b/docs/source/pcapkit/protocols/link/l2tp.rst index 38d18e3129..72fa0922a3 100644 --- a/docs/source/pcapkit/protocols/link/l2tp.rst +++ b/docs/source/pcapkit/protocols/link/l2tp.rst @@ -4,93 +4,75 @@ L2TP - Layer Two Tunnelling Protocol .. module:: pcapkit.protocols.link.l2tp :mod:`pcapkit.protocols.link.l2tp` contains -:class:`~pcapkit.protocols.link.l2tp.L2TP` only, -which implements extractor for Layer Two Tunnelling -Protocol (L2TP) [*]_, whose structure is described -as below: - -.. table:: - - ======= ===== ===================== ========================================== - Octets Bits Name Description - ======= ===== ===================== ========================================== - 0 0 ``l2tp.flags`` Flags and Version Info - ------- ----- --------------------- ------------------------------------------ - 0 0 ``l2tp.flags.type`` Type (control / data) - ------- ----- --------------------- ------------------------------------------ - 0 1 ``l2tp.flags.len`` Length - ------- ----- --------------------- ------------------------------------------ - 0 2 Reserved (must be zero ``x00``) - ------- ----- --------------------- ------------------------------------------ - 0 4 ``l2tp.flags.seq`` Sequence - ------- ----- --------------------- ------------------------------------------ - 0 5 Reserved (must be zero ``x00``) - ------- ----- --------------------- ------------------------------------------ - 0 6 ``l2tp.flags.offset`` Offset - ------- ----- --------------------- ------------------------------------------ - 0 7 ``l2tp.flags.prio`` Priority - ------- ----- --------------------- ------------------------------------------ - 1 8 Reserved (must be zero ``x00``) - ------- ----- --------------------- ------------------------------------------ - 1 12 ``l2tp.version`` Version (``2``) - ------- ----- --------------------- ------------------------------------------ - 2 16 ``l2tp.length`` Length (optional by ``len``) - ------- ----- --------------------- ------------------------------------------ - 4 32 ``l2tp.tunnelid`` Tunnel ID - ------- ----- --------------------- ------------------------------------------ - 6 48 ``l2tp.sessionid`` Session ID - ------- ----- --------------------- ------------------------------------------ - 8 64 ``l2tp.ns`` Sequence Number (optional by ``seq``) - ------- ----- --------------------- ------------------------------------------ - 10 80 ``l2tp.nr`` Next Sequence Number (optional by ``seq``) - ------- ----- --------------------- ------------------------------------------ - 12 96 ``l2tp.offset`` Offset Size (optional by ``offset``) - ======= ===== ===================== ========================================== +:class:`~pcapkit.protocols.link.l2tp.L2TP` only, an abstract base class for the +Layer Two Tunnelling Protocol family [*]_. The concrete versions live in modules +of their own: + +.. list-table:: + :header-rows: 1 + + * - Version + - Class + - Specification + * - L2TPv2 + - :class:`~pcapkit.protocols.link.l2tpv2.L2TPv2` + - :rfc:`2661` + +Only L2TPv2 is implemented. + +The base deliberately carries **no header parsing at all**, in the way +:class:`~pcapkit.protocols.internet.ip.IP` carries none for its family. That is +not tidiness: the versions genuinely do not share a header. All that is common +across them is the *first 16-bit word carrying a version nibble at bits 12-15*; +everything after it differs, so a base that parsed further would be assuming one +version's layout for all of them. + +What the family still wants +--------------------------- + +**L2TPv3** [:rfc:`3931`] has a different session header and a different control +message header from v2, and is reachable two ways -- over UDP port 1701 like v2, +and directly over IP as **protocol number 115**. That second route is why +:attr:`Internet.__proto__ ` +leaves 115 unbound today: the binding waits on an ``L2TPv3`` class, not on a +different framing decision. It also means v3 is the first member of this family +to have a real :meth:`~pcapkit.protocols.protocol.ProtocolBase.__index__`, and so +the first that must have a module of its own under the project's one-module-per-index +rule. + +**L2F** [:rfc:`2341`] is reached when the version nibble reads ``1``. It is *not* +an earlier version of L2TP: :rfc:`2661` §3.1 requires ``Ver`` to be 2 and reserves +the value 1 "to permit detection of L2F packets should they arrive intermixed with +L2TP packets". L2F is a separate protocol with its own header. It is therefore to +be implemented as ``L2F``, the canonical name, carrying ``L2TPv1`` only as an +alias in its :meth:`~pcapkit.protocols.protocol.ProtocolBase.id` -- the same +relationship HTTP/3 has to QUIC. c.f. +:meth:`HTTPv1.id ` for how a +version-flavoured alias is spelled: canonical name first, alias second, since +callers take element zero as canonical. + +Selecting a version +------------------- + +Nothing dispatches on the version nibble yet, because only one version exists. +When a second lands, the mechanism it wants already has a precedent in +:class:`~pcapkit.protocols.application.http.HTTP`, which reads a version and +delegates to a per-version class. L2TP is the easier case: +:meth:`HTTP._guess_version ` +has to *trial-parse* each candidate because the wire format carries no version +field, whereas L2TP states its version explicitly in those four bits. So a +deterministic switch on ``Ver`` is enough, and no new registry is needed. .. autoclass:: pcapkit.protocols.link.l2tp.L2TP :no-members: :show-inheritance: - .. autoproperty:: name - .. autoproperty:: length - .. autoproperty:: type + .. autoproperty:: info_name - .. automethod:: read - .. automethod:: make - - .. automethod:: _make_data + .. automethod:: id .. automethod:: __index__ -Header Schemas --------------- - -.. module:: pcapkit.protocols.schema.link.l2tp - -.. autoclass:: pcapkit.protocols.schema.link.l2tp.L2TP - :members: - :show-inheritance: - -Type Stubs -~~~~~~~~~~ - -.. autoclass:: pcapkit.protocols.schema.link.l2tp.FlagsType - :members: - :show-inheritance: - -Data Models ------------ - -.. module:: pcapkit.protocols.data.link.l2tp - -.. autoclass:: pcapkit.protocols.data.link.l2tp.L2TP - :members: - :show-inheritance: - -.. autoclass:: pcapkit.protocols.data.link.l2tp.Flags - :members: - :show-inheritance: - .. rubric:: Footnotes .. [*] https://en.wikipedia.org/wiki/Layer_2_Tunneling_Protocol diff --git a/docs/source/pcapkit/protocols/link/l2tpv2.rst b/docs/source/pcapkit/protocols/link/l2tpv2.rst new file mode 100644 index 0000000000..e0c3e6d15c --- /dev/null +++ b/docs/source/pcapkit/protocols/link/l2tpv2.rst @@ -0,0 +1,114 @@ +L2TPv2 - Layer Two Tunnelling Protocol version 2 +================================================ + +.. module:: pcapkit.protocols.link.l2tpv2 + +:mod:`pcapkit.protocols.link.l2tpv2` contains +:class:`~pcapkit.protocols.link.l2tpv2.L2TPv2` only, which implements extractor +for the Layer Two Tunnelling Protocol version 2 (L2TPv2) [*]_ as specified by +:rfc:`2661` -- a 16-bit tunnel ID and a 16-bit session ID, with the version nibble +reading ``2``. It is dispatched from +:attr:`UDP.__proto__ ` at port +1701. Its structure is described as below: + +.. table:: + + ======= ===== ===================== ========================================== + Octets Bits Name Description + ======= ===== ===================== ========================================== + 0 0 ``l2tp.flags`` Flags and Version Info + ------- ----- --------------------- ------------------------------------------ + 0 0 ``l2tp.flags.type`` Type (control / data) + ------- ----- --------------------- ------------------------------------------ + 0 1 ``l2tp.flags.len`` Length + ------- ----- --------------------- ------------------------------------------ + 0 2 Reserved (must be zero ``x00``) + ------- ----- --------------------- ------------------------------------------ + 0 4 ``l2tp.flags.seq`` Sequence + ------- ----- --------------------- ------------------------------------------ + 0 5 Reserved (must be zero ``x00``) + ------- ----- --------------------- ------------------------------------------ + 0 6 ``l2tp.flags.offset`` Offset + ------- ----- --------------------- ------------------------------------------ + 0 7 ``l2tp.flags.prio`` Priority + ------- ----- --------------------- ------------------------------------------ + 1 8 Reserved (must be zero ``x00``) + ------- ----- --------------------- ------------------------------------------ + 1 12 ``l2tp.version`` Version (``2``) + ------- ----- --------------------- ------------------------------------------ + 2 16 ``l2tp.length`` Length (optional by ``len``) + ------- ----- --------------------- ------------------------------------------ + 4 32 ``l2tp.tunnelid`` Tunnel ID + ------- ----- --------------------- ------------------------------------------ + 6 48 ``l2tp.sessionid`` Session ID + ------- ----- --------------------- ------------------------------------------ + 8 64 ``l2tp.ns`` Sequence Number (optional by ``seq``) + ------- ----- --------------------- ------------------------------------------ + 10 80 ``l2tp.nr`` Next Sequence Number (optional by ``seq``) + ------- ----- --------------------- ------------------------------------------ + 12 96 ``l2tp.offset`` Offset Size (optional by ``offset``) + ======= ===== ===================== ========================================== + +.. note:: + + The parsed datagram appears under ``l2tp``, not ``l2tpv2``: + :attr:`~pcapkit.protocols.link.l2tp.L2TP.info_name` is declared on the + version-agnostic base so that a consumer finds the data at the same key + whichever version was on the wire. The version is reported by + :attr:`~pcapkit.protocols.link.l2tpv2.L2TPv2.alias` instead. + + IANA protocol number 115 (``L2TP``) is deliberately left unbound. It + references :rfc:`3931`, i.e. **L2TPv3**, whose session and control message + headers are a different shape -- so the binding waits on an ``L2TPv3`` class + rather than on this one. See :mod:`pcapkit.protocols.link.l2tp`. + +.. autoclass:: pcapkit.protocols.link.l2tpv2.L2TPv2 + :no-members: + :show-inheritance: + + .. autoproperty:: name + .. autoproperty:: alias + .. autoproperty:: version + .. autoproperty:: length + .. autoproperty:: type + + .. automethod:: id + .. automethod:: read + .. automethod:: make + + .. automethod:: _make_data + + .. automethod:: __index__ + +Header Schemas +-------------- + +.. module:: pcapkit.protocols.schema.link.l2tp + +.. autoclass:: pcapkit.protocols.schema.link.l2tp.L2TP + :members: + :show-inheritance: + +Type Stubs +~~~~~~~~~~ + +.. autoclass:: pcapkit.protocols.schema.link.l2tp.FlagsType + :members: + :show-inheritance: + +Data Models +----------- + +.. module:: pcapkit.protocols.data.link.l2tp + +.. autoclass:: pcapkit.protocols.data.link.l2tp.L2TP + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.data.link.l2tp.Flags + :members: + :show-inheritance: + +.. rubric:: Footnotes + +.. [*] https://en.wikipedia.org/wiki/Layer_2_Tunneling_Protocol diff --git a/docs/source/pcapkit/protocols/link/s_tag.rst b/docs/source/pcapkit/protocols/link/s_tag.rst new file mode 100644 index 0000000000..97b29e5ce6 --- /dev/null +++ b/docs/source/pcapkit/protocols/link/s_tag.rst @@ -0,0 +1,41 @@ +S_Tag - 802.1ad Service VLAN Tag Type +===================================== + +.. module:: pcapkit.protocols.link.s_tag + +:mod:`pcapkit.protocols.link.s_tag` contains +:class:`~pcapkit.protocols.link.s_tag.S_Tag` only, which implements extractor for +the 802.1ad Service VLAN Tag Type (S-Tag) [*]_, EtherType ``0x88A8``. + +Its structure, and all of its parsing and construction, come from +:class:`~pcapkit.protocols.link.vlan.VLAN`; this class adds only the tag's own +identity -- :attr:`~pcapkit.protocols.link.s_tag.S_Tag.name`, +:attr:`~pcapkit.protocols.link.s_tag.S_Tag.alias`, +:attr:`~pcapkit.protocols.link.s_tag.S_Tag.info_name` -- and its registry index. + +It lives in a module of its own rather than beside +:class:`~pcapkit.protocols.link.c_tag.C_Tag` because the two are reached through +*different* registry indices, ``0x88A8`` against ``0x8100``, which is the +project's rule for when protocols share a module. + +.. note:: + + 802.1ad was incorporated into IEEE 802.1Q-2011, so the service tag is + specified by 802.1Q today. The ``802.1ad`` name is kept because it is what the + provider-bridging tag is universally called. + +.. autoclass:: pcapkit.protocols.link.s_tag.S_Tag + :no-members: + :show-inheritance: + + .. autoproperty:: name + .. autoproperty:: alias + .. autoproperty:: info_name + + .. automethod:: id + + .. automethod:: __index__ + +.. rubric:: Footnotes + +.. [*] https://en.wikipedia.org/wiki/IEEE_802.1ad diff --git a/docs/source/pcapkit/protocols/link/vlan.rst b/docs/source/pcapkit/protocols/link/vlan.rst index 34b6032221..6b3e00092c 100644 --- a/docs/source/pcapkit/protocols/link/vlan.rst +++ b/docs/source/pcapkit/protocols/link/vlan.rst @@ -4,11 +4,21 @@ VLAN - 802.1Q/802.1ad VLAN Tag Types .. module:: pcapkit.protocols.link.vlan :mod:`pcapkit.protocols.link.vlan` contains -:class:`~pcapkit.protocols.link.vlan.VLAN`, an abstract base class holding the -tag layout shared by every VLAN tag, and its two concrete subclasses -- -:class:`~pcapkit.protocols.link.vlan.C_Tag` for the 802.1Q customer tag [*]_ and -:class:`~pcapkit.protocols.link.vlan.S_Tag` for the 802.1ad service tag -- whose -structure is described as below: +:class:`~pcapkit.protocols.link.vlan.VLAN` only, an abstract base class holding +the tag layout shared by every VLAN tag [*]_. The two concrete tags live in +modules of their own: + +.. list-table:: + :header-rows: 1 + + * - EtherType + - Class + * - ``0x8100`` (customer tag, 802.1Q) + - :class:`~pcapkit.protocols.link.c_tag.C_Tag` + * - ``0x88A8`` (service tag, 802.1ad) + - :class:`~pcapkit.protocols.link.s_tag.S_Tag` + +The tag structure is described as below: ======= ========= ====================== ============================= Octets Bits Name Description @@ -25,7 +35,7 @@ solely by the tag protocol identifier (TPID) that selected them -- ``0x8100`` fo the customer tag against ``0x88A8`` for the service tag. That TPID is not part of either tag: it is the EtherType field of whatever encapsulates the tag, so both classes read the same four octets and share every byte of parsing and -construction code. +construction code, which is what this base holds. They are nonetheless distinct classes rather than one class bound at two EtherTypes, because 802.1ad *stacks* them. In a Q-in-Q frame the service tag's @@ -46,6 +56,15 @@ tags appear in one frame: would nest one ``c_tag`` inside another, leaving nothing in the output to say which of the two was the service tag. +Two distinct EtherTypes also means two distinct +:meth:`~pcapkit.protocols.protocol.ProtocolBase.__index__` values, which is the +project's rule for when protocols get separate modules: siblings that *share* an +index may share a module, as :class:`~pcapkit.protocols.link.arp.InARP` shares +:mod:`~pcapkit.protocols.link.arp` and +:class:`~pcapkit.protocols.link.rarp.DRARP` shares +:mod:`~pcapkit.protocols.link.rarp`. This base declares no index of its own -- +it is abstract and nothing dispatches to it -- so its ``__index__`` raises. + .. autoclass:: pcapkit.protocols.link.vlan.VLAN :no-members: :show-inheritance: @@ -53,6 +72,7 @@ which of the two was the service tag. .. autoproperty:: length .. autoproperty:: protocol + .. automethod:: id .. automethod:: read .. automethod:: make @@ -60,26 +80,6 @@ which of the two was the service tag. .. automethod:: __index__ -.. autoclass:: pcapkit.protocols.link.vlan.C_Tag - :no-members: - :show-inheritance: - - .. autoproperty:: name - .. autoproperty:: alias - .. autoproperty:: info_name - - .. automethod:: id - -.. autoclass:: pcapkit.protocols.link.vlan.S_Tag - :no-members: - :show-inheritance: - - .. autoproperty:: name - .. autoproperty:: alias - .. autoproperty:: info_name - - .. automethod:: id - Header Schemas -------------- diff --git a/docs/source/pep.rst b/docs/source/pep.rst index a46b413409..3483479d0d 100644 --- a/docs/source/pep.rst +++ b/docs/source/pep.rst @@ -49,8 +49,8 @@ independent implementations, in the way ICMPv6, TLS/SSL with DTLS, and ``LINUX_SLL`` with ``LINUX_SLL2``. :class:`~pcapkit.protocols.link.vlan.VLAN` is the closer precedent for a pair whose *layout* is identical -- it holds the whole of the tag, and -:class:`~pcapkit.protocols.link.vlan.C_Tag` and -:class:`~pcapkit.protocols.link.vlan.S_Tag` add only how each names itself. +:class:`~pcapkit.protocols.link.c_tag.C_Tag` and +:class:`~pcapkit.protocols.link.s_tag.S_Tag` add only how each names itself. .. note:: @@ -242,7 +242,7 @@ and those have now been made: * **Done.** :class:`~pcapkit.protocols.link.ospf.OSPF` is bound at ``TransType`` 89 (``OSPFIGP``) and - :class:`~pcapkit.protocols.link.l2tp.L2TP` at UDP port 1701. Binding them + :class:`~pcapkit.protocols.link.l2tpv2.L2TPv2` at UDP port 1701. Binding them turned up three defects that had kept OSPF from parsing anything at all -- ``read`` consulted the schema *class* rather than the parsed header, :attr:`~pcapkit.protocols.link.ospf.OSPF.alias` read an ``_info`` that does @@ -261,8 +261,8 @@ and those have now been made: and de-facto 8443 traffic is TLS-wrapped, which pcapkit cannot parse -- see the ``tls`` stub above. Binding it would feed a TLS record to an HTTP parser. * **Done.** The service VLAN tag identifier (S-Tag), ``0x88A8``, is bound to - :class:`~pcapkit.protocols.link.vlan.S_Tag`, and the customer tag ``0x8100`` - to :class:`~pcapkit.protocols.link.vlan.C_Tag`. Both subclass the now-abstract + :class:`~pcapkit.protocols.link.s_tag.S_Tag`, and the customer tag ``0x8100`` + to :class:`~pcapkit.protocols.link.c_tag.C_Tag`. Both subclass the now-abstract :class:`~pcapkit.protocols.link.vlan.VLAN`, which carries the shared tag layout; the split exists so that a Q-in-Q frame's two tags stay distinct in the parsed output. Fixing the shared ``read`` also fixed the DEI flag, which @@ -273,12 +273,18 @@ and those have now been made: Three follow-ups the above deliberately left alone: -* ``TransType`` 115 (``L2TP``) stays unbound. It references :rfc:`3931`, i.e. - L2TPv3 over IP, whose session header differs from the :rfc:`2661` L2TPv2 - framing :class:`~pcapkit.protocols.link.l2tp.L2TP` implements. Binding it - wants a v3 dissector, not a table entry. -* :class:`~pcapkit.protocols.link.ospf.OSPF` and - :class:`~pcapkit.protocols.link.l2tp.L2TP` both live under +* ``TransType`` 115 (``L2TP``) stays unbound, and the reason is now structural + rather than incidental: it references :rfc:`3931`, i.e. L2TPv3 over IP, and + **there is no** ``L2TPv3`` **class for it to point at**. What exists is + :class:`~pcapkit.protocols.link.l2tpv2.L2TPv2`, the :rfc:`2661` v2 framing, + reached over UDP 1701. So the binding waits on a v3 dissector, which is also + the first member of the family to carry an + :meth:`~pcapkit.protocols.protocol.ProtocolBase.__index__` of its own -- 115 + being that index. :mod:`pcapkit.protocols.link.l2tp` records what v3 needs, and + what ``L2F`` needs alongside it: the version nibble reading ``1`` selects L2F + [:rfc:`2341`], a separate protocol, not an earlier L2TP. +* :class:`~pcapkit.protocols.link.ospf.OSPF` and the + :class:`~pcapkit.protocols.link.l2tp.L2TP` family both live under :mod:`pcapkit.protocols.link` and so report ``layer == 'Link'``, although one is carried inside IP and the other inside UDP. Moving them would change their public import paths, so the misclassification is documented rather than diff --git a/pcapkit/all.py b/pcapkit/all.py index b9c4df2894..6f86e9eaf0 100644 --- a/pcapkit/all.py +++ b/pcapkit/all.py @@ -109,8 +109,8 @@ 'Header', 'Frame', # PCAP Headers 'NoPayload', # No Payload 'Raw', # Raw Packet - 'ARP', 'C_Tag', 'DRARP', 'Ethernet', 'InARP', 'L2TP', 'OSPF', 'RARP', - 'S_Tag', 'VLAN', # Link Layer + 'ARP', 'C_Tag', 'DRARP', 'Ethernet', 'InARP', 'L2TP', 'L2TPv2', 'OSPF', + 'RARP', 'S_Tag', 'VLAN', # Link Layer 'AH', 'IP', 'IPsec', 'IPv4', 'IPv6', 'IPX', # Internet Layer 'HIP', 'HOPOPT', 'IPv6_Frag', 'IPv6_Opts', 'IPv6_Route', 'MH', # IPv6 Extension Header diff --git a/pcapkit/protocols/__init__.py b/pcapkit/protocols/__init__.py index fe004992e1..c2a0f815e0 100644 --- a/pcapkit/protocols/__init__.py +++ b/pcapkit/protocols/__init__.py @@ -50,7 +50,7 @@ 'Raw', # Link Layer - 'ARP', 'C_Tag', 'DRARP', 'Ethernet', 'InARP', 'L2TP', + 'ARP', 'C_Tag', 'DRARP', 'Ethernet', 'InARP', 'L2TP', 'L2TPv2', 'OSPF', 'RARP', 'S_Tag', 'VLAN', # Internet Layer diff --git a/pcapkit/protocols/data/link/vlan.py b/pcapkit/protocols/data/link/vlan.py index efa660ee77..0bd2f9fac0 100644 --- a/pcapkit/protocols/data/link/vlan.py +++ b/pcapkit/protocols/data/link/vlan.py @@ -2,8 +2,8 @@ """data models for 802.1Q/802.1ad VLAN tag types The customer tag (802.1Q) and the service tag (802.1ad) carry an identical -layout, so :class:`~pcapkit.protocols.link.vlan.C_Tag` and -:class:`~pcapkit.protocols.link.vlan.S_Tag` share the data model below. +layout, so :class:`~pcapkit.protocols.link.c_tag.C_Tag` and +:class:`~pcapkit.protocols.link.s_tag.S_Tag` share the data model below. """ diff --git a/pcapkit/protocols/link/__init__.py b/pcapkit/protocols/link/__init__.py index 64242031d6..238f665006 100644 --- a/pcapkit/protocols/link/__init__.py +++ b/pcapkit/protocols/link/__init__.py @@ -17,10 +17,17 @@ # Utility Classes for Protocols from pcapkit.protocols.link.arp import ARP, InARP from pcapkit.protocols.link.ethernet import Ethernet -from pcapkit.protocols.link.l2tp import L2TP from pcapkit.protocols.link.ospf import OSPF from pcapkit.protocols.link.rarp import RARP, DRARP -from pcapkit.protocols.link.vlan import VLAN, C_Tag, S_Tag + +# VLAN Tag Family +from pcapkit.protocols.link.vlan import VLAN +from pcapkit.protocols.link.c_tag import C_Tag +from pcapkit.protocols.link.s_tag import S_Tag + +# L2TP Family +from pcapkit.protocols.link.l2tp import L2TP +from pcapkit.protocols.link.l2tpv2 import L2TPv2 # Link-Layer Header Type Values from pcapkit.const.reg.linktype import LinkType as LINKTYPE @@ -30,6 +37,6 @@ 'LINKTYPE', # Link Layer Protocols - 'ARP', 'C_Tag', 'DRARP', 'Ethernet', 'InARP', 'L2TP', + 'ARP', 'C_Tag', 'DRARP', 'Ethernet', 'InARP', 'L2TP', 'L2TPv2', 'OSPF', 'RARP', 'S_Tag', 'VLAN', ] diff --git a/pcapkit/protocols/link/c_tag.py b/pcapkit/protocols/link/c_tag.py new file mode 100644 index 0000000000..42fb23f339 --- /dev/null +++ b/pcapkit/protocols/link/c_tag.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +"""C_Tag - 802.1Q Customer VLAN Tag Type +========================================== + +.. module:: pcapkit.protocols.link.c_tag + +:mod:`pcapkit.protocols.link.c_tag` contains +:class:`~pcapkit.protocols.link.c_tag.C_Tag` only, which implements extractor for +the 802.1Q Customer VLAN Tag Type (C-Tag, formerly the Q-Tag) [*]_, EtherType +``0x8100``. Its structure, and all of its parsing and construction, come from +:class:`~pcapkit.protocols.link.vlan.VLAN`; this class adds only the tag's own +identity and its registry index. + +It lives in a module of its own rather than beside +:class:`~pcapkit.protocols.link.s_tag.S_Tag` because the two are reached through +*different* registry indices -- ``0x8100`` against ``0x88A8`` -- which is the +project's rule for when protocols share a module. Contrast +:class:`~pcapkit.protocols.link.arp.InARP`, which shares +:mod:`~pcapkit.protocols.link.arp` with :class:`~pcapkit.protocols.link.arp.ARP` +precisely because it inherits its index. + +.. [*] https://en.wikipedia.org/wiki/IEEE_802.1Q + +""" +from typing import TYPE_CHECKING + +from pcapkit.const.reg.ethertype import EtherType as Enum_EtherType +from pcapkit.protocols.data.link.vlan import VLAN as Data_VLAN +from pcapkit.protocols.link.vlan import VLAN +from pcapkit.protocols.schema.link.vlan import VLAN as Schema_VLAN + +if TYPE_CHECKING: + from typing_extensions import Literal + +__all__ = ['C_Tag'] + + +# NOTE: ``schema`` and ``data`` are restated here even though :class:`VLAN` +# already declares them. They are not inherited: +# :meth:`ProtocolBase.__init_subclass__ ` +# resolves an omitted schema by looking the *subclass name* up in +# :mod:`pcapkit.protocols.schema`, and assigns unconditionally -- so leaving them +# off would silently bind ``Schema_Raw``/``Data_Raw`` rather than falling back to +# the base class's pair. c.f. :class:`~pcapkit.protocols.link.rarp.RARP`, which +# restates them for the same reason. +class C_Tag(VLAN, schema=Schema_VLAN, data=Data_VLAN): + """This class implements 802.1Q Customer VLAN Tag Type.""" + + ########################################################################## + # Properties. + ########################################################################## + + @property + def name(self) -> 'Literal["802.1Q Customer VLAN Tag Type"]': + """Name of current protocol.""" + return '802.1Q Customer VLAN Tag Type' + + @property + def alias(self) -> 'Literal["802.1Q"]': + """Acronym of corresponding protocol.""" + return '802.1Q' + + #: NOTE: This is what keeps a stacked service tag and customer tag apart in + #: the parsed info dict, so it is spelled out rather than left to the + #: class-name default -- a rename must not silently move the output key. + @property + def info_name(self) -> 'Literal["c_tag"]': + """Key name of the :attr:`info` dict.""" + return 'c_tag' + + ########################################################################## + # Methods. + ########################################################################## + + @classmethod + def id(cls) -> 'tuple[Literal["C_Tag"], Literal["VLAN"]]': # type: ignore[override] + """Index ID of the protocol. + + Returns: + Index ID of the protocol. ``VLAN`` is retained alongside the class's + own name so that selecting the protocol by that name -- as + ``pcapkit.extract(..., protocol='VLAN')`` did when this class *was* + ``VLAN`` -- keeps matching. + + """ + return ('C_Tag', 'VLAN') + + ########################################################################## + # Data models. + ########################################################################## + + @classmethod + def __index__(cls) -> 'Enum_EtherType': # pylint: disable=invalid-index-returned + """Numeral registry index of the protocol. + + Returns: + Numeral registry index of the protocol in `IANA`_. + + .. _IANA: https://www.iana.org/assignments/ieee-802-numbers/ieee-802-numbers.xhtml + + """ + return Enum_EtherType.Customer_VLAN_Tag_Type # type: ignore[return-value] diff --git a/pcapkit/protocols/link/l2tp.py b/pcapkit/protocols/link/l2tp.py index b657473546..ffc1601a22 100644 --- a/pcapkit/protocols/link/l2tp.py +++ b/pcapkit/protocols/link/l2tp.py @@ -5,93 +5,96 @@ .. module:: pcapkit.protocols.link.l2tp :mod:`pcapkit.protocols.link.l2tp` contains -:class:`~pcapkit.protocols.link.l2tp.L2TP` only, -which implements extractor for Layer Two Tunnelling -Protocol (L2TP) [*]_, whose structure is described -as below: - -.. table:: - - ======= ===== ===================== ========================================== - Octets Bits Name Description - ======= ===== ===================== ========================================== - 0 0 ``l2tp.flags`` Flags and Version Info - ------- ----- --------------------- ------------------------------------------ - 0 0 ``l2tp.flags.type`` Type (control / data) - ------- ----- --------------------- ------------------------------------------ - 0 1 ``l2tp.flags.len`` Length - ------- ----- --------------------- ------------------------------------------ - 0 2 Reserved (must be zero ``x00``) - ------- ----- --------------------- ------------------------------------------ - 0 4 ``l2tp.flags.seq`` Sequence - ------- ----- --------------------- ------------------------------------------ - 0 5 Reserved (must be zero ``x00``) - ------- ----- --------------------- ------------------------------------------ - 0 6 ``l2tp.flags.offset`` Offset - ------- ----- --------------------- ------------------------------------------ - 0 7 ``l2tp.flags.prio`` Priority - ------- ----- --------------------- ------------------------------------------ - 1 8 Reserved (must be zero ``x00``) - ------- ----- --------------------- ------------------------------------------ - 1 12 ``l2tp.ver`` Version (``2``) - ------- ----- --------------------- ------------------------------------------ - 2 16 ``l2tp.length`` Length (optional by ``len``) - ------- ----- --------------------- ------------------------------------------ - 4 32 ``l2tp.tunnelid`` Tunnel ID - ------- ----- --------------------- ------------------------------------------ - 6 48 ``l2tp.sessionid`` Session ID - ------- ----- --------------------- ------------------------------------------ - 8 64 ``l2tp.ns`` Sequence Number (optional by ``seq``) - ------- ----- --------------------- ------------------------------------------ - 10 80 ``l2tp.nr`` Next Sequence Number (optional by ``seq``) - ------- ----- --------------------- ------------------------------------------ - 12 96 ``l2tp.offset`` Offset Size (optional by ``offset``) - ======= ===== ===================== ========================================== +:class:`~pcapkit.protocols.link.l2tp.L2TP` only, an abstract base class for the +Layer Two Tunnelling Protocol family [*]_. The concrete versions live in modules +of their own: + +.. list-table:: + :header-rows: 1 + + * - Version + - Class + - Specification + * - L2TPv2 + - :class:`~pcapkit.protocols.link.l2tpv2.L2TPv2` + - :rfc:`2661` + +Only L2TPv2 is implemented. What the family is expected to grow is set out below, +so that the base's shape is not guessed at later. + +The base deliberately carries **no header parsing at all**, in the way +:class:`~pcapkit.protocols.internet.ip.IP` carries none for its family. That is +not tidiness: the versions genuinely do not share a header. All that is common +across them is the *first 16-bit word carrying a version nibble at bits 12-15*; +everything after it differs, so a base that parsed further would be assuming one +version's layout for all of them. + +What the family still wants +--------------------------- + +**L2TPv3** [:rfc:`3931`] has a different session header and a different control +message header from v2, and is reachable two ways -- over UDP port 1701 like v2, +and directly over IP as **protocol number 115**. That second route is why +:attr:`Internet.__proto__ ` +leaves 115 unbound today: the binding waits on an ``L2TPv3`` class, not on a +different framing decision. It also means v3 is the first member of this family +to have a real :meth:`~pcapkit.protocols.protocol.ProtocolBase.__index__`. + +**L2F** [:rfc:`2341`] is reached when the version nibble reads ``1``. It is *not* +an earlier version of L2TP: :rfc:`2661` §3.1 requires ``Ver`` to be 2 and reserves +the value 1 "to permit detection of L2F packets should they arrive intermixed +with L2TP packets". L2F is a separate protocol with its own header. It is +therefore to be implemented as ``L2F``, the canonical name, carrying ``L2TPv1`` +only as an alias in its :meth:`~pcapkit.protocols.protocol.ProtocolBase.id` -- +the same relationship HTTP/3 has to QUIC. c.f. +:meth:`HTTPv1.id ` for how a +version-flavoured alias is spelled: canonical name first, alias second, since +callers take element zero as canonical. + +Selecting a version +------------------- + +Nothing dispatches on the version nibble yet, because only one version exists. +When a second lands, the mechanism it wants already has a precedent in +:class:`~pcapkit.protocols.application.http.HTTP`, which reads a version and +delegates to a per-version class. L2TP is the easier case: HTTP has to +*trial-parse* each candidate in +:meth:`~pcapkit.protocols.application.http.HTTP._guess_version` because the wire +format carries no version field, whereas L2TP states its version explicitly in +those four bits. So a deterministic switch on ``Ver`` is enough, and no new +registry is needed -- the class bound at UDP 1701 reads two octets, masks out the +nibble, and hands the datagram to the matching class. .. [*] https://en.wikipedia.org/wiki/Layer_2_Tunneling_Protocol """ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Generic -from pcapkit.const.l2tp.type import Type as Enum_Type -from pcapkit.protocols.data.link.l2tp import L2TP as Data_L2TP -from pcapkit.protocols.data.link.l2tp import Flags as Data_Flags from pcapkit.protocols.link.link import Link -from pcapkit.protocols.schema.link.l2tp import L2TP as Schema_L2TP +from pcapkit.protocols.protocol import _PT, _ST from pcapkit.utilities.exceptions import UnsupportedCall if TYPE_CHECKING: - from enum import IntEnum as StdlibEnum - from typing import Any, NoReturn, Optional, Type + from typing import NoReturn - from aenum import IntEnum as AenumEnum from typing_extensions import Literal - from pcapkit.protocols.protocol import ProtocolBase as Protocol - from pcapkit.protocols.schema.schema import Schema - __all__ = ['L2TP'] -class L2TP(Link[Data_L2TP, Schema_L2TP], - schema=Schema_L2TP, data=Data_L2TP): - """This class implements Layer Two Tunnelling Protocol. - - The protocol is dispatched from :attr:`UDP.__proto__ - ` at port 1701. +class L2TP(Link[_PT, _ST], Generic[_PT, _ST]): # pylint: disable=abstract-method + """This class implements all protocols in L2TP family. - Note: - This is **L2TPv2** as specified by :rfc:`2661` -- a 16-bit tunnel ID and - a 16-bit session ID, with the version nibble reading 2. IANA protocol - number 115 (``L2TP``) is therefore deliberately left unbound: it - references :rfc:`3931`, i.e. L2TPv3 over IP, whose session header is a - different shape and which this dissector would misparse. + - Layer Two Tunnelling Protocol version 2 + (:class:`~pcapkit.protocols.link.l2tpv2.L2TPv2`) [:rfc:`2661`] - As with :class:`~pcapkit.protocols.link.ospf.OSPF`, the class subclasses - :class:`~pcapkit.protocols.link.link.Link` and so reports - ``layer == 'Link'`` even though it is carried inside UDP. That is a - pre-existing classification, kept because moving the module would change - its public import path. + It is abstract for the same mechanical reason + :class:`~pcapkit.protocols.internet.ip.IP` is: + :attr:`~pcapkit.protocols.protocol.ProtocolBase.name` and + :meth:`~pcapkit.protocols.protocol.ProtocolBase.read` are both declared + abstract by :class:`~pcapkit.protocols.protocol.ProtocolBase` and neither is + defined here, so the class cannot be instantiated. Bind a version, never this + class. """ @@ -99,159 +102,38 @@ class L2TP(Link[Data_L2TP, Schema_L2TP], # Properties. ########################################################################## + #: NOTE: Declared on the base, and so shared by every version, deliberately. + #: This is the key the parsed datagram appears under, and a consumer wants + #: ``udp.l2tp`` whichever version was on the wire -- the version is reported + #: by :attr:`~pcapkit.protocols.protocol.ProtocolBase.alias` instead. Left to + #: the class-name default it would read ``l2tpv2``, ``l2tpv3`` and so on, and + #: every consumer would have to know the version to find the data. @property - def name(self) -> 'Literal["Layer 2 Tunnelling Protocol"]': - """Name of current protocol.""" - return 'Layer 2 Tunnelling Protocol' - - @property - def length(self) -> 'int': - """Header length of current protocol.""" - return self._info.hdr_len - - @property - def type(self) -> 'Literal["control", "data"]': - """L2TP type.""" - return self._info.flags.type + def info_name(self) -> 'Literal["l2tp"]': + """Key name of the :attr:`info` dict.""" + return 'l2tp' ########################################################################## # Methods. ########################################################################## - def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_L2TP': # pylint: disable=unused-argument - """Read Layer Two Tunnelling Protocol. - - Structure of L2TP header [:rfc:`2661`]: - - .. code-block:: text - - 0 1 2 3 - 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 - +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - |T|L|x|x|S|x|O|P|x|x|x|x| Ver | Length (opt) | - +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - | Tunnel ID | Session ID | - +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - | Ns (opt) | Nr (opt) | - +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - | Offset Size (opt) | Offset pad... (opt) - +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - - Args: - length: Length of packet data. - **kwargs: Arbitrary keyword arguments. - - Returns: - Parsed packet data. - - """ - schema = self.__header__ - _flag = schema.flags - - flags = Data_Flags( - type=Enum_Type(_flag['type']), - len=bool(_flag['len']), - seq=bool(_flag['seq']), - offset=bool(_flag['offset']), - prio=bool(_flag['prio']), - ) - - _size = schema.offset if flags.offset else 0 - hdr_len = 6 + 2 * (flags.len + 2 * flags.seq + flags.offset) + _size - - l2tp = Data_L2TP( - flags=flags, - version=_flag['version'], - length=schema.length if flags.len else None, - tunnelid=schema.tunnel_id, - sessionid=schema.session_id, - ns=schema.ns if flags.seq else None, - nr=schema.nr if flags.seq else None, - offset=_size if flags.offset else None, - ) - - l2tp.__update__([ - ('hdr_len', hdr_len), - ]) - if _size: - self._read_fileng(_size) - # l2tp['padding'] = self._read_fileng(_size) - - length = schema.length if flags.len else (length or len(self)) - # L2TP carries no next-protocol field -- the payload is a PPP frame, - # which pcapkit does not dissect -- so dispatch on the -1 sentinel, as - # ARP does, rather than on a code read off the wire. Passing the - # remaining length here (as this did) dispatched on it as if it were an - # EtherType, which resolved to Raw only because a length rarely collides - # with a registered one. - return self._decode_next_layer(l2tp, -1, length - hdr_len) - - def make(self, - version: 'Literal[2]' = 2, - type: 'Enum_Type | StdlibEnum | AenumEnum | str | int' = Enum_Type.Data, - 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, - priority: 'bool' = False, - length: 'Optional[int]' = None, - tunnel_id: 'int' = 0, - session_id: 'int' = 0, - ns: 'Optional[int]' = None, - nr: 'Optional[int]' = None, - offset: 'Optional[int]' = None, - payload: 'bytes | Protocol | Schema' = b'', - **kwargs: 'Any') -> 'Schema_L2TP': # pylint: disable=unused-argument - """Make (construct) packet data. - - Args: - version: L2TP version. - type: L2TP type. - type_default: Default value of type. - type_namespace: Namespace of type. - type_reversed: Reversed namespace of type. - priority: Priority flag. - length: Length of packet data. - tunnel_id: Tunnel ID. - session_id: Session ID. - ns: Sequence number. - nr: Acknowledgement number. - offset: Offset size. - payload: Payload data. - **kwargs: Arbitrary keyword arguments. + @classmethod + def id(cls) -> 'tuple[Literal["L2TP"], Literal["L2TPv2"]]': + """Index ID of the protocol. Returns: - Constructed packet data. + Index ID of the protocol -- the family name, then every version in + it, as :meth:`HTTP.id ` + does for its own family. ``L2F`` and ``L2TPv3`` join this tuple when + they are implemented. """ - type_ = self._make_index(type, type_default, namespace=type_namespace, - reversed=type_reversed, pack=False) - - return Schema_L2TP( - flags={ - 'type': type_, - 'len': length is not None, - 'seq': ns is not None and nr is not None, - 'offset': offset is not None, - 'prio': priority, - 'version': version, - }, - length=length, - tunnel_id=tunnel_id, - session_id=session_id, - ns=ns, - nr=nr, - offset=offset, - payload=payload, - ) + return ('L2TP', 'L2TPv2') ########################################################################## # Data models. ########################################################################## - def __length_hint__(self) -> 'Literal[16]': - """Return an estimated length for the object.""" - return 16 - @classmethod def __index__(cls) -> 'NoReturn': # pylint: disable=invalid-index-returned """Numeral registry index of the protocol. @@ -259,33 +141,11 @@ def __index__(cls) -> 'NoReturn': # pylint: disable=invalid-index-returned Raises: UnsupportedCall: This protocol has no registry entry. - """ - raise UnsupportedCall(f'{cls.__name__!r} object cannot be interpreted as an integer') - - ########################################################################## - # Utilities. - ########################################################################## - - @classmethod - def _make_data(cls, data: 'Data_L2TP') -> '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: + An abstract base is reached by nothing, so it has no index of its + own. That is also the project's rule for module layout -- a distinct + ``__index__`` means a distinct module, and a base carrying none + claims no module of its own beyond holding the family together. """ - return { - 'type': data.flags.type, - 'prio': data.flags.prio, - 'version': data.version, - 'length': data.length, - 'tunnel_id': data.tunnelid, - 'session_id': data.sessionid, - 'ns': data.ns, - 'nr': data.nr, - 'offset': data.offset, - 'payload': cls._make_payload(data), - } + raise UnsupportedCall(f'{cls.__name__!r} object cannot be interpreted as an integer') diff --git a/pcapkit/protocols/link/l2tpv2.py b/pcapkit/protocols/link/l2tpv2.py new file mode 100644 index 0000000000..fe5e67ef61 --- /dev/null +++ b/pcapkit/protocols/link/l2tpv2.py @@ -0,0 +1,344 @@ +# -*- coding: utf-8 -*- +"""L2TPv2 - Layer Two Tunnelling Protocol version 2 +===================================================== + +.. module:: pcapkit.protocols.link.l2tpv2 + +:mod:`pcapkit.protocols.link.l2tpv2` contains +:class:`~pcapkit.protocols.link.l2tpv2.L2TPv2` only, which implements extractor +for the Layer Two Tunnelling Protocol version 2 (L2TPv2) [*]_ as specified by +:rfc:`2661` -- a 16-bit tunnel ID and a 16-bit session ID, with the version +nibble reading ``2``. Its structure is described as below: + +.. table:: + + ======= ===== ===================== ========================================== + Octets Bits Name Description + ======= ===== ===================== ========================================== + 0 0 ``l2tp.flags`` Flags and Version Info + ------- ----- --------------------- ------------------------------------------ + 0 0 ``l2tp.flags.type`` Type (control / data) + ------- ----- --------------------- ------------------------------------------ + 0 1 ``l2tp.flags.len`` Length + ------- ----- --------------------- ------------------------------------------ + 0 2 Reserved (must be zero ``x00``) + ------- ----- --------------------- ------------------------------------------ + 0 4 ``l2tp.flags.seq`` Sequence + ------- ----- --------------------- ------------------------------------------ + 0 5 Reserved (must be zero ``x00``) + ------- ----- --------------------- ------------------------------------------ + 0 6 ``l2tp.flags.offset`` Offset + ------- ----- --------------------- ------------------------------------------ + 0 7 ``l2tp.flags.prio`` Priority + ------- ----- --------------------- ------------------------------------------ + 1 8 Reserved (must be zero ``x00``) + ------- ----- --------------------- ------------------------------------------ + 1 12 ``l2tp.ver`` Version (``2``) + ------- ----- --------------------- ------------------------------------------ + 2 16 ``l2tp.length`` Length (optional by ``len``) + ------- ----- --------------------- ------------------------------------------ + 4 32 ``l2tp.tunnelid`` Tunnel ID + ------- ----- --------------------- ------------------------------------------ + 6 48 ``l2tp.sessionid`` Session ID + ------- ----- --------------------- ------------------------------------------ + 8 64 ``l2tp.ns`` Sequence Number (optional by ``seq``) + ------- ----- --------------------- ------------------------------------------ + 10 80 ``l2tp.nr`` Next Sequence Number (optional by ``seq``) + ------- ----- --------------------- ------------------------------------------ + 12 96 ``l2tp.offset`` Offset Size (optional by ``offset``) + ======= ===== ===================== ========================================== + +.. [*] https://en.wikipedia.org/wiki/Layer_2_Tunneling_Protocol + +""" +from typing import TYPE_CHECKING + +from pcapkit.const.l2tp.type import Type as Enum_Type +from pcapkit.protocols.data.link.l2tp import L2TP as Data_L2TP +from pcapkit.protocols.data.link.l2tp import Flags as Data_Flags +from pcapkit.protocols.link.l2tp import L2TP +from pcapkit.protocols.schema.link.l2tp import L2TP as Schema_L2TP +from pcapkit.utilities.exceptions import UnsupportedCall + +if TYPE_CHECKING: + from enum import IntEnum as StdlibEnum + from typing import Any, NoReturn, Optional, Type + + from aenum import IntEnum as AenumEnum + from typing_extensions import Literal + + from pcapkit.protocols.protocol import ProtocolBase as Protocol + from pcapkit.protocols.schema.schema import Schema + +__all__ = ['L2TPv2'] + + +# NOTE: ``schema`` and ``data`` are stated here rather than inherited from +# :class:`~pcapkit.protocols.link.l2tp.L2TP`, which declares none: the base is +# version-agnostic and the header schema is not. They must be passed explicitly +# in any case, since +# :meth:`ProtocolBase.__init_subclass__ ` +# resolves an omitted schema by looking the *subclass name* up in +# :mod:`pcapkit.protocols.schema` -- there is no ``L2TPv2`` there -- and assigns +# unconditionally, so omitting them would silently bind ``Schema_Raw``/``Data_Raw``. +class L2TPv2(L2TP[Data_L2TP, Schema_L2TP], + schema=Schema_L2TP, data=Data_L2TP): + """This class implements Layer Two Tunnelling Protocol version 2. + + The protocol is dispatched from :attr:`UDP.__proto__ + ` at port 1701. + + Note: + IANA protocol number 115 (``L2TP``) is deliberately left unbound. It + references :rfc:`3931`, i.e. **L2TPv3**, whose session and control + message headers are a different shape -- so the binding waits on an + ``L2TPv3`` class rather than on this one. + + As with :class:`~pcapkit.protocols.link.ospf.OSPF`, the class subclasses + :class:`~pcapkit.protocols.link.link.Link` and so reports + ``layer == 'Link'`` even though it is carried inside UDP. That is a + pre-existing classification, kept because moving the module would change + its public import path. + + """ + + ########################################################################## + # Properties. + ########################################################################## + + @property + def name(self) -> 'Literal["Layer 2 Tunnelling Protocol version 2"]': + """Name of current protocol.""" + return 'Layer 2 Tunnelling Protocol version 2' + + @property + def alias(self) -> 'Literal["L2TPv2"]': + """Acronym of current protocol.""" + return 'L2TPv2' + + @property + def version(self) -> 'Literal[2]': + """Version of current protocol. + + Always ``2``: :rfc:`2661` fixes the version nibble, and a datagram + carrying any other value is a different protocol reached through a + different class. c.f. :class:`~pcapkit.protocols.link.l2tp.L2TP`. + + """ + return 2 + + @property + def length(self) -> 'int': + """Header length of current protocol.""" + return self._info.hdr_len + + @property + def type(self) -> 'Literal["control", "data"]': + """L2TP type.""" + return self._info.flags.type + + ########################################################################## + # Methods. + ########################################################################## + + def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_L2TP': # pylint: disable=unused-argument + """Read Layer Two Tunnelling Protocol. + + Structure of L2TP header [:rfc:`2661`]: + + .. code-block:: text + + 0 1 2 3 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + |T|L|x|x|S|x|O|P|x|x|x|x| Ver | Length (opt) | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Tunnel ID | Session ID | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Ns (opt) | Nr (opt) | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Offset Size (opt) | Offset pad... (opt) + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + + Args: + length: Length of packet data. + **kwargs: Arbitrary keyword arguments. + + Returns: + Parsed packet data. + + """ + schema = self.__header__ + _flag = schema.flags + + flags = Data_Flags( + type=Enum_Type(_flag['type']), + len=bool(_flag['len']), + seq=bool(_flag['seq']), + offset=bool(_flag['offset']), + prio=bool(_flag['prio']), + ) + + _size = schema.offset if flags.offset else 0 + hdr_len = 6 + 2 * (flags.len + 2 * flags.seq + flags.offset) + _size + + l2tp = Data_L2TP( + flags=flags, + version=_flag['version'], + length=schema.length if flags.len else None, + tunnelid=schema.tunnel_id, + sessionid=schema.session_id, + ns=schema.ns if flags.seq else None, + nr=schema.nr if flags.seq else None, + offset=_size if flags.offset else None, + ) + + l2tp.__update__([ + ('hdr_len', hdr_len), + ]) + if _size: + self._read_fileng(_size) + # l2tp['padding'] = self._read_fileng(_size) + + length = schema.length if flags.len else (length or len(self)) + # L2TP carries no next-protocol field -- the payload is a PPP frame, + # which pcapkit does not dissect -- so dispatch on the -1 sentinel, as + # ARP does, rather than on a code read off the wire. Passing the + # remaining length here (as this did) dispatched on it as if it were an + # EtherType, which resolved to Raw only because a length rarely collides + # with a registered one. + return self._decode_next_layer(l2tp, -1, length - hdr_len) + + def make(self, + version: 'Literal[2]' = 2, + type: 'Enum_Type | StdlibEnum | AenumEnum | str | int' = Enum_Type.Data, + 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, + priority: 'bool' = False, + length: 'Optional[int]' = None, + tunnel_id: 'int' = 0, + session_id: 'int' = 0, + ns: 'Optional[int]' = None, + nr: 'Optional[int]' = None, + offset: 'Optional[int]' = None, + payload: 'bytes | Protocol | Schema' = b'', + **kwargs: 'Any') -> 'Schema_L2TP': # pylint: disable=unused-argument + """Make (construct) packet data. + + Args: + version: L2TP version. + type: L2TP type. + type_default: Default value of type. + type_namespace: Namespace of type. + type_reversed: Reversed namespace of type. + priority: Priority flag. + length: Length of packet data. + tunnel_id: Tunnel ID. + session_id: Session ID. + ns: Sequence number. + nr: Acknowledgement number. + offset: Offset size. + payload: Payload data. + **kwargs: Arbitrary keyword arguments. + + Returns: + Constructed packet data. + + """ + type_ = self._make_index(type, type_default, namespace=type_namespace, + reversed=type_reversed, pack=False) + + return Schema_L2TP( + flags={ + 'type': type_, + 'len': length is not None, + 'seq': ns is not None and nr is not None, + 'offset': offset is not None, + 'prio': priority, + 'version': version, + }, + length=length, + tunnel_id=tunnel_id, + session_id=session_id, + ns=ns, + nr=nr, + offset=offset, + payload=payload, + ) + + ########################################################################## + # Methods. + ########################################################################## + + @classmethod + def id(cls) -> 'tuple[Literal["L2TP"], Literal["L2TPv2"]]': + """Index ID of the protocol. + + Returns: + Index ID of the protocol -- the canonical family name first, then + this version's own label, as + :meth:`HTTPv1.id ` + does. Element zero is what callers treat as canonical, so both + ``protocol='L2TP'`` and ``protocol='L2TPv2'`` select this class. + + """ + return ('L2TP', 'L2TPv2') + + ########################################################################## + # Data models. + ########################################################################## + + def __length_hint__(self) -> 'Literal[16]': + """Return an estimated length for the object.""" + return 16 + + @classmethod + def __index__(cls) -> 'NoReturn': # pylint: disable=invalid-index-returned + """Numeral registry index of the protocol. + + Raises: + UnsupportedCall: This protocol has no registry entry. + + Note: + L2TPv2 is reached through **UDP port 1701**, and a port is not an + ``__index__`` value anywhere in this package -- every non-raising + ``__index__`` returns a :class:`~pcapkit.const.reg.transtype.TransType`, + :class:`~pcapkit.const.reg.ethertype.EtherType` or + :class:`~pcapkit.const.reg.linktype.LinkType`, and + :meth:`Application.__index__ + ` + raises for exactly this reason. So there is no index to return here. + ``L2TPv3`` will be the first class in this family with one, IANA + protocol number 115. + + """ + raise UnsupportedCall(f'{cls.__name__!r} object cannot be interpreted as an integer') + + ########################################################################## + # Utilities. + ########################################################################## + + @classmethod + def _make_data(cls, data: 'Data_L2TP') -> 'dict[str, Any]': # type: ignore[override] + """Create key-value pairs from ``data`` for protocol construction. + + Args: + data: protocol data + + Returns: + Key-value pairs for protocol construction. + + """ + return { + 'type': data.flags.type, + 'prio': data.flags.prio, + 'version': data.version, + 'length': data.length, + 'tunnel_id': data.tunnelid, + 'session_id': data.sessionid, + 'ns': data.ns, + 'nr': data.nr, + 'offset': data.offset, + 'payload': cls._make_payload(data), + } diff --git a/pcapkit/protocols/link/link.py b/pcapkit/protocols/link/link.py index cea1e99754..0aa3735a1c 100644 --- a/pcapkit/protocols/link/link.py +++ b/pcapkit/protocols/link/link.py @@ -51,9 +51,9 @@ class Link(Protocol[_PT, _ST], Generic[_PT, _ST]): # pylint: disable=abstract-m * - :attr:`~pcapkit.const.reg.ethertype.EtherType.Reverse_Address_Resolution_Protocol` - :class:`pcapkit.protocols.link.rarp.RARP` * - :attr:`~pcapkit.const.reg.ethertype.EtherType.Customer_VLAN_Tag_Type` - - :class:`pcapkit.protocols.link.vlan.C_Tag` + - :class:`pcapkit.protocols.link.c_tag.C_Tag` * - :attr:`~pcapkit.const.reg.ethertype.EtherType.IEEE_Std_802_1Q_Service_VLAN_tag_identifier` - - :class:`pcapkit.protocols.link.vlan.S_Tag` + - :class:`pcapkit.protocols.link.s_tag.S_Tag` * - :attr:`~pcapkit.const.reg.ethertype.EtherType.Internet_Protocol_version_4` - :class:`pcapkit.protocols.internet.ipv4.IPv4` * - :attr:`~pcapkit.const.reg.ethertype.EtherType.Internet_Protocol_version_6` @@ -86,9 +86,9 @@ class Link(Protocol[_PT, _ST], Generic[_PT, _ST]): # pylint: disable=abstract-m # identical, and both share the code in # :class:`~pcapkit.protocols.link.vlan.VLAN`. Enum_EtherType.Customer_VLAN_Tag_Type: - ModuleDescriptor('pcapkit.protocols.link.vlan', 'C_Tag'), + ModuleDescriptor('pcapkit.protocols.link.c_tag', 'C_Tag'), Enum_EtherType.IEEE_Std_802_1Q_Service_VLAN_tag_identifier: - ModuleDescriptor('pcapkit.protocols.link.vlan', 'S_Tag'), + ModuleDescriptor('pcapkit.protocols.link.s_tag', 'S_Tag'), Enum_EtherType.Internet_Protocol_version_4: ModuleDescriptor('pcapkit.protocols.internet.ipv4', 'IPv4'), Enum_EtherType.Internet_Protocol_version_6: ModuleDescriptor('pcapkit.protocols.internet.ipv6', 'IPv6'), diff --git a/pcapkit/protocols/link/ospf.py b/pcapkit/protocols/link/ospf.py index 91ba9bcdd9..94671b5ee1 100644 --- a/pcapkit/protocols/link/ospf.py +++ b/pcapkit/protocols/link/ospf.py @@ -41,6 +41,7 @@ from pcapkit.const.ospf.authentication import Authentication as Enum_Authentication from pcapkit.const.ospf.packet import Packet as Enum_Packet +from pcapkit.const.reg.transtype import TransType as Enum_TransType from pcapkit.protocols.data.link.ospf import OSPF as Data_OSPF from pcapkit.protocols.data.link.ospf import \ CrytographicAuthentication as Data_CrytographicAuthentication @@ -48,12 +49,12 @@ from pcapkit.protocols.schema.link.ospf import OSPF as Schema_OSPF from pcapkit.protocols.schema.link.ospf import \ CrytographicAuthentication as Schema_CrytographicAuthentication -from pcapkit.utilities.exceptions import ProtocolError, UnsupportedCall +from pcapkit.utilities.exceptions import ProtocolError if TYPE_CHECKING: from enum import IntEnum as StdlibEnum from ipaddress import IPv4Address - from typing import Any, NoReturn, Optional, Type + from typing import Any, Optional, Type from aenum import IntEnum as AenumEnum from typing_extensions import Literal @@ -263,14 +264,23 @@ def __length_hint__(self) -> 'Literal[24]': return 24 @classmethod - def __index__(cls) -> 'NoReturn': # pylint: disable=invalid-index-returned + def __index__(cls) -> 'Enum_TransType': # pylint: disable=invalid-index-returned """Numeral registry index of the protocol. - Raises: - UnsupportedCall: This protocol has no registry entry. + Returns: + Numeral registry index of the protocol in `IANA`_. + + Note: + This raised :exc:`~pcapkit.utilities.exceptions.UnsupportedCall` + while the protocol was reachable from no registry at all. It is now + dispatched from :attr:`Internet.__proto__ + ` at + protocol number 89, so it has an index to report. + + .. _IANA: https://www.iana.org/assignments/protocol-numbers/protocol-numbers.xhtml """ - raise UnsupportedCall(f'{cls.__name__!r} object cannot be interpreted as an integer') + return Enum_TransType.OSPFIGP # type: ignore[return-value] ########################################################################## # Utilities. diff --git a/pcapkit/protocols/link/s_tag.py b/pcapkit/protocols/link/s_tag.py new file mode 100644 index 0000000000..1ebafafa3f --- /dev/null +++ b/pcapkit/protocols/link/s_tag.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +"""S_Tag - 802.1ad Service VLAN Tag Type +========================================= + +.. module:: pcapkit.protocols.link.s_tag + +:mod:`pcapkit.protocols.link.s_tag` contains +:class:`~pcapkit.protocols.link.s_tag.S_Tag` only, which implements extractor for +the 802.1ad Service VLAN Tag Type (S-Tag) [*]_, EtherType ``0x88A8``. Its +structure, and all of its parsing and construction, come from +:class:`~pcapkit.protocols.link.vlan.VLAN`; this class adds only the tag's own +identity and its registry index. + +It lives in a module of its own rather than beside +:class:`~pcapkit.protocols.link.c_tag.C_Tag` because the two are reached through +*different* registry indices -- ``0x88A8`` against ``0x8100`` -- which is the +project's rule for when protocols share a module. + +.. [*] https://en.wikipedia.org/wiki/IEEE_802.1ad + +""" +from typing import TYPE_CHECKING + +from pcapkit.const.reg.ethertype import EtherType as Enum_EtherType +from pcapkit.protocols.data.link.vlan import VLAN as Data_VLAN +from pcapkit.protocols.link.vlan import VLAN +from pcapkit.protocols.schema.link.vlan import VLAN as Schema_VLAN + +if TYPE_CHECKING: + from typing_extensions import Literal + +__all__ = ['S_Tag'] + + +# NOTE: ``schema`` and ``data`` are restated here for the reason given in +# :mod:`pcapkit.protocols.link.c_tag` -- they are resolved by subclass *name*, +# not inherited, so omitting them would silently bind the Raw pair. +class S_Tag(VLAN, schema=Schema_VLAN, data=Data_VLAN): + """This class implements 802.1ad Service VLAN Tag Type. + + Note: + 802.1ad was incorporated into IEEE 802.1Q-2011, so the service tag is + specified by 802.1Q today. The ``802.1ad`` name is kept because it is + what the provider-bridging tag is universally called, and because it is + the only thing distinguishing this class from + :class:`~pcapkit.protocols.link.c_tag.C_Tag` by name. + + """ + + ########################################################################## + # Properties. + ########################################################################## + + @property + def name(self) -> 'Literal["802.1ad Service VLAN Tag Type"]': + """Name of current protocol.""" + return '802.1ad Service VLAN Tag Type' + + @property + def alias(self) -> 'Literal["802.1ad"]': + """Acronym of corresponding protocol.""" + return '802.1ad' + + #: NOTE: c.f. :attr:`C_Tag.info_name ` + #: -- spelled out deliberately, since it is what keeps a stacked service tag + #: and customer tag apart in the parsed info dict. + @property + def info_name(self) -> 'Literal["s_tag"]': + """Key name of the :attr:`info` dict.""" + return 's_tag' + + ########################################################################## + # Methods. + ########################################################################## + + @classmethod + def id(cls) -> 'tuple[Literal["S_Tag"], Literal["VLAN"]]': # type: ignore[override] + """Index ID of the protocol. + + Returns: + Index ID of the protocol. ``VLAN`` is retained alongside the class's + own name so that selecting VLAN tags by that name matches the + service tag as well as the customer tag. + + """ + return ('S_Tag', 'VLAN') + + ########################################################################## + # Data models. + ########################################################################## + + @classmethod + def __index__(cls) -> 'Enum_EtherType': # pylint: disable=invalid-index-returned + """Numeral registry index of the protocol. + + Returns: + Numeral registry index of the protocol in `IANA`_. + + .. _IANA: https://www.iana.org/assignments/ieee-802-numbers/ieee-802-numbers.xhtml + + """ + return Enum_EtherType.IEEE_Std_802_1Q_Service_VLAN_tag_identifier # type: ignore[return-value] diff --git a/pcapkit/protocols/link/vlan.py b/pcapkit/protocols/link/vlan.py index 3be77e7a89..0058b110e4 100644 --- a/pcapkit/protocols/link/vlan.py +++ b/pcapkit/protocols/link/vlan.py @@ -5,11 +5,12 @@ .. module:: pcapkit.protocols.link.vlan :mod:`pcapkit.protocols.link.vlan` contains -:class:`~pcapkit.protocols.link.vlan.VLAN`, an abstract base class holding the -tag layout shared by every VLAN tag, and its two concrete subclasses -- -:class:`~pcapkit.protocols.link.vlan.C_Tag` for the 802.1Q customer tag [*]_ and -:class:`~pcapkit.protocols.link.vlan.S_Tag` for the 802.1ad service tag -- whose -structure is described as below: +:class:`~pcapkit.protocols.link.vlan.VLAN` only, an abstract base class holding +the tag layout shared by every VLAN tag [*]_. The two concrete tags live in +modules of their own, since they are reached through different registry indices +-- :class:`~pcapkit.protocols.link.c_tag.C_Tag` for the 802.1Q customer tag +(``0x8100``) and :class:`~pcapkit.protocols.link.s_tag.S_Tag` for the 802.1ad +service tag (``0x88A8``). The tag structure is described as below: ======= ========= ====================== ============================= Octets Bits Name Description @@ -37,6 +38,15 @@ would nest one ``c_tag`` inside another, leaving nothing in the output to say which of the two was the service tag. +Two distinct EtherTypes also means two distinct +:meth:`~pcapkit.protocols.protocol.ProtocolBase.__index__` values, which is the +project's rule for when protocols get separate modules: siblings that *share* an +index may share a module, as :class:`~pcapkit.protocols.link.arp.InARP` shares +:mod:`~pcapkit.protocols.link.arp` and +:class:`~pcapkit.protocols.link.rarp.DRARP` shares +:mod:`~pcapkit.protocols.link.rarp`. This base declares no index of its own -- +it is abstract and nothing dispatches to it -- so its ``__index__`` raises. + .. [*] https://en.wikipedia.org/wiki/IEEE_802.1Q """ @@ -62,7 +72,7 @@ from pcapkit.protocols.schema.link.vlan import TCIType from pcapkit.protocols.schema.schema import Schema -__all__ = ['VLAN', 'C_Tag', 'S_Tag'] +__all__ = ['VLAN'] class VLAN(Link[Data_VLAN, Schema_VLAN], # pylint: disable=abstract-method @@ -101,6 +111,20 @@ def protocol(self) -> 'Enum_EtherType': # Methods. ########################################################################## + @classmethod + def id(cls) -> 'tuple[Literal["VLAN"], Literal["C_Tag"], Literal["S_Tag"]]': + """Index ID of the protocol. + + Returns: + Index ID of the protocol -- the family name, then every tag in it, as + :meth:`HTTP.id ` does for + its own family. Note that unlike HTTP's versions, the two tags are + *distinct protocols* rather than flavours of one, so each is its own + canonical name; they carry ``VLAN`` only as a secondary alias. + + """ + return ('VLAN', 'C_Tag', 'S_Tag') + def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_VLAN': # pylint: disable=unused-argument """Read 802.1Q/802.1ad VLAN tag type. @@ -239,103 +263,3 @@ def _make_data(cls, data: 'Data_VLAN') -> 'dict[str, Any]': # type: ignore[over 'type': data.type, 'payload': cls._make_payload(data), } - - -# NOTE: Both concrete tags restate ``schema`` and ``data`` even though -# :class:`VLAN` already declares them. They are not inherited: -# :meth:`ProtocolBase.__init_subclass__ ` -# resolves an omitted schema by looking the *subclass name* up in -# :mod:`pcapkit.protocols.schema`, and assigns unconditionally -- so leaving them -# off would silently bind ``Schema_Raw``/``Data_Raw`` here rather than falling -# back to the base class's pair. - - -class C_Tag(VLAN, schema=Schema_VLAN, data=Data_VLAN): - """This class implements 802.1Q Customer VLAN Tag Type.""" - - ########################################################################## - # Properties. - ########################################################################## - - @property - def name(self) -> 'Literal["802.1Q Customer VLAN Tag Type"]': - """Name of current protocol.""" - return '802.1Q Customer VLAN Tag Type' - - @property - def alias(self) -> 'Literal["802.1Q"]': - """Acronym of corresponding protocol.""" - return '802.1Q' - - #: NOTE: This is what keeps a stacked service tag and customer tag apart in - #: the parsed info dict, so it is spelled out rather than left to the - #: class-name default -- a rename must not silently move the output key. - @property - def info_name(self) -> 'Literal["c_tag"]': - """Key name of the :attr:`info` dict.""" - return 'c_tag' - - ########################################################################## - # Methods. - ########################################################################## - - @classmethod - def id(cls) -> 'tuple[Literal["C_Tag"], Literal["VLAN"]]': - """Index ID of the protocol. - - Returns: - Index ID of the protocol. ``VLAN`` is retained alongside the class's - own name so that selecting the protocol by that name -- as - ``pcapkit.extract(..., protocol='VLAN')`` did when this class *was* - ``VLAN`` -- keeps matching. - - """ - return ('C_Tag', 'VLAN') - - -class S_Tag(VLAN, schema=Schema_VLAN, data=Data_VLAN): - """This class implements 802.1ad Service VLAN Tag Type. - - Note: - 802.1ad was incorporated into IEEE 802.1Q-2011, so the service tag is - specified by 802.1Q today. The ``802.1ad`` name is kept because it is - what the provider-bridging tag is universally called, and because it is - the only thing distinguishing this class from :class:`C_Tag` by name. - - """ - - ########################################################################## - # Properties. - ########################################################################## - - @property - def name(self) -> 'Literal["802.1ad Service VLAN Tag Type"]': - """Name of current protocol.""" - return '802.1ad Service VLAN Tag Type' - - @property - def alias(self) -> 'Literal["802.1ad"]': - """Acronym of corresponding protocol.""" - return '802.1ad' - - #: NOTE: c.f. :attr:`C_Tag.info_name` -- spelled out deliberately. - @property - def info_name(self) -> 'Literal["s_tag"]': - """Key name of the :attr:`info` dict.""" - return 's_tag' - - ########################################################################## - # Methods. - ########################################################################## - - @classmethod - def id(cls) -> 'tuple[Literal["S_Tag"], Literal["VLAN"]]': - """Index ID of the protocol. - - Returns: - Index ID of the protocol. ``VLAN`` is retained alongside the class's - own name so that selecting VLAN tags by that name matches the - service tag as well as the customer tag. - - """ - return ('S_Tag', 'VLAN') diff --git a/pcapkit/protocols/schema/link/vlan.py b/pcapkit/protocols/schema/link/vlan.py index e44ebac6ea..ea6f2bc6c3 100644 --- a/pcapkit/protocols/schema/link/vlan.py +++ b/pcapkit/protocols/schema/link/vlan.py @@ -4,8 +4,8 @@ The customer tag (802.1Q, TPID ``0x8100``) and the service tag (802.1ad, TPID ``0x88A8``) carry an identical layout, so -:class:`~pcapkit.protocols.link.vlan.C_Tag` and -:class:`~pcapkit.protocols.link.vlan.S_Tag` share the schema below. The TPID that +:class:`~pcapkit.protocols.link.c_tag.C_Tag` and +:class:`~pcapkit.protocols.link.s_tag.S_Tag` share the schema below. The TPID that told them apart belongs to the encapsulating header, not to the tag. """ diff --git a/pcapkit/protocols/transport/udp.py b/pcapkit/protocols/transport/udp.py index 8cd7963ea7..9f5fec7ff6 100644 --- a/pcapkit/protocols/transport/udp.py +++ b/pcapkit/protocols/transport/udp.py @@ -60,7 +60,7 @@ class UDP(Transport[Data_UDP, Schema_UDP], * - 80 - :class:`pcapkit.protocols.application.http.HTTP` * - 1701 - - :class:`pcapkit.protocols.link.l2tp.L2TP` + - :class:`pcapkit.protocols.link.l2tpv2.L2TPv2` * - 8080 - :class:`pcapkit.protocols.application.http.HTTP` @@ -102,11 +102,13 @@ class UDP(Transport[Data_UDP, Schema_UDP], 80: ModuleDescriptor('pcapkit.protocols.application.http', 'HTTP'), 8080: ModuleDescriptor('pcapkit.protocols.application.http', 'HTTP'), - # L2TPv2 (RFC 2661) is UDP-borne, and v2 is what the dissector - # implements. IANA protocol number 115 is deliberately *not* bound - # to it: that assignment references RFC 3931, i.e. L2TPv3 over IP, - # whose session header is a different shape. - 1701: ModuleDescriptor('pcapkit.protocols.link.l2tp', 'L2TP'), + # L2TPv2 (RFC 2661) is UDP-borne, and v2 is the only version the + # package implements, so the concrete class is bound rather than the + # abstract L2TP base. IANA protocol number 115 stays unbound because + # it is L2TPv3 (RFC 3931), which has no class yet -- when it does, it + # takes 115 and this entry becomes a version switch on the Ver + # nibble. c.f. pcapkit.protocols.link.l2tp. + 1701: ModuleDescriptor('pcapkit.protocols.link.l2tpv2', 'L2TPv2'), }, ) diff --git a/tests/protocols/link/test_link_unit.py b/tests/protocols/link/test_link_unit.py index ab6f7ec639..49ea3f60fa 100644 --- a/tests/protocols/link/test_link_unit.py +++ b/tests/protocols/link/test_link_unit.py @@ -21,18 +21,41 @@ class LinkProtocolUnitTests(unittest.TestCase): def setUp(self) -> None: purge_modules(['pcapkit']) - def test_vlan_index_is_unsupported(self) -> None: - from pcapkit.protocols.link.vlan import VLAN, C_Tag, S_Tag + def test_vlan_indices_follow_the_one_module_per_index_rule(self) -> None: + """Each tag declares the EtherType it is reached by; the base declares none. + + The two indices differing is *why* the tags live in separate modules -- + the project's rule is that a shared index may share a module (as + ``InARP`` shares ``ARP``'s), and a distinct one may not. + + """ + from pcapkit.const.reg.ethertype import EtherType + from pcapkit.protocols.link.c_tag import C_Tag + from pcapkit.protocols.link.s_tag import S_Tag + from pcapkit.protocols.link.vlan import VLAN from pcapkit.utilities.exceptions import UnsupportedCall - for klass in (VLAN, C_Tag, S_Tag): - with self.subTest(protocol=klass.__name__): - with self.assertRaises(UnsupportedCall): - klass.__index__() + self.assertEqual(C_Tag.__index__(), EtherType.Customer_VLAN_Tag_Type) + self.assertEqual(C_Tag.__index__(), 0x8100) + self.assertEqual(S_Tag.__index__(), + EtherType.IEEE_Std_802_1Q_Service_VLAN_tag_identifier) + self.assertEqual(S_Tag.__index__(), 0x88A8) + self.assertNotEqual(C_Tag.__index__(), S_Tag.__index__()) + + # The abstract base is reached by nothing, so it has no index. + with self.assertRaises(UnsupportedCall): + VLAN.__index__() + + # ... and each tag is in a module of its own, keyed on that difference. + self.assertEqual(C_Tag.__module__, 'pcapkit.protocols.link.c_tag') + self.assertEqual(S_Tag.__module__, 'pcapkit.protocols.link.s_tag') + self.assertEqual(VLAN.__module__, 'pcapkit.protocols.link.vlan') def test_vlan_base_is_abstract_and_tags_are_concrete(self) -> None: from pcapkit.protocols.data.link.vlan import VLAN as DataVLAN - from pcapkit.protocols.link.vlan import VLAN, C_Tag, S_Tag + from pcapkit.protocols.link.c_tag import C_Tag + from pcapkit.protocols.link.s_tag import S_Tag + from pcapkit.protocols.link.vlan import VLAN from pcapkit.protocols.schema.link.vlan import VLAN as SchemaVLAN # The base leaves ``name`` to its subclasses, which is what makes it @@ -52,7 +75,8 @@ def test_vlan_base_is_abstract_and_tags_are_concrete(self) -> None: self.assertIs(klass.__data__, DataVLAN) def test_vlan_tags_keep_distinct_names_and_share_vlan_id(self) -> None: - from pcapkit.protocols.link.vlan import C_Tag, S_Tag + from pcapkit.protocols.link.c_tag import C_Tag + from pcapkit.protocols.link.s_tag import S_Tag c_tag = object.__new__(C_Tag) s_tag = object.__new__(S_Tag) @@ -69,9 +93,17 @@ def test_vlan_tags_keep_distinct_names_and_share_vlan_id(self) -> None: # service tag and customer tag apart in the parsed info dict. self.assertNotEqual(c_tag.info_name, s_tag.info_name) - # Both still answer to 'VLAN' for protocol selection. + # Both still answer to 'VLAN' for protocol selection, with their own + # name canonical -- element zero is what callers treat as canonical. self.assertEqual(C_Tag.id(), ('C_Tag', 'VLAN')) self.assertEqual(S_Tag.id(), ('S_Tag', 'VLAN')) + self.assertEqual(C_Tag.id()[0], 'C_Tag') + self.assertEqual(S_Tag.id()[0], 'S_Tag') + + def test_vlan_base_claims_its_family_in_id(self) -> None: + from pcapkit.protocols.link.vlan import VLAN + + self.assertEqual(VLAN.id(), ('VLAN', 'C_Tag', 'S_Tag')) def test_vlan_tags_are_registered_at_their_own_ethertypes(self) -> None: from pcapkit.const.reg.ethertype import EtherType @@ -118,7 +150,8 @@ def test_rarp_ids_and_index_are_stable(self) -> None: self.assertEqual(RARP.__index__(), EtherType.Reverse_Address_Resolution_Protocol) def test_vlan_length_hint_is_stable(self) -> None: - from pcapkit.protocols.link.vlan import C_Tag, S_Tag + from pcapkit.protocols.link.c_tag import C_Tag + from pcapkit.protocols.link.s_tag import S_Tag for klass in (C_Tag, S_Tag): with self.subTest(protocol=klass.__name__): @@ -249,8 +282,41 @@ class DummyData(dict): self.assertEqual(values['type'], EtherType.Internet_Protocol_version_6) self.assertIn('payload', values) + def test_l2tp_base_is_abstract_and_carries_no_header(self) -> None: + """The base holds the family, not a header shape. + + The versions do not share one: all that is common is the version nibble + in the first 16-bit word. So the base defines no ``read``/``make`` and + binds no schema, exactly as ``internet.ip.IP`` does for its family. + + """ + from pcapkit.protocols.link.l2tp import L2TP + from pcapkit.protocols.link.l2tpv2 import L2TPv2 + from pcapkit.protocols.schema.link.l2tp import L2TP as SchemaL2TP + + self.assertEqual(L2TP.__abstractmethods__, + frozenset({'name', 'read', 'make', 'length'})) + with self.assertRaises(TypeError): + object.__new__(L2TP) + + self.assertTrue(issubclass(L2TPv2, L2TP)) + self.assertEqual(L2TPv2.__abstractmethods__, frozenset()) + # v2 binds the RFC 2661 schema; the version-agnostic base binds none of + # its own, so this must be stated on the subclass, not inherited. + self.assertIs(L2TPv2.__schema__, SchemaL2TP) + + # Canonical family name first, then this version's label -- the shape + # ``httpv1.HTTP.id`` uses, so ``protocol='L2TP'`` still selects v2. + self.assertEqual(L2TP.id(), ('L2TP', 'L2TPv2')) + self.assertEqual(L2TPv2.id(), ('L2TP', 'L2TPv2')) + self.assertEqual(L2TPv2.id()[0], 'L2TP') + + self.assertEqual(L2TP.__module__, 'pcapkit.protocols.link.l2tp') + self.assertEqual(L2TPv2.__module__, 'pcapkit.protocols.link.l2tpv2') + def test_l2tp_index_is_unsupported_and_make_data_preserves_flags(self) -> None: from pcapkit.protocols.link.l2tp import L2TP + from pcapkit.protocols.link.l2tpv2 import L2TPv2 from pcapkit.utilities.exceptions import UnsupportedCall data = DummyData( @@ -264,12 +330,17 @@ def test_l2tp_index_is_unsupported_and_make_data_preserves_flags(self) -> None: offset=0, __next_type__=None, ) - proto = object.__new__(L2TP) + proto = object.__new__(L2TPv2) - with self.assertRaises(UnsupportedCall): - L2TP.__index__() + # Neither the abstract base nor v2 has a numeral registry index: v2 is + # reached by UDP port 1701, and a port is not an ``__index__`` value + # anywhere in this package. L2TPv3 will be the first with one (115). + for klass in (L2TP, L2TPv2): + with self.subTest(protocol=klass.__name__): + with self.assertRaises(UnsupportedCall): + klass.__index__() self.assertEqual(proto.__length_hint__(), 16) - values = L2TP._make_data(data) + values = L2TPv2._make_data(data) self.assertEqual(values['type'], True) self.assertEqual(values['prio'], False) self.assertEqual(values['version'], 2) @@ -277,10 +348,10 @@ def test_l2tp_index_is_unsupported_and_make_data_preserves_flags(self) -> None: self.assertEqual(values['session_id'], 4) self.assertIn('payload', values) - def test_ospf_index_is_unsupported_and_make_data_preserves_header(self) -> None: + def test_ospf_index_is_its_transtype_and_make_data_preserves_header(self) -> None: from pcapkit.const.ospf.packet import Packet + from pcapkit.const.reg.transtype import TransType from pcapkit.protocols.link.ospf import OSPF - from pcapkit.utilities.exceptions import UnsupportedCall data = DummyData( version=2, @@ -294,8 +365,10 @@ def test_ospf_index_is_unsupported_and_make_data_preserves_header(self) -> None: ) proto = object.__new__(OSPF) - with self.assertRaises(UnsupportedCall): - OSPF.__index__() + # This raised UnsupportedCall while OSPF was reachable from no registry + # at all; it is now dispatched from Internet.__proto__ at 89. + self.assertEqual(OSPF.__index__(), TransType.OSPFIGP) + self.assertEqual(OSPF.__index__(), 89) self.assertEqual(proto.__length_hint__(), 24) values = OSPF._make_data(data) self.assertEqual(values['version'], 2) @@ -521,20 +594,25 @@ def read_operation(oper: int) -> tuple[str, str]: def test_l2tp_properties_read_and_make_variants(self) -> None: from pcapkit.const.l2tp.type import Type - from pcapkit.protocols.link.l2tp import L2TP + from pcapkit.protocols.link.l2tpv2 import L2TPv2 from pcapkit.protocols.schema.link.l2tp import L2TP as SchemaL2TP - l2tp = object.__new__(L2TP) + l2tp = object.__new__(L2TPv2) l2tp._info = types.SimpleNamespace( hdr_len=12, flags=types.SimpleNamespace(type=Type.Data), ) - self.assertEqual(l2tp.name, 'Layer 2 Tunnelling Protocol') + self.assertEqual(l2tp.name, 'Layer 2 Tunnelling Protocol version 2') + self.assertEqual(l2tp.alias, 'L2TPv2') + self.assertEqual(l2tp.version, 2) + # info_name comes from the abstract base and is version-independent, so + # a consumer finds the datagram under ``l2tp`` whichever version it was. + self.assertEqual(l2tp.info_name, 'l2tp') self.assertEqual(l2tp.length, 12) self.assertEqual(l2tp.type, Type.Data) - reader = object.__new__(L2TP) + reader = object.__new__(L2TPv2) reader.__header__ = SchemaL2TP( flags={'type': Type.Control, 'len': True, 'seq': True, 'offset': True, 'prio': True, 'version': 2}, @@ -555,7 +633,7 @@ def test_l2tp_properties_read_and_make_variants(self) -> None: self.assertEqual(data.hdr_len, 16) reader._read_fileng.assert_called_once_with(2) - reader_no_flags = object.__new__(L2TP) + reader_no_flags = object.__new__(L2TPv2) reader_no_flags.__cached__ = {} reader_no_flags._data = b'\x00' * 14 reader_no_flags.__header__ = SchemaL2TP( @@ -576,7 +654,7 @@ def test_l2tp_properties_read_and_make_variants(self) -> None: self.assertIsNone(data_no_flags.offset) self.assertEqual(data_no_flags.hdr_len, 6) - maker = object.__new__(L2TP) + maker = object.__new__(L2TPv2) schema = maker.make( type=Type.Control, priority=True, @@ -597,7 +675,7 @@ def test_l2tp_properties_read_and_make_variants(self) -> None: def test_vlan_properties_read_and_make_variants(self) -> None: from pcapkit.const.reg.ethertype import EtherType from pcapkit.const.vlan.priority_level import PriorityLevel - from pcapkit.protocols.link.vlan import C_Tag + from pcapkit.protocols.link.c_tag import C_Tag from pcapkit.protocols.schema.link.vlan import TCI as SchemaTCI from pcapkit.protocols.schema.link.vlan import VLAN as SchemaVLAN @@ -660,7 +738,8 @@ def test_vlan_dei_is_read_from_the_dei_bit_not_the_pcp(self) -> None: """ from pcapkit.const.reg.ethertype import EtherType from pcapkit.const.vlan.priority_level import PriorityLevel - from pcapkit.protocols.link.vlan import C_Tag, S_Tag + from pcapkit.protocols.link.c_tag import C_Tag + from pcapkit.protocols.link.s_tag import S_Tag from pcapkit.protocols.schema.link.vlan import VLAN as SchemaVLAN cases = ( diff --git a/tests/protocols/test_dispatch_bindings_unit.py b/tests/protocols/test_dispatch_bindings_unit.py index 7845f239b7..836397adf1 100644 --- a/tests/protocols/test_dispatch_bindings_unit.py +++ b/tests/protocols/test_dispatch_bindings_unit.py @@ -212,7 +212,9 @@ def test_l2tp_parses_when_dispatched_from_udp_port_1701(self) -> None: 0x0800, ipv4(17, udp(1701, 1701, l2tp_data())), ))[0] - self.assertEqual(str(frame.protochain), 'Ethernet:IPv4:UDP:L2TP:Raw') + # The alias reports the version; the info key deliberately does not, so + # a consumer finds the datagram under ``l2tp`` whichever version it was. + self.assertEqual(str(frame.protochain), 'Ethernet:IPv4:UDP:L2TPv2:Raw') l2tp = frame.info.to_dict()['ethernet']['ipv4']['udp']['l2tp'] self.assertEqual(l2tp['version'], 2) self.assertEqual(l2tp['tunnelid'], 0x1234) @@ -223,18 +225,22 @@ def test_l2tp_parses_when_dispatched_from_udp_port_1701(self) -> None: # PPP is not dissected, so the payload is Raw under the -1 sentinel. self.assertEqual(l2tp['raw']['protocol'], -1) - def test_l2tp_over_ip_is_deliberately_not_bound(self) -> None: - """``TransType.L2TP`` (115) stays unbound: it is L2TPv3, not v2. + def test_l2tp_over_ip_waits_on_an_l2tpv3_class(self) -> None: + """``TransType.L2TP`` (115) stays unbound: it is L2TPv3, and v3 has no class. - :rfc:`3931` gives protocol number 115 a different session header from - the :rfc:`2661` framing this dissector implements, so binding it would - hand the parser the wrong shape. + :rfc:`3931` gives protocol number 115 a different session header from the + :rfc:`2661` framing :class:`~pcapkit.protocols.link.l2tpv2.L2TPv2` + implements, so the binding waits on an ``L2TPv3`` class rather than on a + framing decision. 115 is also the first index anything in this family + would carry, which is why v3 gets a module of its own when written. """ from pcapkit.const.reg.transtype import TransType from pcapkit.protocols.internet.internet import Internet self.assertNotIn(TransType.L2TP, Internet.__proto__) + self.assertFalse(hasattr( + __import__('pcapkit.protocols.link', fromlist=['link']), 'L2TPv3')) ########################################################################## # Port bindings. From c8b2f68226672f273ed90d54faef4bb6341b156d Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Thu, 17 Sep 2026 16:45:24 -0400 Subject: [PATCH 5/5] protocols: centre the new module headings on their rules, align the Q-in-Q illustration Review feedback on #436, all cosmetic. * The module-docstring heading rule now runs three characters past the end of the title in ``vlan.py``, ``c_tag.py``, ``s_tag.py`` and ``l2tpv2.py``. Since the title starts at source column 3, after the ``\"\"\"``, that leaves three rule characters either side of it -- the title centred on the rule, which is what 172 of the package's 189 module headings already do, ``arp.py`` and ``rarp.py`` among them. ``l2tp.py`` already matched, having kept its original rule. * The Q-in-Q illustration in ``vlan.rst`` puts every ``=`` in one column, and the ``<-`` annotations in another. * Drops the note on ``OSPF.__index__`` recording that it used to raise. The behaviour stays -- it returns ``TransType.OSPFIGP``, since OSPF is dispatched from ``Internet.__proto__`` at 89 -- and the docstring now has the same shape as ``ARP.__index__``. The two ``read`` comments explaining the ``-1`` sentinel are left alone: those are source comments where the history *is* the explanation, not published API documentation where it is noise. No behaviour change. All 45 capture output files byte-identical to origin/main, ``make_samples.py`` regenerates byte-identically, suite 915 passed / 18 skipped unchanged, mypy 128 errors in 41 files unchanged, pylint unchanged. --- docs/source/pcapkit/protocols/link/vlan.rst | 8 ++++---- pcapkit/protocols/link/c_tag.py | 2 +- pcapkit/protocols/link/l2tpv2.py | 2 +- pcapkit/protocols/link/ospf.py | 7 ------- pcapkit/protocols/link/s_tag.py | 2 +- pcapkit/protocols/link/vlan.py | 2 +- 6 files changed, 8 insertions(+), 15 deletions(-) diff --git a/docs/source/pcapkit/protocols/link/vlan.rst b/docs/source/pcapkit/protocols/link/vlan.rst index 6b3e00092c..73660f61b7 100644 --- a/docs/source/pcapkit/protocols/link/vlan.rst +++ b/docs/source/pcapkit/protocols/link/vlan.rst @@ -44,10 +44,10 @@ tags appear in one frame: .. code-block:: text - ethernet.type = 0x88A8 - ethernet.s_tag.tci.vid = 100 <- service tag, 802.1ad - ethernet.s_tag.type = 0x8100 - ethernet.s_tag.c_tag.tci.vid = 200 <- customer tag, 802.1Q + ethernet.type = 0x88A8 + ethernet.s_tag.tci.vid = 100 <- service tag, 802.1ad + ethernet.s_tag.type = 0x8100 + ethernet.s_tag.c_tag.tci.vid = 200 <- customer tag, 802.1Q ethernet.s_tag.c_tag.type = 0x0800 :attr:`~pcapkit.protocols.protocol.ProtocolBase.info_name` -- ``s_tag`` against diff --git a/pcapkit/protocols/link/c_tag.py b/pcapkit/protocols/link/c_tag.py index 42fb23f339..52df3db8dc 100644 --- a/pcapkit/protocols/link/c_tag.py +++ b/pcapkit/protocols/link/c_tag.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """C_Tag - 802.1Q Customer VLAN Tag Type -========================================== +=========================================== .. module:: pcapkit.protocols.link.c_tag diff --git a/pcapkit/protocols/link/l2tpv2.py b/pcapkit/protocols/link/l2tpv2.py index fe5e67ef61..13c23762fe 100644 --- a/pcapkit/protocols/link/l2tpv2.py +++ b/pcapkit/protocols/link/l2tpv2.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """L2TPv2 - Layer Two Tunnelling Protocol version 2 -===================================================== +====================================================== .. module:: pcapkit.protocols.link.l2tpv2 diff --git a/pcapkit/protocols/link/ospf.py b/pcapkit/protocols/link/ospf.py index 94671b5ee1..a70d6afedb 100644 --- a/pcapkit/protocols/link/ospf.py +++ b/pcapkit/protocols/link/ospf.py @@ -270,13 +270,6 @@ def __index__(cls) -> 'Enum_TransType': # pylint: disable=invalid-index-returne Returns: Numeral registry index of the protocol in `IANA`_. - Note: - This raised :exc:`~pcapkit.utilities.exceptions.UnsupportedCall` - while the protocol was reachable from no registry at all. It is now - dispatched from :attr:`Internet.__proto__ - ` at - protocol number 89, so it has an index to report. - .. _IANA: https://www.iana.org/assignments/protocol-numbers/protocol-numbers.xhtml """ diff --git a/pcapkit/protocols/link/s_tag.py b/pcapkit/protocols/link/s_tag.py index 1ebafafa3f..efe0f48c23 100644 --- a/pcapkit/protocols/link/s_tag.py +++ b/pcapkit/protocols/link/s_tag.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """S_Tag - 802.1ad Service VLAN Tag Type -========================================= +=========================================== .. module:: pcapkit.protocols.link.s_tag diff --git a/pcapkit/protocols/link/vlan.py b/pcapkit/protocols/link/vlan.py index 0058b110e4..477dcee8fd 100644 --- a/pcapkit/protocols/link/vlan.py +++ b/pcapkit/protocols/link/vlan.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """VLAN - 802.1Q/802.1ad VLAN Tag Types -========================================= +========================================== .. module:: pcapkit.protocols.link.vlan