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('