From a8b5c0ec7d395c2763f98ac6a6b477be21da44de Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Sat, 31 Oct 2026 16:08:58 -0400 Subject: [PATCH] feat(link): add a FRAD dissector for Frame Relay over LinuxSLL (#2460) Part of #2460's five arphrd_type follow-ups: LinuxSLL/LinuxSLL2 fell back to Raw for ARPHRD_FRAD, the narrowest of the five, since no Frame Relay dissector existed. - Add pcapkit.protocols.link.frad.FRAD, parsing the Q.922 address, control field and NLPID of RFC 2427's multiprotocol encapsulation (obsoleting RFC 1490), both the direct-NLPID and SNAP-encapsulated forms. - Wire LinuxSLL1/LinuxSLL2 to dispatch ARPHRD_FRAD to it instead of Raw. - Direct NLPID 0xCC/0x8E dispatch onward to IPv4/IPv6; a SNAP header with OUI 00-00-00 dispatches by its PID through the EtherType registry Ethernet/VLAN/LinuxSLL already share (reaching ARP, among others). Every other NLPID, and a SNAP header naming a bridged medium, stays Raw. - Add tests/protocols/link/test_frad_unit.py; move ARPHRD_FRAD out of the LinuxSLL "kept raw" sweep into its own dispatch test; add two FRAD cases to the short-read round-trip sweep. - Register FRAD in every export registry it was missing from: pcapkit, pcapkit.all, pcapkit.protocols, and the data/schema registries under pcapkit.protocols.{data,schema}[.link]. - Add NOT_A_COPY entries to the #1739 constant-sweep test for FRAD's bit offsets and field bounds that coincidentally match homed RFC constants. Build: mypy and pylint clean; FRAD, LinuxSLL, export-registry, #1739 sweep, and protocol-convention test suites all pass. --- CHANGELOG.md | 2 + docs/source/changelog/1.6.0.rst | 13 + docs/source/pcapkit/protocols/link/frad.rst | 71 ++++ docs/source/pcapkit/protocols/link/index.rst | 1 + pcapkit/__init__.py | 2 +- pcapkit/all.py | 2 +- pcapkit/protocols/__init__.py | 2 +- pcapkit/protocols/data/__init__.py | 3 + pcapkit/protocols/data/link/__init__.py | 6 + pcapkit/protocols/data/link/frad.py | 45 ++ pcapkit/protocols/link/__init__.py | 3 +- pcapkit/protocols/link/frad.py | 388 ++++++++++++++++++ pcapkit/protocols/link/link.py | 1 + pcapkit/protocols/link/linux_sll.py | 17 +- pcapkit/protocols/schema/__init__.py | 1 + pcapkit/protocols/schema/link/__init__.py | 2 + pcapkit/protocols/schema/link/frad.py | 111 +++++ .../test_rfc_constants_one_home_1739_unit.py | 12 + tests/protocols/link/test_frad_unit.py | 261 ++++++++++++ tests/protocols/link/test_linux_sll1_unit.py | 28 +- tests/protocols/link/test_linux_sll2_unit.py | 25 +- .../test_short_read_roundtrip_unit.py | 5 + 22 files changed, 987 insertions(+), 14 deletions(-) create mode 100644 docs/source/pcapkit/protocols/link/frad.rst create mode 100644 pcapkit/protocols/data/link/frad.py create mode 100644 pcapkit/protocols/link/frad.py create mode 100644 pcapkit/protocols/schema/link/frad.py create mode 100644 tests/protocols/link/test_frad_unit.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e9a616c60e..8dace3030f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ Wave 2 of the roadmap, per the contributing docs' enhancement-proposal page: the protocol pairs that wanted a shared abstract base, and the two that simply had no dissector at all. +- **Added** -- `FRAD`, a dissector for Frame Relay's multiprotocol encapsulation ([RFC 2427](https://datatracker.ietf.org/doc/html/rfc2427), obsoleting [RFC 1490](https://datatracker.ietf.org/doc/html/rfc1490)). `LinuxSLL`/ `LinuxSLL1`/ `LinuxSLL2` dispatch to it for the `ARPHRD_FRAD` case instead of falling back to `Raw`; the direct NLPID and SNAP-encapsulated forms both dispatch onward, to IPv4/IPv6 and (via the EtherType registry every Ethernet/VLAN/LinuxSLL frame shares) ARP among others. The first of [#2460](https://github.com/JarryShaw/PyPCAPKit/issues/2460)'s five `arphrd_type` follow-ups to get a dissector; the other four (`NETLINK`, `IPGRE`/`IP6GRE`, `IEEE80211_RADIOTAP`) remain `Raw`. + --- Full changelog: diff --git a/docs/source/changelog/1.6.0.rst b/docs/source/changelog/1.6.0.rst index 4800343b1f..e8b637dfbd 100644 --- a/docs/source/changelog/1.6.0.rst +++ b/docs/source/changelog/1.6.0.rst @@ -4,3 +4,16 @@ Wave 2 of the roadmap, per the contributing docs' enhancement-proposal page: the protocol pairs that wanted a shared abstract base, and the two that simply had no dissector at all. + +* **Added** -- :class:`~pcapkit.protocols.link.frad.FRAD`, a dissector for Frame + Relay's multiprotocol encapsulation (:rfc:`2427`, obsoleting :rfc:`1490`). + :class:`~pcapkit.protocols.link.linux_sll.LinuxSLL`/ + :class:`~pcapkit.protocols.link.linux_sll1.LinuxSLL1`/ + :class:`~pcapkit.protocols.link.linux_sll2.LinuxSLL2` dispatch to it for the + ``ARPHRD_FRAD`` case instead of falling back to + :class:`~pcapkit.protocols.misc.raw.Raw`; the direct NLPID and SNAP-encapsulated + forms both dispatch onward, to IPv4/IPv6 and (via the EtherType registry every + Ethernet/VLAN/LinuxSLL frame shares) ARP among others. The first of :issue:`2460`'s + five ``arphrd_type`` follow-ups to get a dissector; the other four + (``NETLINK``, ``IPGRE``/``IP6GRE``, ``IEEE80211_RADIOTAP``) remain + :class:`~pcapkit.protocols.misc.raw.Raw`. diff --git a/docs/source/pcapkit/protocols/link/frad.rst b/docs/source/pcapkit/protocols/link/frad.rst new file mode 100644 index 0000000000..efd9ea2cd4 --- /dev/null +++ b/docs/source/pcapkit/protocols/link/frad.rst @@ -0,0 +1,71 @@ +FRAD - Frame Relay Access Device +================================= + +.. module:: pcapkit.protocols.link.frad + +:mod:`pcapkit.protocols.link.frad` contains +:class:`~pcapkit.protocols.link.frad.FRAD` +only, which implements the extractor for Frame Relay's multiprotocol +encapsulation [*]_, as seen on a ``LINKTYPE_LINUX_SLL``/``LINKTYPE_LINUX_SLL2`` +capture whose ``arphrd_type`` is ``ARPHRD_FRAD``. Its structure, the Q.922 +LAPF address onward with no trailing FCS, is described as below: + +.. table:: + + ====== ==== ================= ========================================== + Octets Bits Name Description + ====== ==== ================= ========================================== + 0 0 ``frad.addr`` Q.922 Address (2-octet form) + 2 16 ``frad.control`` Control (``0x03``, UI, for data traffic) + 3 24 ``frad.nlpid`` NLPID, or the ``0x00`` pad ahead of a SNAP + header + ====== ==== ================= ========================================== + +When ``nlpid`` is the ``0x00`` pad marker, a further NLPID octet (``0x80`` +for SNAP), a 3-octet OUI and a 2-octet PID follow, kept as ``frad.ext_nlpid``, +``frad.oui`` and ``frad.pid``. + +.. autoclass:: pcapkit.protocols.link.frad.FRAD + :no-members: + :show-inheritance: + + .. autoproperty:: name + .. autoproperty:: alias + .. autoproperty:: length + .. autoproperty:: protocol + + .. automethod:: id + + .. automethod:: read + .. automethod:: make + + .. automethod:: __length_hint__ + .. automethod:: __index__ + + .. automethod:: _make_data + .. automethod:: _import_next_layer + + .. autoattribute:: __proto__ + :no-value: + +Header Schemas +-------------- + +.. module:: pcapkit.protocols.schema.link.frad + +.. autoclass:: pcapkit.protocols.schema.link.frad.FRAD + :members: + :show-inheritance: + +Data Models +----------- + +.. module:: pcapkit.protocols.data.link.frad + +.. autoclass:: pcapkit.protocols.data.link.frad.FRAD + :members: + :show-inheritance: + +.. rubric:: Footnotes + +.. [*] :rfc:`2427` (obsoleting :rfc:`1490`) diff --git a/docs/source/pcapkit/protocols/link/index.rst b/docs/source/pcapkit/protocols/link/index.rst index f0b47982e2..93fa5d01c8 100644 --- a/docs/source/pcapkit/protocols/link/index.rst +++ b/docs/source/pcapkit/protocols/link/index.rst @@ -24,6 +24,7 @@ link layer, with detailed implementation and methods. link ethernet arp + frad l2tp l2tpv2 linux_sll diff --git a/pcapkit/__init__.py b/pcapkit/__init__.py index b60c1e8e61..f870672581 100644 --- a/pcapkit/__init__.py +++ b/pcapkit/__init__.py @@ -111,7 +111,7 @@ 'NoPayload', # No Payload 'Raw', # Raw Packet - 'ARP', 'C_Tag', 'Ethernet', 'InARP', 'L2TP', # Link Layer + 'ARP', 'C_Tag', 'Ethernet', 'FRAD', 'InARP', 'L2TP', # Link Layer 'L2TPv2', 'LinuxSLL', 'LinuxSLL1', 'LinuxSLL2', 'Loopback', 'S_Tag', 'VLAN', 'AH', 'ESP', 'IP', 'IPsec', 'IPv4', 'IPv6', 'IPX', # Internet Layer diff --git a/pcapkit/all.py b/pcapkit/all.py index a5a782c8b3..f64a8c7640 100644 --- a/pcapkit/all.py +++ b/pcapkit/all.py @@ -119,7 +119,7 @@ 'PCAPNG', # PCAPNG Format 'NoPayload', # No Payload 'Raw', # Raw Packet - 'ARP', 'C_Tag', 'Ethernet', 'InARP', 'L2TP', 'L2TPv2', 'LinuxSLL', 'LinuxSLL1', + 'ARP', 'C_Tag', 'Ethernet', 'FRAD', 'InARP', 'L2TP', 'L2TPv2', 'LinuxSLL', 'LinuxSLL1', 'LinuxSLL2', 'Loopback', 'S_Tag', 'VLAN', # Link Layer 'AH', 'ESP', 'IP', 'IPsec', 'IPv4', 'IPv6', 'IPX', # Internet Layer 'HIP', 'HOPOPT', 'IPv6_Ext', 'IPv6_Frag', 'IPv6_Opts', 'IPv6_Route', 'MH', diff --git a/pcapkit/protocols/__init__.py b/pcapkit/protocols/__init__.py index 225b11131b..b2561aaf31 100644 --- a/pcapkit/protocols/__init__.py +++ b/pcapkit/protocols/__init__.py @@ -50,7 +50,7 @@ 'Raw', # Link Layer - 'ARP', 'C_Tag', 'Ethernet', 'InARP', 'L2TP', 'L2TPv2', 'LinuxSLL', 'LinuxSLL1', + 'ARP', 'C_Tag', 'Ethernet', 'FRAD', 'InARP', 'L2TP', 'L2TPv2', 'LinuxSLL', 'LinuxSLL1', 'LinuxSLL2', 'Loopback', 'S_Tag', 'VLAN', # Internet Layer diff --git a/pcapkit/protocols/data/__init__.py b/pcapkit/protocols/data/__init__.py index 4b3b0f06fa..a11414e247 100644 --- a/pcapkit/protocols/data/__init__.py +++ b/pcapkit/protocols/data/__init__.py @@ -62,6 +62,9 @@ # Ethernet Protocol 'Ethernet', + # Frame Relay Access Device + 'FRAD', + # Layer Two Tunnelling Protocol 'L2TP', 'L2TP_Flags', diff --git a/pcapkit/protocols/data/link/__init__.py b/pcapkit/protocols/data/link/__init__.py index cae293e6cc..e846d8d6aa 100644 --- a/pcapkit/protocols/data/link/__init__.py +++ b/pcapkit/protocols/data/link/__init__.py @@ -9,6 +9,9 @@ # Ethernet Protocol from pcapkit.protocols.data.link.ethernet import Ethernet +# Frame Relay Access Device +from pcapkit.protocols.data.link.frad import FRAD + # Layer Two Tunnelling Protocol from pcapkit.protocols.data.link.l2tp import L2TP from pcapkit.protocols.data.link.l2tp import Flags as L2TP_Flags @@ -31,6 +34,9 @@ # Ethernet Protocol 'Ethernet', + # Frame Relay Access Device + 'FRAD', + # Layer Two Tunnelling Protocol 'L2TP', 'L2TP_Flags', diff --git a/pcapkit/protocols/data/link/frad.py b/pcapkit/protocols/data/link/frad.py new file mode 100644 index 0000000000..6af1092de4 --- /dev/null +++ b/pcapkit/protocols/data/link/frad.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +"""data models for Frame Relay Access Device (FRAD) frames""" + +from typing import TYPE_CHECKING + +from pcapkit.corekit.infoclass import info_final +from pcapkit.protocols.data.protocol import Protocol + +if TYPE_CHECKING: + from typing import Optional + +__all__ = ['FRAD'] + + +@info_final +class FRAD(Protocol): + """Data model for a Frame Relay Access Device (FRAD) frame.""" + + #: Data Link Connection Identifier, from the Q.922 address field. + dlci: 'int' + #: Command/response indication bit. + cr: 'int' + #: Forward explicit congestion notification. + fecn: 'int' + #: Backward explicit congestion notification. + becn: 'int' + #: Discard eligibility. + de: 'int' + #: Control field; ``0x03`` (UI) for ordinary data traffic. + control: 'int' + #: NLPID selecting the encapsulated protocol, with any ``0x00`` pad + #: already resolved to the real value that followed it. + nlpid: 'int' + #: SNAP OUI, present only when :attr:`nlpid` is ``0x80``. + snap_oui: 'Optional[bytes]' + #: SNAP PID, present only when :attr:`nlpid` is ``0x80``. + snap_pid: 'Optional[int]' + #: Header length, in octets -- 4 for a direct NLPID, 5 with the pad byte, + #: or 10 with the SNAP header besides. + len: 'int' + + if TYPE_CHECKING: + def __init__(self, dlci: 'int', cr: 'int', fecn: 'int', becn: 'int', de: 'int', # pylint: disable=unused-argument,super-init-not-called + control: 'int', nlpid: 'int', snap_oui: 'Optional[bytes]', # pylint: disable=unused-argument + snap_pid: 'Optional[int]', len: 'int') -> 'None': ... # pylint: disable=unused-argument,redefined-builtin diff --git a/pcapkit/protocols/link/__init__.py b/pcapkit/protocols/link/__init__.py index 0a35e6a5db..9d1ef08ac2 100644 --- a/pcapkit/protocols/link/__init__.py +++ b/pcapkit/protocols/link/__init__.py @@ -17,6 +17,7 @@ # Link Layer Protocols from pcapkit.protocols.link.arp import ARP, InARP from pcapkit.protocols.link.ethernet import Ethernet +from pcapkit.protocols.link.frad import FRAD from pcapkit.protocols.link.loopback import Loopback # Linux "Cooked" Capture Encapsulation Family @@ -41,6 +42,6 @@ 'LINKTYPE', # Link Layer Protocols - 'ARP', 'C_Tag', 'Ethernet', 'InARP', 'L2TP', 'L2TPv2', 'LinuxSLL', 'LinuxSLL1', + 'ARP', 'C_Tag', 'Ethernet', 'FRAD', 'InARP', 'L2TP', 'L2TPv2', 'LinuxSLL', 'LinuxSLL1', 'LinuxSLL2', 'Loopback', 'S_Tag', 'VLAN', ] diff --git a/pcapkit/protocols/link/frad.py b/pcapkit/protocols/link/frad.py new file mode 100644 index 0000000000..b3f0707044 --- /dev/null +++ b/pcapkit/protocols/link/frad.py @@ -0,0 +1,388 @@ +# -*- coding: utf-8 -*- +"""FRAD - Frame Relay Access Device +====================================== + +.. module:: pcapkit.protocols.link.frad + +:mod:`pcapkit.protocols.link.frad` contains +:class:`~pcapkit.protocols.link.frad.FRAD` only, which implements the +extractor for Frame Relay's multiprotocol encapsulation [*]_, as seen on a +``LINKTYPE_LINUX_SLL``/``LINKTYPE_LINUX_SLL2`` capture whose ``arphrd_type`` +is ``ARPHRD_FRAD`` -- :class:`~pcapkit.protocols.link.linux_sll.LinuxSLL` +dispatches here instead of falling back to +:class:`~pcapkit.protocols.misc.raw.Raw` for that one case; see +:data:`~pcapkit.protocols.link.linux_sll._ARPHRD_NOT_ETHERTYPE`. Its +structure is the Q.922 LAPF address onward, with no trailing FCS: a 2-octet +``frad.addr``, a 1-octet ``frad.control`` (``0x03``, UI, for ordinary data +traffic), then a 1-octet ``frad.nlpid`` -- or, when SNAP-encapsulated, the +``0x00`` pad RFC 2427 writes in its place, followed by the real NLPID +(``0x80``) and a 5-octet SNAP header (OUI and PID). See +:meth:`FRAD.read ` for the full byte +diagram and :mod:`pcapkit.protocols.schema.link.frad` for the field-by-field +schema. + +.. [*] :rfc:`2427` (obsoleting :rfc:`1490`) + +.. versionadded:: 1.6.0 + +""" +import collections +from typing import TYPE_CHECKING + +from pcapkit.corekit.decorators import beholder +from pcapkit.corekit.module import ModuleDescriptor +from pcapkit.protocols.data.link.frad import FRAD as Data_FRAD +from pcapkit.protocols.link.link import Link +from pcapkit.protocols.schema.link.frad import FRAD as Schema_FRAD +from pcapkit.utilities.exceptions import ProtocolError, UnsupportedCall + +if TYPE_CHECKING: + from typing import Any, DefaultDict, NoReturn, Optional, Type + + from typing_extensions import Literal + + from pcapkit.protocols.protocol import ProtocolBase + from pcapkit.protocols.schema.schema import Schema + +__all__ = ['FRAD'] + + +class FRAD(Link[Data_FRAD, Schema_FRAD], + schema=Schema_FRAD, data=Data_FRAD): + """This class implements Frame Relay Access Device (FRAD) frames. + + A frame whose NLPID (after resolving the ``0x00`` pad RFC 2427 writes + ahead of a SNAP header) is ``0xCC`` or ``0x8E`` dispatches to + :class:`~pcapkit.protocols.internet.ipv4.IPv4` or + :class:`~pcapkit.protocols.internet.ipv6.IPv6` respectively, registered + in the :attr:`self.__proto__ ` + attribute. A SNAP header whose OUI is ``00-00-00`` carries an EtherType + in its PID, resolved instead through the family registry every + Ethernet/VLAN/LinuxSLL frame shares, + :attr:`Link.__proto__ ` -- + which is how an ARP frame (PID ``0x0806``) over Frame Relay is reached. + Every other NLPID (Q.933 signalling, ISO CLNP/ES-IS/IS-IS, FRF.9/FRF.12 + compression and fragmentation, PPP-in-Frame-Relay, and a SNAP header + whose OUI names a bridged medium rather than an EtherType) is kept as + :class:`~pcapkit.protocols.misc.raw.Raw`, exactly as every ``arphrd_type`` + this module's docstring lists already was before this class existed. + + """ + + ########################################################################## + # Defaults. + ########################################################################## + + #: Protocol index mapping for decoding next layer, keyed by the resolved + #: NLPID rather than by EtherType, so this class keeps a registry of its + #: own instead of sharing :attr:`Link.__proto__ + #: ` -- the same reason + #: :class:`~pcapkit.protocols.link.loopback.Loopback` keeps one of its own. + __proto__ = collections.defaultdict( + lambda: ModuleDescriptor('pcapkit.protocols.misc.raw', 'Raw'), + { + 0xCC: ModuleDescriptor('pcapkit.protocols.internet.ipv4', 'IPv4'), + 0x8E: ModuleDescriptor('pcapkit.protocols.internet.ipv6', 'IPv6'), + }, + ) # type: DefaultDict[int, ModuleDescriptor[ProtocolBase] | Type[ProtocolBase]] + + ########################################################################## + # Properties. + ########################################################################## + + @property + def name(self) -> 'Literal["Frame Relay Access Device"]': + """Name of current protocol.""" + return 'Frame Relay Access Device' + + @property + def alias(self) -> 'Literal["FRAD"]': + """Acronym of corresponding protocol.""" + return 'FRAD' + + @property + def length(self) -> 'int': + """Header length of current protocol.""" + return self._info.len + + @property + def protocol(self) -> 'int': # type: ignore[override] + """NLPID of next layer protocol.""" + return self._info.nlpid + + ########################################################################## + # Methods. + ########################################################################## + + @classmethod + def id(cls) -> 'tuple[Literal["FRAD"]]': + """Index ID of the protocol.""" + return ('FRAD',) + + def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_FRAD': # pylint: disable=unused-argument + """Read Frame Relay Access Device (FRAD) frame. + + Structure of a direct-NLPID ``FRAD`` frame, the common case: + + .. 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 + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | DLCI (high) |C/R|EA0| DLCI (low) |FECN|BECN|DE|EA1| + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Control | NLPID | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + + A SNAP-encapsulated frame inserts a ``0x00`` pad and a ``0x80`` NLPID + in place of the single NLPID octet above, followed by a 3-octet OUI + and a 2-octet PID (:rfc:`2427#section-4.1`). + + Args: + length: Length of packet data. + **kwargs: Arbitrary keyword arguments. + + Returns: + Parsed packet data. + + """ + if length is None: + length = len(self) + schema = self.__header__ + + addr = schema.addr + _dlci = (addr['dlci_hi'] << 4) | addr['dlci_lo'] + + _raw_nlpid = schema.nlpid + if not _raw_nlpid: + _ext = schema.ext_nlpid + _nlpid = _ext if isinstance(_ext, int) else 0 + _hdr_len = 5 + else: + _nlpid = _raw_nlpid + _hdr_len = 4 + + _snap_oui = None # type: Optional[bytes] + _snap_pid = None # type: Optional[int] + self._snap_ethertype = None # type: Optional[int] # pylint: disable=attribute-defined-outside-init + if _nlpid == 0x80: + _snap_oui = schema.oui + _snap_pid = schema.pid + _hdr_len += 5 + if _snap_oui == b'\x00\x00\x00' and isinstance(_snap_pid, int): + self._snap_ethertype = _snap_pid # pylint: disable=attribute-defined-outside-init + + frad = Data_FRAD( + dlci=_dlci, + cr=addr['cr'], + fecn=addr['fecn'], + becn=addr['becn'], + de=addr['de'], + control=schema.control, + nlpid=_nlpid, + snap_oui=_snap_oui, + snap_pid=_snap_pid, + len=_hdr_len, + ) + return self._decode_next_layer(frad, _nlpid, length - _hdr_len) + + def make(self, + dlci: 'int' = 0, + cr: 'int' = 0, + fecn: 'int' = 0, + becn: 'int' = 0, + de: 'int' = 0, + control: 'int' = 0x03, + nlpid: 'int' = 0xCC, + snap_oui: 'Optional[bytes]' = None, + snap_pid: 'Optional[int]' = None, + payload: 'bytes | ProtocolBase | Schema' = b'', + **kwargs: 'Any') -> 'Schema_FRAD': + """Make (construct) packet data. + + Args: + dlci: Data Link Connection Identifier, 10 bits (``0`` to ``1023``). + cr: Command/response indication bit. + fecn: Forward explicit congestion notification bit. + becn: Backward explicit congestion notification bit. + de: Discard eligibility bit. + control: Control field; ``0x03`` (UI) for ordinary data traffic. + nlpid: NLPID selecting the encapsulated protocol; ``0xCC`` for + IPv4, ``0x8E`` for IPv6, ``0x80`` for a SNAP header. + snap_oui: SNAP OUI; required, and only meaningful, when ``nlpid`` + is ``0x80``. + snap_pid: SNAP PID; required, and only meaningful, when ``nlpid`` + is ``0x80``. + payload: Payload data. + **kwargs: Arbitrary keyword arguments. + + Returns: + Constructed packet data. + + Raises: + ProtocolError: If ``dlci`` does not fit in 10 bits; if ``cr``, + ``fecn``, ``becn`` or ``de`` is not ``0`` or ``1``; if + ``control`` or ``nlpid`` does not fit in 8 bits; if ``nlpid`` + is ``0x80`` and ``snap_oui`` is not exactly 3 octets or + ``snap_pid`` does not fit in 16 bits; or if either + ``snap_oui`` or ``snap_pid`` is given while ``nlpid`` is not + ``0x80``. + + Note: + ``nlpid`` is not refused for being ``0`` -- the SNAP pad marker, + never a value of its own on the wire -- even though no caller + building a frame from scratch should pass it: :meth:`from_data + ` replays a capture + that ended inside the header through this same method, and a + capture cut short of the ``nlpid`` octet reads as ``0`` (zero + rather than missing). Rejecting it here would make a short + capture fail to rebuild, the one invariant every protocol in + this package keeps (:issue:`1458`). + + """ + if not isinstance(dlci, int) or isinstance(dlci, bool) or not 0 <= dlci <= 0x3FF: + raise ProtocolError(f'{self.alias}: invalid DLCI: {dlci!r}') + for name, bit in (('cr', cr), ('fecn', fecn), ('becn', becn), ('de', de)): + if bit not in (0, 1): + raise ProtocolError(f'{self.alias}: invalid {name}: {bit!r}, must be 0 or 1') + if not isinstance(control, int) or isinstance(control, bool) or not 0 <= control <= 0xFF: + raise ProtocolError(f'{self.alias}: invalid control: {control!r}') + if not isinstance(nlpid, int) or isinstance(nlpid, bool) or not 0 <= nlpid <= 0xFF: + raise ProtocolError(f'{self.alias}: invalid nlpid: {nlpid!r}') + + if nlpid == 0x80: + if not isinstance(snap_oui, (bytes, bytearray)) or len(snap_oui) != 3: + raise ProtocolError(f'{self.alias}: invalid snap_oui: {snap_oui!r}, ' + f'must be exactly 3 octets when nlpid is 0x80') + if (not isinstance(snap_pid, int) or isinstance(snap_pid, bool) + or not 0 <= snap_pid <= 0xFFFF): + raise ProtocolError(f'{self.alias}: invalid snap_pid: {snap_pid!r}, ' + f'must fit in 16 bits when nlpid is 0x80') + _schema_nlpid, _ext_nlpid = 0, 0x80 + _oui, _pid = bytes(snap_oui), snap_pid + else: + if snap_oui is not None or snap_pid is not None: + raise ProtocolError(f'{self.alias}: snap_oui and snap_pid are only meaningful ' + f'when nlpid is 0x80, not {nlpid!r}') + _schema_nlpid, _ext_nlpid = nlpid, None + _oui, _pid = None, None + + return Schema_FRAD( + addr={ + 'dlci_hi': dlci >> 4, + 'cr': cr, + 'ea1': 0, + 'dlci_lo': dlci & 0xF, + 'fecn': fecn, + 'becn': becn, + 'de': de, + 'ea2': 1, + }, + control=control, + nlpid=_schema_nlpid, + ext_nlpid=_ext_nlpid, + oui=_oui, + pid=_pid, + payload=payload, + ) + + ########################################################################## + # Data models. + ########################################################################## + + def __length_hint__(self) -> 'Literal[10]': + """Return an estimated length for the object. + + The longest form -- a SNAP-encapsulated header, 10 octets -- per the + reason :meth:`L2TPv2.__length_hint__ + ` gives for a + variable-length header: the shortest form would reject a fully + captured longer header as too short. + + """ + return 10 + + @classmethod + def __index__(cls) -> 'NoReturn': # pylint: disable=invalid-index-returned + """Numeral registry index of the protocol. + + Raises: + UnsupportedCall: This protocol is reached only through + :class:`~pcapkit.protocols.link.linux_sll.LinuxSLL`'s + ``arphrd_type`` dispatch, not through a link-layer header + type of its own. + + """ + raise UnsupportedCall(f'{cls.__name__!r} object cannot be interpreted as an integer') + + ########################################################################## + # Utilities. + ########################################################################## + + @classmethod + def _make_data(cls, data: 'Data_FRAD') -> '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 { + 'dlci': data.dlci, + 'cr': data.cr, + 'fecn': data.fecn, + 'becn': data.becn, + 'de': data.de, + 'control': data.control, + 'nlpid': data.nlpid, + 'snap_oui': data.snap_oui, + 'snap_pid': data.snap_pid, + 'payload': cls._make_payload(data), + } + + @beholder # type: ignore[arg-type] + def _import_next_layer(self, proto: 'int', length: 'Optional[int]' = None, *, # type: ignore[misc] + packet: 'Optional[dict[str, Any]]' = None) -> 'ProtocolBase': + """Import next layer extractor. + + Arguments: + proto: next layer protocol index -- the resolved NLPID, unused + when a SNAP EtherType was recorded by :meth:`read`. + length: valid (*non-padding*) length + packet: packet info (passed from :meth:`self.unpack + `) + + Returns: + Instance of next layer. A SNAP header whose OUI is ``00-00-00`` + is dispatched through :attr:`Link.__proto__ + ` instead of + :attr:`self.__proto__ `, + keyed by the PID as an EtherType -- see :meth:`read`, which + records it as ``self._snap_ethertype``. + + """ + ethertype = getattr(self, '_snap_ethertype', None) + if ethertype is None: + return super()._import_next_layer(proto, length, packet=packet) # type: ignore[misc,call-arg] + + file_ = self._get_payload() + if length is None: + length = len(file_) + + if TYPE_CHECKING: + protocol: 'Type[ProtocolBase]' + if not min(length, len(file_)): + from pcapkit.protocols.misc.null import \ + NoPayload as protocol # isort: skip # pylint: disable=import-outside-toplevel + elif self._sigterm: + from pcapkit.protocols.misc.raw import \ + Raw as protocol # isort: skip # pylint: disable=import-outside-toplevel + else: + protocol = self._lookup_next_layer(Link.__proto__, ethertype) + + return self._parse_next_layer(protocol, file_, length, alias=ethertype, packet=packet, + layer=self._exlayer, protocol=self._exproto, + __context__=self._exctx) diff --git a/pcapkit/protocols/link/link.py b/pcapkit/protocols/link/link.py index 2132ac5ce6..8a932d0d7f 100644 --- a/pcapkit/protocols/link/link.py +++ b/pcapkit/protocols/link/link.py @@ -10,6 +10,7 @@ which is a base class for link layer protocols, e.g. :class:`~pcapkit.protocols.link.arp.ARP`/:class:`~pcapkit.protocols.link.arp.InARP`, :class:`~pcapkit.protocols.link.ethernet.Ethernet`, +:class:`~pcapkit.protocols.link.frad.FRAD`, :class:`~pcapkit.protocols.link.l2tp.L2TP`, :class:`~pcapkit.protocols.link.loopback.Loopback` and :class:`~pcapkit.protocols.link.vlan.VLAN`. diff --git a/pcapkit/protocols/link/linux_sll.py b/pcapkit/protocols/link/linux_sll.py index bc5209d8fc..3b938e0cad 100644 --- a/pcapkit/protocols/link/linux_sll.py +++ b/pcapkit/protocols/link/linux_sll.py @@ -150,8 +150,10 @@ def _import_next_layer(self, proto: 'int', length: 'Optional[int]' = None, *, # `) Returns: - Instance of next layer: :class:`~pcapkit.protocols.misc.raw.Raw`, - recording ``proto``, when ``arphrd_type`` is in + Instance of next layer: :class:`~pcapkit.protocols.link.frad.FRAD` + when ``arphrd_type`` is :attr:`ARPHRD.FRAD `; + otherwise :class:`~pcapkit.protocols.misc.raw.Raw`, recording + ``proto``, for every other ``arphrd_type`` in :data:`_ARPHRD_NOT_ETHERTYPE`. """ @@ -159,7 +161,16 @@ def _import_next_layer(self, proto: 'int', length: 'Optional[int]' = None, *, # if length is None: length = len(file_) arphrd_type = int(self.__header__.arphrd_type) # type: ignore[attr-defined] - if not min(length, len(file_)) or arphrd_type not in _ARPHRD_NOT_ETHERTYPE: + if not min(length, len(file_)): + return super()._import_next_layer(proto, length, packet=packet) # type: ignore[misc,call-arg] + + if arphrd_type == ARPHRD.FRAD: + from pcapkit.protocols.link.frad import FRAD # pylint: disable=import-outside-toplevel + return self._parse_next_layer(FRAD, file_, length, alias=proto, packet=packet, + layer=self._exlayer, protocol=self._exproto, + __context__=self._exctx) + + if arphrd_type not in _ARPHRD_NOT_ETHERTYPE: return super()._import_next_layer(proto, length, packet=packet) # type: ignore[misc,call-arg] from pcapkit.protocols.misc.raw import Raw # pylint: disable=import-outside-toplevel # isort: skip diff --git a/pcapkit/protocols/schema/__init__.py b/pcapkit/protocols/schema/__init__.py index f10675e583..478075a31f 100644 --- a/pcapkit/protocols/schema/__init__.py +++ b/pcapkit/protocols/schema/__init__.py @@ -27,6 +27,7 @@ # Link Layer Protocols 'ARP', 'Ethernet', + 'FRAD', 'L2TP', 'LinuxSLL1', 'LinuxSLL2', 'Loopback', diff --git a/pcapkit/protocols/schema/link/__init__.py b/pcapkit/protocols/schema/link/__init__.py index b9eae9ffab..c48b6f6e87 100644 --- a/pcapkit/protocols/schema/link/__init__.py +++ b/pcapkit/protocols/schema/link/__init__.py @@ -3,6 +3,7 @@ from pcapkit.protocols.schema.link.arp import ARP from pcapkit.protocols.schema.link.ethernet import Ethernet +from pcapkit.protocols.schema.link.frad import FRAD from pcapkit.protocols.schema.link.l2tp import L2TP from pcapkit.protocols.schema.link.linux_sll1 import LinuxSLL1 from pcapkit.protocols.schema.link.linux_sll2 import LinuxSLL2 @@ -13,6 +14,7 @@ __all__ = [ 'ARP', 'Ethernet', + 'FRAD', 'L2TP', 'LinuxSLL1', 'LinuxSLL2', 'Loopback', diff --git a/pcapkit/protocols/schema/link/frad.py b/pcapkit/protocols/schema/link/frad.py new file mode 100644 index 0000000000..b70b033eef --- /dev/null +++ b/pcapkit/protocols/schema/link/frad.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +# mypy: disable-error-code=assignment +"""header schema for Frame Relay Access Device (FRAD) frames""" + +from typing import TYPE_CHECKING, cast + +from pcapkit.corekit.fields.misc import ConditionalField, PayloadField +from pcapkit.corekit.fields.numbers import UInt8Field, UInt16Field +from pcapkit.corekit.fields.strings import BitField, BytesField +from pcapkit.protocols.schema.schema import Schema, schema_final +from pcapkit.utilities.logging import SPHINX_TYPE_CHECKING + +__all__ = ['FRAD'] + +if TYPE_CHECKING: + from typing import Any, Optional + + from pcapkit.protocols.protocol import ProtocolBase + +if SPHINX_TYPE_CHECKING: # pragma: no cover + from typing_extensions import TypedDict + + class AddressType(TypedDict): + """Type of the Q.922 address field (2-octet form).""" + + #: DLCI, high-order 6 bits. + dlci_hi: int + #: Command/response indication. + cr: int + #: Address field extension, octet 1 -- ``0`` in the 2-octet form. + ea1: int + #: DLCI, low-order 4 bits. + dlci_lo: int + #: Forward explicit congestion notification. + fecn: int + #: Backward explicit congestion notification. + becn: int + #: Discard eligibility. + de: int + #: Address field extension, octet 2 -- ``1`` in the 2-octet form. + ea2: int + + +def _effective_nlpid(packet: 'dict[str, Any]') -> 'int': + """Resolve the NLPID that actually selects the encapsulated protocol. + + Args: + packet: Packet data, built incrementally by :meth:`Schema.unpack + ` or supplied whole + by :meth:`FRAD.make `. + + Returns: + :attr:`~FRAD.ext_nlpid` when :attr:`~FRAD.nlpid` is the ``0x00`` pad + marker :rfc:`2427#section-3` writes ahead of a SNAP header; otherwise + :attr:`~FRAD.nlpid` itself. + + """ + nlpid = packet['nlpid'] + if nlpid: + return cast('int', nlpid) + ext = packet.get('ext_nlpid') + return ext if isinstance(ext, int) else 0 + + +def _is_snap(packet: 'dict[str, Any]') -> 'bool': + """Whether the frame is SNAP-encapsulated, per :func:`_effective_nlpid`.""" + return _effective_nlpid(packet) == 0x80 + + +@schema_final +class FRAD(Schema): + """Header schema for a Frame Relay Access Device (FRAD) frame. + + The frame is Frame Relay's multiprotocol encapsulation [:rfc:`2427`, + obsoleting :rfc:`1490`] as presented to a ``LINKTYPE_LINUX_SLL``/ + ``LINKTYPE_LINUX_SLL2`` capture whose ``arphrd_type`` is ``ARPHRD_FRAD``: + the Q.922 LAPF address onward, with no trailing FCS. + + """ + + #: Q.922 address field (2-octet form only; see :rfc:`2427#section-3`). + addr: 'AddressType' = BitField(length=2, namespace={ + 'dlci_hi': (0, 6), + 'cr': (6, 1), + 'ea1': (7, 1), + 'dlci_lo': (8, 4), + 'fecn': (12, 1), + 'becn': (13, 1), + 'de': (14, 1), + 'ea2': (15, 1), + }) + #: Control field; ``0x03`` (UI) for ordinary data traffic. + control: 'int' = UInt8Field() + #: NLPID, or the ``0x00`` pad marker ahead of a SNAP header. + nlpid: 'int' = UInt8Field() + #: NLPID proper, present only when :attr:`nlpid` is the pad marker. + ext_nlpid: 'Optional[int]' = ConditionalField( + UInt8Field(), + lambda packet: not packet['nlpid'], + ) + #: SNAP OUI, present only when the frame is SNAP-encapsulated. + oui: 'Optional[bytes]' = ConditionalField(BytesField(length=3), _is_snap) + #: SNAP PID, present only when the frame is SNAP-encapsulated. + pid: 'Optional[int]' = ConditionalField(UInt16Field(), _is_snap) + #: Payload. + payload: 'bytes' = PayloadField(length=lambda pkt: pkt['__length__']) + + if TYPE_CHECKING: + def __init__(self, addr: 'AddressType', control: 'int', nlpid: 'int', # pylint: disable=unused-argument,super-init-not-called + ext_nlpid: 'Optional[int]', oui: 'Optional[bytes]', pid: 'Optional[int]', # pylint: disable=unused-argument + payload: 'bytes | ProtocolBase | Schema') -> 'None': ... # pylint: disable=unused-argument,multiple-statements diff --git a/tests/project/test_rfc_constants_one_home_1739_unit.py b/tests/project/test_rfc_constants_one_home_1739_unit.py index 73c50ff8da..34f77ac5a2 100644 --- a/tests/project/test_rfc_constants_one_home_1739_unit.py +++ b/tests/project/test_rfc_constants_one_home_1739_unit.py @@ -214,6 +214,15 @@ ('pcapkit/protocols/schema/application/dtls.py', "{'epoch': (6, 2)}"): "the EE bits of a DTLS 1.3 unified header, at bit 6 of its first octet " '(:rfc:`9147#section-4.1`), not the IPv6 Next Header offset', + ('pcapkit/protocols/schema/link/frad.py', "{'dlci_hi': (0, 6)}"): + "the 6-bit width of a Q.922 address field's high-order DLCI bits " + '(:rfc:`2427#section-3`), not the IPv6 Next Header offset', + ('pcapkit/protocols/schema/link/frad.py', "{'cr': (6, 1)}"): + 'the C/R bit at bit offset 6 of a Q.922 address field (:rfc:`2427#section-3`), ' + 'not the IPv6 Next Header offset', + ('pcapkit/protocols/schema/link/frad.py', "{'fecn': (12, 1)}"): + 'the FECN bit at bit offset 12 of a Q.922 address field (:rfc:`2427#section-3`), ' + 'not the TCP Data Offset octet', ('pcapkit/protocols/application/dtls.py', '_CIPHERTEXT_MIN_LENGTH = 16'): "the shortest DTLS 1.3 ciphertext, the record-number sample length " '(:rfc:`9147#section-4.2.3`), not a pcap record header', @@ -225,6 +234,9 @@ 'the IP version number, not the Next Header offset', ('pcapkit/protocols/schema/internet/ipv6.py', 'ptr + 6'): 'the end of a Jumbo Payload option (2 octets of header, 4 of value)', + ('pcapkit/protocols/link/frad.py', '0 <= snap_pid <= 65535'): + "the max value of a SNAP PID, FRAD's own 2-octet field (:rfc:`2427#section-3`), " + 'not an IP total length', ('pcapkit/protocols/link/linux_sll1.py', 'return 16'): 'the fixed length of the LINUX_SLL (v1) header, not the PCAP record header', ('pcapkit/protocols/link/linux_sll2.py', 'return 20'): diff --git a/tests/protocols/link/test_frad_unit.py b/tests/protocols/link/test_frad_unit.py new file mode 100644 index 0000000000..e735afe6f0 --- /dev/null +++ b/tests/protocols/link/test_frad_unit.py @@ -0,0 +1,261 @@ +# -*- coding: utf-8 -*- +"""GitHub issue #2460: Frame Relay Access Device (FRAD) frames. + +:class:`~pcapkit.protocols.link.frad.FRAD` dissects the ``arphrd_type`` +``ARPHRD_FRAD`` payload that +:class:`~pcapkit.protocols.link.linux_sll.LinuxSLL` previously kept whole as +:class:`~pcapkit.protocols.misc.raw.Raw` (see +:data:`~pcapkit.protocols.link.linux_sll._ARPHRD_NOT_ETHERTYPE`). Covers the +Q.922 address field, the direct-NLPID and SNAP-encapsulated forms per +:rfc:`2427`, and byte-exact round trips. The FRAD-dispatch side of +``LinuxSLL1``/``LinuxSLL2`` is covered in their own test modules. + +Each test imports :mod:`pcapkit` itself, after +:func:`~tests._support.reimport_once_per_class`. + +""" + +import unittest + +from tests._support import reimport_once_per_class + +#: An IPv4 header (DF clear, protocol UDP) and an 8-octet UDP header. +IPV4 = (bytes.fromhex('4500001c00010000401100000a0000010a000002') + + bytes.fromhex('3039003500080000')) +#: An IPv6 header (next header UDP) and an 8-octet UDP header. +IPV6 = (bytes.fromhex('6000000000081140') + bytes(15) + b'\x01' + bytes(15) + b'\x02' + + bytes.fromhex('3039003500080000')) +#: A minimal ARP request (RFC 826), 28 octets. +ARP_REQUEST = (bytes.fromhex('00010800060400010011223344550a000001') + + bytes.fromhex('0000000000000a000002')) + +#: The Q.922 address RFC 2427 section 3 uses as its own worked example: DLCI 50. +DLCI_50 = bytes.fromhex('0c21') + + +def _direct(nlpid: int, payload: bytes, addr: bytes = DLCI_50, control: int = 0x03) -> bytes: + """A direct-NLPID FRAD frame: address, control, one NLPID octet, payload.""" + return addr + bytes([control, nlpid]) + payload + + +def _snap(oui: bytes, pid: int, payload: bytes, addr: bytes = DLCI_50, + control: int = 0x03) -> bytes: + """A SNAP-encapsulated FRAD frame: pad, 0x80, 3-octet OUI, 2-octet PID.""" + return addr + bytes([control, 0x00, 0x80]) + oui + pid.to_bytes(2, 'big') + payload + + +class TestFRAD(unittest.TestCase): + """Pin the FRAD layer, alone.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def _assert_rebuilds(self, layer, **kwargs) -> None: # type: ignore[no-untyped-def] + """``layer`` rebuilds byte-exactly from its info, and from ``info.to_dict()``.""" + for data in (layer.info, layer.info.to_dict()): + with self.subTest(layer=type(layer).__name__, data=type(data).__name__): + self.assertEqual(type(layer).from_data(data, **kwargs).data.hex(), layer.data.hex()) + + def test_address_field_decodes_the_rfc_example(self) -> None: + """RFC 2427 section 3's own example: DLCI 50 packs as ``0x0C21``.""" + from pcapkit.protocols.link.frad import FRAD + + raw = _direct(0xCC, IPV4) + parsed = FRAD(raw, len(raw)) + self.assertEqual(parsed.info.dlci, 50) + self.assertEqual((parsed.info.cr, parsed.info.fecn, parsed.info.becn, parsed.info.de), + (0, 0, 0, 0)) + self.assertEqual(parsed.info.control, 0x03) + self.assertEqual(parsed.data, raw) + self._assert_rebuilds(parsed) + + def test_direct_nlpid_dispatches_ipv4_and_ipv6(self) -> None: + from pcapkit.protocols.link.frad import FRAD + + for nlpid, payload, name in ((0xCC, IPV4, 'IPv4'), (0x8E, IPV6, 'IPv6')): + with self.subTest(nlpid=hex(nlpid)): + raw = _direct(nlpid, payload) + parsed = FRAD(raw, len(raw)) + self.assertEqual(parsed.info.nlpid, nlpid) + self.assertIsNone(parsed.info.snap_oui) + self.assertIsNone(parsed.info.snap_pid) + self.assertEqual(parsed.protocol, nlpid) + self.assertEqual((parsed.name, parsed.alias, parsed.length), + ('Frame Relay Access Device', 'FRAD', 4)) + self.assertEqual(type(parsed.payload).__name__, name) + self.assertEqual(parsed.protochain.chain, f'FRAD:{name}:UDP') + self.assertEqual(parsed.data, raw) + self._assert_rebuilds(parsed) + + def test_snap_with_zero_oui_dispatches_by_ethertype(self) -> None: + """A SNAP header whose OUI is ``00-00-00`` carries an EtherType in its + PID -- ARP's own, ``0x0806`` -- resolved through the family registry + Ethernet/VLAN/LinuxSLL share, not through :attr:`FRAD.__proto__ + `. + """ + from pcapkit.protocols.link.frad import FRAD + + raw = _snap(b'\x00\x00\x00', 0x0806, ARP_REQUEST) + parsed = FRAD(raw, len(raw)) + self.assertEqual(parsed.info.nlpid, 0x80) + self.assertEqual(parsed.info.snap_oui, b'\x00\x00\x00') + self.assertEqual(parsed.info.snap_pid, 0x0806) + self.assertEqual(parsed.length, 10) + self.assertEqual(type(parsed.payload).__name__, 'ARP') + self.assertEqual(parsed.protochain.chain, 'FRAD:ARP') + self.assertEqual(parsed.data, raw) + self._assert_rebuilds(parsed) + + def test_snap_with_nonzero_oui_is_kept_raw(self) -> None: + """A SNAP header naming a bridged medium (OUI ``00-80-C2``, :rfc:`2427` + section 4.2) is not a bridging dissector this class implements, so it + is kept as :class:`~pcapkit.protocols.misc.raw.Raw`, OUI and PID + intact in :attr:`~pcapkit.protocols.data.link.frad.FRAD.snap_oui` / + :attr:`~pcapkit.protocols.data.link.frad.FRAD.snap_pid`. + """ + from pcapkit.protocols.link.frad import FRAD + from pcapkit.protocols.misc.raw import Raw + + raw = _snap(b'\x00\x80\xc2', 0x0001, IPV4) + parsed = FRAD(raw, len(raw)) + self.assertEqual(parsed.info.snap_oui, b'\x00\x80\xc2') + self.assertEqual(parsed.info.snap_pid, 0x0001) + self.assertIsInstance(parsed.payload, Raw) + self.assertEqual(parsed.protochain.chain, 'FRAD:Raw') + self.assertEqual(parsed.data, raw) + self._assert_rebuilds(parsed) + + def test_every_other_nlpid_is_kept_raw(self) -> None: + """Q.933 (``0x08``), ISO CLNP/ES-IS/IS-IS (``0x81``/``0x82``/``0x83``), + FRF.9/FRF.12 (``0xB0``/``0xB1``) and PPP-in-Frame-Relay (``0xCF``), per + :rfc:`2427` Appendix A, have no dissector in this package. + """ + from pcapkit.protocols.link.frad import FRAD + from pcapkit.protocols.misc.raw import Raw + + for nlpid in (0x08, 0x81, 0x82, 0x83, 0xB0, 0xB1, 0xCF): + with self.subTest(nlpid=hex(nlpid)): + raw = _direct(nlpid, IPV4) + parsed = FRAD(raw, len(raw)) + self.assertEqual(parsed.info.nlpid, nlpid) + self.assertIsInstance(parsed.payload, Raw) + self.assertEqual(parsed.protochain.chain, 'FRAD:Raw') + self.assertEqual(parsed.data, raw) + self._assert_rebuilds(parsed) + + def test_make_then_parse_direct(self) -> None: + from pcapkit.protocols.link.frad import FRAD + + made = FRAD(dlci=50, nlpid=0xCC, payload=IPV4).data + self.assertEqual(made, _direct(0xCC, IPV4)) + + parsed = FRAD(made, len(made)) + self.assertEqual(parsed.info.dlci, 50) + self.assertEqual(type(parsed.payload).__name__, 'IPv4') + self._assert_rebuilds(parsed) + + def test_make_then_parse_snap(self) -> None: + from pcapkit.protocols.link.frad import FRAD + + made = FRAD(dlci=50, nlpid=0x80, snap_oui=b'\x00\x00\x00', snap_pid=0x0806, + payload=ARP_REQUEST).data + self.assertEqual(made, _snap(b'\x00\x00\x00', 0x0806, ARP_REQUEST)) + + parsed = FRAD(made, len(made)) + self.assertEqual(type(parsed.payload).__name__, 'ARP') + self._assert_rebuilds(parsed) + + def test_make_defaults(self) -> None: + from pcapkit.protocols.link.frad import FRAD + + # dlci=0, with ea2=1 per the 2-octet address form: 0x00 0x01. + self.assertEqual(FRAD().data, b'\x00\x01\x03\xcc') + + def test_make_round_trips_every_bit_of_the_address(self) -> None: + from pcapkit.protocols.link.frad import FRAD + + made = FRAD(dlci=1023, cr=1, fecn=1, becn=1, de=1, nlpid=0xCC, payload=IPV4) + parsed = FRAD(made.data, len(made.data)) + self.assertEqual((parsed.info.dlci, parsed.info.cr, parsed.info.fecn, + parsed.info.becn, parsed.info.de), (1023, 1, 1, 1, 1)) + self._assert_rebuilds(parsed) + + def test_make_rejects_an_invalid_dlci(self) -> None: + from pcapkit.protocols.link.frad import FRAD + from pcapkit.utilities.exceptions import ProtocolError + + for dlci in (-1, 1024, True, '50'): + with self.subTest(dlci=dlci), self.assertRaises(ProtocolError): + FRAD(dlci=dlci) + + def test_make_rejects_an_invalid_flag_bit(self) -> None: + # NOTE: True/False are not cases here -- bool is an int subclass and + # True == 1, so a bit flag (unlike dlci, a numeric identifier) treats + # it as a legitimate spelling of 1 rather than rejecting it. + from pcapkit.protocols.link.frad import FRAD + from pcapkit.utilities.exceptions import ProtocolError + + for kwargs in ({'cr': 2}, {'fecn': -1}, {'becn': 3}, {'de': 'x'}): + with self.subTest(**kwargs), self.assertRaises(ProtocolError): + FRAD(**kwargs) + + def test_make_tolerates_nlpid_zero_for_short_read_replay(self) -> None: + """``0x00`` is the SNAP pad marker, never a value of its own on the + wire, but :meth:`make` must not refuse it: see the ``Note`` on + :meth:`FRAD.make `. + """ + from pcapkit.protocols.link.frad import FRAD + + FRAD(nlpid=0) # must not raise + + def test_make_rejects_snap_fields_without_snap(self) -> None: + from pcapkit.protocols.link.frad import FRAD + from pcapkit.utilities.exceptions import ProtocolError + + with self.assertRaises(ProtocolError): + FRAD(nlpid=0xCC, snap_oui=b'\x00\x00\x00') + with self.assertRaises(ProtocolError): + FRAD(nlpid=0xCC, snap_pid=0x0806) + + def test_make_rejects_snap_without_snap_fields(self) -> None: + from pcapkit.protocols.link.frad import FRAD + from pcapkit.utilities.exceptions import ProtocolError + + with self.assertRaises(ProtocolError): + FRAD(nlpid=0x80) + with self.assertRaises(ProtocolError): + FRAD(nlpid=0x80, snap_oui=b'\x00\x00') # wrong width + with self.assertRaises(ProtocolError): + FRAD(nlpid=0x80, snap_oui=b'\x00\x00\x00', snap_pid=0x1_0000) # out of range + + def test_id_and_index(self) -> None: + from pcapkit.protocols.link.frad import FRAD + from pcapkit.utilities.exceptions import UnsupportedCall + + self.assertEqual(FRAD.id(), ('FRAD',)) + with self.assertRaises(UnsupportedCall): + FRAD.__index__() + + def test_truncated_header_rebuilds_as_captured(self) -> None: + """A capture ending inside the header must not blow up the parse, and + rebuilds to exactly the octets captured. See + ``tests/protocols/test_short_read_roundtrip_unit.py`` for the sweep + that cuts this (and every other) family at every offset. + """ + import warnings + + from pcapkit.protocols.link.frad import FRAD + + raw = _direct(0xCC, IPV4) + for keep in (1, 2, 3): + with self.subTest(keep=keep): + cut = raw[:keep] + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + parsed = FRAD(cut, len(cut)) + self.assertEqual(parsed.data, cut) + self.assertEqual(FRAD.from_data(parsed.info).data, cut) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/protocols/link/test_linux_sll1_unit.py b/tests/protocols/link/test_linux_sll1_unit.py index 54813ffd0e..07148bca60 100644 --- a/tests/protocols/link/test_linux_sll1_unit.py +++ b/tests/protocols/link/test_linux_sll1_unit.py @@ -73,16 +73,17 @@ def test_ethernet_arphrd_dispatches_by_ethertype(self) -> None: self._assert_rebuilds(parsed) def test_the_non_ethertype_arphrds_are_kept_raw(self) -> None: - """``NETLINK``, ``IPGRE``, ``IP6GRE``, ``IEEE80211_RADIOTAP`` and - ``FRAD``, per tcpdump.org's LINUX_SLL page, never have ``protocol_type`` looked up as an - EtherType, even though 0x0800 is IPv4's. + """``NETLINK``, ``IPGRE``, ``IP6GRE`` and ``IEEE80211_RADIOTAP``, per + tcpdump.org's LINUX_SLL page, never have ``protocol_type`` looked up as an + EtherType, even though 0x0800 is IPv4's. ``FRAD`` is the exception: see + :meth:`test_frad_arphrd_dispatches_to_frad`. """ from pcapkit.const.linux.arphrd import ARPHRD from pcapkit.protocols.link.linux_sll1 import LinuxSLL1 from pcapkit.protocols.misc.raw import Raw for arphrd in (ARPHRD.NETLINK, ARPHRD.IPGRE, ARPHRD.IP6GRE, - ARPHRD.IEEE80211_RADIOTAP, ARPHRD.FRAD): + ARPHRD.IEEE80211_RADIOTAP): with self.subTest(arphrd=arphrd): # protocol_type 0x0800 is IPv4's EtherType, but these # hardware types never carry an EtherType in protocol_type. @@ -97,6 +98,25 @@ def test_the_non_ethertype_arphrds_are_kept_raw(self) -> None: self.assertEqual(parsed.data, raw) self._assert_rebuilds(parsed) + def test_frad_arphrd_dispatches_to_frad(self) -> None: + """GitHub issue #2460: ``ARPHRD_FRAD`` dispatches to + :class:`~pcapkit.protocols.link.frad.FRAD` rather than falling back to + :class:`~pcapkit.protocols.misc.raw.Raw`; ``protocol_type`` is + ignored, per tcpdump.org's LINUX_SLL page. + """ + from pcapkit.const.linux.arphrd import ARPHRD + from pcapkit.protocols.link.linux_sll1 import LinuxSLL1 + + frad = bytes.fromhex('0c21') + b'\x03\xcc' + IPV4 + raw = _header(2, ARPHRD.FRAD, b'', 0, 0x0800) + frad + parsed = LinuxSLL1(raw, len(raw)) + self.assertEqual(parsed.info.arphrd_type, ARPHRD.FRAD) + self.assertEqual(type(parsed.payload).__name__, 'FRAD') + self.assertEqual(parsed.payload.info.dlci, 50) + self.assertEqual(parsed.protochain.chain, 'LINUX_SLL1:FRAD:IPv4:UDP') + self.assertEqual(parsed.data, raw) + self._assert_rebuilds(parsed) + def test_non_ethernet_non_excluded_arphrds_still_dispatch_by_ethertype(self) -> None: """Loopback, PPP and SLIP/CSLIP are not :attr:`~pcapkit.const.arp.hardware.Hardware.Ethernet`, and have no diff --git a/tests/protocols/link/test_linux_sll2_unit.py b/tests/protocols/link/test_linux_sll2_unit.py index 619dd93277..6161728e42 100644 --- a/tests/protocols/link/test_linux_sll2_unit.py +++ b/tests/protocols/link/test_linux_sll2_unit.py @@ -75,15 +75,16 @@ def test_ethernet_arphrd_dispatches_by_ethertype(self) -> None: self._assert_rebuilds(parsed) def test_the_non_ethertype_arphrds_are_kept_raw(self) -> None: - """``NETLINK``, ``IPGRE``, ``IP6GRE``, ``IEEE80211_RADIOTAP`` and - ``FRAD``, per tcpdump.org's LINUX_SLL2 page, are kept raw. + """``NETLINK``, ``IPGRE``, ``IP6GRE`` and ``IEEE80211_RADIOTAP``, per + tcpdump.org's LINUX_SLL2 page, are kept raw. ``FRAD`` is the + exception: see :meth:`test_frad_arphrd_dispatches_to_frad`. """ from pcapkit.const.linux.arphrd import ARPHRD from pcapkit.protocols.link.linux_sll2 import LinuxSLL2 from pcapkit.protocols.misc.raw import Raw for arphrd in (ARPHRD.NETLINK, ARPHRD.IPGRE, ARPHRD.IP6GRE, - ARPHRD.IEEE80211_RADIOTAP, ARPHRD.FRAD): + ARPHRD.IEEE80211_RADIOTAP): with self.subTest(arphrd=arphrd): raw = _header(0x0800, 0, 0, arphrd, 2, b'', 0) + IPV4 parsed = LinuxSLL2(raw, len(raw)) @@ -96,6 +97,24 @@ def test_the_non_ethertype_arphrds_are_kept_raw(self) -> None: self.assertEqual(parsed.data, raw) self._assert_rebuilds(parsed) + def test_frad_arphrd_dispatches_to_frad(self) -> None: + """GitHub issue #2460: ``ARPHRD_FRAD`` dispatches to + :class:`~pcapkit.protocols.link.frad.FRAD` rather than falling back to + :class:`~pcapkit.protocols.misc.raw.Raw`. + """ + from pcapkit.const.linux.arphrd import ARPHRD + from pcapkit.protocols.link.linux_sll2 import LinuxSLL2 + + frad = bytes.fromhex('0c21') + b'\x03\xcc' + IPV4 + raw = _header(0x0800, 0, 0, ARPHRD.FRAD, 2, b'', 0) + frad + parsed = LinuxSLL2(raw, len(raw)) + self.assertEqual(parsed.info.arphrd_type, ARPHRD.FRAD) + self.assertEqual(type(parsed.payload).__name__, 'FRAD') + self.assertEqual(parsed.payload.info.dlci, 50) + self.assertEqual(parsed.protochain.chain, 'LINUX_SLL2:FRAD:IPv4:UDP') + self.assertEqual(parsed.data, raw) + self._assert_rebuilds(parsed) + def test_non_ethernet_non_excluded_arphrds_still_dispatch_by_ethertype(self) -> None: """Loopback, PPP and SLIP/CSLIP are not among the exceptions, so ``protocol_type`` is still an EtherType for them. diff --git a/tests/protocols/test_short_read_roundtrip_unit.py b/tests/protocols/test_short_read_roundtrip_unit.py index f3c81b28c0..22592a5cd4 100644 --- a/tests/protocols/test_short_read_roundtrip_unit.py +++ b/tests/protocols/test_short_read_roundtrip_unit.py @@ -52,6 +52,11 @@ def _ipv6(code: int, body: bytes) -> bytes: ('l2tpv2', 'link.l2tpv2:L2TPv2', {}, bytes.fromhex('fff2000e00000000000000000000')), ('loopback-null', 'link.loopback:Loopback', {}, bytes.fromhex('020000004500')), ('loopback-loop', 'link.loopback:Loopback', {'alias': 108}, bytes.fromhex('000000024500')), + ('frad', 'link.frad:FRAD', {}, bytes.fromhex( + '0c21' '03cc' '450000141234000040fd7b83c0000201c6336401')), + ('frad-snap', 'link.frad:FRAD', {}, bytes.fromhex( + '0c21' '0300' '80' '000000' '0806' + '00010800060400010011223344550a0000010000000000000a000002')), ('ospf', 'application.ospf:OSPF', {}, bytes.fromhex('020100180102030400000000abcd0000') + bytes(8)), ('ipx', 'internet.ipx:IPX', {}, bytes.fromhex('ffff001e0000000000000101010101010451000000000202020202020452')), ('ipv4', 'internet.ipv4:IPv4', {}, bytes.fromhex('450000141234000040fd7b83c0000201c6336401')),