diff --git a/docs/source/ext.rst b/docs/source/ext.rst index f74b7357f1..1eee18895a 100644 --- a/docs/source/ext.rst +++ b/docs/source/ext.rst @@ -30,11 +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` | +| | | :class:`pcapkit.protocols.link.vlan.VLAN` | ++ + +-----------------------+-------------------------------------------------------------+ +| | VLAN Family | :class:`pcapkit.protocols.link.c_tag.C_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 9499763ccd..1b212f057e 100644 --- a/docs/source/pcapkit/protocols/index.rst +++ b/docs/source/pcapkit/protocols/index.rst @@ -38,6 +38,14 @@ diagram of the class hierarchy of :mod:`pcapkit.protocols`: RARP --> DRARP end end + + subgraph vlan [VLAN Family] + VLAN --> C_Tag & S_Tag + end + + subgraph l2tp [L2TP Family] + L2TP --> L2TPv2 + end end subgraph internet [Internet Layer] @@ -100,8 +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/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 130a21218e..73660f61b7 100644 --- a/docs/source/pcapkit/protocols/link/vlan.rst +++ b/docs/source/pcapkit/protocols/link/vlan.rst @@ -1,13 +1,24 @@ -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` 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 @@ -19,16 +30,49 @@ 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, 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 +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. + +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: - .. autoproperty:: name - .. autoproperty:: alias - .. autoproperty:: info_name .. autoproperty:: length .. autoproperty:: protocol + .. automethod:: id .. automethod:: read .. automethod:: make @@ -41,6 +85,8 @@ 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 8951f9d546..3483479d0d 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.c_tag.C_Tag` and +:class:`~pcapkit.protocols.link.s_tag.S_Tag` add only how each names itself. .. note:: @@ -214,14 +218,17 @@ 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. +* **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__ @@ -230,23 +237,69 @@ 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.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 + 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.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 + 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, 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 + 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..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', 'DRARP', 'Ethernet', 'InARP', 'L2TP', 'OSPF', 'RARP', '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 2e440fbb1b..c2a0f815e0 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', 'L2TPv2', + '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..0bd2f9fac0 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.c_tag.C_Tag` and +:class:`~pcapkit.protocols.link.s_tag.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..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 + +# 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', 'DRARP', 'Ethernet', 'InARP', 'L2TP', - 'OSPF', 'RARP', 'VLAN', + '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..52df3db8dc --- /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 be37c12197..ffc1601a22 100644 --- a/pcapkit/protocols/link/l2tp.py +++ b/pcapkit/protocols/link/l2tp.py @@ -5,229 +5,135 @@ .. 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.""" +class L2TP(Link[_PT, _ST], Generic[_PT, _ST]): # pylint: disable=abstract-method + """This class implements all protocols in L2TP family. + + - Layer Two Tunnelling Protocol version 2 + (:class:`~pcapkit.protocols.link.l2tpv2.L2TPv2`) [:rfc:`2661`] + + 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. + + """ ########################################################################## # 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)) - return self._decode_next_layer(l2tp, 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. @@ -235,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..13c23762fe --- /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 e5201c3e6c..0aa3735a1c 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.c_tag.C_Tag` + * - :attr:`~pcapkit.const.reg.ethertype.EtherType.IEEE_Std_802_1Q_Service_VLAN_tag_identifier` + - :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` @@ -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.c_tag', 'C_Tag'), + Enum_EtherType.IEEE_Std_802_1Q_Service_VLAN_tag_identifier: + 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 b3c1c739aa..a70d6afedb 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 @@ -69,7 +70,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 +106,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 +158,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 +184,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, @@ -227,14 +264,16 @@ 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`_. + + .. _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..efe0f48c23 --- /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 c0ebf711e2..477dcee8fd 100644 --- a/pcapkit/protocols/link/vlan.py +++ b/pcapkit/protocols/link/vlan.py @@ -1,14 +1,16 @@ # -*- 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` 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 @@ -20,6 +22,31 @@ 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. + +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 """ @@ -48,28 +75,27 @@ __all__ = ['VLAN'] -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]': @@ -85,10 +111,24 @@ 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 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 +158,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, diff --git a/pcapkit/protocols/schema/link/vlan.py b/pcapkit/protocols/schema/link/vlan.py index 71ffdaf3c6..ea6f2bc6c3 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.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. + +""" 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..9f5fec7ff6 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.l2tpv2.L2TPv2` + * - 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,27 @@ 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 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 0ae776cf73..49ea3f60fa 100644 --- a/tests/protocols/link/test_link_unit.py +++ b/tests/protocols/link/test_link_unit.py @@ -21,13 +21,106 @@ class LinkProtocolUnitTests(unittest.TestCase): def setUp(self) -> None: purge_modules(['pcapkit']) - def test_vlan_index_is_unsupported(self) -> None: + 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 + 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.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 + # 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.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) + + 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, 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 + 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 from pcapkit.protocols.link.ethernet import Ethernet @@ -57,11 +150,12 @@ 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 - - proto = object.__new__(VLAN) + from pcapkit.protocols.link.c_tag import C_Tag + from pcapkit.protocols.link.s_tag import S_Tag - 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 +261,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__ @@ -186,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( @@ -201,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) - - with self.assertRaises(UnsupportedCall): - L2TP.__index__() + proto = object.__new__(L2TPv2) + + # 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) @@ -214,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, @@ -231,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) @@ -458,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}, @@ -484,7 +625,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) @@ -492,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( @@ -506,14 +647,14 @@ 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) 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, @@ -534,11 +675,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.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 - 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 +688,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 +703,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 +729,45 @@ 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.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 = ( + # 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 +780,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 +791,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 +802,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 +824,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..836397adf1 --- /dev/null +++ b/tests/protocols/test_dispatch_bindings_unit.py @@ -0,0 +1,328 @@ +# -*- 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] + + # 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) + 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_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 :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. + ########################################################################## + + 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()