From 7f8a832673c790bd39bd0ac68ba91974455f4204 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Sat, 31 Oct 2026 18:16:58 -0400 Subject: [PATCH 1/5] feat(link): add a radiotap dissector for IEEE80211_RADIOTAP on LinuxSLL (#2502) - Add `pcapkit.protocols.link.radiotap.Radiotap`: decodes the fixed 8-octet radiotap start (it_version/it_pad/it_len) and walks the present-flags chain, including bit-31 extended-word chaining, far enough to count its words. The optional-field region those bits select is skipped whole rather than decoded field by field; the 802.11 MAC frame behind it has no dissector in this package yet and is kept as Raw/NoPayload. - Register the new protocol in all 8 places (pcapkit/__init__.py, pcapkit/all.py, protocols/__init__.py, protocols/data/__init__.py, protocols/data/link/__init__.py, protocols/schema/__init__.py, protocols/schema/link/__init__.py, protocols/link/__init__.py), plus the Link base class docstring. - Wire LinuxSLL's `_import_next_layer` to dispatch ARPHRD_IEEE80211_RADIOTAP to Radiotap instead of falling back to Raw. - Add a docs page, index entry, and changelog entry (1.6.0.rst, regenerated CHANGELOG.md). - Tests: a new unit module covering the minimal header, present-field skipping, extended-presence chaining, malformed it_len (both too-small and too-large), truncated-capture round trips, and make()/validation; updated LinuxSLL1/SLL2 dispatch tests and the short-read round-trip sweep; one new UNRELATED entry in the RFC-constant-home test for radiotap's own 16-bit it_len bound. - Build: full tests/protocols/link/ (166 tests, 924 subtests) and the targeted tests/project/ modules (test_all_exports_unit, test_module_isolation, test_rfc_constants_one_home_1739_unit: 24 tests, 149 subtests) pass clean; pylint 10/10 and mypy clean on the new files. The last of #2460's five arphrd_type follow-ups to get a dissector of its own; FRAD (#2499), NETLINK (#2507) and IPGRE/IP6GRE (#2501) are tracked on their own reviews. --- CHANGELOG.md | 2 + docs/source/changelog/1.6.0.rst | 18 + docs/source/pcapkit/protocols/link/index.rst | 1 + .../pcapkit/protocols/link/radiotap.rst | 100 ++++ pcapkit/__init__.py | 3 +- 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/radiotap.py | 48 ++ pcapkit/protocols/link/__init__.py | 3 +- pcapkit/protocols/link/link.py | 3 +- pcapkit/protocols/link/linux_sll.py | 20 +- pcapkit/protocols/link/radiotap.py | 428 ++++++++++++++++++ pcapkit/protocols/schema/__init__.py | 1 + pcapkit/protocols/schema/link/__init__.py | 2 + pcapkit/protocols/schema/link/radiotap.py | 46 ++ .../test_rfc_constants_one_home_1739_unit.py | 2 + tests/protocols/link/test_linux_sll1_unit.py | 30 +- tests/protocols/link/test_linux_sll2_unit.py | 28 +- tests/protocols/link/test_radiotap_unit.py | 259 +++++++++++ .../test_short_read_roundtrip_unit.py | 2 + 22 files changed, 991 insertions(+), 18 deletions(-) create mode 100644 docs/source/pcapkit/protocols/link/radiotap.rst create mode 100644 pcapkit/protocols/data/link/radiotap.py create mode 100644 pcapkit/protocols/link/radiotap.py create mode 100644 pcapkit/protocols/schema/link/radiotap.py create mode 100644 tests/protocols/link/test_radiotap_unit.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e9a616c60e..c6d9ab2e2c 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** -- `Radiotap`, a dissector for the fixed 8-octet start of the radiotap header () capture tools prepend to an 802.11 frame. `LinuxSLL`/ `LinuxSLL1`/ `LinuxSLL2` dispatch to it for the `ARPHRD_IEEE80211_RADIOTAP` case instead of falling back to `Raw`. Decodes `it_version`, `it_pad` and `it_len`, and walks the present-flags chain (including its bit-31 extended-word chaining) far enough to count its words; the optional-field region those bits select is skipped whole rather than decoded field by field, and the 802.11 MAC frame behind it -- which has no dissector anywhere in this package yet -- is kept as `Raw`. The last of [#2460](https://github.com/JarryShaw/PyPCAPKit/issues/2460)'s five `arphrd_type` follow-ups to get a dissector of its own -- `FRAD` ([#2499](https://github.com/JarryShaw/PyPCAPKit/pull/2499)), `NETLINK` ([#2507](https://github.com/JarryShaw/PyPCAPKit/pull/2507)) and `IPGRE`/`IP6GRE` ([#2501](https://github.com/JarryShaw/PyPCAPKit/issues/2501)) are tracked on their own reviews. + --- Full changelog: diff --git a/docs/source/changelog/1.6.0.rst b/docs/source/changelog/1.6.0.rst index 4800343b1f..f93062796b 100644 --- a/docs/source/changelog/1.6.0.rst +++ b/docs/source/changelog/1.6.0.rst @@ -4,3 +4,21 @@ 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.radiotap.Radiotap`, a dissector + for the fixed 8-octet start of the radiotap header + () capture tools prepend to an 802.11 frame. + :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_IEEE80211_RADIOTAP`` case instead of falling back to + :class:`~pcapkit.protocols.misc.raw.Raw`. Decodes ``it_version``, + ``it_pad`` and ``it_len``, and walks the present-flags chain (including its + bit-31 extended-word chaining) far enough to count its words; the + optional-field region those bits select is skipped whole rather than + decoded field by field, and the 802.11 MAC frame behind it -- which has no + dissector anywhere in this package yet -- is kept as + :class:`~pcapkit.protocols.misc.raw.Raw`. The last of :issue:`2460`'s five + ``arphrd_type`` follow-ups to get a dissector of its own -- ``FRAD`` + (:pr:`2499`), ``NETLINK`` (:pr:`2507`) and ``IPGRE``/``IP6GRE`` + (:issue:`2501`) are tracked on their own reviews. diff --git a/docs/source/pcapkit/protocols/link/index.rst b/docs/source/pcapkit/protocols/link/index.rst index f0b47982e2..9c4076f991 100644 --- a/docs/source/pcapkit/protocols/link/index.rst +++ b/docs/source/pcapkit/protocols/link/index.rst @@ -30,6 +30,7 @@ link layer, with detailed implementation and methods. linux_sll1 linux_sll2 loopback + radiotap vlan c_tag s_tag diff --git a/docs/source/pcapkit/protocols/link/radiotap.rst b/docs/source/pcapkit/protocols/link/radiotap.rst new file mode 100644 index 0000000000..d93aa7df39 --- /dev/null +++ b/docs/source/pcapkit/protocols/link/radiotap.rst @@ -0,0 +1,100 @@ +Radiotap - IEEE 802.11 Radiotap Header +======================================== + +.. module:: pcapkit.protocols.link.radiotap + +:mod:`pcapkit.protocols.link.radiotap` contains +:class:`~pcapkit.protocols.link.radiotap.Radiotap` +only, which implements the extractor for the radiotap header [*]_ capture +tools prepend to an 802.11 frame, as seen on a +``LINKTYPE_LINUX_SLL``/``LINKTYPE_LINUX_SLL2`` capture whose ``arphrd_type`` +is ``ARPHRD_IEEE80211_RADIOTAP``. Radiotap is not part of the 802.11 +standard itself -- it is a prefix written by the capturing tool, in front of +the raw 802.11 MAC frame. Its fixed start, all little-endian, is described +as below: + +.. table:: + + ====== ==== ======================== ================================ + Octets Bits Name Description + ====== ==== ======================== ================================ + 0 0 ``radiotap.it_version`` Header Revision (always ``0``) + 1 8 ``radiotap.it_pad`` Padding + 2 16 ``radiotap.it_len`` Header Length (header included) + 4 32 ``radiotap.it_present`` First Present-Flags Word + ====== ==== ======================== ================================ + +followed by a chain of further present-flags words -- bit 31 of a word +signals that another follows -- and then the optional-field region those +bitmasks select, each field aligned and sized per its own type. ``it_len`` +bounds the whole of it; the 802.11 MAC frame starts at that offset. + +Scope +----- + +Decoding every optional field radiotap.org defines, and the 802.11 MAC +frame behind them, is future work tracked on :issue:`2460` and +:issue:`2502` -- this is already the widest of that issue's five +follow-ups, and the 802.11 MAC frame is a protocol family of its own. This +first cut instead: + +* **Decodes** the fixed 8-octet start -- ``it_version``, ``it_pad``, + ``it_len`` -- and walks the present-flags chain far enough to know how + many words it has + (:attr:`~pcapkit.protocols.data.link.radiotap.Radiotap.present`), without + decoding what any bit selects. +* **Skips, rather than decodes,** the optional-field region those bits + select -- the octets between the present-flags chain and ``it_len`` are + kept whole and undissected, as + :attr:`~pcapkit.protocols.data.link.radiotap.Radiotap.fields`. +* **Leaves opaque** everything from ``it_len`` on: the 802.11 MAC frame + itself, which has no dissector anywhere in this package yet. It is kept + as :class:`~pcapkit.protocols.misc.raw.Raw` (or + :class:`~pcapkit.protocols.misc.null.NoPayload` when nothing follows), + unconditionally. + +.. autoclass:: pcapkit.protocols.link.radiotap.Radiotap + :no-members: + :show-inheritance: + + .. autoproperty:: name + .. autoproperty:: alias + .. autoproperty:: length + + .. 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.radiotap + +.. autoclass:: pcapkit.protocols.schema.link.radiotap.Radiotap + :members: + :show-inheritance: + +Data Models +----------- + +.. module:: pcapkit.protocols.data.link.radiotap + +.. autoclass:: pcapkit.protocols.data.link.radiotap.Radiotap + :members: + :show-inheritance: + +.. rubric:: Footnotes + +.. [*] ; the Linux kernel's + ``include/net/ieee80211_radiotap.h`` and ``net/wireless/radiotap.c``; and + Wireshark's ``packet-ieee80211-radiotap.c``. diff --git a/pcapkit/__init__.py b/pcapkit/__init__.py index 19841fab0c..1ff6c2bf32 100644 --- a/pcapkit/__init__.py +++ b/pcapkit/__init__.py @@ -112,7 +112,8 @@ 'Raw', # Raw Packet 'ARP', 'C_Tag', 'Ethernet', 'InARP', 'L2TP', # Link Layer - 'L2TPv2', 'LinuxSLL', 'LinuxSLL1', 'LinuxSLL2', 'Loopback', 'S_Tag', 'VLAN', + 'L2TPv2', 'LinuxSLL', 'LinuxSLL1', 'LinuxSLL2', 'Loopback', 'Radiotap', + 'S_Tag', 'VLAN', 'AH', 'ESP', 'IGMP', 'IGMPv1', 'IGMPv2', # Internet Layer 'IP', 'IPsec', 'IPv4', 'IPv6', 'IPX', diff --git a/pcapkit/all.py b/pcapkit/all.py index 20b9563aa7..420bef3545 100644 --- a/pcapkit/all.py +++ b/pcapkit/all.py @@ -121,7 +121,7 @@ 'NoPayload', # No Payload 'Raw', # Raw Packet 'ARP', 'C_Tag', 'Ethernet', 'InARP', 'L2TP', 'L2TPv2', 'LinuxSLL', 'LinuxSLL1', - 'LinuxSLL2', 'Loopback', 'S_Tag', 'VLAN', # Link Layer + 'LinuxSLL2', 'Loopback', 'Radiotap', 'S_Tag', 'VLAN', # Link Layer 'AH', 'ESP', 'IGMP', 'IGMPv1', 'IGMPv2', # Internet Layer 'IP', 'IPsec', 'IPv4', 'IPv6', 'IPX', 'HIP', 'HOPOPT', 'IPv6_Ext', 'IPv6_Frag', 'IPv6_Opts', 'IPv6_Route', 'MH', diff --git a/pcapkit/protocols/__init__.py b/pcapkit/protocols/__init__.py index 5aab10622d..031940b2f9 100644 --- a/pcapkit/protocols/__init__.py +++ b/pcapkit/protocols/__init__.py @@ -51,7 +51,7 @@ # Link Layer 'ARP', 'C_Tag', 'Ethernet', 'InARP', 'L2TP', 'L2TPv2', 'LinuxSLL', 'LinuxSLL1', - 'LinuxSLL2', 'Loopback', 'S_Tag', 'VLAN', + 'LinuxSLL2', 'Loopback', 'Radiotap', 'S_Tag', 'VLAN', # Internet Layer 'AH', 'ESP', 'IGMP', 'IGMPv1', 'IGMPv2', 'IP', 'IPsec', 'IPv4', 'IPv6', 'IPX', diff --git a/pcapkit/protocols/data/__init__.py b/pcapkit/protocols/data/__init__.py index 4fee25a195..6b8efc1e1e 100644 --- a/pcapkit/protocols/data/__init__.py +++ b/pcapkit/protocols/data/__init__.py @@ -71,6 +71,9 @@ # BSD Loopback Encapsulation 'Loopback', + # IEEE 802.11 Radiotap Header + 'Radiotap', + # 802.1Q/802.1ad VLAN Tag Types 'VLAN', 'VLAN_TCI', diff --git a/pcapkit/protocols/data/link/__init__.py b/pcapkit/protocols/data/link/__init__.py index cae293e6cc..ebc6e66ecf 100644 --- a/pcapkit/protocols/data/link/__init__.py +++ b/pcapkit/protocols/data/link/__init__.py @@ -20,6 +20,9 @@ # BSD Loopback Encapsulation from pcapkit.protocols.data.link.loopback import Loopback +# IEEE 802.11 Radiotap Header +from pcapkit.protocols.data.link.radiotap import Radiotap + # 802.1Q/802.1ad VLAN Tag Types from pcapkit.protocols.data.link.vlan import TCI as VLAN_TCI from pcapkit.protocols.data.link.vlan import VLAN @@ -40,6 +43,9 @@ # BSD Loopback Encapsulation 'Loopback', + # IEEE 802.11 Radiotap Header + 'Radiotap', + # 802.1Q/802.1ad VLAN Tag Types 'VLAN', 'VLAN_TCI', ] diff --git a/pcapkit/protocols/data/link/radiotap.py b/pcapkit/protocols/data/link/radiotap.py new file mode 100644 index 0000000000..d358ee9c9f --- /dev/null +++ b/pcapkit/protocols/data/link/radiotap.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +"""data models for IEEE 802.11 radiotap headers""" + +from typing import TYPE_CHECKING + +from pcapkit.corekit.infoclass import info_final +from pcapkit.protocols.data.protocol import Protocol + +if TYPE_CHECKING: + pass + +__all__ = ['Radiotap'] + + +@info_final +class Radiotap(Protocol): + """Data model for an IEEE 802.11 radiotap header. + + Only the fixed 8-octet start is decoded field by field; the rest is kept + whole, see :attr:`present` and :attr:`fields` and the module docstring's + *Scope* section on :mod:`pcapkit.protocols.link.radiotap`. + + """ + + #: Header revision; ``0`` is the only version radiotap.org defines, and + #: the only one this package decodes. + it_version: 'int' + #: Padding octet, kept for alignment; not interpreted. + it_pad: 'int' + #: Total length of the radiotap header -- the fixed 8-octet start, + #: every extended present-flags word, and the optional-field region + #: they select -- in octets; the 802.11 MAC frame starts here. Kept + #: exactly as captured, even where it disagrees with :attr:`present` + #: and :attr:`fields`. + it_len: 'int' + #: Every present-flags word in the chain that was actually read, in + #: order; index ``0`` is the mandatory first word (radiotap.org's own + #: ``it_present``). Bit 31 of a word signals that another word + #: follows; it is clear on the last word read, unless the capture ran + #: out before a word it claimed would follow. + present: 'tuple[int, ...]' + #: The optional-field region :attr:`present` selects, kept whole and + #: undissected. + fields: 'bytes' + + if TYPE_CHECKING: + def __init__(self, it_version: 'int', it_pad: 'int', it_len: 'int', # pylint: disable=unused-argument,super-init-not-called,multiple-statements + present: 'tuple[int, ...]', fields: 'bytes') -> 'None': ... # pylint: disable=unused-argument diff --git a/pcapkit/protocols/link/__init__.py b/pcapkit/protocols/link/__init__.py index 0a35e6a5db..6b05ff51d2 100644 --- a/pcapkit/protocols/link/__init__.py +++ b/pcapkit/protocols/link/__init__.py @@ -18,6 +18,7 @@ from pcapkit.protocols.link.arp import ARP, InARP from pcapkit.protocols.link.ethernet import Ethernet from pcapkit.protocols.link.loopback import Loopback +from pcapkit.protocols.link.radiotap import Radiotap # Linux "Cooked" Capture Encapsulation Family from pcapkit.protocols.link.linux_sll import LinuxSLL @@ -42,5 +43,5 @@ # Link Layer Protocols 'ARP', 'C_Tag', 'Ethernet', 'InARP', 'L2TP', 'L2TPv2', 'LinuxSLL', 'LinuxSLL1', - 'LinuxSLL2', 'Loopback', 'S_Tag', 'VLAN', + 'LinuxSLL2', 'Loopback', 'Radiotap', 'S_Tag', 'VLAN', ] diff --git a/pcapkit/protocols/link/link.py b/pcapkit/protocols/link/link.py index 2132ac5ce6..cd135f8564 100644 --- a/pcapkit/protocols/link/link.py +++ b/pcapkit/protocols/link/link.py @@ -11,7 +11,8 @@ :class:`~pcapkit.protocols.link.arp.ARP`/:class:`~pcapkit.protocols.link.arp.InARP`, :class:`~pcapkit.protocols.link.ethernet.Ethernet`, :class:`~pcapkit.protocols.link.l2tp.L2TP`, -:class:`~pcapkit.protocols.link.loopback.Loopback` and +:class:`~pcapkit.protocols.link.loopback.Loopback`, +:class:`~pcapkit.protocols.link.radiotap.Radiotap` 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..e83a0283ec 100644 --- a/pcapkit/protocols/link/linux_sll.py +++ b/pcapkit/protocols/link/linux_sll.py @@ -150,16 +150,28 @@ 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 - :data:`_ARPHRD_NOT_ETHERTYPE`. + Instance of next layer: :class:`~pcapkit.protocols.link.radiotap.Radiotap` + when ``arphrd_type`` is :attr:`ARPHRD.IEEE80211_RADIOTAP + `; otherwise + :class:`~pcapkit.protocols.misc.raw.Raw`, recording ``proto``, for + every other ``arphrd_type`` in :data:`_ARPHRD_NOT_ETHERTYPE`. """ file_ = self._get_payload() 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.IEEE80211_RADIOTAP: + from pcapkit.protocols.link.radiotap import \ + Radiotap # pylint: disable=import-outside-toplevel # isort: skip + return self._parse_next_layer(Radiotap, 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/link/radiotap.py b/pcapkit/protocols/link/radiotap.py new file mode 100644 index 0000000000..f67349af79 --- /dev/null +++ b/pcapkit/protocols/link/radiotap.py @@ -0,0 +1,428 @@ +# -*- coding: utf-8 -*- +"""Radiotap - IEEE 802.11 Radiotap Header +============================================ + +.. module:: pcapkit.protocols.link.radiotap + +:mod:`pcapkit.protocols.link.radiotap` contains +:class:`~pcapkit.protocols.link.radiotap.Radiotap` only, which implements the +extractor for the radiotap header [*]_ capture tools prepend to an 802.11 +frame, as seen on a ``LINKTYPE_LINUX_SLL``/``LINKTYPE_LINUX_SLL2`` capture +whose ``arphrd_type`` is ``ARPHRD_IEEE80211_RADIOTAP`` -- +: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`. +Radiotap is not part of the 802.11 standard itself: it is a prefix *in front +of* the raw 802.11 MAC frame, written by the capturing tool rather than by +the radio. Its structure is a fixed 8-octet start, all little-endian: + +.. table:: + + ====== ==== =================== ================================ + Octets Bits Name Description + ====== ==== =================== ================================ + 0 0 ``radiotap.it_version`` Header Revision (always ``0``) + 1 8 ``radiotap.it_pad`` Padding + 2 16 ``radiotap.it_len`` Header Length (header included) + 4 32 ``radiotap.it_present`` First Present-Flags Word + ====== ==== =================== ================================ + +followed by a chain of further present-flags words -- bit 31 of a word +signals that another follows -- and then the optional-field region those +bitmasks select, each field aligned and sized per its own type. ``it_len`` +bounds the whole of it; the 802.11 MAC frame starts at that offset. + +Scope +----- + +Decoding every optional field radiotap.org defines, and the 802.11 MAC frame +behind them, is future work tracked on :issue:`2460` and :issue:`2502` -- +this is already the widest of that issue's five follow-ups, and the 802.11 +MAC frame is a protocol family of its own (frame control, address fields +that vary in count with ToDS/FromDS, sequence control, optional QoS/HT +control, then a body that again dispatches by frame type/subtype). This +first cut instead: + +* **Decodes** the fixed 8-octet start -- ``it_version``, ``it_pad``, + ``it_len`` -- and walks the present-flags chain far enough to know how many + words it has (:attr:`~pcapkit.protocols.data.link.radiotap.Radiotap.present`), + without decoding what any bit selects. +* **Skips, rather than decodes,** the optional-field region those bits + select -- the octets between the present-flags chain and ``it_len`` are + kept whole and undissected, as + :attr:`~pcapkit.protocols.data.link.radiotap.Radiotap.fields`. +* **Leaves opaque** everything from ``it_len`` on: the 802.11 MAC frame + itself, which has no dissector anywhere in this package yet. It is kept as + :class:`~pcapkit.protocols.misc.raw.Raw` (or + :class:`~pcapkit.protocols.misc.null.NoPayload` when nothing follows), + unconditionally -- there is no next-layer dispatch table here, the same + choice :class:`~pcapkit.protocols.link.netlink.Netlink` makes for its own + per-family payloads. + +.. [*] ; the Linux kernel's + ``include/net/ieee80211_radiotap.h`` and ``net/wireless/radiotap.c``; and + Wireshark's ``packet-ieee80211-radiotap.c``, all of which agree on the + fixed start and the present-flags chaining described above. + +.. 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.radiotap import Radiotap as Data_Radiotap +from pcapkit.protocols.link.link import Link +from pcapkit.protocols.protocol import ProtocolBase +from pcapkit.protocols.schema.link.radiotap import Radiotap as Schema_Radiotap +from pcapkit.protocols.schema.schema import Schema +from pcapkit.utilities.exceptions import ProtocolError, UnsupportedCall + +if TYPE_CHECKING: + from typing import Any, DefaultDict, NoReturn, Optional, Sequence, Type + + from typing_extensions import Literal + +__all__ = ['Radiotap'] + +#: Bit 31 of an ``it_present`` word: set, another present-flags word +#: follows; clear, the field-data region starts right after this word. +_IT_PRESENT_EXTENDED = 1 << 31 + +#: Octets of the fixed start every radiotap header has, before any extended +#: present-flags word or field data. +_FIXED_HEADER_LEN = 8 + +#: Octets of one present-flags word, extended or not. +_PRESENT_WORD_LEN = 4 + + +class Radiotap(Link[Data_Radiotap, Schema_Radiotap], + schema=Schema_Radiotap, data=Data_Radiotap): + """This class implements the IEEE 802.11 radiotap header. + + The 802.11 MAC frame this header precedes has no dissector in this + package; it is always kept as :class:`~pcapkit.protocols.misc.raw.Raw` + (or :class:`~pcapkit.protocols.misc.null.NoPayload`), registered in + :attr:`self.__proto__ ` + only so :meth:`register` works the same way every other + :class:`~pcapkit.protocols.link.link.Link` subclass's does -- see the + module docstring's *Scope* section for what is, and is not, decoded. + + """ + + ########################################################################## + # Defaults. + ########################################################################## + + #: Protocol index mapping for decoding next layer. Deliberately empty -- + #: see the class docstring -- so every key resolves through the + #: :class:`~collections.defaultdict` fallback to + #: :class:`~pcapkit.protocols.misc.raw.Raw`. Unused in practice, since + #: :meth:`_import_next_layer` is overridden below and never consults it. + __proto__ = collections.defaultdict( + lambda: ModuleDescriptor('pcapkit.protocols.misc.raw', 'Raw'), + ) # type: DefaultDict[int, ModuleDescriptor[ProtocolBase] | Type[ProtocolBase]] + + ########################################################################## + # Properties. + ########################################################################## + + @property + def name(self) -> 'Literal["Radiotap"]': + """Name of current protocol.""" + return 'Radiotap' + + @property + def alias(self) -> 'Literal["Radiotap"]': + """Acronym of corresponding protocol.""" + return 'Radiotap' + + @property + def length(self) -> 'int': + """Header length of current protocol. + + This is :attr:`self._info.it_len ` + exactly as captured, even when it disagrees with + :attr:`~pcapkit.protocols.data.link.radiotap.Radiotap.present` and + :attr:`~pcapkit.protocols.data.link.radiotap.Radiotap.fields` -- see + :meth:`read`. + + """ + return self._info.it_len + + ########################################################################## + # Methods. + ########################################################################## + + @classmethod + def id(cls) -> 'tuple[Literal["Radiotap"]]': + """Index ID of the protocol.""" + return ('Radiotap',) + + def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_Radiotap': # pylint: disable=unused-argument + """Read IEEE 802.11 radiotap header. + + Structure of the fixed start this class dissects, all little-endian: + + .. 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 + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | it_version | it_pad | it_len | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | it_present | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + + followed, while the previous word's bit 31 is set, by a further + ``it_present`` word, then by the field-data region those bits + select (kept whole, see the module docstring's *Scope* section), + then by the 802.11 MAC frame at offset ``it_len``. + + Args: + length: Length of packet data. + **kwargs: Arbitrary keyword arguments. + + Returns: + Parsed packet data. + + """ + if length is None: + length = len(self) + schema = self.__header__ + + it_version = schema.it_version + it_pad = schema.it_pad + it_len = schema.it_len + + #: The octets after the fixed start: any extended present-flags + #: words, the field-data region, and the 802.11 frame, all run + #: together -- see :meth:`_import_next_layer` for how the frame is + #: separated back out. + rest = schema.payload + + present = [schema.it_present] + offset = 0 + while present[-1] & _IT_PRESENT_EXTENDED: + if offset + _PRESENT_WORD_LEN > len(rest): + # Truncated mid present-flags chain, or a capture that never + # supplies the word bit 31 promised -- stop rather than read + # past what is actually here. + break + present.append(int.from_bytes(rest[offset:offset + _PRESENT_WORD_LEN], 'little')) + offset += _PRESENT_WORD_LEN + + # ``it_len`` is supposed to bound the fixed start, the present-flags + # chain and the field-data region together; clamp defensively + # against a value that is smaller than the chain just walked + # (internally inconsistent, but not a short *capture*) or larger + # than what was actually captured (a short capture). + tail_offset = max(it_len - _FIXED_HEADER_LEN, offset) + tail_offset = min(tail_offset, len(rest)) + + fields = rest[offset:tail_offset] + #: int: Offset into :meth:`self._get_payload() ` + #: where the 802.11 MAC frame starts -- read by + #: :meth:`_import_next_layer`. + self._mac_offset = tail_offset # pylint: disable=attribute-defined-outside-init + + radiotap = Data_Radiotap( + it_version=it_version, + it_pad=it_pad, + it_len=it_len, + present=tuple(present), + fields=fields, + ) + header_len = _FIXED_HEADER_LEN + tail_offset + return self._decode_next_layer(radiotap, 0, max(0, length - header_len)) + + def make(self, + it_version: 'int' = 0, + it_pad: 'int' = 0, + present: 'Sequence[int]' = (0,), + fields: 'bytes' = b'', + it_len: 'Optional[int]' = None, + payload: 'bytes | ProtocolBase | Schema' = b'', + **kwargs: 'Any') -> 'Schema_Radiotap': + """Make (construct) packet data. + + Args: + it_version: Header revision. + it_pad: Padding octet. + present: Every present-flags word, in order; the first is the + mandatory one radiotap.org calls ``it_present``, and each + but the last conventionally has bit 31 set (not enforced + here, so a capture that read short of its own chain + replays exactly: see the ``Note`` on + :meth:`FRAD.make ` + for the same tolerance). + fields: The optional-field region ``present`` selects, kept + whole; see the module docstring's *Scope* section. + it_len: Total header length, header included; computed from + ``present`` and ``fields`` when omitted. + payload: Payload data (the 802.11 MAC frame, opaque to this + class). + **kwargs: Arbitrary keyword arguments. + + Returns: + Constructed packet data. + + Raises: + ProtocolError: If ``it_version`` or ``it_pad`` does not fit in + 8 bits; if ``present`` is empty or any of its words does not + fit in 32 bits; if ``fields`` is not :class:`bytes`; or if + ``it_len`` is given and does not fit in 16 bits. + + """ + for name, value in (('it_version', it_version), ('it_pad', it_pad)): + if not isinstance(value, int) or isinstance(value, bool) or not 0 <= value <= 0xFF: + raise ProtocolError(f'{self.alias}: invalid {name}: {value!r}') + + present = tuple(present) + if not present: + raise ProtocolError(f'{self.alias}: invalid present: {present!r}, must not be empty') + for word in present: + if (not isinstance(word, int) or isinstance(word, bool) + or not 0 <= word <= 0xFFFF_FFFF): + raise ProtocolError(f'{self.alias}: invalid present word: {word!r}') + + if not isinstance(fields, (bytes, bytearray)): + raise ProtocolError(f'{self.alias}: invalid fields: {fields!r}, must be bytes') + fields = bytes(fields) + + if isinstance(payload, ProtocolBase): + payload_bytes = bytes(payload) + elif isinstance(payload, Schema): + payload_bytes = payload.pack() + else: + payload_bytes = bytes(payload) + + chain_bytes = b''.join(word.to_bytes(_PRESENT_WORD_LEN, 'little') for word in present[1:]) + + if it_len is None: + it_len = _FIXED_HEADER_LEN + len(chain_bytes) + len(fields) + elif (not isinstance(it_len, int) or isinstance(it_len, bool) + or not 0 <= it_len <= 0xFFFF): + raise ProtocolError(f'{self.alias}: invalid it_len: {it_len!r}') + + return Schema_Radiotap( + it_version=it_version, + it_pad=it_pad, + it_len=it_len, + it_present=present[0], + payload=chain_bytes + fields + payload_bytes, + ) + + ########################################################################## + # Data models. + ########################################################################## + + def __length_hint__(self) -> 'Literal[8]': + """Return an estimated length for the object. + + The fixed start alone, ``8`` -- the shortest a radiotap header can + structurally be (an empty present-flags word, no extended words, no + field data). A capture shorter than this is not a radiotap header at + all, so it is kept as :class:`~pcapkit.protocols.misc.raw.Raw` + rather than parsed; see :meth:`Protocol._parse_next_layer + `. + + """ + return 8 + + @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_Radiotap') -> '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 { + 'it_version': data.it_version, + 'it_pad': data.it_pad, + 'present': data.present, + 'fields': data.fields, + 'it_len': data.it_len, + '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 -- unused, see below. + length: valid (*non-padding*) length + packet: packet info (passed from :meth:`self.unpack + `) + + Returns: + Instance of next layer: always + :class:`~pcapkit.protocols.misc.raw.Raw`, or + :class:`~pcapkit.protocols.misc.null.NoPayload` when nothing + follows -- the 802.11 MAC frame this header precedes has no + dissector in this package; see the module docstring's *Scope* + section. The only reason this overrides the base + implementation is to start that payload at + :attr:`self._mac_offset` rather than immediately after the + fixed 8-octet start: everything in between -- the + present-flags chain and the field-data region it selects -- + belongs to this header, not to the frame behind it, and + :meth:`self._get_payload ` + does not know that boundary on its own. + + """ + if TYPE_CHECKING: + protocol: 'Type[ProtocolBase]' + + file_ = self._get_payload() + offset = min(self._mac_offset, len(file_)) + file_ = file_[offset:] + # NOTE: ``length``, as handed down from :meth:`read` through + # :meth:`_decode_next_layer`, already counts only the octets beyond + # the *whole* header (fixed start, present-flags chain and + # field-data region) -- i.e. it is already relative to the start + # ``file_`` is being trimmed to here, not to + # :meth:`self._get_payload() `'s + # untrimmed start. Subtracting ``offset`` a second time would read + # as though the chain and field-data region were being skipped + # twice. + if length is None: + length = len(file_) + else: + length = max(0, min(length, len(file_))) + + if not min(length, len(file_)): + from pcapkit.protocols.misc.null import \ + NoPayload as protocol # isort: skip # pylint: disable=import-outside-toplevel + else: + from pcapkit.protocols.misc.raw import \ + Raw as protocol # isort: skip # pylint: disable=import-outside-toplevel + + return self._parse_next_layer(protocol, file_, length, alias=proto, packet=packet, + layer=self._exlayer, protocol=self._exproto, + __context__=self._exctx) diff --git a/pcapkit/protocols/schema/__init__.py b/pcapkit/protocols/schema/__init__.py index cf7cca6790..ddb1375c32 100644 --- a/pcapkit/protocols/schema/__init__.py +++ b/pcapkit/protocols/schema/__init__.py @@ -30,6 +30,7 @@ 'L2TP', 'LinuxSLL1', 'LinuxSLL2', 'Loopback', + 'Radiotap', 'VLAN', 'VLAN_TCI', # Internet Layer Protocols diff --git a/pcapkit/protocols/schema/link/__init__.py b/pcapkit/protocols/schema/link/__init__.py index b9eae9ffab..9c77b09432 100644 --- a/pcapkit/protocols/schema/link/__init__.py +++ b/pcapkit/protocols/schema/link/__init__.py @@ -7,6 +7,7 @@ from pcapkit.protocols.schema.link.linux_sll1 import LinuxSLL1 from pcapkit.protocols.schema.link.linux_sll2 import LinuxSLL2 from pcapkit.protocols.schema.link.loopback import Loopback +from pcapkit.protocols.schema.link.radiotap import Radiotap from pcapkit.protocols.schema.link.vlan import TCI as VLAN_TCI from pcapkit.protocols.schema.link.vlan import VLAN @@ -16,5 +17,6 @@ 'L2TP', 'LinuxSLL1', 'LinuxSLL2', 'Loopback', + 'Radiotap', 'VLAN', 'VLAN_TCI', ] diff --git a/pcapkit/protocols/schema/link/radiotap.py b/pcapkit/protocols/schema/link/radiotap.py new file mode 100644 index 0000000000..3d41bd9cf6 --- /dev/null +++ b/pcapkit/protocols/schema/link/radiotap.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# mypy: disable-error-code=assignment +"""header schema for IEEE 802.11 radiotap headers""" + +from typing import TYPE_CHECKING + +from pcapkit.corekit.fields.misc import PayloadField +from pcapkit.corekit.fields.numbers import UInt8Field, UInt16Field, UInt32Field +from pcapkit.protocols.schema.schema import Schema, schema_final + +__all__ = ['Radiotap'] + +if TYPE_CHECKING: + from pcapkit.protocols.protocol import ProtocolBase + + +@schema_final +class Radiotap(Schema): + """Header schema for an IEEE 802.11 radiotap header. + + Only the fixed 8-octet start -- ``it_version``, ``it_pad``, ``it_len`` + and the first ``it_present`` word -- is given its own field, all + little-endian per radiotap.org. Everything that follows -- any extended + present-flags words, the optional-field region they select, and the + 802.11 MAC frame behind it -- is captured together as :attr:`payload`, + since where one ends and the next begins is only known once the + present-flags chain itself has been walked; see :meth:`Radiotap.read + `. + + """ + + #: Header revision. + it_version: 'int' = UInt8Field() + #: Padding octet. + it_pad: 'int' = UInt8Field() + #: Total header length, including this fixed start. + it_len: 'int' = UInt16Field(byteorder='little') + #: First present-flags word. + it_present: 'int' = UInt32Field(byteorder='little') + #: Everything after the fixed start -- see the class docstring. + payload: 'bytes' = PayloadField() + + if TYPE_CHECKING: + def __init__(self, it_version: 'int', it_pad: 'int', it_len: 'int', # pylint: disable=unused-argument,super-init-not-called,multiple-statements + it_present: 'int', # pylint: disable=unused-argument + payload: 'bytes | ProtocolBase | Schema') -> 'None': ... # pylint: disable=unused-argument 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..62844bc3e2 100644 --- a/tests/project/test_rfc_constants_one_home_1739_unit.py +++ b/tests/project/test_rfc_constants_one_home_1739_unit.py @@ -410,6 +410,8 @@ 'pcapkit/protocols/link/linux_sll1.py': ((65535,), "the max value of LINUX_SLL's own " '2-octet link_addr_length field, not an IP total ' 'length'), + 'pcapkit/protocols/link/radiotap.py': ((65535,), "the max value of radiotap's own " + '2-octet it_len field, not an IP total length'), 'pcapkit/protocols/schema/link/ethernet.py': ((6,), 'a MAC address is 6 octets'), 'pcapkit/protocols/schema/link/l2tp.py': ((6, 12), 'an L2TP header field offset, and the ' 'version bit-field'), diff --git a/tests/protocols/link/test_linux_sll1_unit.py b/tests/protocols/link/test_linux_sll1_unit.py index 54813ffd0e..d3a1598907 100644 --- a/tests/protocols/link/test_linux_sll1_unit.py +++ b/tests/protocols/link/test_linux_sll1_unit.py @@ -73,16 +73,18 @@ 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 ``FRAD``, per tcpdump.org's + LINUX_SLL page, never have ``protocol_type`` looked up as an + EtherType, even though 0x0800 is IPv4's. ``IEEE80211_RADIOTAP`` is + also in :data:`~pcapkit.protocols.link.linux_sll._ARPHRD_NOT_ETHERTYPE` + but dispatches to :class:`~pcapkit.protocols.link.radiotap.Radiotap` + instead; see :meth:`test_radiotap_arphrd_dispatches_to_radiotap`. """ 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): + for arphrd in (ARPHRD.NETLINK, ARPHRD.IPGRE, ARPHRD.IP6GRE, ARPHRD.FRAD): 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 +99,24 @@ def test_the_non_ethertype_arphrds_are_kept_raw(self) -> None: self.assertEqual(parsed.data, raw) self._assert_rebuilds(parsed) + def test_radiotap_arphrd_dispatches_to_radiotap(self) -> None: + """GitHub issue #2502: ``ARPHRD_IEEE80211_RADIOTAP`` dispatches to + :class:`~pcapkit.protocols.link.radiotap.Radiotap` rather than + falling back to :class:`~pcapkit.protocols.misc.raw.Raw`. + """ + from pcapkit.const.linux.arphrd import ARPHRD + from pcapkit.protocols.link.linux_sll1 import LinuxSLL1 + + radiotap = bytes.fromhex('00' '00' '0800' '00000000') + b'\xaa' * 10 + raw = _header(2, ARPHRD.IEEE80211_RADIOTAP, b'', 0, 0x0800) + radiotap + parsed = LinuxSLL1(raw, len(raw)) + self.assertEqual(parsed.info.arphrd_type, ARPHRD.IEEE80211_RADIOTAP) + self.assertEqual(type(parsed.payload).__name__, 'Radiotap') + self.assertEqual(parsed.payload.info.it_len, 8) + self.assertEqual(parsed.protochain.chain, 'LINUX_SLL1:Radiotap:Raw') + 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..0a69e4289b 100644 --- a/tests/protocols/link/test_linux_sll2_unit.py +++ b/tests/protocols/link/test_linux_sll2_unit.py @@ -75,15 +75,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_SLL2 page, are kept raw. + """``NETLINK``, ``IPGRE``, ``IP6GRE`` and ``FRAD``, per tcpdump.org's + LINUX_SLL2 page, are kept raw. ``IEEE80211_RADIOTAP`` is also in + :data:`~pcapkit.protocols.link.linux_sll._ARPHRD_NOT_ETHERTYPE` but + dispatches to :class:`~pcapkit.protocols.link.radiotap.Radiotap` + instead; see :meth:`test_radiotap_arphrd_dispatches_to_radiotap`. """ 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): + for arphrd in (ARPHRD.NETLINK, ARPHRD.IPGRE, ARPHRD.IP6GRE, ARPHRD.FRAD): with self.subTest(arphrd=arphrd): raw = _header(0x0800, 0, 0, arphrd, 2, b'', 0) + IPV4 parsed = LinuxSLL2(raw, len(raw)) @@ -96,6 +98,24 @@ def test_the_non_ethertype_arphrds_are_kept_raw(self) -> None: self.assertEqual(parsed.data, raw) self._assert_rebuilds(parsed) + def test_radiotap_arphrd_dispatches_to_radiotap(self) -> None: + """GitHub issue #2502: ``ARPHRD_IEEE80211_RADIOTAP`` dispatches to + :class:`~pcapkit.protocols.link.radiotap.Radiotap` 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 + + radiotap = bytes.fromhex('00' '00' '0800' '00000000') + b'\xaa' * 10 + raw = _header(0x0800, 0, 0, ARPHRD.IEEE80211_RADIOTAP, 2, b'', 0) + radiotap + parsed = LinuxSLL2(raw, len(raw)) + self.assertEqual(parsed.info.arphrd_type, ARPHRD.IEEE80211_RADIOTAP) + self.assertEqual(type(parsed.payload).__name__, 'Radiotap') + self.assertEqual(parsed.payload.info.it_len, 8) + self.assertEqual(parsed.protochain.chain, 'LINUX_SLL2:Radiotap:Raw') + 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/link/test_radiotap_unit.py b/tests/protocols/link/test_radiotap_unit.py new file mode 100644 index 0000000000..27cc60e8d1 --- /dev/null +++ b/tests/protocols/link/test_radiotap_unit.py @@ -0,0 +1,259 @@ +# -*- coding: utf-8 -*- +"""GitHub issue #2502: the IEEE 802.11 radiotap header. + +:class:`~pcapkit.protocols.link.radiotap.Radiotap` dissects the +``arphrd_type`` ``ARPHRD_IEEE80211_RADIOTAP`` 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 +fixed 8-octet start, the present-flags chain (plain and extended), the +field-data region being skipped rather than decoded, the 802.11 MAC frame +being kept as :class:`~pcapkit.protocols.misc.raw.Raw` (per the module +docstring's *Scope* section), malformed/truncated headers, and byte-exact +round trips. The Radiotap-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 + + +def _radiotap(it_len: int, present: 'list[int]', fields: bytes = b'', + it_version: int = 0, it_pad: int = 0) -> bytes: + """Pack a bare radiotap header: fixed start, present-flags chain, fields.""" + chain = b''.join(word.to_bytes(4, 'little') for word in present) + return (bytes([it_version, it_pad]) + it_len.to_bytes(2, 'little') + + chain + fields) + + +class TestRadiotap(unittest.TestCase): + """Pin the Radiotap 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_minimal_header_with_empty_bitmask(self) -> None: + """The shortest valid radiotap header: 8 octets, no fields, no + extended present-flags word. + """ + from pcapkit.protocols.link.radiotap import Radiotap + from pcapkit.protocols.misc.null import NoPayload + + raw = _radiotap(8, [0]) + parsed = Radiotap(raw, len(raw)) + self.assertEqual(parsed.info.it_version, 0) + self.assertEqual(parsed.info.it_pad, 0) + self.assertEqual(parsed.info.it_len, 8) + self.assertEqual(parsed.info.present, (0,)) + self.assertEqual(parsed.info.fields, b'') + self.assertEqual((parsed.name, parsed.alias, parsed.length), ('Radiotap', 'Radiotap', 8)) + self.assertIsInstance(parsed.payload, NoPayload) + self.assertEqual(parsed.protochain.chain, 'Radiotap') + self.assertEqual(parsed.data, raw) + self._assert_rebuilds(parsed) + + def test_header_with_present_fields_is_skipped_not_decoded(self) -> None: + """A non-empty bitmask with a field-data region: the region is + skipped over whole, per the module docstring's *Scope* section, and + the 802.11 MAC frame behind it is kept as + :class:`~pcapkit.protocols.misc.raw.Raw`. + """ + from pcapkit.protocols.link.radiotap import Radiotap + from pcapkit.protocols.misc.raw import Raw + + # bit 0 (TSFT, 8 octets) + bit 1 (Flags, 1 octet) + bit 2 (Rate, 1 + # octet) -- the exact field widths are not decoded, so any 10-octet + # filler stands in for them. + fields = bytes.fromhex('0102030405060708090a') + mac = b'\xde\xad\xbe\xef' * 3 + raw = _radiotap(8 + len(fields), [0b111], fields) + mac + parsed = Radiotap(raw, len(raw)) + self.assertEqual(parsed.info.it_len, 8 + len(fields)) + self.assertEqual(parsed.info.present, (0b111,)) + self.assertEqual(parsed.info.fields, fields) + self.assertIsInstance(parsed.payload, Raw) + self.assertEqual(parsed.payload.data, mac) + self.assertEqual(parsed.protochain.chain, 'Radiotap:Raw') + self.assertEqual(parsed.data, raw) + self._assert_rebuilds(parsed) + + def test_extended_presence_chaining(self) -> None: + """Bit 31 of a present-flags word chains in another word; the chain + is walked fully, every word recorded in :attr:`~pcapkit.protocols.data.link.radiotap.Radiotap.present`. + """ + from pcapkit.protocols.link.radiotap import Radiotap + from pcapkit.protocols.misc.raw import Raw + + word0 = (1 << 31) | 0b1 # bit 31 set: a second word follows + word1 = (1 << 31) | 0b10 # bit 31 set: a third word follows + word2 = 0b100 # bit 31 clear: chain ends here + fields = bytes.fromhex('aabbccdd') + mac = b'\x01\x02\x03' + raw = _radiotap(8 + 8 + len(fields), [word0, word1, word2], fields) + mac + parsed = Radiotap(raw, len(raw)) + self.assertEqual(parsed.info.present, (word0, word1, word2)) + self.assertEqual(parsed.info.fields, fields) + self.assertEqual(parsed.info.it_len, 20) + self.assertIsInstance(parsed.payload, Raw) + self.assertEqual(parsed.payload.data, mac) + self.assertEqual(parsed.protochain.chain, 'Radiotap:Raw') + self.assertEqual(parsed.data, raw) + self._assert_rebuilds(parsed) + + def test_malformed_it_len_smaller_than_the_chain_it_claims(self) -> None: + """``it_len`` claims a header too small to hold the present-flags + word its own first word's bit 31 promises. The chain is still + walked from the actual octets captured; the field-data region is + empty rather than negative, and the frame behind the (malformed) + header is not swallowed by it. + """ + from pcapkit.protocols.link.radiotap import Radiotap + from pcapkit.protocols.misc.raw import Raw + + word0 = (1 << 31) | 0b1 + word1 = 0b10 + # it_len=8 claims only the fixed start, but word0's bit 31 means a + # second present-flags word genuinely follows on the wire. + raw = _radiotap(8, [word0, word1]) + b'\xaa\xbb' + parsed = Radiotap(raw, len(raw)) + self.assertEqual(parsed.info.it_len, 8) + self.assertEqual(parsed.info.present, (word0, word1)) + self.assertEqual(parsed.info.fields, b'') + self.assertIsInstance(parsed.payload, Raw) + self.assertEqual(parsed.payload.data, b'\xaa\xbb') + self.assertEqual(parsed.data, raw) + self._assert_rebuilds(parsed) + + def test_malformed_it_len_larger_than_what_was_captured(self) -> None: + """``it_len`` claims more octets than the capture actually holds + (fully captured, just internally inconsistent -- distinct from a + genuinely short capture, covered below). Nothing is decoded past + what is there, and the payload is empty rather than negative-length. + """ + from pcapkit.protocols.link.radiotap import Radiotap + from pcapkit.protocols.misc.null import NoPayload + + raw = _radiotap(200, [0]) + b'\x01\x02\x03' + parsed = Radiotap(raw, len(raw)) + self.assertEqual(parsed.info.it_len, 200) + self.assertEqual(parsed.info.present, (0,)) + self.assertEqual(parsed.info.fields, b'\x01\x02\x03') + self.assertIsInstance(parsed.payload, NoPayload) + self.assertEqual(parsed.data, raw) + self._assert_rebuilds(parsed) + + def test_truncated_header_rebuilds_as_captured(self) -> None: + """A capture ending inside the header -- the fixed start, the + present-flags chain, or the field-data region -- 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.radiotap import Radiotap + + word0 = (1 << 31) | 0b1 + word1 = 0b10 + fields = bytes.fromhex('aabbccdd') + raw = _radiotap(16, [word0, word1], fields) + b'\x01\x02\x03' + for keep in range(1, len(raw)): + with self.subTest(keep=keep): + cut = raw[:keep] + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + parsed = Radiotap(cut, len(cut)) + self.assertEqual(parsed.data, cut) + self.assertEqual(Radiotap.from_data(parsed.info).data, cut) + + def test_make_then_parse_minimal(self) -> None: + from pcapkit.protocols.link.radiotap import Radiotap + + made = Radiotap().data + self.assertEqual(made, _radiotap(8, [0])) + + parsed = Radiotap(made, len(made)) + self.assertEqual(parsed.info.present, (0,)) + self._assert_rebuilds(parsed) + + def test_make_then_parse_with_fields_and_payload(self) -> None: + from pcapkit.protocols.link.radiotap import Radiotap + + fields = bytes.fromhex('0102030405060708090a') + mac = b'\xde\xad\xbe\xef' * 3 + made = Radiotap(present=(0b111,), fields=fields, payload=mac).data + self.assertEqual(made, _radiotap(8 + len(fields), [0b111], fields) + mac) + + parsed = Radiotap(made, len(made)) + self.assertEqual(parsed.info.fields, fields) + self.assertEqual(type(parsed.payload).__name__, 'Raw') + self.assertEqual(parsed.payload.data, mac) + self._assert_rebuilds(parsed) + + def test_make_then_parse_extended_presence(self) -> None: + from pcapkit.protocols.link.radiotap import Radiotap + + word0 = (1 << 31) | 0b1 + word1 = 0b10 + made = Radiotap(present=(word0, word1)).data + self.assertEqual(made, _radiotap(12, [word0, word1])) + + parsed = Radiotap(made, len(made)) + self.assertEqual(parsed.info.present, (word0, word1)) + self._assert_rebuilds(parsed) + + def test_make_computes_it_len_from_present_and_fields(self) -> None: + from pcapkit.protocols.link.radiotap import Radiotap + + made = Radiotap(present=(0, 0), fields=bytes(5), payload=bytes(3)) + self.assertEqual(made.info.it_len, 8 + 4 + 5) + + def test_make_explicit_it_len_is_written_as_given(self) -> None: + """An explicit ``it_len`` is written as given, not recomputed -- the + same reason :meth:`UDP.make ` + keeps an explicit ``total_length`` (:issue:`1155`), and + :meth:`Netlink.make ` + keeps an explicit ``nlmsg_len``. + """ + from pcapkit.protocols.link.radiotap import Radiotap + + made = Radiotap(it_len=999) + self.assertEqual(made.info.it_len, 999) + + def test_make_rejects_what_does_not_fit(self) -> None: + from pcapkit.protocols.link.radiotap import Radiotap + from pcapkit.utilities.exceptions import ProtocolError + + for kwargs in ( + {'it_version': -1}, {'it_version': 0x100}, {'it_version': True}, + {'it_pad': -1}, {'it_pad': 0x100}, + {'present': ()}, + {'present': (-1,)}, {'present': (0x1_0000_0000,)}, + {'fields': 'not bytes'}, + {'it_len': -1}, {'it_len': 0x1_0000}, {'it_len': True}, + ): + with self.subTest(**kwargs), self.assertRaises(ProtocolError): + Radiotap(**kwargs) + + def test_id_and_index(self) -> None: + from pcapkit.protocols.link.radiotap import Radiotap + from pcapkit.utilities.exceptions import UnsupportedCall + + self.assertEqual(Radiotap.id(), ('Radiotap',)) + with self.assertRaises(UnsupportedCall): + Radiotap.__index__() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/protocols/test_short_read_roundtrip_unit.py b/tests/protocols/test_short_read_roundtrip_unit.py index f3c81b28c0..5c2f623c79 100644 --- a/tests/protocols/test_short_read_roundtrip_unit.py +++ b/tests/protocols/test_short_read_roundtrip_unit.py @@ -52,6 +52,8 @@ 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')), + ('radiotap', 'link.radiotap:Radiotap', {}, bytes.fromhex('0000' '0c00' '01000000' '11223344') + + bytes.fromhex('aabbccddeeff')), ('ospf', 'application.ospf:OSPF', {}, bytes.fromhex('020100180102030400000000abcd0000') + bytes(8)), ('ipx', 'internet.ipx:IPX', {}, bytes.fromhex('ffff001e0000000000000101010101010451000000000202020202020452')), ('ipv4', 'internet.ipv4:IPv4', {}, bytes.fromhex('450000141234000040fd7b83c0000201c6336401')), From 7917cd96a8943726d7f19add9bab1a9e0c7a2519 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Nov 2026 05:31:31 +0000 Subject: [PATCH 2/5] Co-authored-by: JarryShaw <15666417+JarryShaw@users.noreply.github.com> --- pcapkit/protocols/link/linux_sll.py | 1 - tests/protocols/link/test_linux_sll1_unit.py | 12 ++++++------ tests/protocols/link/test_linux_sll2_unit.py | 12 ++++++------ 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/pcapkit/protocols/link/linux_sll.py b/pcapkit/protocols/link/linux_sll.py index a8dd46e497..141c99fe8c 100644 --- a/pcapkit/protocols/link/linux_sll.py +++ b/pcapkit/protocols/link/linux_sll.py @@ -36,7 +36,6 @@ #: ``arphrd_type`` values whose ``protocol_type`` is not an EtherType, per #: tcpdump.org's LINUX_SLL and LINUX_SLL2 pages. _ARPHRD_NOT_ETHERTYPE = frozenset({ - ARPHRD.FRAD, ARPHRD.IPGRE, ARPHRD.IEEE80211_RADIOTAP, ARPHRD.IP6GRE, diff --git a/tests/protocols/link/test_linux_sll1_unit.py b/tests/protocols/link/test_linux_sll1_unit.py index 8ee540ac5f..59eddaa211 100644 --- a/tests/protocols/link/test_linux_sll1_unit.py +++ b/tests/protocols/link/test_linux_sll1_unit.py @@ -73,18 +73,18 @@ 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`` and ``FRAD``, per tcpdump.org's + """``NETLINK``, ``IPGRE`` and ``IP6GRE``, per tcpdump.org's LINUX_SLL page, never have ``protocol_type`` looked up as an - EtherType, even though 0x0800 is IPv4's. ``IEEE80211_RADIOTAP`` is - also in :data:`~pcapkit.protocols.link.linux_sll._ARPHRD_NOT_ETHERTYPE` - but dispatches to :class:`~pcapkit.protocols.link.radiotap.Radiotap` - instead; see :meth:`test_radiotap_arphrd_dispatches_to_radiotap`. + EtherType, even though 0x0800 is IPv4's. ``FRAD`` and + ``IEEE80211_RADIOTAP`` are special-cased elsewhere: the former + dispatches to :class:`~pcapkit.protocols.link.frad.FRAD`, and the + latter to :class:`~pcapkit.protocols.link.radiotap.Radiotap`. """ 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.FRAD): + for arphrd in (ARPHRD.NETLINK, ARPHRD.IPGRE, ARPHRD.IP6GRE): with self.subTest(arphrd=arphrd): # protocol_type 0x0800 is IPv4's EtherType, but these # hardware types never carry an EtherType in protocol_type. diff --git a/tests/protocols/link/test_linux_sll2_unit.py b/tests/protocols/link/test_linux_sll2_unit.py index 614d21ab61..44cbfaaf4c 100644 --- a/tests/protocols/link/test_linux_sll2_unit.py +++ b/tests/protocols/link/test_linux_sll2_unit.py @@ -75,17 +75,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`` and ``FRAD``, per tcpdump.org's - LINUX_SLL2 page, are kept raw. ``IEEE80211_RADIOTAP`` is also in - :data:`~pcapkit.protocols.link.linux_sll._ARPHRD_NOT_ETHERTYPE` but - dispatches to :class:`~pcapkit.protocols.link.radiotap.Radiotap` - instead; see :meth:`test_radiotap_arphrd_dispatches_to_radiotap`. + """``NETLINK``, ``IPGRE`` and ``IP6GRE``, per tcpdump.org's + LINUX_SLL2 page, are kept raw. ``FRAD`` and + ``IEEE80211_RADIOTAP`` are special-cased elsewhere: the former + dispatches to :class:`~pcapkit.protocols.link.frad.FRAD`, and the + latter to :class:`~pcapkit.protocols.link.radiotap.Radiotap`. """ 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.FRAD): + for arphrd in (ARPHRD.NETLINK, ARPHRD.IPGRE, ARPHRD.IP6GRE): with self.subTest(arphrd=arphrd): raw = _header(0x0800, 0, 0, arphrd, 2, b'', 0) + IPV4 parsed = LinuxSLL2(raw, len(raw)) From 671102ef6ce581390d3b5fb86151440a133d41ec Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Sun, 1 Nov 2026 01:28:41 -0500 Subject: [PATCH 3/5] docs(changelog): drop the Radiotap entries, centralised in #2483 (#2502) Restore CHANGELOG.md and docs/source/changelog/1.6.0.rst to main's content; the Radiotap entry moves to the shared changelog pull request. --- CHANGELOG.md | 2 -- docs/source/changelog/1.6.0.rst | 18 ------------------ 2 files changed, 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32e7cdf274..d88b4c1dad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,6 @@ 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** -- `Radiotap`, a dissector for the fixed 8-octet start of the radiotap header () capture tools prepend to an 802.11 frame. `LinuxSLL`/ `LinuxSLL1`/ `LinuxSLL2` dispatch to it for the `ARPHRD_IEEE80211_RADIOTAP` case instead of falling back to `Raw`. Decodes `it_version`, `it_pad` and `it_len`, and walks the present-flags chain (including its bit-31 extended-word chaining) far enough to count its words; the optional-field region those bits select is skipped whole rather than decoded field by field, and the 802.11 MAC frame behind it -- which has no dissector anywhere in this package yet -- is kept as `Raw`. The last of [#2460](https://github.com/JarryShaw/PyPCAPKit/issues/2460)'s five `arphrd_type` follow-ups to get a dissector of its own -- `FRAD` ([#2499](https://github.com/JarryShaw/PyPCAPKit/pull/2499)), `NETLINK` ([#2507](https://github.com/JarryShaw/PyPCAPKit/pull/2507)) and `IPGRE`/`IP6GRE` ([#2501](https://github.com/JarryShaw/PyPCAPKit/issues/2501)) are tracked on their own reviews. - - **Added** -- `GRE`, a dissector for the Generic Routing Encapsulation base header ([RFC 2784](https://datatracker.ietf.org/doc/html/rfc2784)) plus the optional Key and Sequence Number extensions ([RFC 2890](https://datatracker.ietf.org/doc/html/rfc2890)). `LinuxSLL`/ `LinuxSLL1`/ `LinuxSLL2` dispatch to it for both the `ARPHRD_IPGRE` and `ARPHRD_IP6GRE` cases -- the same GRE framing either way, the "IP"/"IP6" distinction being only which outer IP version the tunnel carries -- instead of falling back to `Raw`. The Protocol Type field dispatches onward as an EtherType, through the same family registry (`Link.__proto__`) Ethernet, VLAN and LinuxSLL already share. One of [#2460](https://github.com/JarryShaw/PyPCAPKit/issues/2460)'s five `arphrd_type` follow-ups to get a dissector (`NETLINK` and `FRAD` are tracked separately on [#2507](https://github.com/JarryShaw/PyPCAPKit/pull/2507) and [#2499](https://github.com/JarryShaw/PyPCAPKit/pull/2499)); `IEEE80211_RADIOTAP` remains `Raw`. - **Added** -- `Netlink`, a dissector for the fixed 16-octet `nlmsghdr` framing of Linux's own kernel/userspace IPC transport (the kernel's own `netlink(7)` man page; [RFC 3549 Section 2.3.2](https://datatracker.ietf.org/doc/html/rfc3549#section-2.3.2)). `LinuxSLL`/ `LinuxSLL1`/ `LinuxSLL2` dispatch to it for the `ARPHRD_NETLINK` case instead of falling back to `Raw`. The byte order the header was written in is ambiguous -- Netlink is kernel/userspace IPC, not wire format -- and is guessed the way Wireshark's own netlink dissector does, by `nlmsg_byteorder()`. Per-family message payloads (routing table entries and the like) and multiple messages packed into one capture are out of scope for this first cut; every message's payload is kept as `Raw`, unconditionally. One of [#2460](https://github.com/JarryShaw/PyPCAPKit/issues/2460)'s five `arphrd_type` follow-ups to get a dissector (`FRAD` is tracked separately on [#2499](https://github.com/JarryShaw/PyPCAPKit/pull/2499)); `IPGRE`/`IP6GRE` and `IEEE80211_RADIOTAP` remain `Raw`. diff --git a/docs/source/changelog/1.6.0.rst b/docs/source/changelog/1.6.0.rst index 913f7c0f64..2b5b603f2a 100644 --- a/docs/source/changelog/1.6.0.rst +++ b/docs/source/changelog/1.6.0.rst @@ -5,24 +5,6 @@ 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.radiotap.Radiotap`, a dissector - for the fixed 8-octet start of the radiotap header - () capture tools prepend to an 802.11 frame. - :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_IEEE80211_RADIOTAP`` case instead of falling back to - :class:`~pcapkit.protocols.misc.raw.Raw`. Decodes ``it_version``, - ``it_pad`` and ``it_len``, and walks the present-flags chain (including its - bit-31 extended-word chaining) far enough to count its words; the - optional-field region those bits select is skipped whole rather than - decoded field by field, and the 802.11 MAC frame behind it -- which has no - dissector anywhere in this package yet -- is kept as - :class:`~pcapkit.protocols.misc.raw.Raw`. The last of :issue:`2460`'s five - ``arphrd_type`` follow-ups to get a dissector of its own -- ``FRAD`` - (:pr:`2499`), ``NETLINK`` (:pr:`2507`) and ``IPGRE``/``IP6GRE`` - (:issue:`2501`) are tracked on their own reviews. - * **Added** -- :class:`~pcapkit.protocols.link.gre.GRE`, a dissector for the Generic Routing Encapsulation base header (:rfc:`2784`) plus the optional Key and Sequence Number extensions (:rfc:`2890`). From b1f1f57b77250d1c1ee8c5b1005f7eefd7d92ecb Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Sun, 1 Nov 2026 01:49:00 -0500 Subject: [PATCH 4/5] fix(link): keep ARPHRD.FRAD in LinuxSLL's non-EtherType set (#2502) 7917cd96a dropped ARPHRD.FRAD from _ARPHRD_NOT_ETHERTYPE, so _resolve_next_layer_proto turned FRAD's protocol_type into an EtherType. Restore it where main has it, and pin it in both LinuxSLL test modules. --- pcapkit/protocols/link/linux_sll.py | 1 + tests/protocols/link/test_linux_sll1_unit.py | 15 +++++++++++++++ tests/protocols/link/test_linux_sll2_unit.py | 15 +++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/pcapkit/protocols/link/linux_sll.py b/pcapkit/protocols/link/linux_sll.py index e7c404995a..877a6a1017 100644 --- a/pcapkit/protocols/link/linux_sll.py +++ b/pcapkit/protocols/link/linux_sll.py @@ -36,6 +36,7 @@ #: ``arphrd_type`` values whose ``protocol_type`` is not an EtherType, per #: tcpdump.org's LINUX_SLL and LINUX_SLL2 pages. _ARPHRD_NOT_ETHERTYPE = frozenset({ + ARPHRD.FRAD, ARPHRD.IPGRE, ARPHRD.IEEE80211_RADIOTAP, ARPHRD.IP6GRE, diff --git a/tests/protocols/link/test_linux_sll1_unit.py b/tests/protocols/link/test_linux_sll1_unit.py index 124600573d..27453e373a 100644 --- a/tests/protocols/link/test_linux_sll1_unit.py +++ b/tests/protocols/link/test_linux_sll1_unit.py @@ -156,6 +156,21 @@ def test_frad_arphrd_dispatches_to_frad(self) -> None: self.assertEqual(parsed.data, raw) self._assert_rebuilds(parsed) + def test_frad_protocol_type_is_not_an_ethertype(self) -> None: + """``ARPHRD_FRAD`` is in + :data:`~pcapkit.protocols.link.linux_sll._ARPHRD_NOT_ETHERTYPE`, so its + ``protocol_type`` is never looked up as an EtherType, per tcpdump.org's + LINUX_SLL page (:issue:`2502`). + """ + from pcapkit.const.linux.arphrd import ARPHRD + from pcapkit.protocols.link.linux_sll import (_ARPHRD_NOT_ETHERTYPE, + _resolve_next_layer_proto) + + self.assertIn(ARPHRD.FRAD, _ARPHRD_NOT_ETHERTYPE) + proto = _resolve_next_layer_proto(ARPHRD.FRAD, 0x0800) + self.assertIs(type(proto), int) + self.assertEqual(proto, 0x0800) + def test_ipgre_and_ip6gre_arphrd_dispatch_to_gre(self) -> None: """GitHub issue #2501: ``ARPHRD_IPGRE`` and ``ARPHRD_IP6GRE`` both dispatch to :class:`~pcapkit.protocols.link.gre.GRE` rather than diff --git a/tests/protocols/link/test_linux_sll2_unit.py b/tests/protocols/link/test_linux_sll2_unit.py index 0122f401d1..4bb0b6677e 100644 --- a/tests/protocols/link/test_linux_sll2_unit.py +++ b/tests/protocols/link/test_linux_sll2_unit.py @@ -155,6 +155,21 @@ def test_frad_arphrd_dispatches_to_frad(self) -> None: self.assertEqual(parsed.data, raw) self._assert_rebuilds(parsed) + def test_frad_protocol_type_is_not_an_ethertype(self) -> None: + """``ARPHRD_FRAD`` is in + :data:`~pcapkit.protocols.link.linux_sll._ARPHRD_NOT_ETHERTYPE`, so its + ``protocol_type`` is never looked up as an EtherType, per tcpdump.org's + LINUX_SLL2 page (:issue:`2502`). + """ + from pcapkit.const.linux.arphrd import ARPHRD + from pcapkit.protocols.link.linux_sll import (_ARPHRD_NOT_ETHERTYPE, + _resolve_next_layer_proto) + + self.assertIn(ARPHRD.FRAD, _ARPHRD_NOT_ETHERTYPE) + proto = _resolve_next_layer_proto(ARPHRD.FRAD, 0x0800) + self.assertIs(type(proto), int) + self.assertEqual(proto, 0x0800) + def test_ipgre_and_ip6gre_arphrd_dispatch_to_gre(self) -> None: """GitHub issue #2501: ``ARPHRD_IPGRE`` and ``ARPHRD_IP6GRE`` both dispatch to :class:`~pcapkit.protocols.link.gre.GRE` rather than From de90b3a660cd7b031eb2dbc996390f99a70f3d3b Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Sun, 1 Nov 2026 02:22:35 -0500 Subject: [PATCH 5/5] fix(link): drop LinuxSLL's dead Raw fallback, pin truncated FRAD (#2502) - LinuxSLL._import_next_layer: every _ARPHRD_NOT_ETHERTYPE member now has its own branch, so the trailing Raw fallback is removed and the method ends in the EtherType lookup; the Returns docstring says so. - Remove the SLL1/SLL2 test_the_non_ethertype_arphrds_are_kept_raw tests, which looped over an empty tuple. - Add test_truncated_frad_is_kept_raw_with_an_int_protocol to both: a 1-3 octet FRAD payload parses as LINUX_SLL1:Raw / LINUX_SLL2:Raw with Raw.info.protocol a plain int. - test_frad_protocol_type_is_not_an_ethertype cites #2460, not #2502. --- pcapkit/protocols/link/linux_sll.py | 14 ++---- tests/protocols/link/test_linux_sll1_unit.py | 48 +++++++++----------- tests/protocols/link/test_linux_sll2_unit.py | 46 +++++++++---------- 3 files changed, 48 insertions(+), 60 deletions(-) diff --git a/pcapkit/protocols/link/linux_sll.py b/pcapkit/protocols/link/linux_sll.py index 877a6a1017..5d46619458 100644 --- a/pcapkit/protocols/link/linux_sll.py +++ b/pcapkit/protocols/link/linux_sll.py @@ -161,9 +161,9 @@ def _import_next_layer(self, proto: 'int', length: 'Optional[int]' = None, *, # :class:`~pcapkit.protocols.link.radiotap.Radiotap` when ``arphrd_type`` is :attr:`ARPHRD.IEEE80211_RADIOTAP `; - otherwise :class:`~pcapkit.protocols.misc.raw.Raw`, recording - ``proto``, for every other ``arphrd_type`` in - :data:`_ARPHRD_NOT_ETHERTYPE`. + otherwise the dissector :attr:`Link.__proto__ + ` holds for ``proto``, + an EtherType. """ file_ = self._get_payload() @@ -199,10 +199,4 @@ def _import_next_layer(self, proto: 'int', length: 'Optional[int]' = None, *, # 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 - return self._parse_next_layer(Raw, file_, length, alias=proto, packet=packet, - layer=self._exlayer, protocol=self._exproto, - __context__=self._exctx) + return super()._import_next_layer(proto, length, packet=packet) # type: ignore[misc,call-arg] diff --git a/tests/protocols/link/test_linux_sll1_unit.py b/tests/protocols/link/test_linux_sll1_unit.py index 27453e373a..a563f1444b 100644 --- a/tests/protocols/link/test_linux_sll1_unit.py +++ b/tests/protocols/link/test_linux_sll1_unit.py @@ -72,31 +72,6 @@ def test_ethernet_arphrd_dispatches_by_ethertype(self) -> None: self.assertEqual(parsed.data, raw) self._assert_rebuilds(parsed) - def test_the_non_ethertype_arphrds_are_kept_raw(self) -> None: - """The generic ``arphrd_type``s that never have ``protocol_type`` looked - up as an EtherType are kept raw. ``NETLINK``, ``FRAD``, - ``IPGRE``/``IP6GRE`` and ``IEEE80211_RADIOTAP`` are the exceptional - dispatch cases, covered by their dedicated tests. - """ - 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 (): - with self.subTest(arphrd=arphrd): - # protocol_type 0x0800 is IPv4's EtherType, but these - # hardware types never carry an EtherType in protocol_type. - raw = _header(2, arphrd, b'', 0, 0x0800) + IPV4 - parsed = LinuxSLL1(raw, len(raw)) - self.assertEqual(parsed.info.arphrd_type, arphrd) - self.assertEqual(parsed.info.protocol_type, 0x0800) - self.assertIsInstance(parsed.payload, Raw) - self.assertIs(type(parsed.payload.info.protocol), int) - self.assertEqual(parsed.payload.info.protocol, 0x0800) - self.assertEqual(parsed.protochain.chain, 'LINUX_SLL1:Raw') - self.assertEqual(parsed.data, raw) - self._assert_rebuilds(parsed) - def test_netlink_arphrd_dispatches_to_netlink(self) -> None: """``ARPHRD_NETLINK`` dispatches to :class:`~pcapkit.protocols.link.netlink.Netlink`, not @@ -160,7 +135,7 @@ def test_frad_protocol_type_is_not_an_ethertype(self) -> None: """``ARPHRD_FRAD`` is in :data:`~pcapkit.protocols.link.linux_sll._ARPHRD_NOT_ETHERTYPE`, so its ``protocol_type`` is never looked up as an EtherType, per tcpdump.org's - LINUX_SLL page (:issue:`2502`). + LINUX_SLL page (:issue:`2460`). """ from pcapkit.const.linux.arphrd import ARPHRD from pcapkit.protocols.link.linux_sll import (_ARPHRD_NOT_ETHERTYPE, @@ -171,6 +146,27 @@ def test_frad_protocol_type_is_not_an_ethertype(self) -> None: self.assertIs(type(proto), int) self.assertEqual(proto, 0x0800) + def test_truncated_frad_is_kept_raw_with_an_int_protocol(self) -> None: + """A FRAD payload too short for its header is kept as + :class:`~pcapkit.protocols.misc.raw.Raw`, which records + ``protocol_type`` as a plain :class:`int`, not an EtherType member + (:issue:`2460`). + """ + from pcapkit.const.linux.arphrd import ARPHRD + from pcapkit.protocols.link.linux_sll1 import LinuxSLL1 + from pcapkit.protocols.misc.raw import Raw + + for size in (1, 2, 3): + with self.subTest(size=size): + raw = _header(2, ARPHRD.FRAD, b'', 0, 0x0800) + bytes.fromhex('0c2103')[:size] + parsed = LinuxSLL1(raw, len(raw)) + self.assertEqual(parsed.protochain.chain, 'LINUX_SLL1:Raw') + self.assertIsInstance(parsed.payload, Raw) + self.assertIs(type(parsed.payload.info.protocol), int) + self.assertEqual(parsed.payload.info.protocol, 0x0800) + self.assertEqual(parsed.data, raw) + self._assert_rebuilds(parsed) + def test_ipgre_and_ip6gre_arphrd_dispatch_to_gre(self) -> None: """GitHub issue #2501: ``ARPHRD_IPGRE`` and ``ARPHRD_IP6GRE`` both dispatch to :class:`~pcapkit.protocols.link.gre.GRE` rather than diff --git a/tests/protocols/link/test_linux_sll2_unit.py b/tests/protocols/link/test_linux_sll2_unit.py index 4bb0b6677e..39d2ef47b9 100644 --- a/tests/protocols/link/test_linux_sll2_unit.py +++ b/tests/protocols/link/test_linux_sll2_unit.py @@ -74,29 +74,6 @@ def test_ethernet_arphrd_dispatches_by_ethertype(self) -> None: self.assertEqual(parsed.data, raw) self._assert_rebuilds(parsed) - def test_the_non_ethertype_arphrds_are_kept_raw(self) -> None: - """The generic ``arphrd_type``s that never have ``protocol_type`` looked - up as an EtherType are kept raw. ``NETLINK``, ``FRAD``, - ``IPGRE``/``IP6GRE`` and ``IEEE80211_RADIOTAP`` are the exceptional - dispatch cases, covered by their dedicated tests. - """ - 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 (): - with self.subTest(arphrd=arphrd): - raw = _header(0x0800, 0, 0, arphrd, 2, b'', 0) + IPV4 - parsed = LinuxSLL2(raw, len(raw)) - self.assertEqual(parsed.info.arphrd_type, arphrd) - self.assertEqual(parsed.info.protocol_type, 0x0800) - self.assertIsInstance(parsed.payload, Raw) - self.assertIs(type(parsed.payload.info.protocol), int) - self.assertEqual(parsed.payload.info.protocol, 0x0800) - self.assertEqual(parsed.protochain.chain, 'LINUX_SLL2:Raw') - self.assertEqual(parsed.data, raw) - self._assert_rebuilds(parsed) - def test_netlink_arphrd_dispatches_to_netlink(self) -> None: """``ARPHRD_NETLINK`` dispatches to :class:`~pcapkit.protocols.link.netlink.Netlink`, not @@ -159,7 +136,7 @@ def test_frad_protocol_type_is_not_an_ethertype(self) -> None: """``ARPHRD_FRAD`` is in :data:`~pcapkit.protocols.link.linux_sll._ARPHRD_NOT_ETHERTYPE`, so its ``protocol_type`` is never looked up as an EtherType, per tcpdump.org's - LINUX_SLL2 page (:issue:`2502`). + LINUX_SLL2 page (:issue:`2460`). """ from pcapkit.const.linux.arphrd import ARPHRD from pcapkit.protocols.link.linux_sll import (_ARPHRD_NOT_ETHERTYPE, @@ -170,6 +147,27 @@ def test_frad_protocol_type_is_not_an_ethertype(self) -> None: self.assertIs(type(proto), int) self.assertEqual(proto, 0x0800) + def test_truncated_frad_is_kept_raw_with_an_int_protocol(self) -> None: + """A FRAD payload too short for its header is kept as + :class:`~pcapkit.protocols.misc.raw.Raw`, which records + ``protocol_type`` as a plain :class:`int`, not an EtherType member + (:issue:`2460`). + """ + from pcapkit.const.linux.arphrd import ARPHRD + from pcapkit.protocols.link.linux_sll2 import LinuxSLL2 + from pcapkit.protocols.misc.raw import Raw + + for size in (1, 2, 3): + with self.subTest(size=size): + raw = _header(0x0800, 0, 0, ARPHRD.FRAD, 2, b'', 0) + bytes.fromhex('0c2103')[:size] + parsed = LinuxSLL2(raw, len(raw)) + self.assertEqual(parsed.protochain.chain, 'LINUX_SLL2:Raw') + self.assertIsInstance(parsed.payload, Raw) + self.assertIs(type(parsed.payload.info.protocol), int) + self.assertEqual(parsed.payload.info.protocol, 0x0800) + self.assertEqual(parsed.data, raw) + self._assert_rebuilds(parsed) + def test_ipgre_and_ip6gre_arphrd_dispatch_to_gre(self) -> None: """GitHub issue #2501: ``ARPHRD_IPGRE`` and ``ARPHRD_IP6GRE`` both dispatch to :class:`~pcapkit.protocols.link.gre.GRE` rather than