diff --git a/docs/source/pcapkit/protocols/link/index.rst b/docs/source/pcapkit/protocols/link/index.rst
index dcfedaf46c..44afecf4d6 100644
--- a/docs/source/pcapkit/protocols/link/index.rst
+++ b/docs/source/pcapkit/protocols/link/index.rst
@@ -33,6 +33,7 @@ link layer, with detailed implementation and methods.
linux_sll2
loopback
netlink
+ 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 6f83c6c1fb..a00ab4c6e7 100644
--- a/pcapkit/__init__.py
+++ b/pcapkit/__init__.py
@@ -112,7 +112,8 @@
'Raw', # Raw Packet
'ARP', 'C_Tag', 'Ethernet', 'FRAD', 'GRE', 'InARP', 'L2TP', # Link Layer
- 'L2TPv2', 'LinuxSLL', 'LinuxSLL1', 'LinuxSLL2', 'Loopback', 'Netlink', 'S_Tag', 'VLAN',
+ 'L2TPv2', 'LinuxSLL', 'LinuxSLL1', 'LinuxSLL2', 'Loopback', 'Netlink', 'Radiotap',
+ 'S_Tag', 'VLAN',
'AH', 'ESP', 'ICMP', 'ICMPv4', 'ICMPv6', # Internet Layer
'IGMP', 'IGMPv1', 'IGMPv2', 'IGMPv3', 'IP', 'IPsec', 'IPv4', 'IPv6', 'IPX',
diff --git a/pcapkit/all.py b/pcapkit/all.py
index 90b845478c..f70d325aff 100644
--- a/pcapkit/all.py
+++ b/pcapkit/all.py
@@ -122,10 +122,9 @@
'NoPayload', # No Payload
'Raw', # Raw Packet
'ARP', 'C_Tag', 'Ethernet', 'FRAD', 'GRE', 'InARP', 'L2TP', 'L2TPv2', 'LinuxSLL', 'LinuxSLL1',
- 'LinuxSLL2', 'Loopback', 'Netlink', 'S_Tag', 'VLAN', # Link Layer
+ 'LinuxSLL2', 'Loopback', 'Netlink', 'Radiotap', 'S_Tag', 'VLAN', # Link Layer
'AH', 'ESP', 'ICMP', 'ICMPv4', 'ICMPv6', 'IGMP', 'IGMPv1', 'IGMPv2', 'IGMPv3',
'IP', 'IPsec', 'IPv4', 'IPv6', 'IPX', # Internet Layer
-
'HIP', 'HOPOPT', 'IPv6_Ext', 'IPv6_Frag', 'IPv6_Opts', 'IPv6_Route', 'MH',
# IPv6 Extension Header
'IP_Pseudo', # IP Pseudo-Header
diff --git a/pcapkit/protocols/__init__.py b/pcapkit/protocols/__init__.py
index 0633e16c50..cc40f6a774 100644
--- a/pcapkit/protocols/__init__.py
+++ b/pcapkit/protocols/__init__.py
@@ -51,7 +51,7 @@
# Link Layer
'ARP', 'C_Tag', 'Ethernet', 'FRAD', 'GRE', 'InARP', 'L2TP', 'L2TPv2', 'LinuxSLL', 'LinuxSLL1',
- 'LinuxSLL2', 'Loopback', 'Netlink', 'S_Tag', 'VLAN',
+ 'LinuxSLL2', 'Loopback', 'Netlink', 'Radiotap', 'S_Tag', 'VLAN',
# Internet Layer
'AH', 'ESP', 'ICMP', 'ICMPv4', 'ICMPv6', 'IGMP', 'IGMPv1', 'IGMPv2', 'IGMPv3',
diff --git a/pcapkit/protocols/data/__init__.py b/pcapkit/protocols/data/__init__.py
index d8c9fb68bf..2cf167ad04 100644
--- a/pcapkit/protocols/data/__init__.py
+++ b/pcapkit/protocols/data/__init__.py
@@ -80,6 +80,9 @@
# Netlink Socket Framing
'Netlink',
+ # 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 1ad9f6f6b0..3cbe049fc4 100644
--- a/pcapkit/protocols/data/link/__init__.py
+++ b/pcapkit/protocols/data/link/__init__.py
@@ -29,6 +29,9 @@
# Netlink Socket Framing
from pcapkit.protocols.data.link.netlink import Netlink
+# 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
@@ -58,6 +61,9 @@
# Netlink Socket Framing
'Netlink',
+ # 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 0a8c6f285c..1216774a82 100644
--- a/pcapkit/protocols/link/__init__.py
+++ b/pcapkit/protocols/link/__init__.py
@@ -21,6 +21,7 @@
from pcapkit.protocols.link.gre import GRE
from pcapkit.protocols.link.loopback import Loopback
from pcapkit.protocols.link.netlink import Netlink
+from pcapkit.protocols.link.radiotap import Radiotap
# Linux "Cooked" Capture Encapsulation Family
from pcapkit.protocols.link.linux_sll import LinuxSLL
@@ -45,5 +46,5 @@
# Link Layer Protocols
'ARP', 'C_Tag', 'Ethernet', 'FRAD', 'GRE', 'InARP', 'L2TP', 'L2TPv2', 'LinuxSLL', 'LinuxSLL1',
- 'LinuxSLL2', 'Loopback', 'Netlink', 'S_Tag', 'VLAN',
+ 'LinuxSLL2', 'Loopback', 'Netlink', 'Radiotap', 'S_Tag', 'VLAN',
]
diff --git a/pcapkit/protocols/link/link.py b/pcapkit/protocols/link/link.py
index 8a932d0d7f..67956825c8 100644
--- a/pcapkit/protocols/link/link.py
+++ b/pcapkit/protocols/link/link.py
@@ -12,7 +12,8 @@
: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.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 c38522704b..5d46619458 100644
--- a/pcapkit/protocols/link/linux_sll.py
+++ b/pcapkit/protocols/link/linux_sll.py
@@ -158,9 +158,12 @@ def _import_next_layer(self, proto: 'int', length: 'Optional[int]' = None, *, #
:class:`~pcapkit.protocols.link.gre.GRE` when ``arphrd_type`` is
:attr:`ARPHRD.IPGRE ` or
:attr:`ARPHRD.IP6GRE `;
- otherwise :class:`~pcapkit.protocols.misc.raw.Raw`, recording
- ``proto``, for every other ``arphrd_type`` in
- :data:`_ARPHRD_NOT_ETHERTYPE`.
+ :class:`~pcapkit.protocols.link.radiotap.Radiotap` when ``arphrd_type``
+ is :attr:`ARPHRD.IEEE80211_RADIOTAP
+ `;
+ otherwise the dissector :attr:`Link.__proto__
+ ` holds for ``proto``,
+ an EtherType.
"""
file_ = self._get_payload()
@@ -189,10 +192,11 @@ 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]
+ 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)
- 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/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 e5665d783b..9964874063 100644
--- a/pcapkit/protocols/schema/__init__.py
+++ b/pcapkit/protocols/schema/__init__.py
@@ -33,6 +33,7 @@
'LinuxSLL1', 'LinuxSLL2',
'Loopback',
'Netlink',
+ 'Radiotap',
'VLAN', 'VLAN_TCI',
# Internet Layer Protocols
diff --git a/pcapkit/protocols/schema/link/__init__.py b/pcapkit/protocols/schema/link/__init__.py
index 5cb206d0f1..f49ad74187 100644
--- a/pcapkit/protocols/schema/link/__init__.py
+++ b/pcapkit/protocols/schema/link/__init__.py
@@ -10,6 +10,7 @@
from pcapkit.protocols.schema.link.linux_sll2 import LinuxSLL2
from pcapkit.protocols.schema.link.loopback import Loopback
from pcapkit.protocols.schema.link.netlink import Netlink
+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
@@ -22,5 +23,6 @@
'LinuxSLL1', 'LinuxSLL2',
'Loopback',
'Netlink',
+ '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 a349ab8595..fa9ea841f6 100644
--- a/tests/project/test_rfc_constants_one_home_1739_unit.py
+++ b/tests/project/test_rfc_constants_one_home_1739_unit.py
@@ -452,6 +452,8 @@
'octets, not a PCAP record header) and the max value '
"of its 16-bit nlmsg_type/nlmsg_flags fields (not "
'an IPv4 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 a08ed1621f..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`` and
- ``IPGRE``/``IP6GRE`` 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 (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.
- 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
@@ -119,6 +94,24 @@ def test_netlink_arphrd_dispatches_to_netlink(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_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
@@ -138,6 +131,42 @@ 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:`2460`).
+ """
+ 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_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 d6f0d93671..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`` and
- ``IPGRE``/``IP6GRE`` 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 (ARPHRD.IEEE80211_RADIOTAP,):
- 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
@@ -119,6 +96,24 @@ def test_netlink_arphrd_dispatches_to_netlink(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_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
@@ -137,6 +132,42 @@ 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:`2460`).
+ """
+ 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_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
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 7fdceaf86a..0fb0291e8f 100644
--- a/tests/protocols/test_short_read_roundtrip_unit.py
+++ b/tests/protocols/test_short_read_roundtrip_unit.py
@@ -56,6 +56,8 @@ def _ipv6(code: int, body: bytes) -> bytes:
('loopback-null', 'link.loopback:Loopback', {}, bytes.fromhex('020000004500')),
('loopback-loop', 'link.loopback:Loopback', {'alias': 108}, bytes.fromhex('000000024500')),
('netlink', 'link.netlink:Netlink', {}, struct.pack('