From 845c94eaa8d81ebb372f3d5209be6406b50c354b Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Sun, 1 Nov 2026 11:45:12 -0500 Subject: [PATCH] feat(link): dissect the IEEE 802.11 MAC header behind radiotap (#2575) - Add IEEE802_11: Frame Control, Duration/ID, two to four addresses, Sequence, QoS and HT Control (IEEE Std 802.11-2020 clause 9). The body and any FCS stay Raw, versions other than 0 are refused, and field ranges go through ProtocolBase._make_uint. - Radiotap dispatches through its own __proto__, keyed by link type, with Radiotap.register and register_radiotap, replacing _import_next_layer; _get_payload starts at it_len, so a refused frame falls back to Raw. - Add pcapkit.const.ieee802_11 (Table 9-1) with DATA-literal crawlers; register and document the new names and the DATA-source rule. - Tests: an 802.11 sweep, crawler regeneration, radiotap dispatch, a dispatch-generator family, and the registrar tables and census pins. The protocols/link leg and the touched modules pass. --- .../conventions/const-vendor-pairing.rst | 6 + .../conventions/mint-criterion.rst | 21 +- .../conventions/registry-protocol.rst | 4 + docs/source/pcapkit/const/ieee802_11.rst | 88 +++ docs/source/pcapkit/const/index.rst | 3 + docs/source/pcapkit/foundation/registry.rst | 2 + .../pcapkit/protocols/link/ieee802_11.rst | 130 ++++ docs/source/pcapkit/protocols/link/index.rst | 1 + .../pcapkit/protocols/link/radiotap.rst | 26 +- docs/source/pcapkit/vendor/ieee802_11.rst | 96 +++ docs/source/pcapkit/vendor/index.rst | 1 + examples/generators/dispatch.py | 39 +- pcapkit/__init__.py | 6 +- pcapkit/all.py | 7 +- pcapkit/const/__init__.py | 4 + pcapkit/const/ieee802_11/__init__.py | 39 ++ pcapkit/const/ieee802_11/control_subtype.py | 106 +++ pcapkit/const/ieee802_11/data_subtype.py | 97 +++ pcapkit/const/ieee802_11/extension_subtype.py | 70 ++ .../const/ieee802_11/management_subtype.py | 109 +++ pcapkit/const/ieee802_11/type.py | 73 ++ pcapkit/foundation/__init__.py | 2 +- pcapkit/foundation/registry/__init__.py | 2 +- pcapkit/foundation/registry/protocols.py | 52 +- pcapkit/protocols/__init__.py | 4 +- pcapkit/protocols/data/__init__.py | 4 + pcapkit/protocols/data/link/__init__.py | 11 + pcapkit/protocols/data/link/ieee802_11.py | 172 +++++ pcapkit/protocols/link/__init__.py | 5 +- pcapkit/protocols/link/ieee802_11.py | 640 ++++++++++++++++++ pcapkit/protocols/link/link.py | 1 + pcapkit/protocols/link/radiotap.py | 188 ++--- pcapkit/protocols/schema/__init__.py | 1 + pcapkit/protocols/schema/link/__init__.py | 2 + pcapkit/protocols/schema/link/ieee802_11.py | 163 +++++ pcapkit/vendor/__init__.py | 4 + pcapkit/vendor/ieee802_11/__init__.py | 54 ++ pcapkit/vendor/ieee802_11/control_subtype.py | 107 +++ pcapkit/vendor/ieee802_11/data_subtype.py | 106 +++ .../vendor/ieee802_11/extension_subtype.py | 95 +++ .../vendor/ieee802_11/management_subtype.py | 102 +++ pcapkit/vendor/ieee802_11/type.py | 90 +++ tests/const/test_const_enum_builtin_parity.py | 9 +- tests/const/test_const_enum_get.py | 4 +- tests/const/test_const_enum_lookup.py | 2 +- tests/const/test_const_registry_protocol.py | 4 +- tests/foundation/registry/test_protocols.py | 1 + .../test_registrar_code_check_unit.py | 2 + .../registry/test_registry_symmetry_unit.py | 3 + ...est_registry_wrapper_coverage_2511_unit.py | 2 - .../test_rfc_constants_one_home_1739_unit.py | 5 + tests/protocols/link/test_ieee802_11_unit.py | 460 +++++++++++++ tests/protocols/link/test_linux_sll1_unit.py | 4 +- tests/protocols/link/test_linux_sll2_unit.py | 4 +- .../test_linux_sll_arphrd_registry_unit.py | 3 +- tests/protocols/link/test_radiotap_unit.py | 174 ++++- .../protocols/test_dispatch_registry_unit.py | 15 +- .../test_protocol_code_registration_unit.py | 7 +- .../test_short_read_roundtrip_unit.py | 7 + .../test_ieee802_11_crawlers_2575_unit.py | 124 ++++ 60 files changed, 3399 insertions(+), 164 deletions(-) create mode 100644 docs/source/pcapkit/const/ieee802_11.rst create mode 100644 docs/source/pcapkit/protocols/link/ieee802_11.rst create mode 100644 docs/source/pcapkit/vendor/ieee802_11.rst create mode 100644 pcapkit/const/ieee802_11/__init__.py create mode 100644 pcapkit/const/ieee802_11/control_subtype.py create mode 100644 pcapkit/const/ieee802_11/data_subtype.py create mode 100644 pcapkit/const/ieee802_11/extension_subtype.py create mode 100644 pcapkit/const/ieee802_11/management_subtype.py create mode 100644 pcapkit/const/ieee802_11/type.py create mode 100644 pcapkit/protocols/data/link/ieee802_11.py create mode 100644 pcapkit/protocols/link/ieee802_11.py create mode 100644 pcapkit/protocols/schema/link/ieee802_11.py create mode 100644 pcapkit/vendor/ieee802_11/__init__.py create mode 100644 pcapkit/vendor/ieee802_11/control_subtype.py create mode 100644 pcapkit/vendor/ieee802_11/data_subtype.py create mode 100644 pcapkit/vendor/ieee802_11/extension_subtype.py create mode 100644 pcapkit/vendor/ieee802_11/management_subtype.py create mode 100644 pcapkit/vendor/ieee802_11/type.py create mode 100644 tests/protocols/link/test_ieee802_11_unit.py create mode 100644 tests/vendor/test_ieee802_11_crawlers_2575_unit.py diff --git a/docs/source/contributing/conventions/const-vendor-pairing.rst b/docs/source/contributing/conventions/const-vendor-pairing.rst index a8fa09dfee..78f659f8cf 100644 --- a/docs/source/contributing/conventions/const-vendor-pairing.rst +++ b/docs/source/contributing/conventions/const-vendor-pairing.rst @@ -40,6 +40,12 @@ module docstring and comments, alongside the ``DATA`` literal it documents; the generated ``const`` module carries only the plain, templated docstring every other generated module does. +Where the primary source is paywalled, a pinned secondary revision is an acceptable +``DATA`` source once its values are cross-checked against independent implementations, +ruled in review of :pr:`2607` (:issue:`2575`). :mod:`pcapkit.vendor.ipx` is the earlier +example, and the :mod:`pcapkit.vendor.ieee802_11` crawlers check theirs against the +Linux kernel's header and Wireshark. + ``LINK``-and-Scrape Pairing: TLS Content Types and HandshakeType ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/source/contributing/conventions/mint-criterion.rst b/docs/source/contributing/conventions/mint-criterion.rst index 5efdb91c13..ff3559bc89 100644 --- a/docs/source/contributing/conventions/mint-criterion.rst +++ b/docs/source/contributing/conventions/mint-criterion.rst @@ -67,11 +67,15 @@ Measured against the tree, by class: ``cls._value2member_map_.setdefault(value, pseudo_member)``. * **Raise for any unknown value**: :class:`~pcapkit.const.reg.apptype.apptype.AppType`, :class:`~pcapkit.const.ipv6.extension_header.ExtensionHeader`, - :class:`~pcapkit.const.pcapng.tls_key_label.TLSKeyLabel` and - :class:`~pcapkit.const.hip.transport.Transport`. - :class:`~pcapkit.const.hip.transport.Transport` raises from its own range check: its - declared range ``0..3`` is exactly its member set, so no unassigned value is left for - ``super()._missing_`` (the line after the check is unreachable). + :class:`~pcapkit.const.pcapng.tls_key_label.TLSKeyLabel`, + :class:`~pcapkit.const.hip.transport.Transport`, + :class:`~pcapkit.const.ieee802_11.type.Type` and + :class:`~pcapkit.const.ieee802_11.management_subtype.ManagementSubtype`. + :class:`~pcapkit.const.hip.transport.Transport`, + :class:`~pcapkit.const.ieee802_11.type.Type` and + :class:`~pcapkit.const.ieee802_11.management_subtype.ManagementSubtype` raise from their + own range checks: each declared range is exactly its member set, so no unassigned value + is left for ``super()._missing_`` (the line after the check is unreachable). Note that these are |aenum|_ classes: ``issubclass(Flags, enum.IntFlag)`` is ``False`` against the standard library and ``True`` against ``aenum.IntFlag``, so a probe against @@ -209,8 +213,11 @@ only ``ast.Name`` nodes finds every mint and no unmint at all: The walk reads each file's own ``_missing_``, so it reports :class:`~pcapkit.const.reg.apptype.apptype.AppType` as an unmint although it raises, - prints nothing for the flag registries and - :class:`~pcapkit.const.hip.transport.Transport` (they call neither function) nor for + prints nothing for the flag registries, + :class:`~pcapkit.const.hip.transport.Transport`, + :class:`~pcapkit.const.ieee802_11.type.Type` and + :class:`~pcapkit.const.ieee802_11.management_subtype.ManagementSubtype` (they call + neither function) nor for :class:`~pcapkit.const.ipv6.extension_header.ExtensionHeader` and :class:`~pcapkit.const.pcapng.tls_key_label.TLSKeyLabel` (they define no ``_missing_``), and does not see the transport subclasses, which inherit theirs. diff --git a/docs/source/contributing/conventions/registry-protocol.rst b/docs/source/contributing/conventions/registry-protocol.rst index c705e808b7..67a97da106 100644 --- a/docs/source/contributing/conventions/registry-protocol.rst +++ b/docs/source/contributing/conventions/registry-protocol.rst @@ -802,6 +802,10 @@ from it: :class:`~pcapkit.const.reg.linktype.LinkType` members, but :func:`~pcapkit.foundation.registry.protocols.register_linktype` holds that name for writing both their maps at once, so each takes its capture format's name. +* :func:`~pcapkit.foundation.registry.protocols.register_radiotap` takes only + :class:`~pcapkit.const.reg.linktype.LinkType` members too, keyed by the link type + of the frame behind a radiotap header, and takes its owning protocol's name for the + same reason. * :func:`~pcapkit.foundation.registry.protocols.register_apptype` takes bare :class:`int` keys, but writes :class:`~pcapkit.protocols.transport.tcp.TCP`'s map, :class:`~pcapkit.protocols.transport.udp.UDP`'s or both, by transport, so no one diff --git a/docs/source/pcapkit/const/ieee802_11.rst b/docs/source/pcapkit/const/ieee802_11.rst new file mode 100644 index 0000000000..e636dcfae8 --- /dev/null +++ b/docs/source/pcapkit/const/ieee802_11.rst @@ -0,0 +1,88 @@ +============================================================================== +:class:`~pcapkit.protocols.link.ieee802_11.IEEE802_11` Constant Enumerations +============================================================================== + +.. module:: pcapkit.const.ieee802_11 + +This module contains all constant enumerations of +:class:`~pcapkit.protocols.link.ieee802_11.IEEE802_11` implementations, each +from IEEE Std 802.11-2020, Table 9-1, *Valid type and subtype combinations*. +Available enumerations include: + +.. list-table:: + + * - :class:`IEEE802_11_Type ` + - Frame Types + * - :class:`IEEE802_11_ManagementSubtype ` + - Management Frame Subtypes + * - :class:`IEEE802_11_ControlSubtype ` + - Control Frame Subtypes + * - :class:`IEEE802_11_DataSubtype ` + - Data Frame Subtypes + * - :class:`IEEE802_11_ExtensionSubtype ` + - Extension Frame Subtypes + +Frame Types +=========== + +.. module:: pcapkit.const.ieee802_11.type + +This module contains the constant enumeration for **IEEE 802.11 Frame Types**, +which is automatically generated from :class:`pcapkit.vendor.ieee802_11.type.Type`. + +.. autoclass:: pcapkit.const.ieee802_11.type.Type + :members: + :undoc-members: + :show-inheritance: + +Management Frame Subtypes +========================= + +.. module:: pcapkit.const.ieee802_11.management_subtype + +This module contains the constant enumeration for **IEEE 802.11 Management Frame Subtypes**, +which is automatically generated from :class:`pcapkit.vendor.ieee802_11.management_subtype.ManagementSubtype`. + +.. autoclass:: pcapkit.const.ieee802_11.management_subtype.ManagementSubtype + :members: + :undoc-members: + :show-inheritance: + +Control Frame Subtypes +====================== + +.. module:: pcapkit.const.ieee802_11.control_subtype + +This module contains the constant enumeration for **IEEE 802.11 Control Frame Subtypes**, +which is automatically generated from :class:`pcapkit.vendor.ieee802_11.control_subtype.ControlSubtype`. + +.. autoclass:: pcapkit.const.ieee802_11.control_subtype.ControlSubtype + :members: + :undoc-members: + :show-inheritance: + +Data Frame Subtypes +=================== + +.. module:: pcapkit.const.ieee802_11.data_subtype + +This module contains the constant enumeration for **IEEE 802.11 Data Frame Subtypes**, +which is automatically generated from :class:`pcapkit.vendor.ieee802_11.data_subtype.DataSubtype`. + +.. autoclass:: pcapkit.const.ieee802_11.data_subtype.DataSubtype + :members: + :undoc-members: + :show-inheritance: + +Extension Frame Subtypes +======================== + +.. module:: pcapkit.const.ieee802_11.extension_subtype + +This module contains the constant enumeration for **IEEE 802.11 Extension Frame Subtypes**, +which is automatically generated from :class:`pcapkit.vendor.ieee802_11.extension_subtype.ExtensionSubtype`. + +.. autoclass:: pcapkit.const.ieee802_11.extension_subtype.ExtensionSubtype + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/pcapkit/const/index.rst b/docs/source/pcapkit/const/index.rst index fa0a275647..35115c306b 100644 --- a/docs/source/pcapkit/const/index.rst +++ b/docs/source/pcapkit/const/index.rst @@ -31,6 +31,8 @@ Some behave otherwise, and :ref:`mint-criterion` gives the full split: table only: a second lookup returns the same object, while ``__members__`` and iteration are unchanged. * :class:`~pcapkit.const.hip.transport.Transport`, + :class:`~pcapkit.const.ieee802_11.type.Type`, + :class:`~pcapkit.const.ieee802_11.management_subtype.ManagementSubtype`, :class:`~pcapkit.const.ipv6.extension_header.ExtensionHeader`, :class:`~pcapkit.const.pcapng.tls_key_label.TLSKeyLabel` and the memberless :class:`~pcapkit.const.reg.apptype.apptype.AppType` base raise @@ -69,6 +71,7 @@ Link Layer :maxdepth: 2 arp + ieee802_11 l2tp linux vlan diff --git a/docs/source/pcapkit/foundation/registry.rst b/docs/source/pcapkit/foundation/registry.rst index 2c835568eb..9fbff51587 100644 --- a/docs/source/pcapkit/foundation/registry.rst +++ b/docs/source/pcapkit/foundation/registry.rst @@ -91,6 +91,8 @@ Link Layer Registries .. autofunction:: pcapkit.foundation.registry.protocols.register_netlink +.. autofunction:: pcapkit.foundation.registry.protocols.register_radiotap + Internet Layer Registries ~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/source/pcapkit/protocols/link/ieee802_11.rst b/docs/source/pcapkit/protocols/link/ieee802_11.rst new file mode 100644 index 0000000000..7bdb4ad73c --- /dev/null +++ b/docs/source/pcapkit/protocols/link/ieee802_11.rst @@ -0,0 +1,130 @@ +IEEE 802.11 - Wireless LAN MAC Header +===================================== + +.. module:: pcapkit.protocols.link.ieee802_11 + +:mod:`pcapkit.protocols.link.ieee802_11` contains +:class:`~pcapkit.protocols.link.ieee802_11.IEEE802_11` only, which implements +the extractor for the IEEE 802.11 MAC header [*]_ of a protocol version 0 +frame, as :class:`~pcapkit.protocols.link.radiotap.Radiotap` hands it on. Its +fields, in the order they appear, numeric ones little-endian: + +.. table:: + + ======================= ======= ============================================ + Name Octets Present + ======================= ======= ============================================ + ``frame_control`` 2 always (§9.2.4.1) + ``duration_id`` 2 always (§9.2.4.2) + ``addr1`` 6 always (§9.2.4.3) + ``addr2`` 6 Management, Data, and Control frames that + carry a TA (§9.2.4.3, §9.3.1) + ``addr3`` 6 Management and Data frames (§9.2.4.3) + ``seq_ctrl`` 2 Management and Data frames (§9.2.4.4) + ``addr4`` 6 Data frames with To DS and From DS set + (§9.2.4.3) + ``qos_control`` 2 QoS Data subtypes (§9.2.4.5) + ``ht_control`` 4 QoS Data subtypes and Management frames + with +HTC/Order set (§9.2.4.6) + ======================= ======= ============================================ + +:func:`~pcapkit.protocols.schema.link.ieee802_11.layout` gives the rule per +frame type. A Control frame of a subtype whose layout this class does not +model, and an Extension frame, are read as the minimal frame format of +§9.2.3: the first three fields only. A Control Frame Extension frame and an +S1G Beacon lay out B8-B15 of Frame Control differently; see +:class:`~pcapkit.protocols.data.link.ieee802_11.FrameControl`. + +Scope +----- + +* **The frame body** (§9.2.4.7) is kept as + :class:`~pcapkit.protocols.misc.raw.Raw`, protected or not. +* **The FCS** (§9.2.4.8), where one was captured, stays at the end of the + body: whether a capture carries it is a radiotap Flags bit, which + :class:`~pcapkit.protocols.link.radiotap.Radiotap` does not decode. +* **Pad octets** that radiotap's data-pad flag says follow the header stay + at the start of the body, for the same reason. +* **A protocol version other than 0** is refused: version 1 is the PV1 + frame format of §9.8, whose Frame Control field is laid out differently, + and versions 2 and 3 are reserved. + +.. autoclass:: pcapkit.protocols.link.ieee802_11.IEEE802_11 + :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:: _read_frame_control + .. automethod:: _make_frame_control + .. automethod:: _read_mac_addr + .. automethod:: _make_mac_addr + .. automethod:: _read_seq_ctrl + .. automethod:: _make_seq_ctrl + .. automethod:: _read_qos_control + .. automethod:: _make_qos_control + .. automethod:: _read_ht_control + .. automethod:: _make_ht_control + +.. autodata:: pcapkit.protocols.link.ieee802_11.SUBTYPE + :no-value: + +Header Schemas +-------------- + +.. module:: pcapkit.protocols.schema.link.ieee802_11 + +.. autoclass:: pcapkit.protocols.schema.link.ieee802_11.IEEE802_11 + :members: + :show-inheritance: + +.. autofunction:: pcapkit.protocols.schema.link.ieee802_11.layout + +.. autoclass:: pcapkit.protocols.schema.link.ieee802_11.Layout + :members: + :show-inheritance: + +.. autodata:: pcapkit.protocols.schema.link.ieee802_11.CONTROL_WITH_ADDR2 + :no-value: + +Data Models +----------- + +.. module:: pcapkit.protocols.data.link.ieee802_11 + +.. autoclass:: pcapkit.protocols.data.link.ieee802_11.IEEE802_11 + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.data.link.ieee802_11.FrameControl + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.data.link.ieee802_11.SequenceControl + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.data.link.ieee802_11.QoSControl + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.data.link.ieee802_11.HTControl + :members: + :show-inheritance: + +.. rubric:: Footnotes + +.. [*] IEEE Std 802.11-2020, clause 9, *Frame formats*. The clause numbers + above are its own, checked against Wireshark's ``packet-ieee80211.c`` where + it cites one: §9.2.4.1.10, §9.2.4.6, §9.2.4.7 and §9.8. diff --git a/docs/source/pcapkit/protocols/link/index.rst b/docs/source/pcapkit/protocols/link/index.rst index d091bb03b3..52399c9a1c 100644 --- a/docs/source/pcapkit/protocols/link/index.rst +++ b/docs/source/pcapkit/protocols/link/index.rst @@ -27,6 +27,7 @@ link layer, with detailed implementation and methods. inarp frad gre + ieee802_11 l2tp l2tpv2 linux_sll diff --git a/docs/source/pcapkit/protocols/link/radiotap.rst b/docs/source/pcapkit/protocols/link/radiotap.rst index d93aa7df39..7181e9dca8 100644 --- a/docs/source/pcapkit/protocols/link/radiotap.rst +++ b/docs/source/pcapkit/protocols/link/radiotap.rst @@ -32,11 +32,8 @@ 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: +Decoding every optional field radiotap.org defines is tracked on +:issue:`2576`. This class: * **Decodes** the fixed 8-octet start -- ``it_version``, ``it_pad``, ``it_len`` -- and walks the present-flags chain far enough to know how @@ -47,11 +44,15 @@ first cut instead: 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. +* **Hands** everything from ``it_len`` on, the 802.11 MAC frame, to + :class:`~pcapkit.protocols.link.ieee802_11.IEEE802_11` + (:class:`~pcapkit.protocols.misc.null.NoPayload` when nothing follows). + The header names no next protocol, since an 802.11 frame always follows it, + so the frame is looked up in + :attr:`Radiotap.__proto__ ` + under :attr:`LinkType.IEEE802_11 `; + :func:`~pcapkit.foundation.registry.protocols.register_radiotap` replaces + the entry. .. autoclass:: pcapkit.protocols.link.radiotap.Radiotap :no-members: @@ -65,15 +66,18 @@ first cut instead: .. automethod:: read .. automethod:: make + .. automethod:: register .. automethod:: __length_hint__ .. automethod:: __index__ .. automethod:: _make_data - .. automethod:: _import_next_layer + .. automethod:: _get_payload .. autoattribute:: __proto__ :no-value: + .. autoattribute:: __proto_key__ + :no-value: Header Schemas -------------- diff --git a/docs/source/pcapkit/vendor/ieee802_11.rst b/docs/source/pcapkit/vendor/ieee802_11.rst new file mode 100644 index 0000000000..9d8809c7ce --- /dev/null +++ b/docs/source/pcapkit/vendor/ieee802_11.rst @@ -0,0 +1,96 @@ +========================================================================== +:class:`~pcapkit.protocols.link.ieee802_11.IEEE802_11` Vendor Crawlers +========================================================================== + +.. module:: pcapkit.vendor.ieee802_11 + +This module contains all vendor crawlers of +:class:`~pcapkit.protocols.link.ieee802_11.IEEE802_11` implementations. +Available vendor crawlers include: + +.. list-table:: + + * - :class:`IEEE802_11_Type ` + - Frame Types + * - :class:`IEEE802_11_ManagementSubtype ` + - Management Frame Subtypes + * - :class:`IEEE802_11_ControlSubtype ` + - Control Frame Subtypes + * - :class:`IEEE802_11_DataSubtype ` + - Data Frame Subtypes + * - :class:`IEEE802_11_ExtensionSubtype ` + - Extension Frame Subtypes + +.. note:: + + These crawlers do not crawl. IEEE Std 802.11-2020 assigns the frame type + and subtype values in Table 9-1 of the standard's text, and neither IEEE + nor IANA publishes them as a machine-readable registry, so each crawler + reads a ``DATA`` literal and defines no ``LINK`` + (:ref:`const-vendor-pairing`). Each ``DATA`` is transcribed from the table + on Wikipedia's *802.11 frame types* page [*]_, which reproduces Table 9-1 + with the Trigger frame of IEEE Std 802.11ax-2021. + +Frame Types +=========== + +.. module:: pcapkit.vendor.ieee802_11.type + +This module contains the vendor crawler for **IEEE 802.11 Frame Types**, +which is automatically generating :class:`pcapkit.const.ieee802_11.type.Type`. + +.. autoclass:: pcapkit.vendor.ieee802_11.type.Type + :members: FLAG + :show-inheritance: + +Management Frame Subtypes +========================= + +.. module:: pcapkit.vendor.ieee802_11.management_subtype + +This module contains the vendor crawler for **IEEE 802.11 Management Frame Subtypes**, +which is automatically generating :class:`pcapkit.const.ieee802_11.management_subtype.ManagementSubtype`. + +.. autoclass:: pcapkit.vendor.ieee802_11.management_subtype.ManagementSubtype + :members: FLAG + :show-inheritance: + +Control Frame Subtypes +====================== + +.. module:: pcapkit.vendor.ieee802_11.control_subtype + +This module contains the vendor crawler for **IEEE 802.11 Control Frame Subtypes**, +which is automatically generating :class:`pcapkit.const.ieee802_11.control_subtype.ControlSubtype`. + +.. autoclass:: pcapkit.vendor.ieee802_11.control_subtype.ControlSubtype + :members: FLAG + :show-inheritance: + +Data Frame Subtypes +=================== + +.. module:: pcapkit.vendor.ieee802_11.data_subtype + +This module contains the vendor crawler for **IEEE 802.11 Data Frame Subtypes**, +which is automatically generating :class:`pcapkit.const.ieee802_11.data_subtype.DataSubtype`. + +.. autoclass:: pcapkit.vendor.ieee802_11.data_subtype.DataSubtype + :members: FLAG + :show-inheritance: + +Extension Frame Subtypes +======================== + +.. module:: pcapkit.vendor.ieee802_11.extension_subtype + +This module contains the vendor crawler for **IEEE 802.11 Extension Frame Subtypes**, +which is automatically generating :class:`pcapkit.const.ieee802_11.extension_subtype.ExtensionSubtype`. + +.. autoclass:: pcapkit.vendor.ieee802_11.extension_subtype.ExtensionSubtype + :members: FLAG + :show-inheritance: + +.. rubric:: Footnotes + +.. [*] https://en.wikipedia.org/w/index.php?title=802.11_frame_types&oldid=1369762968 diff --git a/docs/source/pcapkit/vendor/index.rst b/docs/source/pcapkit/vendor/index.rst index 14eb04673f..2e2e576d1d 100644 --- a/docs/source/pcapkit/vendor/index.rst +++ b/docs/source/pcapkit/vendor/index.rst @@ -44,6 +44,7 @@ Link Layer :maxdepth: 2 arp + ieee802_11 l2tp linux vlan diff --git a/examples/generators/dispatch.py b/examples/generators/dispatch.py index 0dc1afa0ec..39a271c168 100644 --- a/examples/generators/dispatch.py +++ b/examples/generators/dispatch.py @@ -645,6 +645,37 @@ def _frad_build(nlpid: 'int') -> 'Any': return _extract_first(_pcap_bytes((record,), linktype=113), '.pcap') +############################################################################### +# Radiotap -- link type of the frame behind the header. +############################################################################### + + +def _radiotap_registry() -> 'Any': + from pcapkit.protocols.link.radiotap import Radiotap + return Radiotap.__proto__ + + +def _radiotap_build(code: 'Any') -> 'Any': + """An 802.11 Ack behind radiotap, on a ``LINUX_SLL`` capture. + + :meth:`Radiotap.read ` + consults ``LinkType.IEEE802_11`` alone, so that is the only code with a + case. + + """ + from pcapkit.const.linux.arphrd import ARPHRD + from pcapkit.const.reg.linktype import LinkType + from pcapkit.protocols.link.ieee802_11 import IEEE802_11 + from pcapkit.protocols.link.linux_sll1 import LinuxSLL1 + from pcapkit.protocols.link.radiotap import Radiotap + + if code != LinkType.IEEE802_11: + raise ValueError(f'no radiotap case for link type {code!r}') + ack = bytes(IEEE802_11(type='Control', subtype='ACK', addr1='02:00:00:00:00:01')) + frame = bytes(LinuxSLL1(arphrd_type=ARPHRD.IEEE80211_RADIOTAP, payload=bytes(Radiotap(payload=ack)))) + return _extract_first(_pcap_bytes((frame,), linktype=int(LinkType.LINUX_SLL)), '.pcap') + + ############################################################################### # The families, the cases, and the probe. ############################################################################### @@ -672,8 +703,8 @@ def _link_enum() -> 'Any': #: Every family this module exercises, one per ``__proto__`` table named in -#: GitHub issue #496, the loopback table #1574 added, and FRAD's NLPID table -#: (#2560). +#: GitHub issue #496, the loopback table #1574 added, FRAD's NLPID table +#: (#2560), and the radiotap table of #2575. FAMILIES = ( Family('link', _link_registry, _link_build, _link_enum), Family('internet', _internet_registry, _internet_build), @@ -684,6 +715,7 @@ def _link_enum() -> 'Any': Family('pcapng-frame', _pcapng_registry, _pcapng_build), Family('loopback', _loopback_registry, _loopback_build), Family('frad', _frad_registry, _frad_build), + Family('radiotap', _radiotap_registry, _radiotap_build), ) #: The families keyed by label, for a caller -- :func:`probe` -- that wants @@ -798,6 +830,9 @@ def _link_enum() -> 'Any': # -- FRAD.__proto__ (NLPID, keyed in decimal: 0x8E, 0xCC) ------------------ 'frad/142': ('pcapkit.protocols.internet.ipv6', 'IPv6'), 'frad/204': ('pcapkit.protocols.internet.ipv4', 'IPv4'), + + # -- Radiotap.__proto__ (LinkType of the frame behind it) -------------------- + 'radiotap/IEEE802_11': ('pcapkit.protocols.link.ieee802_11', 'IEEE802_11'), } diff --git a/pcapkit/__init__.py b/pcapkit/__init__.py index 736b6875e0..7c55683138 100644 --- a/pcapkit/__init__.py +++ b/pcapkit/__init__.py @@ -111,9 +111,9 @@ 'NoPayload', # No Payload 'Raw', # Raw Packet - 'ARP', 'C_Tag', 'Ethernet', 'FRAD', 'GRE', 'InARP', 'L2TP', # Link Layer - 'L2TPv2', 'LinuxSLL', 'LinuxSLL1', 'LinuxSLL2', 'Loopback', 'Netlink', 'Radiotap', - 'S_Tag', 'VLAN', + 'ARP', 'C_Tag', 'Ethernet', 'FRAD', 'GRE', 'IEEE802_11', 'InARP', # Link Layer + 'L2TP', '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 3b39c0a982..320e7a68fb 100644 --- a/pcapkit/all.py +++ b/pcapkit/all.py @@ -94,7 +94,7 @@ 'register_pcap', 'register_pcapng', 'register_ethertype', 'register_loopback', - 'register_arphrd', 'register_frad', 'register_netlink', + 'register_arphrd', 'register_frad', 'register_netlink', 'register_radiotap', 'register_transtype', 'register_ipx', 'register_icmp_type', @@ -130,8 +130,9 @@ 'PCAPNG', # PCAPNG Format 'NoPayload', # No Payload 'Raw', # Raw Packet - 'ARP', 'C_Tag', 'Ethernet', 'FRAD', 'GRE', 'InARP', 'L2TP', 'L2TPv2', 'LinuxSLL', 'LinuxSLL1', - 'LinuxSLL2', 'Loopback', 'Netlink', 'Radiotap', 'S_Tag', 'VLAN', # Link Layer + 'ARP', 'C_Tag', 'Ethernet', 'FRAD', 'GRE', 'IEEE802_11', 'InARP', 'L2TP', 'L2TPv2', + 'LinuxSLL', 'LinuxSLL1', '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', diff --git a/pcapkit/const/__init__.py b/pcapkit/const/__init__.py index 1df7a63be4..ebb70523f9 100644 --- a/pcapkit/const/__init__.py +++ b/pcapkit/const/__init__.py @@ -23,6 +23,7 @@ from pcapkit.const.http import * from pcapkit.const.icmp import * from pcapkit.const.icmpv6 import * +from pcapkit.const.ieee802_11 import * from pcapkit.const.igmp import * from pcapkit.const.ipv4 import * from pcapkit.const.ipv6 import * @@ -61,6 +62,9 @@ # ICMPv6 'ICMPv6_Type', 'ICMPv6_DestinationUnreachableCode', 'ICMPv6_TimeExceededCode', 'ICMPv6_ParameterProblemCode', + # IEEE 802.11 + 'IEEE802_11_Type', 'IEEE802_11_ManagementSubtype', 'IEEE802_11_ControlSubtype', + 'IEEE802_11_DataSubtype', 'IEEE802_11_ExtensionSubtype', # IGMP 'IGMP_Type', 'IGMP_RecordType', # IPv4 diff --git a/pcapkit/const/ieee802_11/__init__.py b/pcapkit/const/ieee802_11/__init__.py new file mode 100644 index 0000000000..f9d0b78b5c --- /dev/null +++ b/pcapkit/const/ieee802_11/__init__.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# pylint: disable=unused-import +""":class:`~pcapkit.protocols.link.ieee802_11.IEEE802_11` Constant Enumerations +================================================================================== + +.. module:: pcapkit.const.ieee802_11 + +This module contains all constant enumerations of +:class:`~pcapkit.protocols.link.ieee802_11.IEEE802_11` implementations, each +from IEEE Std 802.11-2020, Table 9-1, *Valid type and subtype combinations*. +Available enumerations include: + +.. list-table:: + + * - :class:`IEEE802_11_Type ` + - Frame Types + * - :class:`IEEE802_11_ManagementSubtype ` + - Management Frame Subtypes + * - :class:`IEEE802_11_ControlSubtype ` + - Control Frame Subtypes + * - :class:`IEEE802_11_DataSubtype ` + - Data Frame Subtypes + * - :class:`IEEE802_11_ExtensionSubtype ` + - Extension Frame Subtypes + +""" + +from pcapkit.const.ieee802_11.control_subtype import ControlSubtype as IEEE802_11_ControlSubtype +from pcapkit.const.ieee802_11.data_subtype import DataSubtype as IEEE802_11_DataSubtype +from pcapkit.const.ieee802_11.extension_subtype import \ + ExtensionSubtype as IEEE802_11_ExtensionSubtype +from pcapkit.const.ieee802_11.management_subtype import \ + ManagementSubtype as IEEE802_11_ManagementSubtype +from pcapkit.const.ieee802_11.type import Type as IEEE802_11_Type + +__all__ = [ + 'IEEE802_11_Type', 'IEEE802_11_ManagementSubtype', 'IEEE802_11_ControlSubtype', + 'IEEE802_11_DataSubtype', 'IEEE802_11_ExtensionSubtype', +] diff --git a/pcapkit/const/ieee802_11/control_subtype.py b/pcapkit/const/ieee802_11/control_subtype.py new file mode 100644 index 0000000000..385c4cde76 --- /dev/null +++ b/pcapkit/const/ieee802_11/control_subtype.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +# pylint: disable=line-too-long,consider-using-f-string +"""IEEE 802.11 Control Frame Subtypes +======================================== + +.. module:: pcapkit.const.ieee802_11.control_subtype + +This module contains the constant enumeration for **IEEE 802.11 Control Frame Subtypes**, +which is automatically generated from :class:`pcapkit.vendor.ieee802_11.control_subtype.ControlSubtype`. + +""" + +from typing import TYPE_CHECKING + +from aenum import IntEnum + +from pcapkit.corekit.enum import EnumRegistry +from pcapkit.utilities.exceptions import EnumValueError + +if TYPE_CHECKING: + from typing import Any + +__all__ = ['ControlSubtype'] + + +class ControlSubtype(EnumRegistry, IntEnum): + """[ControlSubtype] IEEE 802.11 Control Frame Subtypes [IEEE Std 802.11-2020, Table 9-1]""" + + #: Trigger + Trigger = 2 + + #: TACK + TACK = 3 + + #: Beamforming Report Poll + Beamforming_Report_Poll = 4 + + #: VHT/HE NDP Announcement + VHT_HE_NDP_Announcement = 5 + + #: Control Frame Extension + Control_Frame_Extension = 6 + + #: Control Wrapper + Control_Wrapper = 7 + + #: Block Ack Request (BAR) + Block_Ack_Request = 8 + + #: Block Ack (BA) + Block_Ack = 9 + + #: PS-Poll + PS_Poll = 10 + + #: RTS + RTS = 11 + + #: CTS + CTS = 12 + + #: ACK + ACK = 13 + + #: CF-End + CF_End = 14 + + #: CF-End + CF-ACK + CF_End_CF_ACK = 15 + + @classmethod + def _validate_value(cls, value: 'Any') -> 'None': + """Reject ``value`` unless it passes the range check :meth:`_missing_` makes. + + Overrides :meth:`~pcapkit.corekit.enum.EnumLookup._validate_value` + with that check and the message it raises, so + :meth:`~pcapkit.corekit.enum.EnumRegistry.register` refuses exactly the + values :meth:`~pcapkit.corekit.enum.EnumLookup.get` refuses, in the + same words (:issue:`1816`). + + Args: + value: Value about to be looked up or minted. + + Raises: + EnumValueError: If ``value`` fails that check. A + :exc:`ValueError`, so an ``except ValueError`` caller is + unaffected. + + """ + if not (isinstance(value, int) and 0b0000 <= value <= 0b1111): + raise EnumValueError('%r is not a valid %s' % (value, cls.__name__)) + + @classmethod + def _missing_(cls, value: 'int') -> 'ControlSubtype': + """Lookup function used when value is not found. + + Args: + value: Value to get enum item. + + """ + if not (isinstance(value, int) and 0b0000 <= value <= 0b1111): + raise ValueError('%r is not a valid %s' % (value, cls.__name__)) + if 0 <= value <= 1: + #: Reserved + return cls._unregistered_member(value, 'Reserved') + return super()._missing_(value) diff --git a/pcapkit/const/ieee802_11/data_subtype.py b/pcapkit/const/ieee802_11/data_subtype.py new file mode 100644 index 0000000000..8284d4def2 --- /dev/null +++ b/pcapkit/const/ieee802_11/data_subtype.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +# pylint: disable=line-too-long,consider-using-f-string +"""IEEE 802.11 Data Frame Subtypes +===================================== + +.. module:: pcapkit.const.ieee802_11.data_subtype + +This module contains the constant enumeration for **IEEE 802.11 Data Frame Subtypes**, +which is automatically generated from :class:`pcapkit.vendor.ieee802_11.data_subtype.DataSubtype`. + +""" + +from typing import TYPE_CHECKING + +from aenum import IntEnum + +from pcapkit.corekit.enum import EnumRegistry +from pcapkit.utilities.exceptions import EnumValueError + +if TYPE_CHECKING: + from typing import Any + +__all__ = ['DataSubtype'] + + +class DataSubtype(EnumRegistry, IntEnum): + """[DataSubtype] IEEE 802.11 Data Frame Subtypes [IEEE Std 802.11-2020, Table 9-1]""" + + #: Data + Data = 0 + + #: Null (no data) + Null = 4 + + #: QoS Data + QoS_Data = 8 + + #: QoS Data + CF-ACK + QoS_Data_CF_ACK = 9 + + #: QoS Data + CF-Poll + QoS_Data_CF_Poll = 10 + + #: QoS Data + CF-ACK + CF-Poll + QoS_Data_CF_ACK_CF_Poll = 11 + + #: QoS Null (no data) + QoS_Null = 12 + + #: Reserved + Reserved_13 = 13 + + #: QoS CF-Poll (no data) + QoS_CF_Poll = 14 + + #: QoS CF-ACK + CF-Poll (no data) + QoS_CF_ACK_CF_Poll = 15 + + @classmethod + def _validate_value(cls, value: 'Any') -> 'None': + """Reject ``value`` unless it passes the range check :meth:`_missing_` makes. + + Overrides :meth:`~pcapkit.corekit.enum.EnumLookup._validate_value` + with that check and the message it raises, so + :meth:`~pcapkit.corekit.enum.EnumRegistry.register` refuses exactly the + values :meth:`~pcapkit.corekit.enum.EnumLookup.get` refuses, in the + same words (:issue:`1816`). + + Args: + value: Value about to be looked up or minted. + + Raises: + EnumValueError: If ``value`` fails that check. A + :exc:`ValueError`, so an ``except ValueError`` caller is + unaffected. + + """ + if not (isinstance(value, int) and 0b0000 <= value <= 0b1111): + raise EnumValueError('%r is not a valid %s' % (value, cls.__name__)) + + @classmethod + def _missing_(cls, value: 'int') -> 'DataSubtype': + """Lookup function used when value is not found. + + Args: + value: Value to get enum item. + + """ + if not (isinstance(value, int) and 0b0000 <= value <= 0b1111): + raise ValueError('%r is not a valid %s' % (value, cls.__name__)) + if 1 <= value <= 3: + #: Reserved + return cls._unregistered_member(value, 'Reserved') + if 5 <= value <= 7: + #: Reserved + return cls._unregistered_member(value, 'Reserved') + return super()._missing_(value) diff --git a/pcapkit/const/ieee802_11/extension_subtype.py b/pcapkit/const/ieee802_11/extension_subtype.py new file mode 100644 index 0000000000..2a32852fe1 --- /dev/null +++ b/pcapkit/const/ieee802_11/extension_subtype.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# pylint: disable=line-too-long,consider-using-f-string +"""IEEE 802.11 Extension Frame Subtypes +========================================== + +.. module:: pcapkit.const.ieee802_11.extension_subtype + +This module contains the constant enumeration for **IEEE 802.11 Extension Frame Subtypes**, +which is automatically generated from :class:`pcapkit.vendor.ieee802_11.extension_subtype.ExtensionSubtype`. + +""" + +from typing import TYPE_CHECKING + +from aenum import IntEnum + +from pcapkit.corekit.enum import EnumRegistry +from pcapkit.utilities.exceptions import EnumValueError + +if TYPE_CHECKING: + from typing import Any + +__all__ = ['ExtensionSubtype'] + + +class ExtensionSubtype(EnumRegistry, IntEnum): + """[ExtensionSubtype] IEEE 802.11 Extension Frame Subtypes [IEEE Std 802.11-2020, Table 9-1]""" + + #: DMG Beacon + DMG_Beacon = 0 + + #: S1G Beacon + S1G_Beacon = 1 + + @classmethod + def _validate_value(cls, value: 'Any') -> 'None': + """Reject ``value`` unless it passes the range check :meth:`_missing_` makes. + + Overrides :meth:`~pcapkit.corekit.enum.EnumLookup._validate_value` + with that check and the message it raises, so + :meth:`~pcapkit.corekit.enum.EnumRegistry.register` refuses exactly the + values :meth:`~pcapkit.corekit.enum.EnumLookup.get` refuses, in the + same words (:issue:`1816`). + + Args: + value: Value about to be looked up or minted. + + Raises: + EnumValueError: If ``value`` fails that check. A + :exc:`ValueError`, so an ``except ValueError`` caller is + unaffected. + + """ + if not (isinstance(value, int) and 0b0000 <= value <= 0b1111): + raise EnumValueError('%r is not a valid %s' % (value, cls.__name__)) + + @classmethod + def _missing_(cls, value: 'int') -> 'ExtensionSubtype': + """Lookup function used when value is not found. + + Args: + value: Value to get enum item. + + """ + if not (isinstance(value, int) and 0b0000 <= value <= 0b1111): + raise ValueError('%r is not a valid %s' % (value, cls.__name__)) + if 2 <= value <= 15: + #: Reserved + return cls._unregistered_member(value, 'Reserved') + return super()._missing_(value) diff --git a/pcapkit/const/ieee802_11/management_subtype.py b/pcapkit/const/ieee802_11/management_subtype.py new file mode 100644 index 0000000000..00df061066 --- /dev/null +++ b/pcapkit/const/ieee802_11/management_subtype.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +# pylint: disable=line-too-long,consider-using-f-string +"""IEEE 802.11 Management Frame Subtypes +=========================================== + +.. module:: pcapkit.const.ieee802_11.management_subtype + +This module contains the constant enumeration for **IEEE 802.11 Management Frame Subtypes**, +which is automatically generated from :class:`pcapkit.vendor.ieee802_11.management_subtype.ManagementSubtype`. + +""" + +from typing import TYPE_CHECKING + +from aenum import IntEnum + +from pcapkit.corekit.enum import EnumRegistry +from pcapkit.utilities.exceptions import EnumValueError + +if TYPE_CHECKING: + from typing import Any + +__all__ = ['ManagementSubtype'] + + +class ManagementSubtype(EnumRegistry, IntEnum): + """[ManagementSubtype] IEEE 802.11 Management Frame Subtypes [IEEE Std 802.11-2020, Table 9-1]""" + + #: Association Request + Association_Request = 0 + + #: Association Response + Association_Response = 1 + + #: Reassociation Request + Reassociation_Request = 2 + + #: Reassociation Response + Reassociation_Response = 3 + + #: Probe Request + Probe_Request = 4 + + #: Probe Response + Probe_Response = 5 + + #: Timing Advertisement + Timing_Advertisement = 6 + + #: Reserved + Reserved_7 = 7 + + #: Beacon + Beacon = 8 + + #: ATIM + ATIM = 9 + + #: Disassociation + Disassociation = 10 + + #: Authentication + Authentication = 11 + + #: Deauthentication + Deauthentication = 12 + + #: Action + Action = 13 + + #: Action No Ack (NACK) + Action_No_Ack = 14 + + #: Reserved + Reserved_15 = 15 + + @classmethod + def _validate_value(cls, value: 'Any') -> 'None': + """Reject ``value`` unless it passes the range check :meth:`_missing_` makes. + + Overrides :meth:`~pcapkit.corekit.enum.EnumLookup._validate_value` + with that check and the message it raises, so + :meth:`~pcapkit.corekit.enum.EnumRegistry.register` refuses exactly the + values :meth:`~pcapkit.corekit.enum.EnumLookup.get` refuses, in the + same words (:issue:`1816`). + + Args: + value: Value about to be looked up or minted. + + Raises: + EnumValueError: If ``value`` fails that check. A + :exc:`ValueError`, so an ``except ValueError`` caller is + unaffected. + + """ + if not (isinstance(value, int) and 0b0000 <= value <= 0b1111): + raise EnumValueError('%r is not a valid %s' % (value, cls.__name__)) + + @classmethod + def _missing_(cls, value: 'int') -> 'ManagementSubtype': + """Lookup function used when value is not found. + + Args: + value: Value to get enum item. + + """ + if not (isinstance(value, int) and 0b0000 <= value <= 0b1111): + raise ValueError('%r is not a valid %s' % (value, cls.__name__)) + return super()._missing_(value) diff --git a/pcapkit/const/ieee802_11/type.py b/pcapkit/const/ieee802_11/type.py new file mode 100644 index 0000000000..f9fd19985d --- /dev/null +++ b/pcapkit/const/ieee802_11/type.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# pylint: disable=line-too-long,consider-using-f-string +"""IEEE 802.11 Frame Types +============================= + +.. module:: pcapkit.const.ieee802_11.type + +This module contains the constant enumeration for **IEEE 802.11 Frame Types**, +which is automatically generated from :class:`pcapkit.vendor.ieee802_11.type.Type`. + +""" + +from typing import TYPE_CHECKING + +from aenum import IntEnum + +from pcapkit.corekit.enum import EnumRegistry +from pcapkit.utilities.exceptions import EnumValueError + +if TYPE_CHECKING: + from typing import Any + +__all__ = ['Type'] + + +class Type(EnumRegistry, IntEnum): + """[Type] IEEE 802.11 Frame Types [IEEE Std 802.11-2020, Table 9-1]""" + + #: Management + Management = 0 + + #: Control + Control = 1 + + #: Data + Data = 2 + + #: Extension + Extension = 3 + + @classmethod + def _validate_value(cls, value: 'Any') -> 'None': + """Reject ``value`` unless it passes the range check :meth:`_missing_` makes. + + Overrides :meth:`~pcapkit.corekit.enum.EnumLookup._validate_value` + with that check and the message it raises, so + :meth:`~pcapkit.corekit.enum.EnumRegistry.register` refuses exactly the + values :meth:`~pcapkit.corekit.enum.EnumLookup.get` refuses, in the + same words (:issue:`1816`). + + Args: + value: Value about to be looked up or minted. + + Raises: + EnumValueError: If ``value`` fails that check. A + :exc:`ValueError`, so an ``except ValueError`` caller is + unaffected. + + """ + if not (isinstance(value, int) and 0b00 <= value <= 0b11): + raise EnumValueError('%r is not a valid %s' % (value, cls.__name__)) + + @classmethod + def _missing_(cls, value: 'int') -> 'Type': + """Lookup function used when value is not found. + + Args: + value: Value to get enum item. + + """ + if not (isinstance(value, int) and 0b00 <= value <= 0b11): + raise ValueError('%r is not a valid %s' % (value, cls.__name__)) + return super()._missing_(value) diff --git a/pcapkit/foundation/__init__.py b/pcapkit/foundation/__init__.py index 1ba63a7ccf..21bc345fe4 100644 --- a/pcapkit/foundation/__init__.py +++ b/pcapkit/foundation/__init__.py @@ -36,7 +36,7 @@ 'register_pcap', 'register_pcapng', 'register_ethertype', 'register_loopback', - 'register_arphrd', 'register_frad', 'register_netlink', + 'register_arphrd', 'register_frad', 'register_netlink', 'register_radiotap', 'register_transtype', 'register_ipx', 'register_icmp_type', diff --git a/pcapkit/foundation/registry/__init__.py b/pcapkit/foundation/registry/__init__.py index b1869c267d..a86b32a887 100644 --- a/pcapkit/foundation/registry/__init__.py +++ b/pcapkit/foundation/registry/__init__.py @@ -32,7 +32,7 @@ 'register_ethertype', 'register_loopback', - 'register_arphrd', 'register_frad', 'register_netlink', + 'register_arphrd', 'register_frad', 'register_netlink', 'register_radiotap', 'register_transtype', 'register_ipx', diff --git a/pcapkit/foundation/registry/protocols.py b/pcapkit/foundation/registry/protocols.py index 4499a04b15..fb8eb3048d 100644 --- a/pcapkit/foundation/registry/protocols.py +++ b/pcapkit/foundation/registry/protocols.py @@ -70,6 +70,7 @@ from pcapkit.protocols.link.linux_sll import LinuxSLL from pcapkit.protocols.link.loopback import Loopback from pcapkit.protocols.link.netlink import Netlink +from pcapkit.protocols.link.radiotap import Radiotap from pcapkit.protocols.misc.pcap.frame import Frame from pcapkit.protocols.misc.pcapng import PCAPNG from pcapkit.protocols.protocol import ProtocolBase @@ -162,7 +163,7 @@ 'register_ethertype', 'register_loopback', - 'register_arphrd', 'register_frad', 'register_netlink', + 'register_arphrd', 'register_frad', 'register_netlink', 'register_radiotap', 'register_transtype', 'register_ipx', @@ -1037,6 +1038,55 @@ def register_netlink(code: 'int', module: 'str | ModuleDescriptor[ProtocolBase] _register_name(module, (Netlink, code)) +@overload +def register_radiotap(code: 'Enum_LinkType', module: 'ModuleDescriptor[ProtocolBase] | Type[ProtocolBase]') -> 'None': ... +@overload +def register_radiotap(code: 'Enum_LinkType', module: 'str', class_: 'str') -> 'None': ... + + +# NOTE: pcapkit.protocols.link.radiotap.Radiotap.__proto__ +def register_radiotap(code: 'Enum_LinkType', module: 'str | ModuleDescriptor[ProtocolBase] | Type[ProtocolBase]', + class_: 'str | NullType' = NULL) -> 'None': + r"""Register a new protocol class. + + Notes: + The fully qualified class name of the new protocol class + should be ``{module}.{class_}``. + + The function will register the given protocol class to the + :data:`pcapkit.protocols.link.radiotap.Radiotap.__proto__` registry, keyed + by the link type of the frame a radiotap header precedes. Any link type is + accepted, but :meth:`Radiotap.read ` + consults :attr:`LinkType.IEEE802_11 ` + alone: registering that key replaces the 802.11 dissector, and an entry + under any other key is stored and never reached. + + Arguments: + code: link type as in :class:`~pcapkit.const.reg.linktype.LinkType` + module: module name or module descriptor or a + :class:`~pcapkit.protocols.protocol.ProtocolBase` subclass + class\_: class name + + Raises: + RegistryError: If ``code`` is not a :class:`~pcapkit.const.reg.linktype.LinkType` + member, e.g. a member of another enum, or ``module`` + is not a :class:`~pcapkit.protocols.protocol.ProtocolBase` + subclass. + + """ + _check_code(code, Enum_LinkType, Radiotap) + if isinstance(module, str): + module = cast('ModuleDescriptor[ProtocolBase]', ModuleDescriptor(module, class_)) + + Radiotap.register(code, module) + logger.debug('registered radiotap link type: %s', code.name) + + # register protocol to protocol registry + if isinstance(module, ModuleDescriptor): + module = module.klass + _register_name(module, (Radiotap, code)) + + ############################################################################### # Internet Layer Registries ############################################################################### diff --git a/pcapkit/protocols/__init__.py b/pcapkit/protocols/__init__.py index bab21da932..8d6ffb4932 100644 --- a/pcapkit/protocols/__init__.py +++ b/pcapkit/protocols/__init__.py @@ -50,8 +50,8 @@ 'Raw', # Link Layer - 'ARP', 'C_Tag', 'Ethernet', 'FRAD', 'GRE', 'InARP', 'L2TP', 'L2TPv2', 'LinuxSLL', 'LinuxSLL1', - 'LinuxSLL2', 'Loopback', 'Netlink', 'Radiotap', 'S_Tag', 'VLAN', + 'ARP', 'C_Tag', 'Ethernet', 'FRAD', 'GRE', 'IEEE802_11', 'InARP', 'L2TP', 'L2TPv2', + 'LinuxSLL', 'LinuxSLL1', '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 2f23aae8e0..45d5a9df13 100644 --- a/pcapkit/protocols/data/__init__.py +++ b/pcapkit/protocols/data/__init__.py @@ -68,6 +68,10 @@ # Generic Routing Encapsulation 'GRE', + # IEEE 802.11 MAC Header + 'IEEE802_11', 'IEEE802_11_FrameControl', 'IEEE802_11_SequenceControl', + 'IEEE802_11_QoSControl', 'IEEE802_11_HTControl', + # Layer Two Tunnelling Protocol 'L2TP', 'L2TP_Flags', diff --git a/pcapkit/protocols/data/link/__init__.py b/pcapkit/protocols/data/link/__init__.py index 3cbe049fc4..bb85863975 100644 --- a/pcapkit/protocols/data/link/__init__.py +++ b/pcapkit/protocols/data/link/__init__.py @@ -15,6 +15,13 @@ # Generic Routing Encapsulation from pcapkit.protocols.data.link.gre import GRE +# IEEE 802.11 MAC Header +from pcapkit.protocols.data.link.ieee802_11 import IEEE802_11 +from pcapkit.protocols.data.link.ieee802_11 import FrameControl as IEEE802_11_FrameControl +from pcapkit.protocols.data.link.ieee802_11 import HTControl as IEEE802_11_HTControl +from pcapkit.protocols.data.link.ieee802_11 import QoSControl as IEEE802_11_QoSControl +from pcapkit.protocols.data.link.ieee802_11 import SequenceControl as IEEE802_11_SequenceControl + # Layer Two Tunnelling Protocol from pcapkit.protocols.data.link.l2tp import L2TP from pcapkit.protocols.data.link.l2tp import Flags as L2TP_Flags @@ -49,6 +56,10 @@ # Generic Routing Encapsulation 'GRE', + # IEEE 802.11 MAC Header + 'IEEE802_11', 'IEEE802_11_FrameControl', 'IEEE802_11_SequenceControl', + 'IEEE802_11_QoSControl', 'IEEE802_11_HTControl', + # Layer Two Tunnelling Protocol 'L2TP', 'L2TP_Flags', diff --git a/pcapkit/protocols/data/link/ieee802_11.py b/pcapkit/protocols/data/link/ieee802_11.py new file mode 100644 index 0000000000..15ea4a8efe --- /dev/null +++ b/pcapkit/protocols/data/link/ieee802_11.py @@ -0,0 +1,172 @@ +# -*- coding: utf-8 -*- +"""data models for IEEE 802.11 MAC headers""" + +# NOTE: ``Optional`` is imported at run time so that ``from_dict`` can resolve the +# nested annotations below and rebuild them as their own classes. +from typing import TYPE_CHECKING, Optional # pylint: disable=unused-import + +from pcapkit.corekit.infoclass import info_final +from pcapkit.protocols.data.data import Data +from pcapkit.protocols.data.protocol import Protocol + +if TYPE_CHECKING: + from typing_extensions import Literal + + from pcapkit.const.ieee802_11.control_subtype import ControlSubtype + from pcapkit.const.ieee802_11.data_subtype import DataSubtype + from pcapkit.const.ieee802_11.extension_subtype import ExtensionSubtype + from pcapkit.const.ieee802_11.management_subtype import ManagementSubtype + from pcapkit.const.ieee802_11.type import Type + +__all__ = [ + 'IEEE802_11', + 'FrameControl', 'SequenceControl', 'QoSControl', 'HTControl', +] + + +@info_final +class FrameControl(Data): + """Data model for the Frame Control field (IEEE Std 802.11-2020 §9.2.4.1). + + Two frames lay out B8-B15 differently, and the flags they replace are + :obj:`None` there: a Control Frame Extension frame carries + :attr:`control_frame_extension` in B8-B11, and an S1G Beacon carries + :attr:`s1g_beacon_flags` in all of B8-B15. + + """ + + #: Protocol Version. + version: 'int' + #: Type. + type: 'Type' + #: Subtype, from the registry :attr:`type` selects. + subtype: 'ManagementSubtype | ControlSubtype | DataSubtype | ExtensionSubtype' + #: To DS. + to_ds: 'Optional[bool]' + #: From DS. + from_ds: 'Optional[bool]' + #: More Fragments. + more_fragments: 'Optional[bool]' + #: Retry. + retry: 'Optional[bool]' + #: Control Frame Extension (B8-B11) of a Control Frame Extension frame; + #: :obj:`None` in any other frame. + control_frame_extension: 'Optional[int]' + #: Power Management. + power_management: 'Optional[bool]' + #: More Data. + more_data: 'Optional[bool]' + #: Protected Frame. + protected: 'Optional[bool]' + #: +HTC/Order. + order: 'Optional[bool]' + #: B8-B15 of an S1G Beacon, kept whole -- Next TBTT Present, Compressed SSID + #: Present, ANO Present, BSS BW, Security and AP PM, as Wireshark's + #: ``packet-ieee80211.c`` names them; :obj:`None` in any other frame. + s1g_beacon_flags: 'Optional[int]' + + if TYPE_CHECKING: + def __init__(self, version: 'int', type: 'Type', # pylint: disable=unused-argument,super-init-not-called,multiple-statements,redefined-builtin + subtype: 'ManagementSubtype | ControlSubtype | DataSubtype | ExtensionSubtype', # pylint: disable=unused-argument + to_ds: 'Optional[bool]', from_ds: 'Optional[bool]', # pylint: disable=unused-argument + more_fragments: 'Optional[bool]', retry: 'Optional[bool]', # pylint: disable=unused-argument + control_frame_extension: 'Optional[int]', power_management: 'Optional[bool]', # pylint: disable=unused-argument + more_data: 'Optional[bool]', protected: 'Optional[bool]', # pylint: disable=unused-argument + order: 'Optional[bool]', s1g_beacon_flags: 'Optional[int]') -> 'None': ... # pylint: disable=unused-argument + + +@info_final +class SequenceControl(Data): + """Data model for the Sequence Control field (IEEE Std 802.11-2020 §9.2.4.4).""" + + #: Fragment Number. + fragment_number: 'int' + #: Sequence Number. + sequence_number: 'int' + + if TYPE_CHECKING: + def __init__(self, fragment_number: 'int', sequence_number: 'int') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements + + +@info_final +class QoSControl(Data): + """Data model for the QoS Control field (IEEE Std 802.11-2020 §9.2.4.5). + + Only the subfields every QoS Data subtype places alike are decoded. What + bits 8-15 carry depends on the subtype and on the kind of station that + sent the frame, which the MAC header does not say, so they are kept + whole as :attr:`bits_8_15`. + + """ + + #: TID (bits 0-3). + tid: 'int' + #: EOSP (bit 4). In a frame a non-AP station sends, the same bit says + #: whether :attr:`bits_8_15` is TXOP Duration Requested or Queue Size. + eosp: 'bool' + #: Ack Policy Indicator (bits 5-6). + ack_policy: 'int' + #: A-MSDU Present (bit 7); reserved in the subtypes that carry no data. + amsdu_present: 'bool' + #: Bits 8-15, which are TXOP Limit, TXOP Duration Requested, Queue Size, + #: AP PS Buffer State, or the mesh subfields, by subtype and sender. + bits_8_15: 'int' + + if TYPE_CHECKING: + def __init__(self, tid: 'int', eosp: 'bool', ack_policy: 'int', # pylint: disable=unused-argument,super-init-not-called,multiple-statements + amsdu_present: 'bool', bits_8_15: 'int') -> 'None': ... # pylint: disable=unused-argument + + +@info_final +class HTControl(Data): + """Data model for the HT Control field (IEEE Std 802.11-2020 §9.2.4.6). + + Its first bits name the variant the rest of the field takes; the + variants' own subfields are not decoded. + + """ + + #: The variant, ``'HT'`` when bit 0 is clear, ``'VHT'`` when bit 0 is set + #: and bit 1 clear, and ``'HE'`` when both are set (IEEE Std 802.11ax-2021). + variant: 'Literal["HT", "VHT", "HE"]' + #: The whole field, as the little-endian integer read off the wire. + value: 'int' + + if TYPE_CHECKING: + def __init__(self, variant: 'Literal["HT", "VHT", "HE"]', value: 'int') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements + + +@info_final +class IEEE802_11(Protocol): + """Data model for an IEEE 802.11 MAC header (IEEE Std 802.11-2020 §9.2). + + A field the header does not carry is :obj:`None`; see + :func:`~pcapkit.protocols.schema.link.ieee802_11.layout`. + + """ + + #: Frame Control. + frame_control: 'FrameControl' + #: Duration/ID; the AID in a PS-Poll frame. + duration_id: 'int' + #: Address 1. + addr1: 'str' + #: Address 2. + addr2: 'Optional[str]' + #: Address 3. + addr3: 'Optional[str]' + #: Sequence Control. + seq_ctrl: 'Optional[SequenceControl]' + #: Address 4. + addr4: 'Optional[str]' + #: QoS Control. + qos_control: 'Optional[QoSControl]' + #: HT Control. + ht_control: 'Optional[HTControl]' + + if TYPE_CHECKING: + def __init__(self, frame_control: 'FrameControl', duration_id: 'int', addr1: 'str', # pylint: disable=unused-argument,super-init-not-called,multiple-statements + addr2: 'Optional[str]', addr3: 'Optional[str]', # pylint: disable=unused-argument + seq_ctrl: 'Optional[SequenceControl]', addr4: 'Optional[str]', # pylint: disable=unused-argument + qos_control: 'Optional[QoSControl]', # pylint: disable=unused-argument + ht_control: 'Optional[HTControl]') -> 'None': ... # pylint: disable=unused-argument diff --git a/pcapkit/protocols/link/__init__.py b/pcapkit/protocols/link/__init__.py index 5e4452ce43..fb3c1db735 100644 --- a/pcapkit/protocols/link/__init__.py +++ b/pcapkit/protocols/link/__init__.py @@ -20,6 +20,7 @@ from pcapkit.protocols.link.ethernet import Ethernet from pcapkit.protocols.link.frad import FRAD from pcapkit.protocols.link.gre import GRE +from pcapkit.protocols.link.ieee802_11 import IEEE802_11 from pcapkit.protocols.link.loopback import Loopback from pcapkit.protocols.link.netlink import Netlink from pcapkit.protocols.link.radiotap import Radiotap @@ -46,6 +47,6 @@ 'LINKTYPE', # Link Layer Protocols - 'ARP', 'C_Tag', 'Ethernet', 'FRAD', 'GRE', 'InARP', 'L2TP', 'L2TPv2', 'LinuxSLL', 'LinuxSLL1', - 'LinuxSLL2', 'Loopback', 'Netlink', 'Radiotap', 'S_Tag', 'VLAN', + 'ARP', 'C_Tag', 'Ethernet', 'FRAD', 'GRE', 'IEEE802_11', 'InARP', 'L2TP', 'L2TPv2', + 'LinuxSLL', 'LinuxSLL1', 'LinuxSLL2', 'Loopback', 'Netlink', 'Radiotap', 'S_Tag', 'VLAN', ] diff --git a/pcapkit/protocols/link/ieee802_11.py b/pcapkit/protocols/link/ieee802_11.py new file mode 100644 index 0000000000..87a9586959 --- /dev/null +++ b/pcapkit/protocols/link/ieee802_11.py @@ -0,0 +1,640 @@ +# -*- coding: utf-8 -*- +"""IEEE 802.11 - Wireless LAN MAC Header +=========================================== + +.. module:: pcapkit.protocols.link.ieee802_11 + +:mod:`pcapkit.protocols.link.ieee802_11` contains +:class:`~pcapkit.protocols.link.ieee802_11.IEEE802_11` only, which implements +the extractor for the IEEE 802.11 MAC header [*]_ of a protocol version 0 +frame, as :class:`~pcapkit.protocols.link.radiotap.Radiotap` hands it on. Its +fields, in the order they appear, numeric ones little-endian: + +.. table:: + + ======================= ======= ============================================ + Name Octets Present + ======================= ======= ============================================ + ``frame_control`` 2 always (§9.2.4.1) + ``duration_id`` 2 always (§9.2.4.2) + ``addr1`` 6 always (§9.2.4.3) + ``addr2`` 6 Management, Data, and Control frames that + carry a TA (§9.2.4.3, §9.3.1) + ``addr3`` 6 Management and Data frames (§9.2.4.3) + ``seq_ctrl`` 2 Management and Data frames (§9.2.4.4) + ``addr4`` 6 Data frames with To DS and From DS set + (§9.2.4.3) + ``qos_control`` 2 QoS Data subtypes (§9.2.4.5) + ``ht_control`` 4 QoS Data subtypes and Management frames + with +HTC/Order set (§9.2.4.6) + ======================= ======= ============================================ + +:func:`~pcapkit.protocols.schema.link.ieee802_11.layout` gives the rule per +frame type. A Control frame of a subtype whose layout this class does not +model, and an Extension frame, are read as the minimal frame format of +§9.2.3: the first three fields only. A Control Frame Extension frame and an +S1G Beacon lay out B8-B15 of Frame Control differently; see +:class:`~pcapkit.protocols.data.link.ieee802_11.FrameControl`. + +The frame body (§9.2.4.7) follows the header and is kept as +:class:`~pcapkit.protocols.misc.raw.Raw`, protected or not. So is the FCS +(§9.2.4.8) where one was captured: whether a capture carries it is a +radiotap Flags bit, which :class:`~pcapkit.protocols.link.radiotap.Radiotap` +does not decode, so the FCS stays at the end of the body. For the same reason, +the pad octets that radiotap's data-pad flag says follow the header stay at the +start of the body. + +A frame of any protocol version but 0 is refused: version 1 is the PV1 frame +format of §9.8, whose Frame Control field is laid out differently, and +versions 2 and 3 are reserved. + +.. [*] IEEE Std 802.11-2020, clause 9, *Frame formats*. The clause numbers + above are its own, checked against Wireshark's ``packet-ieee80211.c`` where + it cites one: §9.2.4.1.10, §9.2.4.6, §9.2.4.7 and §9.8. + +.. versionadded:: 1.6.0 + +""" +from typing import TYPE_CHECKING + +from pcapkit.const.ieee802_11.control_subtype import ControlSubtype as Enum_ControlSubtype +from pcapkit.const.ieee802_11.data_subtype import DataSubtype as Enum_DataSubtype +from pcapkit.const.ieee802_11.extension_subtype import ExtensionSubtype as Enum_ExtensionSubtype +from pcapkit.const.ieee802_11.management_subtype import ManagementSubtype as Enum_ManagementSubtype +from pcapkit.const.ieee802_11.type import Type as Enum_Type +from pcapkit.protocols.data.link.ieee802_11 import IEEE802_11 as Data_IEEE802_11 +from pcapkit.protocols.data.link.ieee802_11 import FrameControl as Data_FrameControl +from pcapkit.protocols.data.link.ieee802_11 import HTControl as Data_HTControl +from pcapkit.protocols.data.link.ieee802_11 import QoSControl as Data_QoSControl +from pcapkit.protocols.data.link.ieee802_11 import SequenceControl as Data_SequenceControl +from pcapkit.protocols.link.ethernet import PAT_MAC_ADDR +from pcapkit.protocols.link.link import Link +from pcapkit.protocols.schema.link.ieee802_11 import IEEE802_11 as Schema_IEEE802_11 +from pcapkit.protocols.schema.link.ieee802_11 import Layout, layout +from pcapkit.utilities.exceptions import ProtocolError, UnsupportedCall + +if TYPE_CHECKING: + from enum import IntEnum as StdlibEnum + from typing import Any, Mapping, NoReturn, Optional, Type, Union + + from aenum import IntEnum as AenumEnum + from typing_extensions import Literal + + from pcapkit.protocols.protocol import ProtocolBase + from pcapkit.protocols.schema.schema import Schema + + Subtype = Union[Enum_ManagementSubtype, Enum_ControlSubtype, Enum_DataSubtype, Enum_ExtensionSubtype] + +__all__ = ['IEEE802_11'] + +#: Subtype registry of each frame type (IEEE Std 802.11-2020, Table 9-1). +SUBTYPE = { + Enum_Type.Management: Enum_ManagementSubtype, + Enum_Type.Control: Enum_ControlSubtype, + Enum_Type.Data: Enum_DataSubtype, + Enum_Type.Extension: Enum_ExtensionSubtype, +} # type: dict[int, Type[Subtype]] + + +def _header_length(fields: 'Layout') -> 'int': + """Octets of a MAC header that carries ``fields`` after Address 1.""" + return (10 + 6 * fields.addr2 + 6 * fields.addr3 + 2 * fields.seq_ctrl + + 6 * fields.addr4 + 2 * fields.qos_control + 4 * fields.ht_control) + + +class IEEE802_11(Link[Data_IEEE802_11, Schema_IEEE802_11], + schema=Schema_IEEE802_11, data=Data_IEEE802_11): + """This class implements the IEEE 802.11 MAC header. + + The frame body is kept as :class:`~pcapkit.protocols.misc.raw.Raw`; see + the module docstring. + + """ + + ########################################################################## + # Properties. + ########################################################################## + + @property + def name(self) -> 'Literal["IEEE 802.11 Wireless LAN"]': + """Name of current protocol.""" + return 'IEEE 802.11 Wireless LAN' + + @property + def alias(self) -> 'Literal["802.11"]': + """Acronym of corresponding protocol.""" + return '802.11' + + @property + def length(self) -> 'int': + """Header length of current protocol, from the fields its Frame Control declares.""" + info = self._info + return _header_length(Layout( + addr2=info.addr2 is not None, + addr3=info.addr3 is not None, + seq_ctrl=info.seq_ctrl is not None, + addr4=info.addr4 is not None, + qos_control=info.qos_control is not None, + ht_control=info.ht_control is not None, + )) + + ########################################################################## + # Methods. + ########################################################################## + + @classmethod + def id(cls) -> 'tuple[Literal["IEEE802_11"]]': + """Index ID of the protocol.""" + return ('IEEE802_11',) + + def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_IEEE802_11': # pylint: disable=unused-argument + """Read IEEE 802.11 MAC header. + + Structure of the MAC header with every field present (IEEE Std + 802.11-2020 §9.2.3, *General frame format*), in octets: + + .. code-block:: text + + +-------+--------+------+------+------+------+------+-----+-----+------+-----+ + | Frame | Dura- | Addr | Addr | Addr | Seq | Addr | QoS | HT | Body | FCS | + | Ctrl | tion/ID| 1 | 2 | 3 | Ctrl | 4 | Ctl | Ctl | | | + +-------+--------+------+------+------+------+------+-----+-----+------+-----+ + 2 2 6 6 6 2 6 2 4 var. 4 + + Args: + length: Length of packet data. + **kwargs: Arbitrary keyword arguments. + + Returns: + Parsed packet data. + + Raises: + ProtocolError: If the protocol version is not ``0``. + + """ + if length is None: + length = len(self) + schema = self.__header__ + + frame_control = schema.frame_control + if frame_control & 0b11: + raise ProtocolError(f'{self.alias}: unsupported protocol version: {frame_control & 0b11}') + + ieee802_11 = Data_IEEE802_11( + frame_control=self._read_frame_control(frame_control), + duration_id=schema.duration_id, + addr1=self._read_mac_addr(schema.addr1), + addr2=None if schema.addr2 is None else self._read_mac_addr(schema.addr2), + addr3=None if schema.addr3 is None else self._read_mac_addr(schema.addr3), + seq_ctrl=None if schema.seq_ctrl is None else self._read_seq_ctrl(schema.seq_ctrl), + addr4=None if schema.addr4 is None else self._read_mac_addr(schema.addr4), + qos_control=None if schema.qos_control is None else self._read_qos_control(schema.qos_control), + ht_control=None if schema.ht_control is None else self._read_ht_control(schema.ht_control), + ) + hdr_len = _header_length(layout(frame_control)) + return self._decode_next_layer(ieee802_11, -1, max(0, length - hdr_len)) + + def make(self, + type: 'Enum_Type | StdlibEnum | AenumEnum | str | int' = Enum_Type.Data, # pylint: disable=redefined-builtin + type_default: 'Optional[int]' = None, + type_namespace: 'Optional[dict[str, int] | dict[int, str] | Type[StdlibEnum] | Type[AenumEnum]]' = None, # pylint: disable=line-too-long + type_reversed: 'bool' = False, + subtype: 'Subtype | StdlibEnum | AenumEnum | str | int' = 0, + subtype_default: 'Optional[int]' = None, + subtype_namespace: 'Optional[dict[str, int] | dict[int, str] | Type[StdlibEnum] | Type[AenumEnum]]' = None, # pylint: disable=line-too-long + subtype_reversed: 'bool' = False, + version: 'Literal[0]' = 0, + to_ds: 'Optional[bool]' = None, + from_ds: 'Optional[bool]' = None, + more_fragments: 'Optional[bool]' = None, + retry: 'Optional[bool]' = None, + control_frame_extension: 'Optional[int]' = None, + power_management: 'Optional[bool]' = None, + more_data: 'Optional[bool]' = None, + protected: 'Optional[bool]' = None, + order: 'Optional[bool]' = None, + s1g_beacon_flags: 'Optional[int]' = None, + duration_id: 'int' = 0, + addr1: 'str | bytes | bytearray' = '00:00:00:00:00:00', + addr2: 'Optional[str | bytes | bytearray]' = None, + addr3: 'Optional[str | bytes | bytearray]' = None, + seq_ctrl: 'Optional[Data_SequenceControl | Mapping[str, int]]' = None, + addr4: 'Optional[str | bytes | bytearray]' = None, + qos_control: 'Optional[Data_QoSControl | Mapping[str, Any]]' = None, + ht_control: 'Optional[int]' = None, + payload: 'bytes | ProtocolBase | Schema' = b'', + **kwargs: 'Any') -> 'Schema_IEEE802_11': + """Make (construct) packet data. + + The Frame Control subfields decide which fields the header carries, + as :func:`~pcapkit.protocols.schema.link.ieee802_11.layout` reads them. + A field the header carries and the caller leaves out is written as + zeros; a field the header does not carry must be left out. + + Args: + type: Type. + type_default: Default value of ``type``. + type_namespace: Namespace of ``type``. + type_reversed: Reversed namespace of ``type``. + subtype: Subtype; a name resolves against the registry ``type`` + selects unless ``subtype_namespace`` is given. + subtype_default: Default value of ``subtype``. + subtype_namespace: Namespace of ``subtype``. + subtype_reversed: Reversed namespace of ``subtype``. + version: Protocol Version; only ``0`` is built. + to_ds: To DS. + from_ds: From DS. + more_fragments: More Fragments. + retry: Retry. + control_frame_extension: Control Frame Extension, which takes the + four bits above in a Control Frame Extension frame and is + refused in any other. + power_management: Power Management. + more_data: More Data. + protected: Protected Frame. + order: +HTC/Order. + s1g_beacon_flags: B8-B15 of an S1G Beacon, which take the place of + every flag above there and are refused in any other frame. + duration_id: Duration/ID. + addr1: Address 1. + addr2: Address 2. + addr3: Address 3. + seq_ctrl: Sequence Control, a mapping of ``fragment_number`` and + ``sequence_number``. + addr4: Address 4. + qos_control: QoS Control, a mapping of the keys + :class:`~pcapkit.protocols.data.link.ieee802_11.QoSControl` + has. + ht_control: HT Control, the whole field as an integer. + payload: Payload data: the frame body, and the FCS if any. + **kwargs: Arbitrary keyword arguments. + + Returns: + Constructed packet data. + + Raises: + ProtocolError: If a value does not fit its field, if ``version`` + is not ``0``, or if a field is given that the header does not + carry. + + """ + type_ = self._make_index(type, type_default, namespace=type_namespace, + reversed=type_reversed, pack=False) + type_ = self._make_uint(type_, bits=2, name='type') + subtype_ = self._make_index(subtype, subtype_default, + namespace=subtype_namespace or SUBTYPE[Enum_Type(type_)], + reversed=subtype_reversed, pack=False) + subtype_ = self._make_uint(subtype_, bits=4, name='subtype') + + frame_control = self._make_frame_control( + version=version, type=type_, subtype=subtype_, + to_ds=to_ds, from_ds=from_ds, more_fragments=more_fragments, retry=retry, + control_frame_extension=control_frame_extension, + power_management=power_management, more_data=more_data, + protected=protected, order=order, s1g_beacon_flags=s1g_beacon_flags, + ) + fields = layout(frame_control) + for name, given, carried in (('addr2', addr2, fields.addr2), ('addr3', addr3, fields.addr3), + ('seq_ctrl', seq_ctrl, fields.seq_ctrl), + ('addr4', addr4, fields.addr4), + ('qos_control', qos_control, fields.qos_control), + ('ht_control', ht_control, fields.ht_control)): + if given is not None and not carried: + raise ProtocolError(f'{self.alias}: {name} given, but this frame carries none') + + return Schema_IEEE802_11( + frame_control=frame_control, + duration_id=self._make_uint(duration_id, bits=16, name='duration_id'), + addr1=self._make_mac_addr(addr1), + addr2=self._make_mac_addr(addr2) if fields.addr2 else None, + addr3=self._make_mac_addr(addr3) if fields.addr3 else None, + seq_ctrl=self._make_seq_ctrl(seq_ctrl) if fields.seq_ctrl else None, + addr4=self._make_mac_addr(addr4) if fields.addr4 else None, + qos_control=self._make_qos_control(qos_control) if fields.qos_control else None, + ht_control=self._make_ht_control(ht_control) if fields.ht_control else None, + payload=payload, + ) + + ########################################################################## + # Data models. + ########################################################################## + + def __length_hint__(self) -> 'Literal[36]': + """Return an estimated length for the object. + + The longest form -- every optional field present, 36 octets -- per + the reason ``L2TPv2.__length_hint__`` gives for a variable-length + header. + + """ + return 36 + + @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.radiotap.Radiotap`'s payload + 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_IEEE802_11') -> '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. + + """ + frame_control = data.frame_control + return { + 'type': frame_control.type, + 'subtype': frame_control.subtype, + 'version': frame_control.version, + 'to_ds': frame_control.to_ds, + 'from_ds': frame_control.from_ds, + 'more_fragments': frame_control.more_fragments, + 'retry': frame_control.retry, + 'control_frame_extension': frame_control.control_frame_extension, + 'power_management': frame_control.power_management, + 'more_data': frame_control.more_data, + 'protected': frame_control.protected, + 'order': frame_control.order, + 's1g_beacon_flags': frame_control.s1g_beacon_flags, + 'duration_id': data.duration_id, + 'addr1': data.addr1, + 'addr2': data.addr2, + 'addr3': data.addr3, + 'seq_ctrl': data.seq_ctrl, + 'addr4': data.addr4, + 'qos_control': data.qos_control, + 'ht_control': None if data.ht_control is None else data.ht_control['value'], + 'payload': cls._make_payload(data), + } + + def _read_frame_control(self, value: 'int') -> 'Data_FrameControl': + """Read the Frame Control field (IEEE Std 802.11-2020 §9.2.4.1). + + In a Control Frame Extension frame, B8-B11 are the Control Frame + Extension subfield rather than To DS, From DS, More Fragments and + Retry (§9.2.4.1.1). In an S1G Beacon, B8-B15 are the subfields + Wireshark's ``packet-ieee80211.c`` reads as ``wlan.fc.s1g.*``, so none + of the eight flags is read there. + + Args: + value: Frame Control field, as a little-endian integer. + + Returns: + Parsed Frame Control field. + + """ + type_ = Enum_Type.get((value >> 2) & 0b11) + subtype = SUBTYPE[type_].get((value >> 4) & 0b1111) + extension = type_ == Enum_Type.Control and subtype == Enum_ControlSubtype.Control_Frame_Extension + s1g_beacon = type_ == Enum_Type.Extension and subtype == Enum_ExtensionSubtype.S1G_Beacon + low_flags = not (extension or s1g_beacon) + + return Data_FrameControl( + version=value & 0b11, + type=type_, + subtype=subtype, + to_ds=bool(value >> 8 & 1) if low_flags else None, + from_ds=bool(value >> 9 & 1) if low_flags else None, + more_fragments=bool(value >> 10 & 1) if low_flags else None, + retry=bool(value >> 11 & 1) if low_flags else None, + control_frame_extension=(value >> 8) & 0b1111 if extension else None, + power_management=None if s1g_beacon else bool(value >> 12 & 1), + more_data=None if s1g_beacon else bool(value >> 13 & 1), + protected=None if s1g_beacon else bool(value >> 14 & 1), + order=None if s1g_beacon else bool(value >> 15 & 1), + s1g_beacon_flags=value >> 8 if s1g_beacon else None, + ) + + def _make_frame_control(self, *, version: 'int', type: 'int', subtype: 'int', # pylint: disable=redefined-builtin + to_ds: 'Optional[bool]', from_ds: 'Optional[bool]', + more_fragments: 'Optional[bool]', retry: 'Optional[bool]', + control_frame_extension: 'Optional[int]', + power_management: 'Optional[bool]', more_data: 'Optional[bool]', + protected: 'Optional[bool]', order: 'Optional[bool]', + s1g_beacon_flags: 'Optional[int]') -> 'int': + """Make the Frame Control field (IEEE Std 802.11-2020 §9.2.4.1). + + Args: + version: Protocol Version. + type: Type. + subtype: Subtype. + to_ds: To DS. + from_ds: From DS. + more_fragments: More Fragments. + retry: Retry. + control_frame_extension: Control Frame Extension. + power_management: Power Management. + more_data: More Data. + protected: Protected Frame. + order: +HTC/Order. + s1g_beacon_flags: B8-B15 of an S1G Beacon. + + Returns: + Frame Control field, as a little-endian integer. + + Raises: + ProtocolError: If ``version`` is not ``0``; if a Control Frame + Extension frame sets any of ``to_ds``, ``from_ds``, + ``more_fragments`` and ``retry``, or an S1G Beacon sets any + flag; if ``control_frame_extension`` or ``s1g_beacon_flags`` is + given for a frame that does not carry it; or if a subfield does + not fit. + + """ + if not isinstance(version, int) or isinstance(version, bool) or version: + raise ProtocolError(f'{self.alias}: unsupported protocol version: {version!r}') + + flags = {'to_ds': to_ds, 'from_ds': from_ds, 'more_fragments': more_fragments, 'retry': retry} + high_flags = {'power_management': power_management, 'more_data': more_data, + 'protected': protected, 'order': order} + if type == Enum_Type.Extension and subtype == Enum_ExtensionSubtype.S1G_Beacon: + for name, flag in {**flags, **high_flags}.items(): + if flag: + raise ProtocolError(f'{self.alias}: {name} set in an S1G Beacon') + if control_frame_extension is not None: + raise ProtocolError(f'{self.alias}: control_frame_extension given, but this ' + 'frame is not a Control Frame Extension frame') + byte = self._make_uint(s1g_beacon_flags or 0, bits=8, name='s1g_beacon_flags') + return version | type << 2 | subtype << 4 | byte << 8 + if s1g_beacon_flags is not None: + raise ProtocolError(f'{self.alias}: s1g_beacon_flags given, but this frame is not ' + 'an S1G Beacon') + + if type == Enum_Type.Control and subtype == Enum_ControlSubtype.Control_Frame_Extension: + for name, flag in flags.items(): + if flag: + raise ProtocolError(f'{self.alias}: {name} set in a Control Frame Extension frame') + low = self._make_uint(control_frame_extension or 0, bits=4, name='control_frame_extension') + else: + if control_frame_extension is not None: + raise ProtocolError(f'{self.alias}: control_frame_extension given, but this ' + 'frame is not a Control Frame Extension frame') + low = 0 + for shift, (name, flag) in enumerate(flags.items()): + low |= self._make_uint(False if flag is None else flag, bits=1, name=name) << shift + + high = 0 + for shift, (name, flag) in enumerate(high_flags.items()): + high |= self._make_uint(False if flag is None else flag, bits=1, name=name) << shift + + return version | type << 2 | subtype << 4 | low << 8 | high << 12 + + def _read_mac_addr(self, addr: 'bytes') -> 'str': + """Read MAC address. + + Args: + addr: MAC address. + + Returns: + Colon (``:``) separated *hex* encoded MAC address. + + """ + return addr.hex(':') + + def _make_mac_addr(self, addr: 'Optional[str | bytes | bytearray]') -> 'bytes': + """Make MAC address. + + Args: + addr: MAC address; :obj:`None` makes all zeros. + + Returns: + MAC address packed as 6 octets. + + Raises: + ProtocolError: If ``addr`` is not a ``:``- or ``-``-separated *hex* + encoded MAC address. + + """ + if addr is None: + return bytes(6) + if not isinstance(addr, (str, bytes, bytearray)): + raise ProtocolError(f'{self.alias}: invalid MAC address: must be a str, bytes or ' + f'bytearray, not {addr!r}') + _addr = addr.encode() if isinstance(addr, str) else bytes(addr) + + if PAT_MAC_ADDR.fullmatch(_addr) is not None: + return bytes.fromhex(_addr.replace(b':', b'').replace(b'-', b'').decode()) + raise ProtocolError(f'{self.alias}: invalid MAC address: {addr!r}') + + def _read_seq_ctrl(self, value: 'int') -> 'Data_SequenceControl': + """Read the Sequence Control field (IEEE Std 802.11-2020 §9.2.4.4). + + Args: + value: Sequence Control field, as a little-endian integer. + + Returns: + Parsed Sequence Control field. + + """ + return Data_SequenceControl( + fragment_number=value & 0b1111, + sequence_number=value >> 4, + ) + + def _make_seq_ctrl(self, seq_ctrl: 'Optional[Data_SequenceControl | Mapping[str, int]]') -> 'int': + """Make the Sequence Control field (IEEE Std 802.11-2020 §9.2.4.4). + + Args: + seq_ctrl: Sequence Control field; :obj:`None` makes zeros. + + Returns: + Sequence Control field, as a little-endian integer. + + Raises: + ProtocolError: If a subfield does not fit. + + """ + if seq_ctrl is None: + return 0 + fragment_number = self._make_uint(seq_ctrl['fragment_number'], bits=4, name='fragment_number') + sequence_number = self._make_uint(seq_ctrl['sequence_number'], bits=12, name='sequence_number') + return fragment_number | sequence_number << 4 + + def _read_qos_control(self, value: 'int') -> 'Data_QoSControl': + """Read the QoS Control field (IEEE Std 802.11-2020 §9.2.4.5). + + Args: + value: QoS Control field, as a little-endian integer. + + Returns: + Parsed QoS Control field. + + """ + return Data_QoSControl( + tid=value & 0b1111, + eosp=bool(value >> 4 & 1), + ack_policy=(value >> 5) & 0b11, + amsdu_present=bool(value >> 7 & 1), + bits_8_15=value >> 8, + ) + + def _make_qos_control(self, qos_control: 'Optional[Data_QoSControl | Mapping[str, Any]]') -> 'int': + """Make the QoS Control field (IEEE Std 802.11-2020 §9.2.4.5). + + Args: + qos_control: QoS Control field; :obj:`None` makes zeros. + + Returns: + QoS Control field, as a little-endian integer. + + Raises: + ProtocolError: If a subfield does not fit. + + """ + if qos_control is None: + return 0 + return (self._make_uint(qos_control['tid'], bits=4, name='tid') + | self._make_uint(qos_control['eosp'], bits=1, name='eosp') << 4 + | self._make_uint(qos_control['ack_policy'], bits=2, name='ack_policy') << 5 + | self._make_uint(qos_control['amsdu_present'], bits=1, name='amsdu_present') << 7 + | self._make_uint(qos_control['bits_8_15'], bits=8, name='bits_8_15') << 8) + + def _read_ht_control(self, value: 'int') -> 'Data_HTControl': + """Read the HT Control field (IEEE Std 802.11-2020 §9.2.4.6). + + Args: + value: HT Control field, as a little-endian integer. + + Returns: + Parsed HT Control field. + + """ + if not value & 0b1: + variant = 'HT' # type: Literal['HT', 'VHT', 'HE'] + elif not value & 0b10: + variant = 'VHT' + else: + variant = 'HE' + return Data_HTControl(variant=variant, value=value) + + def _make_ht_control(self, value: 'Optional[int]') -> 'int': + """Make the HT Control field (IEEE Std 802.11-2020 §9.2.4.6). + + Args: + value: HT Control field, as an integer; :obj:`None` makes zeros. + + Returns: + HT Control field, as a little-endian integer. + + Raises: + ProtocolError: If ``value`` does not fit in 32 bits. + + """ + if value is None: + return 0 + return self._make_uint(value, bits=32, name='ht_control') diff --git a/pcapkit/protocols/link/link.py b/pcapkit/protocols/link/link.py index 57d891f5d7..3dea2643b3 100644 --- a/pcapkit/protocols/link/link.py +++ b/pcapkit/protocols/link/link.py @@ -11,6 +11,7 @@ :class:`~pcapkit.protocols.link.arp.ARP`/:class:`~pcapkit.protocols.link.inarp.InARP`, :class:`~pcapkit.protocols.link.ethernet.Ethernet`, :class:`~pcapkit.protocols.link.frad.FRAD`, +:class:`~pcapkit.protocols.link.ieee802_11.IEEE802_11`, :class:`~pcapkit.protocols.link.l2tp.L2TP`, :class:`~pcapkit.protocols.link.loopback.Loopback`, :class:`~pcapkit.protocols.link.radiotap.Radiotap` and diff --git a/pcapkit/protocols/link/radiotap.py b/pcapkit/protocols/link/radiotap.py index 533cb49228..4c500afe2a 100644 --- a/pcapkit/protocols/link/radiotap.py +++ b/pcapkit/protocols/link/radiotap.py @@ -35,13 +35,8 @@ 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: +Decoding every optional field radiotap.org defines is tracked on +:issue:`2576`. This class: * **Decodes** the fixed 8-octet start -- ``it_version``, ``it_pad``, ``it_len`` -- and walks the present-flags chain far enough to know how many @@ -51,13 +46,13 @@ 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. +* **Hands** everything from ``it_len`` on, the 802.11 MAC frame, to + :class:`~pcapkit.protocols.link.ieee802_11.IEEE802_11` + (:class:`~pcapkit.protocols.misc.null.NoPayload` when nothing follows). + The header names no next protocol, since an 802.11 frame always follows it, + so the frame is looked up in + :attr:`Radiotap.__proto__ ` + under :attr:`LinkType.IEEE802_11 `. .. [*] ; the Linux kernel's ``include/net/ieee80211_radiotap.h`` and ``net/wireless/radiotap.c``; and @@ -71,7 +66,7 @@ from typing import TYPE_CHECKING from pcapkit.const.linux.arphrd import ARPHRD as Enum_ARPHRD -from pcapkit.corekit.decorators import beholder +from pcapkit.const.reg.linktype import LinkType as Enum_LinkType from pcapkit.corekit.module import ModuleDescriptor from pcapkit.protocols.data.link.radiotap import Radiotap as Data_Radiotap from pcapkit.protocols.link.link import Link @@ -79,12 +74,15 @@ from pcapkit.protocols.schema.link.radiotap import Radiotap as Schema_Radiotap from pcapkit.protocols.schema.schema import Schema from pcapkit.utilities.exceptions import ProtocolError +from pcapkit.utilities.warnings import RegistryWarning, warn if TYPE_CHECKING: - from typing import Any, DefaultDict, Optional, Sequence, Type + from typing import Any, ClassVar, DefaultDict, Optional, Sequence, Type from typing_extensions import Literal + from pcapkit.corekit.enum import EnumRegistry + __all__ = ['Radiotap'] #: Bit 31 of an ``it_present`` word: set, another present-flags word @@ -103,13 +101,19 @@ 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. + The 802.11 MAC frame this header precedes is dispatched through the + registry below, keyed by the link type of the frame: + + .. list-table:: + :header-rows: 1 + + * - Index + - Protocol + * - :attr:`~pcapkit.const.reg.linktype.LinkType.IEEE802_11` + - :class:`pcapkit.protocols.link.ieee802_11.IEEE802_11` + + See the module docstring's *Scope* section for what is, and is not, + decoded. """ @@ -117,14 +121,19 @@ class Radiotap(Link[Data_Radiotap, Schema_Radiotap], # 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. + #: Protocol index mapping for decoding next layer, keyed by the link type + #: of the frame the header precedes; :meth:`read` always looks up + #: :attr:`LinkType.IEEE802_11 `. __proto__ = collections.defaultdict( lambda: ModuleDescriptor('pcapkit.protocols.misc.raw', 'Raw'), - ) # type: DefaultDict[int, ModuleDescriptor[ProtocolBase] | Type[ProtocolBase]] + { + Enum_LinkType.IEEE802_11: ModuleDescriptor('pcapkit.protocols.link.ieee802_11', 'IEEE802_11'), + }, + ) # type: DefaultDict[int | Enum_LinkType, ModuleDescriptor[ProtocolBase] | Type[ProtocolBase]] + + #: What :attr:`__proto__` is keyed by, declared as + #: :attr:`Link.__proto_key__ ` is. + __proto_key__: 'ClassVar[tuple[Type[EnumRegistry], bool]]' = (Enum_LinkType, False) ########################################################################## # Properties. @@ -200,7 +209,7 @@ def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_Radiota #: 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 + #: together -- see :meth:`_get_payload` for how the frame is #: separated back out. rest = schema.payload @@ -224,9 +233,8 @@ def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_Radiota 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`. + #: int: Offset into the header schema's payload where the 802.11 MAC + #: frame starts -- read by :meth:`_get_payload`. self._mac_offset = tail_offset # pylint: disable=attribute-defined-outside-init radiotap = Data_Radiotap( @@ -237,7 +245,7 @@ def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_Radiota fields=fields, ) header_len = _FIXED_HEADER_LEN + tail_offset - return self._decode_next_layer(radiotap, 0, max(0, length - header_len)) + return self._decode_next_layer(radiotap, Enum_LinkType.IEEE802_11, max(0, length - header_len)) def make(self, it_version: 'int' = 0, @@ -263,8 +271,7 @@ def make(self, 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). + payload: Payload data (the 802.11 MAC frame). **kwargs: Arbitrary keyword arguments. Returns: @@ -316,6 +323,51 @@ def make(self, payload=chain_bytes + fields + payload_bytes, ) + @classmethod + def register(cls, code: 'Enum_LinkType', # type: ignore[override] + protocol: 'ModuleDescriptor[ProtocolBase] | Type[ProtocolBase]') -> 'None': + r"""Register a new protocol class for the frame a radiotap header precedes. + + Notes: + The fully qualified class name of the new protocol class + should be ``{protocol.module}.{protocol.name}``. + + Arguments: + code: link type as in :class:`~pcapkit.const.reg.linktype.LinkType` + protocol: module descriptor or a + :class:`~pcapkit.protocols.protocol.ProtocolBase` subclass + + Important: + This overrides :meth:`Link.register + ` because + :attr:`self.__proto__ ` is keyed by link type + rather than by EtherType. Any link type is accepted, but + :meth:`read` consults + :attr:`LinkType.IEEE802_11 ` + alone: that is the key a replacement 802.11 dissector takes, and + an entry under any other key is stored and never reached. + + Raises: + pcapkit.utilities.exceptions.RegistryError: If ``protocol`` is not a + class, or not a :class:`~pcapkit.protocols.protocol.ProtocolBase` subclass. + + Warns: + pcapkit.utilities.warnings.RegistryWarning: If this link type is + already registered and the incumbent differs from the + replacement; see :meth:`Protocol.register + ` for the guard, + and for the shipped descriptor a restore puts back (:issue:`1504`). + + """ + incumbent = cls.__proto__.get(code) + entry = cls._next_layer_entry(code, protocol) + if entry is None: + return + if incumbent is not None and incumbent is not entry: + warn(f'link type {code} already registered, overwriting ' + f'{incumbent!r} with {entry!r}', RegistryWarning) + cls.__proto__[code] = entry + ########################################################################## # Data models. ########################################################################## @@ -370,60 +422,20 @@ def _make_data(cls, data: 'Data_Radiotap') -> 'dict[str, Any]': # type: ignore[ '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. + def _get_payload(self) -> 'bytes': + """Get the 802.11 MAC frame this header precedes. - Arguments: - proto: next layer protocol index -- unused, see below. - length: valid (*non-padding*) length - packet: packet info (passed from :meth:`self.unpack - `) + The header schema's payload starts right after the fixed 8-octet + start, so it also holds the present-flags chain and the field-data + region it selects; those belong to this header, and the frame starts + ``self._mac_offset`` octets in, as :meth:`read` records it. Both + :meth:`Protocol._import_next_layer ` + and the :class:`~pcapkit.protocols.misc.raw.Raw` that + :func:`~pcapkit.corekit.decorators.beholder` falls back to on a + refused frame read from here. 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. + The 802.11 MAC frame, as :obj:`bytes`. """ - 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) + return self.__header__.get_payload()[self._mac_offset:] diff --git a/pcapkit/protocols/schema/__init__.py b/pcapkit/protocols/schema/__init__.py index 9b61b8f4b9..302d89c934 100644 --- a/pcapkit/protocols/schema/__init__.py +++ b/pcapkit/protocols/schema/__init__.py @@ -29,6 +29,7 @@ 'Ethernet', 'FRAD', 'GRE', + 'IEEE802_11', 'L2TP', 'LinuxSLL1', 'LinuxSLL2', 'Loopback', diff --git a/pcapkit/protocols/schema/link/__init__.py b/pcapkit/protocols/schema/link/__init__.py index f49ad74187..8e8e04b905 100644 --- a/pcapkit/protocols/schema/link/__init__.py +++ b/pcapkit/protocols/schema/link/__init__.py @@ -5,6 +5,7 @@ from pcapkit.protocols.schema.link.ethernet import Ethernet from pcapkit.protocols.schema.link.frad import FRAD from pcapkit.protocols.schema.link.gre import GRE +from pcapkit.protocols.schema.link.ieee802_11 import IEEE802_11 from pcapkit.protocols.schema.link.l2tp import L2TP from pcapkit.protocols.schema.link.linux_sll1 import LinuxSLL1 from pcapkit.protocols.schema.link.linux_sll2 import LinuxSLL2 @@ -19,6 +20,7 @@ 'Ethernet', 'FRAD', 'GRE', + 'IEEE802_11', 'L2TP', 'LinuxSLL1', 'LinuxSLL2', 'Loopback', diff --git a/pcapkit/protocols/schema/link/ieee802_11.py b/pcapkit/protocols/schema/link/ieee802_11.py new file mode 100644 index 0000000000..833d42bf83 --- /dev/null +++ b/pcapkit/protocols/schema/link/ieee802_11.py @@ -0,0 +1,163 @@ +# -*- coding: utf-8 -*- +# mypy: disable-error-code=assignment +"""header schema for IEEE 802.11 MAC headers + +Which fields a MAC header carries is decided by its Frame Control field, so +:func:`layout` reads that field once and every optional field of +:class:`IEEE802_11` is a :class:`~pcapkit.corekit.fields.misc.ConditionalField` +on its answer. Every multi-octet numeric field is little-endian, as IEEE Std +802.11-2020 §9.2.2, *Conventions*, has it. + +""" + +from typing import TYPE_CHECKING, NamedTuple + +from pcapkit.const.ieee802_11.control_subtype import ControlSubtype as Enum_ControlSubtype +from pcapkit.const.ieee802_11.type import Type as Enum_Type +from pcapkit.corekit.fields.misc import ConditionalField, PayloadField +from pcapkit.corekit.fields.numbers import UInt16Field, UInt32Field +from pcapkit.corekit.fields.strings import BytesField +from pcapkit.protocols.schema.schema import Schema, schema_final + +__all__ = ['IEEE802_11'] + +if TYPE_CHECKING: + from typing import Any, Optional + + from pcapkit.protocols.protocol import ProtocolBase + +#: Control frame subtypes whose MAC header carries a TA (or BSSID) as +#: Address 2 after the RA; IEEE Std 802.11-2020 §9.3.1, *Control frames*, and +#: IEEE Std 802.11ax-2021 for ``Trigger``. CTS and Ack carry the RA alone. A +#: Control Frame Extension frame takes the six octets after the RA as Address 2, +#: as the Linux kernel's ``ieee80211_hdrlen()`` reads it; in a DMG DTS +#: (extension 6) they are NAV-SA rather than a TA, and NAV-DA stays in the body. +CONTROL_WITH_ADDR2 = frozenset({ + Enum_ControlSubtype.Trigger, + Enum_ControlSubtype.TACK, + Enum_ControlSubtype.Beamforming_Report_Poll, + Enum_ControlSubtype.VHT_HE_NDP_Announcement, + Enum_ControlSubtype.Control_Frame_Extension, + Enum_ControlSubtype.Block_Ack_Request, + Enum_ControlSubtype.Block_Ack, + Enum_ControlSubtype.PS_Poll, + Enum_ControlSubtype.RTS, + Enum_ControlSubtype.CF_End, + Enum_ControlSubtype.CF_End_CF_ACK, +}) + + +class Layout(NamedTuple): + """Fields a MAC header carries after Frame Control, Duration/ID and Address 1.""" + + #: Address 2. + addr2: 'bool' + #: Address 3. + addr3: 'bool' + #: Sequence Control. + seq_ctrl: 'bool' + #: Address 4. + addr4: 'bool' + #: QoS Control. + qos_control: 'bool' + #: HT Control. + ht_control: 'bool' + + +def layout(frame_control: 'int') -> 'Layout': + """Decide which fields a protocol version 0 MAC header carries. + + * A Management frame carries Address 2, Address 3 and Sequence Control + whatever its subtype, and HT Control when +HTC/Order is set (IEEE Std + 802.11-2020 §9.3.3, *Management frames*; §9.2.4.1.10, *+HTC/Order + subfield*). + * A Data frame carries the same three, Address 4 when To DS and From DS are + both set, QoS Control when the QoS bit (B7) of its subtype is set, and HT + Control when that bit and +HTC/Order are both set (§9.3.2, *Data frames*). + * A Control frame carries Address 2 for the subtypes in + :data:`CONTROL_WITH_ADDR2`, and Address 1 alone for CTS and Ack (§9.3.1, + *Control frames*). + * Every other frame -- a reserved Control subtype, a Control Wrapper, whose + octets after Address 1 are Carried Frame Control and HT Control, and + every Extension frame -- is read as the minimal frame format that §9.2.3, + *General frame format*, makes present in every frame, reserved types and + subtypes included: Frame Control, Duration/ID and Address 1. What follows + stays in the frame body. + + This departs from the Linux kernel's ``ieee80211_hdrlen()`` deliberately: a + Control Wrapper and the reserved Control subtypes 0 and 1 read 10 octets + where it gives 16, and an Extension frame reads 10 where it gives 4. + + Args: + frame_control: Frame Control field, as the little-endian integer read + off the wire. + + Returns: + The fields present after Address 1. + + """ + type_ = (frame_control >> 2) & 0b11 + subtype = (frame_control >> 4) & 0b1111 + to_ds = (frame_control >> 8) & 0b1 + from_ds = (frame_control >> 9) & 0b1 + order = (frame_control >> 15) & 0b1 + + if type_ == Enum_Type.Management: + return Layout(True, True, True, False, False, bool(order)) + if type_ == Enum_Type.Data: + qos = bool(subtype & 0b1000) + return Layout(True, True, True, bool(to_ds and from_ds), qos, qos and bool(order)) + if type_ == Enum_Type.Control and subtype in CONTROL_WITH_ADDR2: + return Layout(True, False, False, False, False, False) + return Layout(False, False, False, False, False, False) + + +@schema_final +class IEEE802_11(Schema): + """Header schema for an IEEE 802.11 MAC header (IEEE Std 802.11-2020 §9.2).""" + + #: Frame Control. + frame_control: 'int' = UInt16Field(byteorder='little') + #: Duration/ID. + duration_id: 'int' = UInt16Field(byteorder='little') + #: Address 1. + addr1: 'bytes' = BytesField(length=6) + #: Address 2, where :func:`layout` says so. + addr2: 'Optional[bytes]' = ConditionalField( + BytesField(length=6), + lambda packet: layout(packet['frame_control']).addr2, + ) + #: Address 3, where :func:`layout` says so. + addr3: 'Optional[bytes]' = ConditionalField( + BytesField(length=6), + lambda packet: layout(packet['frame_control']).addr3, + ) + #: Sequence Control, where :func:`layout` says so. + seq_ctrl: 'Optional[int]' = ConditionalField( + UInt16Field(byteorder='little'), + lambda packet: layout(packet['frame_control']).seq_ctrl, + ) + #: Address 4, where :func:`layout` says so. + addr4: 'Optional[bytes]' = ConditionalField( + BytesField(length=6), + lambda packet: layout(packet['frame_control']).addr4, + ) + #: QoS Control, where :func:`layout` says so. + qos_control: 'Optional[int]' = ConditionalField( + UInt16Field(byteorder='little'), + lambda packet: layout(packet['frame_control']).qos_control, + ) + #: HT Control, where :func:`layout` says so. + ht_control: 'Optional[int]' = ConditionalField( + UInt32Field(byteorder='little'), + lambda packet: layout(packet['frame_control']).ht_control, + ) + #: Frame body, and the FCS where one was captured. + payload: 'bytes' = PayloadField(length=lambda pkt: pkt['__length__']) + + if TYPE_CHECKING: + def __init__(self, frame_control: 'int', duration_id: 'int', addr1: 'bytes', # pylint: disable=unused-argument,super-init-not-called,multiple-statements + addr2: 'Optional[bytes]', addr3: 'Optional[bytes]', # pylint: disable=unused-argument + seq_ctrl: 'Optional[int]', addr4: 'Optional[bytes]', # pylint: disable=unused-argument + qos_control: 'Optional[int]', ht_control: 'Optional[int]', # pylint: disable=unused-argument + payload: 'bytes | ProtocolBase | Schema | dict[str, Any]') -> 'None': ... # pylint: disable=unused-argument diff --git a/pcapkit/vendor/__init__.py b/pcapkit/vendor/__init__.py index bc2652c7f3..30c0a08055 100644 --- a/pcapkit/vendor/__init__.py +++ b/pcapkit/vendor/__init__.py @@ -113,6 +113,7 @@ def __getattr__(name: 'str') -> 'typing.NoReturn': from pcapkit.vendor.http import * from pcapkit.vendor.icmp import * from pcapkit.vendor.icmpv6 import * + from pcapkit.vendor.ieee802_11 import * from pcapkit.vendor.igmp import * from pcapkit.vendor.ipv4 import * from pcapkit.vendor.ipv6 import * @@ -157,6 +158,9 @@ def __getattr__(name: 'str') -> 'typing.NoReturn': # ICMPv6 'ICMPv6_Type', 'ICMPv6_DestinationUnreachableCode', 'ICMPv6_TimeExceededCode', 'ICMPv6_ParameterProblemCode', + # IEEE 802.11 + 'IEEE802_11_Type', 'IEEE802_11_ManagementSubtype', 'IEEE802_11_ControlSubtype', + 'IEEE802_11_DataSubtype', 'IEEE802_11_ExtensionSubtype', # IGMP 'IGMP_Type', 'IGMP_RecordType', # IPv4 diff --git a/pcapkit/vendor/ieee802_11/__init__.py b/pcapkit/vendor/ieee802_11/__init__.py new file mode 100644 index 0000000000..0d94a158a7 --- /dev/null +++ b/pcapkit/vendor/ieee802_11/__init__.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# pylint: disable=unused-import +""":class:`~pcapkit.protocols.link.ieee802_11.IEEE802_11` Vendor Crawlers +============================================================================ + +.. module:: pcapkit.vendor.ieee802_11 + +This module contains all vendor crawlers of +:class:`~pcapkit.protocols.link.ieee802_11.IEEE802_11` implementations. +Available vendor crawlers include: + +.. list-table:: + + * - :class:`IEEE802_11_Type ` + - Frame Types + * - :class:`IEEE802_11_ManagementSubtype ` + - Management Frame Subtypes + * - :class:`IEEE802_11_ControlSubtype ` + - Control Frame Subtypes + * - :class:`IEEE802_11_DataSubtype ` + - Data Frame Subtypes + * - :class:`IEEE802_11_ExtensionSubtype ` + - Extension Frame Subtypes + +None of these crawlers crawls. IEEE Std 802.11-2020 assigns the frame type +and subtype values in Table 9-1, *Valid type and subtype combinations*, of the +standard's text, and neither IEEE nor IANA publishes them as a +machine-readable registry. So each crawler reads a ``DATA`` literal instead of +fetching a URL, the shape :mod:`pcapkit.vendor.linux.arphrd` takes +(:ref:`const-vendor-pairing`). + +Each ``DATA`` is transcribed row for row from the table on Wikipedia's +*802.11 frame types* page [*]_, revision 1369762968, which reproduces Table 9-1 +with the Trigger frame of IEEE Std 802.11ax-2021 and cites both. Its spelling +is kept as written, less the footnote marker after ``Trigger``. The values +agree with the Linux kernel's ``include/linux/ieee80211.h`` for every subtype +both name. + +.. [*] https://en.wikipedia.org/w/index.php?title=802.11_frame_types&oldid=1369762968 + +""" + +from pcapkit.vendor.ieee802_11.control_subtype import ControlSubtype as IEEE802_11_ControlSubtype +from pcapkit.vendor.ieee802_11.data_subtype import DataSubtype as IEEE802_11_DataSubtype +from pcapkit.vendor.ieee802_11.extension_subtype import \ + ExtensionSubtype as IEEE802_11_ExtensionSubtype +from pcapkit.vendor.ieee802_11.management_subtype import \ + ManagementSubtype as IEEE802_11_ManagementSubtype +from pcapkit.vendor.ieee802_11.type import Type as IEEE802_11_Type + +__all__ = [ + 'IEEE802_11_Type', 'IEEE802_11_ManagementSubtype', 'IEEE802_11_ControlSubtype', + 'IEEE802_11_DataSubtype', 'IEEE802_11_ExtensionSubtype', +] diff --git a/pcapkit/vendor/ieee802_11/control_subtype.py b/pcapkit/vendor/ieee802_11/control_subtype.py new file mode 100644 index 0000000000..cb7024e38b --- /dev/null +++ b/pcapkit/vendor/ieee802_11/control_subtype.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +"""IEEE 802.11 Control Frame Subtypes +======================================== + +.. module:: pcapkit.vendor.ieee802_11.control_subtype + +This module contains the vendor crawler for **IEEE 802.11 Control Frame Subtypes**, +which is automatically generating :class:`pcapkit.const.ieee802_11.control_subtype.ControlSubtype`. + +The crawler reads :data:`DATA` rather than a URL, for the reason +:mod:`pcapkit.vendor.ieee802_11` gives. + +""" + +import collections +import sys +from typing import TYPE_CHECKING + +from pcapkit.vendor.default import Vendor + +if TYPE_CHECKING: + from collections import Counter + +__all__ = ['ControlSubtype'] + +#: Control frame (Type ``0b01``) Subtype values (IEEE Std 802.11-2020, +#: Table 9-1, with ``Trigger`` from IEEE Std 802.11ax-2021). +#: +#: Maps a Subtype value to its *Subtype description*. +DATA = { + 0b0010: 'Trigger', + 0b0011: 'TACK', + 0b0100: 'Beamforming Report Poll', + 0b0101: 'VHT/HE NDP Announcement', + 0b0110: 'Control Frame Extension', + 0b0111: 'Control Wrapper', + 0b1000: 'Block Ack Request (BAR)', + 0b1001: 'Block Ack (BA)', + 0b1010: 'PS-Poll', + 0b1011: 'RTS', + 0b1100: 'CTS', + 0b1101: 'ACK', + 0b1110: 'CF-End', + 0b1111: 'CF-End + CF-ACK', +} # type: dict[int, str] + +#: Subtype ranges that Table 9-1 marks ``Reserved`` in one row, resolved by +#: ``_missing_`` under that bare label. +RESERVED = ((0b0000, 0b0001),) # type: tuple[tuple[int, int], ...] + + +class ControlSubtype(Vendor): + """IEEE 802.11 Control Frame Subtypes [IEEE Std 802.11-2020, Table 9-1]""" + + #: Value limit checker. + FLAG = 'isinstance(value, int) and 0b0000 <= value <= 0b1111' + + def request(self) -> 'dict[int, str]': # type: ignore[override] # pylint: disable=arguments-differ + """Fetch registry data. + + Returns: + Registry data + (:data:`~pcapkit.vendor.ieee802_11.control_subtype.DATA`). + + """ + return DATA + + def count(self, data: 'dict[int, str]') -> 'Counter[str]': # type: ignore[override] + """Count field records. + + Args: + data: Registry data. + + Returns: + Field recordings. + + """ + return collections.Counter(self.safe_name(name) for name in data.values()) + + def process(self, data: 'dict[int, str]') -> 'tuple[list[str], list[str]]': # type: ignore[override] + """Process registry data. + + Args: + data: Registry data. + + Returns: + Enumeration fields and missing fields. + + """ + enum = [] # type: list[str] + miss = [] # type: list[str] + + for code, name in data.items(): + pval = str(code) + renm = self.rename(name, pval) + + enum.append(f'#: {self.wrap_comment(name)}\n {renm} = {pval}') + + for start, stop in RESERVED: + miss.append(f'if {start} <= value <= {stop}:') + miss.append(' #: Reserved') + miss.append(" return cls._unregistered_member(value, 'Reserved')") + return enum, miss + + +if __name__ == '__main__': + sys.exit(ControlSubtype()) # type: ignore[arg-type] diff --git a/pcapkit/vendor/ieee802_11/data_subtype.py b/pcapkit/vendor/ieee802_11/data_subtype.py new file mode 100644 index 0000000000..621631dc0a --- /dev/null +++ b/pcapkit/vendor/ieee802_11/data_subtype.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +"""IEEE 802.11 Data Frame Subtypes +===================================== + +.. module:: pcapkit.vendor.ieee802_11.data_subtype + +This module contains the vendor crawler for **IEEE 802.11 Data Frame Subtypes**, +which is automatically generating :class:`pcapkit.const.ieee802_11.data_subtype.DataSubtype`. + +The crawler reads :data:`DATA` rather than a URL, for the reason +:mod:`pcapkit.vendor.ieee802_11` gives. Table 9-1 marks the PCF subtypes +without QoS (Data with CF-Ack or CF-Poll, and their null forms) ``Reserved``, +so they resolve under that label, although the Linux kernel's +``include/linux/ieee80211.h`` and Wireshark's ``packet-ieee80211.c`` name them. + +""" + +import collections +import sys +from typing import TYPE_CHECKING + +from pcapkit.vendor.default import Vendor + +if TYPE_CHECKING: + from collections import Counter + +__all__ = ['DataSubtype'] + +#: Data frame (Type ``0b10``) Subtype values (IEEE Std 802.11-2020, Table 9-1). +#: +#: Maps a Subtype value to its *Subtype description*. A single ``Reserved`` +#: row is a member, which :meth:`~Vendor.rename` suffixes with its value. +DATA = { + 0b0000: 'Data', + 0b0100: 'Null (no data)', + 0b1000: 'QoS Data', + 0b1001: 'QoS Data + CF-ACK', + 0b1010: 'QoS Data + CF-Poll', + 0b1011: 'QoS Data + CF-ACK + CF-Poll', + 0b1100: 'QoS Null (no data)', + 0b1101: 'Reserved', + 0b1110: 'QoS CF-Poll (no data)', + 0b1111: 'QoS CF-ACK + CF-Poll (no data)', +} # type: dict[int, str] + +#: Subtype ranges that Table 9-1 marks ``Reserved`` in one row, resolved by +#: ``_missing_`` under that bare label. +RESERVED = ((0b0001, 0b0011), (0b0101, 0b0111)) # type: tuple[tuple[int, int], ...] + + +class DataSubtype(Vendor): + """IEEE 802.11 Data Frame Subtypes [IEEE Std 802.11-2020, Table 9-1]""" + + #: Value limit checker. + FLAG = 'isinstance(value, int) and 0b0000 <= value <= 0b1111' + + def request(self) -> 'dict[int, str]': # type: ignore[override] # pylint: disable=arguments-differ + """Fetch registry data. + + Returns: + Registry data + (:data:`~pcapkit.vendor.ieee802_11.data_subtype.DATA`). + + """ + return DATA + + def count(self, data: 'dict[int, str]') -> 'Counter[str]': # type: ignore[override] + """Count field records. + + Args: + data: Registry data. + + Returns: + Field recordings. + + """ + return collections.Counter(self.safe_name(name) for name in data.values()) + + def process(self, data: 'dict[int, str]') -> 'tuple[list[str], list[str]]': # type: ignore[override] + """Process registry data. + + Args: + data: Registry data. + + Returns: + Enumeration fields and missing fields. + + """ + enum = [] # type: list[str] + miss = [] # type: list[str] + + for code, name in data.items(): + pval = str(code) + renm = self.rename(name, pval) + + enum.append(f'#: {self.wrap_comment(name)}\n {renm} = {pval}') + + for start, stop in RESERVED: + miss.append(f'if {start} <= value <= {stop}:') + miss.append(' #: Reserved') + miss.append(" return cls._unregistered_member(value, 'Reserved')") + return enum, miss + + +if __name__ == '__main__': + sys.exit(DataSubtype()) # type: ignore[arg-type] diff --git a/pcapkit/vendor/ieee802_11/extension_subtype.py b/pcapkit/vendor/ieee802_11/extension_subtype.py new file mode 100644 index 0000000000..2461ceeb9b --- /dev/null +++ b/pcapkit/vendor/ieee802_11/extension_subtype.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +"""IEEE 802.11 Extension Frame Subtypes +========================================== + +.. module:: pcapkit.vendor.ieee802_11.extension_subtype + +This module contains the vendor crawler for **IEEE 802.11 Extension Frame Subtypes**, +which is automatically generating :class:`pcapkit.const.ieee802_11.extension_subtype.ExtensionSubtype`. + +The crawler reads :data:`DATA` rather than a URL, for the reason +:mod:`pcapkit.vendor.ieee802_11` gives. + +""" + +import collections +import sys +from typing import TYPE_CHECKING + +from pcapkit.vendor.default import Vendor + +if TYPE_CHECKING: + from collections import Counter + +__all__ = ['ExtensionSubtype'] + +#: Extension frame (Type ``0b11``) Subtype values (IEEE Std 802.11-2020, +#: Table 9-1). +#: +#: Maps a Subtype value to its *Subtype description*. +DATA = { + 0b0000: 'DMG Beacon', + 0b0001: 'S1G Beacon', +} # type: dict[int, str] + +#: Subtype ranges that Table 9-1 marks ``Reserved`` in one row, resolved by +#: ``_missing_`` under that bare label. +RESERVED = ((0b0010, 0b1111),) # type: tuple[tuple[int, int], ...] + + +class ExtensionSubtype(Vendor): + """IEEE 802.11 Extension Frame Subtypes [IEEE Std 802.11-2020, Table 9-1]""" + + #: Value limit checker. + FLAG = 'isinstance(value, int) and 0b0000 <= value <= 0b1111' + + def request(self) -> 'dict[int, str]': # type: ignore[override] # pylint: disable=arguments-differ + """Fetch registry data. + + Returns: + Registry data + (:data:`~pcapkit.vendor.ieee802_11.extension_subtype.DATA`). + + """ + return DATA + + def count(self, data: 'dict[int, str]') -> 'Counter[str]': # type: ignore[override] + """Count field records. + + Args: + data: Registry data. + + Returns: + Field recordings. + + """ + return collections.Counter(self.safe_name(name) for name in data.values()) + + def process(self, data: 'dict[int, str]') -> 'tuple[list[str], list[str]]': # type: ignore[override] + """Process registry data. + + Args: + data: Registry data. + + Returns: + Enumeration fields and missing fields. + + """ + enum = [] # type: list[str] + miss = [] # type: list[str] + + for code, name in data.items(): + pval = str(code) + renm = self.rename(name, pval) + + enum.append(f'#: {self.wrap_comment(name)}\n {renm} = {pval}') + + for start, stop in RESERVED: + miss.append(f'if {start} <= value <= {stop}:') + miss.append(' #: Reserved') + miss.append(" return cls._unregistered_member(value, 'Reserved')") + return enum, miss + + +if __name__ == '__main__': + sys.exit(ExtensionSubtype()) # type: ignore[arg-type] diff --git a/pcapkit/vendor/ieee802_11/management_subtype.py b/pcapkit/vendor/ieee802_11/management_subtype.py new file mode 100644 index 0000000000..42e45b1880 --- /dev/null +++ b/pcapkit/vendor/ieee802_11/management_subtype.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +"""IEEE 802.11 Management Frame Subtypes +=========================================== + +.. module:: pcapkit.vendor.ieee802_11.management_subtype + +This module contains the vendor crawler for **IEEE 802.11 Management Frame Subtypes**, +which is automatically generating :class:`pcapkit.const.ieee802_11.management_subtype.ManagementSubtype`. + +The crawler reads :data:`DATA` rather than a URL, for the reason +:mod:`pcapkit.vendor.ieee802_11` gives. Table 9-1 gives every value a row of +its own, so ``_missing_`` has no range to resolve. + +""" + +import collections +import sys +from typing import TYPE_CHECKING + +from pcapkit.vendor.default import Vendor + +if TYPE_CHECKING: + from collections import Counter + +__all__ = ['ManagementSubtype'] + +#: Management frame (Type ``0b00``) Subtype values (IEEE Std 802.11-2020, +#: Table 9-1). +#: +#: Maps a Subtype value to its *Subtype description*. A single ``Reserved`` +#: row is a member, which :meth:`~Vendor.rename` suffixes with its value. +DATA = { + 0b0000: 'Association Request', + 0b0001: 'Association Response', + 0b0010: 'Reassociation Request', + 0b0011: 'Reassociation Response', + 0b0100: 'Probe Request', + 0b0101: 'Probe Response', + 0b0110: 'Timing Advertisement', + 0b0111: 'Reserved', + 0b1000: 'Beacon', + 0b1001: 'ATIM', + 0b1010: 'Disassociation', + 0b1011: 'Authentication', + 0b1100: 'Deauthentication', + 0b1101: 'Action', + 0b1110: 'Action No Ack (NACK)', + 0b1111: 'Reserved', +} # type: dict[int, str] + + +class ManagementSubtype(Vendor): + """IEEE 802.11 Management Frame Subtypes [IEEE Std 802.11-2020, Table 9-1]""" + + #: Value limit checker. + FLAG = 'isinstance(value, int) and 0b0000 <= value <= 0b1111' + + def request(self) -> 'dict[int, str]': # type: ignore[override] # pylint: disable=arguments-differ + """Fetch registry data. + + Returns: + Registry data + (:data:`~pcapkit.vendor.ieee802_11.management_subtype.DATA`). + + """ + return DATA + + def count(self, data: 'dict[int, str]') -> 'Counter[str]': # type: ignore[override] + """Count field records. + + Args: + data: Registry data. + + Returns: + Field recordings. + + """ + return collections.Counter(self.safe_name(name) for name in data.values()) + + def process(self, data: 'dict[int, str]') -> 'tuple[list[str], list[str]]': # type: ignore[override] + """Process registry data. + + Args: + data: Registry data. + + Returns: + Enumeration fields and missing fields. + + """ + enum = [] # type: list[str] + miss = [] # type: list[str] + + for code, name in data.items(): + pval = str(code) + renm = self.rename(name, pval) + + enum.append(f'#: {self.wrap_comment(name)}\n {renm} = {pval}') + return enum, miss + + +if __name__ == '__main__': + sys.exit(ManagementSubtype()) # type: ignore[arg-type] diff --git a/pcapkit/vendor/ieee802_11/type.py b/pcapkit/vendor/ieee802_11/type.py new file mode 100644 index 0000000000..33a2d6c9a1 --- /dev/null +++ b/pcapkit/vendor/ieee802_11/type.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +"""IEEE 802.11 Frame Types +============================= + +.. module:: pcapkit.vendor.ieee802_11.type + +This module contains the vendor crawler for **IEEE 802.11 Frame Types**, +which is automatically generating :class:`pcapkit.const.ieee802_11.type.Type`. + +The crawler reads :data:`DATA` rather than a URL, for the reason +:mod:`pcapkit.vendor.ieee802_11` gives. Table 9-1 names every value the +two-bit Type subfield can take, so no value in range is left for +``_missing_`` to resolve. + +""" + +import collections +import sys +from typing import TYPE_CHECKING + +from pcapkit.vendor.default import Vendor + +if TYPE_CHECKING: + from collections import Counter + +__all__ = ['Type'] + +#: IEEE 802.11 Type subfield values (IEEE Std 802.11-2020, Table 9-1). +#: +#: Maps a Type value to its *Type description*. Names go through +#: :meth:`~Vendor.rename`, so the generated member is the +#: :meth:`~Vendor.safe_name` of the description. +DATA = { + 0b00: 'Management', + 0b01: 'Control', + 0b10: 'Data', + 0b11: 'Extension', +} # type: dict[int, str] + + +class Type(Vendor): + """IEEE 802.11 Frame Types [IEEE Std 802.11-2020, Table 9-1]""" + + #: Value limit checker. + FLAG = 'isinstance(value, int) and 0b00 <= value <= 0b11' + + def request(self) -> 'dict[int, str]': # type: ignore[override] # pylint: disable=arguments-differ + """Fetch registry data. + + Returns: + Registry data (:data:`~pcapkit.vendor.ieee802_11.type.DATA`). + + """ + return DATA + + def count(self, data: 'dict[int, str]') -> 'Counter[str]': # type: ignore[override] + """Count field records. + + Args: + data: Registry data. + + Returns: + Field recordings. + + """ + return collections.Counter(self.safe_name(name) for name in data.values()) + + def process(self, data: 'dict[int, str]') -> 'tuple[list[str], list[str]]': # type: ignore[override] + """Process registry data. + + Args: + data: Registry data. + + Returns: + Enumeration fields and missing fields. + + """ + enum = [] # type: list[str] + miss = [] # type: list[str] + + for code, name in data.items(): + pval = str(code) + renm = self.rename(name, pval) + + enum.append(f'#: {self.wrap_comment(name)}\n {renm} = {pval}') + return enum, miss + + +if __name__ == '__main__': + sys.exit(Type()) # type: ignore[arg-type] diff --git a/tests/const/test_const_enum_builtin_parity.py b/tests/const/test_const_enum_builtin_parity.py index a6712e751e..f7912e0534 100644 --- a/tests/const/test_const_enum_builtin_parity.py +++ b/tests/const/test_const_enum_builtin_parity.py @@ -377,12 +377,13 @@ def test_the_sweep_size_is_pinned(self) -> None: # tests.const.test_const_enum_get's narrower (IntEnum, IntFlag)-only # sweep. The int count includes GitHub issue #880's ngap.ProcedureCode # and ngap.ProtocolIE, which are on EnumRegistry, as are the ICMP, - # ICMPv6, IGMP and DCCP registry families. - self.assertEqual(len(ints), 136) + # ICMPv6, IGMP and DCCP registry families, and the IEEE 802.11 type + # and subtype registries of GitHub issue #2575. + self.assertEqual(len(ints), 141) self.assertEqual(len(flags), 6) self.assertEqual(len(strs), 10) - self.assertEqual(len(self.enums), 152) - self.assertEqual(len({obj.__module__ for obj in self.enums}), 146) + self.assertEqual(len(self.enums), 157) + self.assertEqual(len({obj.__module__ for obj in self.enums}), 151) def test_every_registry_rejects_a_negative_value(self) -> None: """The registry-wide form of GitHub issue #647. diff --git a/tests/const/test_const_enum_get.py b/tests/const/test_const_enum_get.py index 893179dc7a..00a25f5634 100644 --- a/tests/const/test_const_enum_get.py +++ b/tests/const/test_const_enum_get.py @@ -297,7 +297,7 @@ def test_the_sweep_size_is_pinned(self) -> None: # pcapkit.protocols.application.ngap; neither needs an exception-set # entry, since both take the base's own get(key, default). names = {_qualname(obj) for obj in self.enums} - self.assertEqual(len(self.enums), 142) + self.assertEqual(len(self.enums), 147) for expected in (EXPECTED_TO_RESOLVE_ANYTHING, EXPECTED_WITHOUT_AN_INTEGER_DEFAULT, EXPECTED_WITHOUT_A_CACHEABLE_FALLBACK): self.assertTrue(expected.issubset(names), @@ -337,7 +337,7 @@ def test_every_integer_path_consults_the_default(self) -> None: # registry excused above: ``EXPECTED_TO_RESOLVE_ANYTHING`` and # ``EXPECTED_WITHOUT_A_CACHEABLE_FALLBACK``, with # ``EXPECTED_WITHOUT_AN_INTEGER_DEFAULT`` empty. - self.assertEqual(covered, 139) + self.assertEqual(covered, 144) def test_the_always_resolving_registries_have_nothing_to_fall_back_to(self) -> None: """The two registries excused from the sweep, and why. diff --git a/tests/const/test_const_enum_lookup.py b/tests/const/test_const_enum_lookup.py index 851c2f3ca9..2999754dbc 100644 --- a/tests/const/test_const_enum_lookup.py +++ b/tests/const/test_const_enum_lookup.py @@ -200,7 +200,7 @@ def test_every_registry_enum_was_discovered(self) -> None: # ``_iter_const_int_flags``'s, and so are GitHub issue #880's # ngap.ProcedureCode and ngap.ProtocolIE, which are on EnumRegistry. names = {f'{obj.__module__}.{obj.__qualname__}' for obj in self.enums} - self.assertEqual(len(self.enums), 136) + self.assertEqual(len(self.enums), 141) self.assertTrue(EXPECTED_TO_REJECT_ZERO.issubset(names), f'expected reject-list entries missing from the sweep: ' f'{EXPECTED_TO_REJECT_ZERO - names}') diff --git a/tests/const/test_const_registry_protocol.py b/tests/const/test_const_registry_protocol.py index 8444d30767..84aad8338d 100644 --- a/tests/const/test_const_registry_protocol.py +++ b/tests/const/test_const_registry_protocol.py @@ -846,8 +846,8 @@ def test_every_generated_const_module_is_accounted_for(self) -> None: generated = [path for path in all_files if path not in converted_relpaths and path not in EXCLUDED_FROM_INTENUM_CENSUS] - self.assertEqual(len(all_files), 146) - self.assertEqual(len(generated), 129) + self.assertEqual(len(all_files), 151) + self.assertEqual(len(generated), 134) for relpath in generated: with self.subTest(module=relpath): source = (REPO_ROOT / relpath).read_text() diff --git a/tests/foundation/registry/test_protocols.py b/tests/foundation/registry/test_protocols.py index 935b23a8e3..4ce6ae19c3 100644 --- a/tests/foundation/registry/test_protocols.py +++ b/tests/foundation/registry/test_protocols.py @@ -479,6 +479,7 @@ def test_top_level_link_internet_and_transport_protocol_wrappers(self) -> None: (registry.register_arphrd, ARPHRD.FRAD, [(registry.LinuxSLL, 'register')]), (registry.register_frad, 0xCC, [(registry.FRAD, 'register')]), (registry.register_netlink, 16, [(registry.Netlink, 'register')]), + (registry.register_radiotap, LinkType.IEEE802_11, [(registry.Radiotap, 'register')]), (registry.register_transtype, TransType.TCP, [(registry.Internet, 'register')]), (registry.register_ipx, IPXPacket.NCP, [(registry.IPX, 'register')]), ] diff --git a/tests/foundation/registry/test_registrar_code_check_unit.py b/tests/foundation/registry/test_registrar_code_check_unit.py index 6dcf92eeb8..4332fef4e7 100644 --- a/tests/foundation/registry/test_registrar_code_check_unit.py +++ b/tests/foundation/registry/test_registrar_code_check_unit.py @@ -93,6 +93,7 @@ ('register_pcapng', ('pcapkit.protocols.misc.pcapng:PCAPNG',)), ('register_ethertype', ('pcapkit.protocols.link.link:Link',)), ('register_arphrd', ('pcapkit.protocols.link.linux_sll:LinuxSLL',)), + ('register_radiotap', ('pcapkit.protocols.link.radiotap:Radiotap',)), ('register_transtype', ('pcapkit.protocols.internet.internet:Internet',)), ) @@ -126,6 +127,7 @@ 'register_pcapng': 'pcapkit.const.reg.linktype:LinkType', 'register_ethertype': 'pcapkit.const.reg.ethertype:EtherType', 'register_arphrd': 'pcapkit.const.linux.arphrd:ARPHRD', + 'register_radiotap': 'pcapkit.const.reg.linktype:LinkType', 'register_transtype': 'pcapkit.const.reg.transtype:TransType', } diff --git a/tests/foundation/registry/test_registry_symmetry_unit.py b/tests/foundation/registry/test_registry_symmetry_unit.py index be421dd1a6..ecc9b98131 100644 --- a/tests/foundation/registry/test_registry_symmetry_unit.py +++ b/tests/foundation/registry/test_registry_symmetry_unit.py @@ -79,6 +79,7 @@ def resolve(path: 'str') -> 'Any': 'Loopback': 'pcapkit.protocols.link.loopback:Loopback', 'LinuxSLL': 'pcapkit.protocols.link.linux_sll:LinuxSLL', 'FRAD': 'pcapkit.protocols.link.frad:FRAD', + 'Radiotap': 'pcapkit.protocols.link.radiotap:Radiotap', 'Internet': 'pcapkit.protocols.internet.internet:Internet', 'TCP': 'pcapkit.protocols.transport.tcp:TCP', 'UDP': 'pcapkit.protocols.transport.udp:UDP', @@ -191,6 +192,8 @@ class ModuleRegistrar(NamedTuple): ModuleRegistrar('register_arphrd', 'pcapkit.const.linux.arphrd:ARPHRD.FRAD', 'LinuxSLL', 'pcapkit.protocols.link.link:Link'), ModuleRegistrar('register_frad', 0xCC, 'FRAD', 'pcapkit.protocols.internet.internet:Internet'), + ModuleRegistrar('register_radiotap', 'pcapkit.const.reg.linktype:LinkType.IEEE802_11', 'Radiotap', + 'pcapkit.protocols.link.link:Link'), ModuleRegistrar('register_transtype', 'pcapkit.const.reg.transtype:TransType.TCP', 'Internet', 'pcapkit.protocols.transport.transport:Transport'), ModuleRegistrar('register_tcp', 80, 'TCP', 'pcapkit.protocols.application.application:Application'), diff --git a/tests/foundation/registry/test_registry_wrapper_coverage_2511_unit.py b/tests/foundation/registry/test_registry_wrapper_coverage_2511_unit.py index 138417bcd8..4e27935511 100644 --- a/tests/foundation/registry/test_registry_wrapper_coverage_2511_unit.py +++ b/tests/foundation/registry/test_registry_wrapper_coverage_2511_unit.py @@ -44,8 +44,6 @@ #: ``map path -> why it has no wrapper``. Each reason names where the gap is #: owned, so that closing it deletes the entry here. EXCUSED = { - 'pcapkit.protocols.link.radiotap.Radiotap.__proto__': - 'nothing dispatches by it yet; wrapper and classmethod owned by #2575', 'pcapkit.protocols.protocol.ProtocolBase.__proto__': 'the empty default a class without a map of its own inherits; nothing dispatches by it', } 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 e0a9a1ada7..63c77aa558 100644 --- a/tests/project/test_rfc_constants_one_home_1739_unit.py +++ b/tests/project/test_rfc_constants_one_home_1739_unit.py @@ -449,6 +449,9 @@ 'and the 28-octet IPv4 ARP length'), 'pcapkit/protocols/link/loopback.py': ((24, 28), 'the BSD loopback address families of IPv6 ' '(NetBSD and OpenBSD 24, FreeBSD 28)'), + 'pcapkit/protocols/link/ieee802_11.py': ((6, 9, 12, 16, 32), 'a 6-octet MAC address, ' + 'Frame Control bit offsets, and the 12-, 16- and ' + '32-bit widths of 802.11 header fields'), 'pcapkit/protocols/link/l2tpv2.py': ((6, 12, 16), 'L2TPv2 header flags, widths and length'), 'pcapkit/protocols/link/linux_sll1.py': ((65535,), "the max value of LINUX_SLL's own " '2-octet link_addr_length field, not an IP total ' @@ -460,6 +463,8 @@ '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/ieee802_11.py': ((6, 9), 'a MAC address is 6 octets, and ' + 'the From DS bit offset of Frame Control'), 'pcapkit/protocols/schema/link/l2tp.py': ((6, 12), 'an L2TP header field offset, and the ' 'version bit-field'), 'pcapkit/protocols/schema/link/vlan.py': ((12,), 'the 12-bit VLAN identifier'), diff --git a/tests/protocols/link/test_ieee802_11_unit.py b/tests/protocols/link/test_ieee802_11_unit.py new file mode 100644 index 0000000000..2d1bba2086 --- /dev/null +++ b/tests/protocols/link/test_ieee802_11_unit.py @@ -0,0 +1,460 @@ +# -*- coding: utf-8 -*- +"""GitHub issue #2575: the IEEE 802.11 MAC header behind radiotap. + +:class:`~pcapkit.protocols.link.ieee802_11.IEEE802_11` decodes the MAC header +of IEEE Std 802.11-2020 §9.2 and keeps the frame body as +:class:`~pcapkit.protocols.misc.raw.Raw`. Covers data frames under each To +DS/From DS combination, management frames, the header length of every +control subtype, Control Frame Extension and Extension frames, the QoS and HT +Control fields, a refused protocol version, truncated input and ``make()``, +each with byte-exact round trips both ways. The Radiotap side of the dispatch +is covered in ``test_radiotap_unit.py``. + +Each test imports :mod:`pcapkit` itself, after +:func:`~tests._support.reimport_once_per_class`. + +""" + +import unittest +import warnings + +from tests._support import reimport_once_per_class + +#: Six distinct addresses, ``01:01:01:01:01:01`` to ``06:06:06:06:06:06``. +ADDR = tuple(bytes([n]) * 6 for n in range(1, 7)) + + +def _fc(type_: int, subtype: int, flags: int = 0, version: int = 0) -> bytes: + """Frame Control, little-endian: B0-B1 version, B2-B3 type, B4-B7 + subtype, B8-B15 flags (IEEE Std 802.11-2020 §9.2.4.1.1).""" + return bytes([version | type_ << 2 | subtype << 4, flags]) + + +def _mac(n: int) -> str: + return ':'.join([f'{n:02x}'] * 6) + + +#: Flag bits of the second Frame Control octet. +TO_DS, FROM_DS, MORE_FRAG, RETRY, PWR_MGT, MORE_DATA, PROTECTED, ORDER = (1 << i for i in range(8)) + + +class TestIEEE802_11(unittest.TestCase): + """Pin the IEEE 802.11 MAC header layer, alone.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def _parse(self, raw: bytes): # type: ignore[no-untyped-def] + from pcapkit.protocols.link.ieee802_11 import IEEE802_11 + + parsed = IEEE802_11(raw, len(raw)) + self.assertEqual(parsed.data, raw) + for data in (parsed.info, parsed.info.to_dict()): + with self.subTest(rebuild=type(data).__name__): + self.assertEqual(IEEE802_11.from_data(data).data.hex(), raw.hex()) + return parsed + + def test_data_frame_addresses_follow_to_ds_and_from_ds(self) -> None: + """Three addresses in every combination, and Address 4 only when To + DS and From DS are both set (§9.2.4.3); Sequence Control sits + between Address 3 and Address 4 (§9.3.2).""" + from pcapkit.const.ieee802_11.data_subtype import DataSubtype + from pcapkit.const.ieee802_11.type import Type + + body = b'\xaa\xaa\x03\x00\x00\x00\x08\x00' + for flags, addr4 in ((0, False), (TO_DS, False), (FROM_DS, False), + (TO_DS | FROM_DS, True)): + with self.subTest(flags=flags): + raw = (_fc(2, 0, flags) + b'\x2c\x00' + ADDR[0] + ADDR[1] + ADDR[2] + + b'\x51\x34' + (ADDR[3] if addr4 else b'') + body) + parsed = self._parse(raw) + info = parsed.info + self.assertIs(info.frame_control.type, Type.Data) + self.assertIs(info.frame_control.subtype, DataSubtype.Data) + self.assertEqual(info.frame_control.to_ds, bool(flags & TO_DS)) + self.assertEqual(info.frame_control.from_ds, bool(flags & FROM_DS)) + self.assertEqual(info.duration_id, 44) + self.assertEqual((info.addr1, info.addr2, info.addr3), (_mac(1), _mac(2), _mac(3))) + self.assertEqual(info.addr4, _mac(4) if addr4 else None) + # 0x3451: fragment number 1, sequence number 0x345 (§9.2.4.4) + self.assertEqual((info.seq_ctrl.fragment_number, info.seq_ctrl.sequence_number), (1, 0x345)) + self.assertIsNone(info.qos_control) + self.assertIsNone(info.ht_control) + self.assertEqual(parsed.length, 30 if addr4 else 24) + self.assertEqual(type(parsed.payload).__name__, 'Raw') + self.assertEqual(parsed.payload.data, body) + self.assertEqual(parsed.protochain.chain, '802.11:Raw') + + def test_frame_control_flags(self) -> None: + """Every flag of §9.2.4.1 lands on its own bit.""" + for bit, name in ((TO_DS, 'to_ds'), (FROM_DS, 'from_ds'), (MORE_FRAG, 'more_fragments'), + (RETRY, 'retry'), (PWR_MGT, 'power_management'), + (MORE_DATA, 'more_data'), (PROTECTED, 'protected'), (ORDER, 'order')): + with self.subTest(name=name): + raw = _fc(2, 0, bit) + bytes(2) + ADDR[0] + ADDR[1] + ADDR[2] + bytes(2) + bytes(6) + info = self._parse(raw).info.frame_control + for other in ('to_ds', 'from_ds', 'more_fragments', 'retry', 'power_management', + 'more_data', 'protected', 'order'): + self.assertEqual(info[other], other == name) + self.assertEqual(info.version, 0) + self.assertIsNone(info.control_frame_extension) + + def test_protected_body_is_raw(self) -> None: + """The Protected Frame bit leaves the body undecoded.""" + body = bytes.fromhex('0102030420000000') + b'ciphertext' + raw = _fc(2, 0, PROTECTED) + bytes(2) + ADDR[0] + ADDR[1] + ADDR[2] + bytes(2) + body + parsed = self._parse(raw) + self.assertTrue(parsed.info.frame_control.protected) + self.assertEqual(parsed.payload.data, body) + + def test_management_frame(self) -> None: + """A Beacon is 24 octets of header whatever its DS bits say (§9.3.3). + The bytes are the familiar ``80 00`` Beacon Frame Control.""" + from pcapkit.const.ieee802_11.management_subtype import ManagementSubtype + from pcapkit.const.ieee802_11.type import Type + + body = bytes(8) + b'\x64\x00\x31\x04' + for flags in (0, TO_DS | FROM_DS): + with self.subTest(flags=flags): + raw = (bytes([0x80, flags]) + bytes(2) + b'\xff' * 6 + ADDR[1] + ADDR[1] + + b'\x10\x00' + body) + parsed = self._parse(raw) + info = parsed.info + self.assertIs(info.frame_control.type, Type.Management) + self.assertIs(info.frame_control.subtype, ManagementSubtype.Beacon) + self.assertEqual(info.addr1, 'ff:ff:ff:ff:ff:ff') + self.assertIsNone(info.addr4) + self.assertEqual((info.seq_ctrl.fragment_number, info.seq_ctrl.sequence_number), (0, 1)) + self.assertEqual(parsed.length, 24) + self.assertEqual(parsed.payload.data, body) + + def test_management_frame_ht_control(self) -> None: + """+HTC/Order in a Management frame adds HT Control (§9.2.4.1.10).""" + raw = _fc(0, 0b1101, ORDER) + bytes(2) + ADDR[0] + ADDR[1] + ADDR[2] + bytes(2) + b'\x3c\x00\x00\x00' + b'\x7f' + parsed = self._parse(raw) + self.assertEqual(parsed.length, 28) + self.assertEqual(parsed.info.ht_control.value, 0x3c) + self.assertEqual(parsed.info.ht_control.variant, 'HT') + self.assertEqual(parsed.payload.data, b'\x7f') + + def test_control_frame_header_length_per_subtype(self) -> None: + """Every control subtype: RA and TA (16 octets); Control Frame + Extension as the Linux kernel's ``ieee80211_hdrlen()`` reads it (16, + whose Address 2 is NAV-SA in a DMG DTS); RA alone for CTS and Ack + (10); and the minimal frame format of §9.2.3 (10) for the reserved + subtypes and Control Wrapper, where the kernel gives 16.""" + from pcapkit.const.ieee802_11.control_subtype import ControlSubtype + from pcapkit.const.ieee802_11.type import Type + + expected = {0b0000: 10, 0b0001: 10, 0b0010: 16, 0b0011: 16, 0b0100: 16, 0b0101: 16, + 0b0110: 16, 0b0111: 10, 0b1000: 16, 0b1001: 16, 0b1010: 16, 0b1011: 16, + 0b1100: 10, 0b1101: 10, 0b1110: 16, 0b1111: 16} + rest = ADDR[1] + b'\x04\x00\x00\x00' + b'\xde\xad\xbe\xef' + for subtype, hdr_len in expected.items(): + with self.subTest(subtype=subtype): + raw = _fc(1, subtype) + b'\x02\x00' + ADDR[0] + rest + parsed = self._parse(raw) + info = parsed.info + self.assertIs(info.frame_control.type, Type.Control) + self.assertEqual(info.frame_control.subtype, ControlSubtype.get(subtype)) + self.assertEqual(parsed.length, hdr_len) + self.assertEqual(info.addr2, _mac(2) if hdr_len == 16 else None) + for absent in ('addr3', 'seq_ctrl', 'addr4', 'qos_control', 'ht_control'): + self.assertIsNone(info[absent]) + self.assertEqual(parsed.payload.data, raw[hdr_len:]) + + def test_known_control_frames(self) -> None: + """``d4 00`` is an Ack and ``b4 00`` an RTS; PS-Poll carries the AID in + Duration/ID (§9.2.4.2).""" + from pcapkit.const.ieee802_11.control_subtype import ControlSubtype + + fcs = b'\x11\x22\x33\x44' + ack = self._parse(bytes.fromhex('d4000000') + ADDR[0] + fcs) + self.assertIs(ack.info.frame_control.subtype, ControlSubtype.ACK) + self.assertEqual((ack.length, ack.payload.data), (10, fcs)) + + rts = self._parse(bytes.fromhex('b4002c01') + ADDR[0] + ADDR[1] + fcs) + self.assertIs(rts.info.frame_control.subtype, ControlSubtype.RTS) + self.assertEqual((rts.info.duration_id, rts.info.addr2, rts.length), (300, _mac(2), 16)) + + ps_poll = self._parse(bytes.fromhex('a410' '05c0') + ADDR[0] + ADDR[1]) + self.assertIs(ps_poll.info.frame_control.subtype, ControlSubtype.PS_Poll) + self.assertTrue(ps_poll.info.frame_control.power_management) + self.assertEqual(ps_poll.info.duration_id, 0xc005) + + def test_control_frame_extension(self) -> None: + """In a Control Frame Extension frame B8-B11 are the Control Frame + Extension subfield, not To DS, From DS, More Fragments and Retry + (§9.2.4.1.1); Address 2 follows Address 1.""" + raw = _fc(1, 0b0110, 0b0101 | PWR_MGT) + bytes(2) + ADDR[0] + ADDR[1] + b'\x00' + parsed = self._parse(raw) + info = parsed.info.frame_control + self.assertEqual(info.control_frame_extension, 0b0101) + for name in ('to_ds', 'from_ds', 'more_fragments', 'retry', 's1g_beacon_flags'): + self.assertIsNone(info[name]) + self.assertTrue(info.power_management) + self.assertEqual((parsed.length, parsed.info.addr2), (16, _mac(2))) + self.assertEqual(parsed.payload.data, b'\x00') + + # DMG DTS (extension 6): RA, NAV-SA as Address 2, NAV-DA left in the body. + dts = self._parse(_fc(1, 0b0110, 0b0110) + bytes(2) + ADDR[0] + ADDR[1] + ADDR[2]) + self.assertEqual(dts.info.frame_control.control_frame_extension, 6) + self.assertEqual((dts.length, dts.info.addr2, dts.payload.data), (16, _mac(2), ADDR[2])) + + def test_s1g_beacon_frame_control(self) -> None: + """In an S1G Beacon B8-B15 are the subfields Wireshark reads as + ``wlan.fc.s1g.*``, kept whole; none of the eight flags is read.""" + from pcapkit.const.ieee802_11.extension_subtype import ExtensionSubtype + + raw = _fc(3, 0b0001, 0b1010_0101) + bytes(2) + ADDR[0] + bytes(8) + parsed = self._parse(raw) + info = parsed.info.frame_control + self.assertIs(info.subtype, ExtensionSubtype.S1G_Beacon) + self.assertEqual(info.s1g_beacon_flags, 0b1010_0101) + for name in ('to_ds', 'from_ds', 'more_fragments', 'retry', 'power_management', + 'more_data', 'protected', 'order', 'control_frame_extension'): + self.assertIsNone(info[name]) + self.assertEqual(parsed.length, 10) + + def test_extension_frame_is_the_minimal_format(self) -> None: + """A DMG Beacon reads as Frame Control, Duration and Address 1 (the + BSSID); the rest stays in the body.""" + from pcapkit.const.ieee802_11.extension_subtype import ExtensionSubtype + from pcapkit.const.ieee802_11.type import Type + + for subtype in (0b0000, 0b0001, 0b1111): + with self.subTest(subtype=subtype): + raw = _fc(3, subtype, ORDER) + bytes(2) + ADDR[0] + bytes(8) + parsed = self._parse(raw) + self.assertIs(parsed.info.frame_control.type, Type.Extension) + self.assertEqual(parsed.info.frame_control.subtype, ExtensionSubtype.get(subtype)) + self.assertEqual(parsed.length, 10) + self.assertIsNone(parsed.info.ht_control) + + def test_every_type_subtype_and_flag_round_trips(self) -> None: + """Every (Type, Subtype) pair of the Table 9-1 registries, crossed with + To DS, From DS and +HTC/Order, parses to the header length the layout + rule gives and rebuilds byte for byte, and ``make()`` with the same + values builds the same octets.""" + from pcapkit.const.ieee802_11.control_subtype import ControlSubtype + from pcapkit.const.ieee802_11.type import Type + from pcapkit.protocols.link.ieee802_11 import IEEE802_11 + + ta_subtypes = {0b0010, 0b0011, 0b0100, 0b0101, 0b0110, 0b1000, 0b1001, 0b1010, 0b1011, + 0b1110, 0b1111} + body = b'\xaa\xbb\xcc' + for type_ in Type: + for subtype in range(16): + for flags in (0, TO_DS, FROM_DS, TO_DS | FROM_DS, ORDER, TO_DS | FROM_DS | ORDER): + with self.subTest(type=type_.name, subtype=subtype, flags=flags): + qos = type_ == Type.Data and bool(subtype & 0b1000) + addr4 = type_ == Type.Data and bool(flags & TO_DS) and bool(flags & FROM_DS) + htc = (type_ == Type.Management or qos) and bool(flags & ORDER) + if type_ in (Type.Management, Type.Data): + hdr_len = 24 + 6 * addr4 + 2 * qos + 4 * htc + elif type_ == Type.Control: + hdr_len = 16 if subtype in ta_subtypes else 10 + else: + hdr_len = 10 + raw = _fc(type_, subtype, flags) + bytes([subtype, flags]) + raw += bytes(range(1, hdr_len - 3)) + body + parsed = self._parse(raw) + self.assertEqual(parsed.length, hdr_len) + self.assertEqual(parsed.payload.data, body) + + made = IEEE802_11(**IEEE802_11._make_data(parsed.info)) + self.assertEqual(made.data.hex(), raw.hex()) + self.assertEqual(IEEE802_11(made.data, len(made.data)).data, raw) + if type_ == Type.Control: + self.assertEqual(parsed.info.frame_control.subtype, ControlSubtype(subtype)) + + def test_make_zero_fills_what_the_frame_carries(self) -> None: + """Sequence Control, QoS Control and HT Control left out of ``make()`` + are written as zeros where the header carries them.""" + from pcapkit.protocols.link.ieee802_11 import IEEE802_11 + + made = IEEE802_11(type='Data', subtype='QoS_Data', order=True) + self.assertEqual(made.data, bytes.fromhex('8880') + bytes(2 + 18 + 2 + 2 + 4)) + info = made.info + self.assertEqual((info.seq_ctrl.fragment_number, info.seq_ctrl.sequence_number), (0, 0)) + self.assertEqual(info.qos_control.tid, 0) + self.assertEqual((info.ht_control.variant, info.ht_control.value), ('HT', 0)) + + def test_qos_control(self) -> None: + """The QoS bit (B7) of a Data subtype adds QoS Control (§9.2.4.5): + TID in bits 0-3, EOSP bit 4, Ack Policy bits 5-6, A-MSDU Present bit + 7, and bits 8-15 kept whole.""" + from pcapkit.const.ieee802_11.data_subtype import DataSubtype + + for subtype in range(0b1000, 0b10000): + with self.subTest(subtype=subtype): + raw = (_fc(2, subtype, TO_DS) + bytes(2) + ADDR[0] + ADDR[1] + ADDR[2] + + bytes(2) + bytes([0b1011_0101, 0x7e]) + b'body') + parsed = self._parse(raw) + qos = parsed.info.qos_control + self.assertEqual(parsed.info.frame_control.subtype, DataSubtype.get(subtype)) + self.assertEqual((qos.tid, qos.eosp, qos.ack_policy, qos.amsdu_present, qos.bits_8_15), + (0b0101, True, 0b01, True, 0x7e)) + self.assertIsNone(parsed.info.ht_control) + self.assertEqual(parsed.length, 26) + self.assertEqual(parsed.payload.data, b'body') + + def test_ht_control_on_qos_data_only(self) -> None: + """+HTC/Order adds HT Control to a QoS Data frame; on a non-QoS Data + frame the bit is Order and adds nothing (§9.2.4.1.10).""" + for subtype, hdr_len in ((0b1000, 30), (0b0000, 24)): + with self.subTest(subtype=subtype): + raw = (_fc(2, subtype, ORDER) + bytes(2) + ADDR[0] + ADDR[1] + ADDR[2] + + bytes(2) + (bytes(2) if subtype else b'') + bytes.fromhex('03000000') + b'xy') + parsed = self._parse(raw) + self.assertTrue(parsed.info.frame_control.order) + self.assertEqual(parsed.length, hdr_len) + self.assertEqual(parsed.info.ht_control is None, not subtype) + + def test_ht_control_variants_and_four_address_qos(self) -> None: + """The variant follows bits 0 and 1 (§9.2.4.6); with Address 4, QoS + and HT Control the header is its longest, 36 octets.""" + for value, variant in ((0x8000_0000, 'HT'), (0x0000_0001, 'VHT'), (0xffff_ffff, 'HE')): + with self.subTest(variant=variant): + raw = (_fc(2, 0b1000, TO_DS | FROM_DS | ORDER) + bytes(2) + ADDR[0] + ADDR[1] + + ADDR[2] + bytes(2) + ADDR[3] + bytes(2) + value.to_bytes(4, 'little')) + parsed = self._parse(raw) + self.assertEqual((parsed.info.ht_control.variant, parsed.info.ht_control.value), + (variant, value)) + self.assertEqual(parsed.length, 36) + self.assertEqual(type(parsed.payload).__name__, 'NoPayload') + + def test_protocol_version_other_than_zero_is_refused(self) -> None: + """Version 1 is PV1 (§9.8) and versions 2-3 are reserved: never read + as protocol version 0.""" + from pcapkit.protocols.link.ieee802_11 import IEEE802_11 + from pcapkit.utilities.exceptions import ProtocolError + + for version in (1, 2, 3): + with self.subTest(version=version): + raw = _fc(2, 0, 0, version) + bytes(22) + with self.assertRaises(ProtocolError): + IEEE802_11(raw, len(raw)) + + def test_truncated_header_rebuilds_as_captured(self) -> None: + """A capture ending anywhere in the longest header parses without + raising and rebuilds to exactly the octets captured.""" + from pcapkit.protocols.link.ieee802_11 import IEEE802_11 + + raw = (_fc(2, 0b1000, TO_DS | FROM_DS | ORDER) + bytes(2) + ADDR[0] + ADDR[1] + + ADDR[2] + b'\x10\x00' + ADDR[3] + b'\x05\x00' + b'\x01\x00\x00\x00' + b'body') + for keep in range(1, len(raw) + 1): + with self.subTest(keep=keep): + cut = raw[:keep] + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + parsed = IEEE802_11(cut, len(cut)) + self.assertEqual(parsed.data, cut) + self.assertEqual(IEEE802_11.from_data(parsed.info).data, cut) + self.assertEqual(IEEE802_11.from_data(parsed.info.to_dict()).data, cut) + + def test_make_then_parse(self) -> None: + """``make()`` lays out the fields Frame Control declares, zero-filling + those not given, and parses back to the same octets.""" + from pcapkit.const.ieee802_11.data_subtype import DataSubtype + from pcapkit.const.ieee802_11.type import Type + from pcapkit.protocols.link.ieee802_11 import IEEE802_11 + + made = IEEE802_11(type=Type.Data, subtype=DataSubtype.QoS_Data, to_ds=True, from_ds=True, + order=True, duration_id=0x2c, addr1=_mac(1), addr2=_mac(2), addr3=_mac(3), + seq_ctrl={'fragment_number': 1, 'sequence_number': 0x345}, addr4=_mac(4), + qos_control={'tid': 5, 'eosp': True, 'ack_policy': 1, 'amsdu_present': True, + 'bits_8_15': 0x7e}, + ht_control=0x8000_0001, payload=b'body') + expected = (bytes.fromhex('8883') + b'\x2c\x00' + ADDR[0] + ADDR[1] + ADDR[2] + b'\x51\x34' + + ADDR[3] + bytes([0b1011_0101, 0x7e]) + b'\x01\x00\x00\x80' + b'body') + self.assertEqual(made.data.hex(), expected.hex()) + self._parse(made.data) + + self.assertEqual(IEEE802_11(type='Control', subtype='ACK', addr1=_mac(1)).data, + bytes.fromhex('d4000000') + ADDR[0]) + self.assertEqual(IEEE802_11(type=0, subtype=8).data, + bytes.fromhex('80000000') + bytes(20)) + self.assertEqual(IEEE802_11(type=1, subtype=6, control_frame_extension=0b0101).data, + bytes.fromhex('64050000') + bytes(12)) + self.assertEqual(IEEE802_11(type=3, subtype=1, s1g_beacon_flags=0b1010_0101).data, + bytes.fromhex('1ca50000') + bytes(6)) + + def test_make_rejects_what_the_frame_does_not_carry(self) -> None: + from pcapkit.protocols.link.ieee802_11 import IEEE802_11 + from pcapkit.utilities.exceptions import ProtocolError + + for kwargs in ( + {'type': 1, 'subtype': 0b1101, 'addr2': _mac(2)}, + {'type': 2, 'subtype': 0, 'addr4': _mac(4)}, + {'type': 2, 'subtype': 0, 'qos_control': {}}, + {'type': 2, 'subtype': 0b1000, 'ht_control': 0}, + {'type': 1, 'subtype': 0b1011, 'seq_ctrl': {}}, + {'type': 1, 'subtype': 0b0110, 'to_ds': True}, + {'type': 2, 'subtype': 0, 'control_frame_extension': 0}, + {'type': 3, 'subtype': 1, 'to_ds': True}, + {'type': 3, 'subtype': 1, 'order': True}, + {'type': 3, 'subtype': 1, 'control_frame_extension': 0}, + {'type': 3, 'subtype': 0, 's1g_beacon_flags': 0}, + ): + with self.subTest(**{k: repr(v) for k, v in kwargs.items()}), self.assertRaises(ProtocolError): + IEEE802_11(**kwargs) + + def test_make_rejects_what_does_not_fit(self) -> None: + from pcapkit.protocols.link.ieee802_11 import IEEE802_11 + from pcapkit.utilities.exceptions import ProtocolError + + qos = {'tid': 0, 'eosp': False, 'ack_policy': 0, 'amsdu_present': False, 'bits_8_15': 0} + for kwargs in ( + {'version': 1}, {'version': True}, {'type': 4}, {'type': True}, {'subtype': 16}, + {'to_ds': 2}, {'order': 'yes'}, {'duration_id': 0x1_0000}, {'duration_id': -1}, + {'addr1': 'not a mac'}, {'addr1': 1}, + {'seq_ctrl': {'fragment_number': 16, 'sequence_number': 0}}, + {'seq_ctrl': {'fragment_number': 0, 'sequence_number': 0x1000}}, + {'subtype': 8, 'qos_control': {**qos, 'tid': 16}}, + {'subtype': 8, 'qos_control': {**qos, 'bits_8_15': 0x100}}, + {'subtype': 8, 'order': True, 'ht_control': 0x1_0000_0000}, + {'type': 1, 'subtype': 6, 'control_frame_extension': 16}, + {'type': 3, 'subtype': 1, 's1g_beacon_flags': 0x100}, + {'more_data': 2}, + ): + with self.subTest(**{k: repr(v) for k, v in kwargs.items()}), self.assertRaises(ProtocolError): + IEEE802_11(**kwargs) + + def test_id_index_and_names(self) -> None: + from pcapkit.protocols.link.ieee802_11 import IEEE802_11 + from pcapkit.utilities.exceptions import UnsupportedCall + + self.assertEqual(IEEE802_11.id(), ('IEEE802_11',)) + with self.assertRaises(UnsupportedCall): + IEEE802_11.__index__() + made = IEEE802_11() + self.assertEqual((made.name, made.alias), ('IEEE 802.11 Wireless LAN', '802.11')) + self.assertEqual(made.__length_hint__(), 36) + + +class TestIEEE802_11Registries(unittest.TestCase): + """The Table 9-1 registries resolve a reserved value under its bare label.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_reserved_subtypes(self) -> None: + from pcapkit.const.ieee802_11.control_subtype import ControlSubtype + from pcapkit.const.ieee802_11.data_subtype import DataSubtype + from pcapkit.const.ieee802_11.extension_subtype import ExtensionSubtype + from pcapkit.const.ieee802_11.management_subtype import ManagementSubtype + + for registry, value in ((ControlSubtype, 0), (DataSubtype, 2), (DataSubtype, 6), + (ExtensionSubtype, 9)): + with self.subTest(registry=registry.__name__, value=value): + self.assertEqual(registry(value).name, 'Reserved') + self.assertIs(ManagementSubtype(7), ManagementSubtype.Reserved_7) + self.assertIs(DataSubtype(13), DataSubtype.Reserved_13) + for registry in (ControlSubtype, DataSubtype, ExtensionSubtype, ManagementSubtype): + with self.subTest(registry=registry.__name__), self.assertRaises(ValueError): + registry(16) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/protocols/link/test_linux_sll1_unit.py b/tests/protocols/link/test_linux_sll1_unit.py index e174a6c5d8..80e22154a8 100644 --- a/tests/protocols/link/test_linux_sll1_unit.py +++ b/tests/protocols/link/test_linux_sll1_unit.py @@ -102,13 +102,13 @@ def test_radiotap_arphrd_dispatches_to_radiotap(self) -> None: 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 + radiotap = bytes.fromhex('00' '00' '0800' '00000000' 'd4000000' 'aaaaaaaaaaaa' '11223344') 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.protochain.chain, 'LINUX_SLL1:Radiotap:802.11:Raw') self.assertEqual(parsed.data, raw) self._assert_rebuilds(parsed) diff --git a/tests/protocols/link/test_linux_sll2_unit.py b/tests/protocols/link/test_linux_sll2_unit.py index 5059c53a98..ed44d80e67 100644 --- a/tests/protocols/link/test_linux_sll2_unit.py +++ b/tests/protocols/link/test_linux_sll2_unit.py @@ -104,13 +104,13 @@ def test_radiotap_arphrd_dispatches_to_radiotap(self) -> None: 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 + radiotap = bytes.fromhex('00' '00' '0800' '00000000' 'd4000000' 'aaaaaaaaaaaa' '11223344') 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.protochain.chain, 'LINUX_SLL2:Radiotap:802.11:Raw') self.assertEqual(parsed.data, raw) self._assert_rebuilds(parsed) diff --git a/tests/protocols/link/test_linux_sll_arphrd_registry_unit.py b/tests/protocols/link/test_linux_sll_arphrd_registry_unit.py index b8515af517..e9eb13dc32 100644 --- a/tests/protocols/link/test_linux_sll_arphrd_registry_unit.py +++ b/tests/protocols/link/test_linux_sll_arphrd_registry_unit.py @@ -30,7 +30,8 @@ 'FRAD': (bytes.fromhex('0c21') + b'\x03\xcc' + IPV4, 'FRAD:IPv4:UDP'), 'IPGRE': (bytes.fromhex('00000800') + IPV4, 'GRE:IPv4:UDP'), 'IP6GRE': (bytes.fromhex('00000800') + IPV4, 'GRE:IPv4:UDP'), - 'IEEE80211_RADIOTAP': (bytes.fromhex('00' '00' '0800' '00000000') + b'\xaa' * 10, 'Radiotap:Raw'), + 'IEEE80211_RADIOTAP': (bytes.fromhex('00' '00' '0800' '00000000' 'd4000000' 'aaaaaaaaaaaa' '11223344'), + 'Radiotap:802.11:Raw'), 'NETLINK': (struct.pack('`). 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. +being handed to :class:`~pcapkit.protocols.link.ieee802_11.IEEE802_11` +(:issue:`2575`), 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`. @@ -19,10 +19,15 @@ """ import unittest +import warnings from tests._support import reimport_once_per_class +#: An 802.11 Ack frame, ``d4 00``, to ``01:01:01:01:01:01``, with a 4-octet FCS. +ACK = bytes.fromhex('d4000000') + b'\x01' * 6 + b'\x11\x22\x33\x44' + + 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.""" @@ -66,31 +71,90 @@ def test_minimal_header_with_empty_bitmask(self) -> None: 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`. + the 802.11 MAC frame behind it is handed to + :class:`~pcapkit.protocols.link.ieee802_11.IEEE802_11` (:issue:`2575`). """ + from pcapkit.protocols.link.ieee802_11 import IEEE802_11 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 + raw = _radiotap(8 + len(fields), [0b111], fields) + ACK 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, IEEE802_11) + self.assertEqual(parsed.payload.data, ACK) + self.assertEqual(parsed.protochain.chain, 'Radiotap:802.11:Raw') + self.assertEqual(parsed.data, raw) + self._assert_rebuilds(parsed) + + def test_mac_frame_dispatches_to_ieee802_11(self) -> None: + """GitHub issue #2575: the frame from ``it_len`` on is an 802.11 MAC + frame, whose header is decoded and whose body (here the FCS alone) + is kept as :class:`~pcapkit.protocols.misc.raw.Raw`. + """ + from pcapkit.const.ieee802_11.control_subtype import ControlSubtype + from pcapkit.protocols.link.radiotap import Radiotap + + raw = _radiotap(8, [0]) + ACK + parsed = Radiotap(raw, len(raw)) + frame = parsed.payload + self.assertEqual(type(frame).__name__, 'IEEE802_11') + self.assertIs(frame.info.frame_control.subtype, ControlSubtype.ACK) + self.assertEqual(frame.info.addr1, '01:01:01:01:01:01') + self.assertEqual(frame.payload.data, b'\x11\x22\x33\x44') + self.assertIn('IEEE802_11', parsed) + self.assertEqual(parsed.data, raw) + self._assert_rebuilds(parsed) + + def test_mac_frame_of_another_protocol_version_stays_raw(self) -> None: + """A frame whose protocol version is not 0 is refused by + :class:`~pcapkit.protocols.link.ieee802_11.IEEE802_11` and kept as + :class:`~pcapkit.protocols.misc.raw.Raw`, error recorded. The + :class:`~pcapkit.protocols.misc.raw.Raw` holds the frame alone, not + the present-flags chain and field-data region before it, and is + labelled with the link type it was looked up under, as Ethernet's is + with its EtherType. + """ + from pcapkit.protocols.link.radiotap import Radiotap + from pcapkit.protocols.misc.raw import Raw + + mac = b'\xd5' + ACK[1:] + word0 = (1 << 31) | 0b111 + fields = bytes.fromhex('0102030405060708090a') + raw = _radiotap(12 + len(fields), [word0, 0], fields) + mac + parsed = Radiotap(raw, len(raw)) self.assertIsInstance(parsed.payload, Raw) + self.assertIn('protocol version', parsed.payload.info.error) self.assertEqual(parsed.payload.data, mac) - self.assertEqual(parsed.protochain.chain, 'Radiotap:Raw') + self.assertEqual(parsed.protochain.chain, 'Radiotap:IEEE802_11') self.assertEqual(parsed.data, raw) self._assert_rebuilds(parsed) + def test_termination_threshold_keeps_the_frame_raw(self) -> None: + """Extraction that stops at this layer leaves the 802.11 frame as + :class:`~pcapkit.protocols.misc.raw.Raw`, as every other layer's + dispatch does. + """ + from pcapkit.protocols.link.radiotap import Radiotap + + raw = _radiotap(8, [0]) + ACK + for kwargs in ({'protocol': 'Radiotap'}, {'layer': 'Link'}): + with self.subTest(**kwargs): + parsed = Radiotap(raw, len(raw), **kwargs) + self.assertEqual(type(parsed.payload).__name__, 'Raw') + self.assertEqual(parsed.protochain.chain, 'Radiotap:IEEE802_11') + self.assertEqual(parsed.payload.data, ACK) + self.assertEqual(Radiotap.from_data(parsed.info).data, raw) + 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`. + 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 @@ -105,9 +169,10 @@ def test_extended_presence_chaining(self) -> None: self.assertEqual(parsed.info.present, (word0, word1, word2)) self.assertEqual(parsed.info.fields, fields) self.assertEqual(parsed.info.it_len, 20) + # 3 octets are short of an 802.11 header, so the frame is kept Raw. self.assertIsInstance(parsed.payload, Raw) self.assertEqual(parsed.payload.data, mac) - self.assertEqual(parsed.protochain.chain, 'Radiotap:Raw') + self.assertEqual(parsed.protochain.chain, 'Radiotap:IEEE802_11') self.assertEqual(parsed.data, raw) self._assert_rebuilds(parsed) @@ -160,8 +225,6 @@ def test_truncated_header_rebuilds_as_captured(self) -> None: ``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 @@ -191,14 +254,13 @@ 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) + made = Radiotap(present=(0b111,), fields=fields, payload=ACK).data + self.assertEqual(made, _radiotap(8 + len(fields), [0b111], fields) + ACK) 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.assertEqual(type(parsed.payload).__name__, 'IEEE802_11') + self.assertEqual(parsed.payload.data, ACK) self._assert_rebuilds(parsed) def test_make_then_parse_extended_presence(self) -> None: @@ -246,6 +308,80 @@ def test_make_rejects_what_does_not_fit(self) -> None: with self.subTest(**kwargs), self.assertRaises(ProtocolError): Radiotap(**kwargs) + def _snapshot(self) -> None: + """Put back every registry ``register_radiotap`` writes after the test.""" + from pcapkit.foundation.registry import protocols as registrars + from pcapkit.protocols import __proto__ as names + from pcapkit.protocols.link.radiotap import Radiotap + + tables = (Radiotap.__proto__, names, registrars._CODE_LEASES, registrars._NAME_LEASES) + copies = [dict(table) for table in tables] + + def restore() -> None: + for table, copy in zip(tables, copies): + table.clear() + table.update(copy) + + self.addCleanup(restore) + + def test_the_registry_as_shipped(self) -> None: + """Keyed by link type, holding the 802.11 dissector alone (#2575).""" + from pcapkit.const.reg.linktype import LinkType + from pcapkit.corekit.module import ModuleDescriptor + from pcapkit.protocols.link.ieee802_11 import IEEE802_11 + from pcapkit.protocols.link.radiotap import Radiotap + from pcapkit.protocols.misc.raw import Raw + + self.assertIn('__proto__', vars(Radiotap)) + self.assertEqual(dict(Radiotap.__proto__), + {LinkType.IEEE802_11: ModuleDescriptor('pcapkit.protocols.link.ieee802_11', + 'IEEE802_11')}) + self.assertEqual(Radiotap.__proto_key__, (LinkType, False)) + self.assertIs(Radiotap._lookup_next_layer(Radiotap.__proto__, LinkType.IEEE802_11), + IEEE802_11) + self.assertIs(Radiotap._lookup_next_layer(Radiotap.__proto__, LinkType.ETHERNET), Raw) + + def test_register_radiotap_reaches_dispatch(self) -> None: + """A class registered under ``LinkType.IEEE802_11`` takes the frame.""" + from pcapkit.const.reg.linktype import LinkType + from pcapkit.foundation.registry import register_radiotap + from pcapkit.protocols.link.radiotap import Radiotap + from pcapkit.protocols.misc.raw import Raw + from pcapkit.utilities.warnings import RegistryWarning + + self._snapshot() + raw = _radiotap(8, [0]) + ACK + with self.assertWarnsRegex(RegistryWarning, r'^link type 105 already registered'): + register_radiotap(LinkType.IEEE802_11, Raw) + with warnings.catch_warnings(): + warnings.simplefilter('error') + register_radiotap(LinkType.IEEE802_11, Raw) # the entry already stored: silent + self.assertIs(Radiotap.__proto__[LinkType.IEEE802_11], Raw) + parsed = Radiotap(raw, len(raw)) + self.assertIs(type(parsed.payload), Raw) + self.assertEqual(parsed.payload.data, ACK) + + with self.assertWarnsRegex(RegistryWarning, r'^link type 105 already registered, overwriting '): + register_radiotap(LinkType.IEEE802_11, 'pcapkit.protocols.link.ieee802_11', 'IEEE802_11') + self.assertEqual(Radiotap(raw, len(raw)).protochain.chain, 'Radiotap:802.11:Raw') + + def test_register_radiotap_rejects_a_code_of_the_wrong_kind(self) -> None: + """It refuses before writing anything, naming the registry that refused.""" + from pcapkit.const.linux.arphrd import ARPHRD + from pcapkit.foundation.registry import register_radiotap + from pcapkit.protocols.link.radiotap import Radiotap + from pcapkit.protocols.misc.raw import Raw + from pcapkit.utilities.exceptions import RegistryError + + for code, message in ((105, r'^Radiotap: code must be a member of LinkType, not 105$'), + (ARPHRD.IEEE80211_RADIOTAP, r'^Radiotap: code must be a member of ' + r'LinkType, not a member of ARPHRD: ')): + with self.subTest(code=code): + before = dict(Radiotap.__proto__) + with self.assertRaisesRegex(RegistryError, message): + register_radiotap(code, Raw) # type: ignore[arg-type] + self.assertEqual(dict(Radiotap.__proto__), before) + def test_id_and_index(self) -> None: """``__index__`` is the ``arphrd_type`` LinuxSLL dispatches here (#2510).""" from pcapkit.const.linux.arphrd import ARPHRD diff --git a/tests/protocols/test_dispatch_registry_unit.py b/tests/protocols/test_dispatch_registry_unit.py index 4d7c5b899b..bb9fcee35b 100644 --- a/tests/protocols/test_dispatch_registry_unit.py +++ b/tests/protocols/test_dispatch_registry_unit.py @@ -231,14 +231,14 @@ def test_cases_cover_every_table_named_in_the_issue(self) -> None: # Frame.__proto__[LinkType.LINUX_SLL]/[LinkType.LINUX_SLL2] in both the # pcap and pcapng frame tables, #2439's IGMP, #2436's # Internet.__proto__[TransType.ICMP] and [TransType.IPv6_ICMP], #2489's - # Internet.__proto__[TransType.DCCP], and #2560's two NLPIDs of - # FRAD.__proto__. - self.assertEqual(len(cases), 59, - 'expected exactly 59 entries across the seven __proto__ ' + # Internet.__proto__[TransType.DCCP], #2560's two NLPIDs of + # FRAD.__proto__, and #2575's Radiotap.__proto__[LinkType.IEEE802_11]. + self.assertEqual(len(cases), 60, + 'expected exactly 60 entries across the seven __proto__ ' 'tables named in GitHub issue #496, the loopback table ' - 'of #1574 and the FRAD table of #2560; a different count ' - 'means a registry changed shape and this module was not ' - 'updated to match') + 'of #1574, the FRAD table of #2560 and the radiotap table ' + 'of #2575; a different count means a registry changed ' + 'shape and this module was not updated to match') by_family = {} # type: dict[str, int] for case in cases: @@ -246,6 +246,7 @@ def test_cases_cover_every_table_named_in_the_issue(self) -> None: self.assertEqual(by_family, { 'link': 7, 'internet': 21, 'tcp': 5, 'udp': 4, 'sctp': 2, 'pcap-frame': 7, 'pcapng-frame': 7, 'loopback': 4, 'frad': 2, + 'radiotap': 1, }) def test_dispatch_reaches_target_or_is_a_recorded_degrade(self) -> None: diff --git a/tests/protocols/test_protocol_code_registration_unit.py b/tests/protocols/test_protocol_code_registration_unit.py index a2df655b9e..7d5db1cd7f 100644 --- a/tests/protocols/test_protocol_code_registration_unit.py +++ b/tests/protocols/test_protocol_code_registration_unit.py @@ -471,6 +471,7 @@ def _assert_rejected_unchanged(self, call: 'Any') -> None: from pcapkit.protocols.internet.internet import Internet from pcapkit.protocols.internet.ipx import IPX from pcapkit.protocols.link.link import Link + from pcapkit.protocols.link.radiotap import Radiotap from pcapkit.protocols.misc.pcap.frame import Frame from pcapkit.protocols.misc.pcapng import PCAPNG from pcapkit.protocols.transport.sctp import SCTP @@ -479,7 +480,7 @@ def _assert_rejected_unchanged(self, call: 'Any') -> None: from pcapkit.utilities.exceptions import RegistryError tables = {klass.__name__: klass.__proto__ - for klass in (Link, Internet, IPX, Frame, PCAPNG, TCP, UDP, SCTP)} + for klass in (Link, Radiotap, Internet, IPX, Frame, PCAPNG, TCP, UDP, SCTP)} tables['pcapkit.protocols'] = protocol_registry before = {name: dict(table) for name, table in tables.items()} try: @@ -599,6 +600,7 @@ def test_every_destination_kind_row_is_enforced(self) -> None: from pcapkit.protocols.internet.ipx import IPX from pcapkit.protocols.link.link import Link from pcapkit.protocols.link.linux_sll import LinuxSLL + from pcapkit.protocols.link.radiotap import Radiotap from pcapkit.protocols.misc.pcap.frame import Frame from pcapkit.protocols.misc.pcapng import PCAPNG from pcapkit.protocols.misc.raw import Raw @@ -614,6 +616,9 @@ def test_every_destination_kind_row_is_enforced(self) -> None: # 770 is ARPHRD.FRAD, where LinuxSLL ships FRAD; the EtherType is the # key of Link's registry, which LinuxSLL's own refuses (#2510). LinuxSLL: (ARPHRD, False, EtherType.Internet_Protocol_version_4, 770), + # 105 is LinkType.IEEE802_11, where Radiotap ships IEEE802_11, and + # TransType(105) compares equal to it (#2575). + Radiotap: (LinkType, False, TransType(105), 105), # 6 is TransType.TCP, where Internet ships TCP. Internet: (TransType, False, EtherType.XEROX_NS_IDP, 6), # TransType.UDP is 17, Packet.NCP: IPX's registry of its own exists so diff --git a/tests/protocols/test_short_read_roundtrip_unit.py b/tests/protocols/test_short_read_roundtrip_unit.py index 7e64726934..0f7e98066c 100644 --- a/tests/protocols/test_short_read_roundtrip_unit.py +++ b/tests/protocols/test_short_read_roundtrip_unit.py @@ -58,6 +58,13 @@ def _ipv6(code: int, body: bytes) -> bytes: ('netlink', 'link.netlink:Netlink', {}, struct.pack(' 'str': + """Apply the whitespace normalisation the generator writes files through, + as ``test_ipx_packet_unit.py`` does.""" + lines = [] # type: list[str] + for line in context.splitlines(): + if line: + if line.strip(): + lines.append(line.rstrip()) + else: + lines.append(line) + return '\n'.join(lines) + '\n' + + +@unittest.skipUnless(HAS_VENDOR_DEPS, f'vendor extra not installed ({", ".join(VENDOR_DEPS)})') +class IEEE80211CrawlerTests(unittest.TestCase): + """The hand-maintained Table 9-1 registries, and the crawlers that do not crawl.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def _crawler(self, module: 'str', name: 'str') -> 'Any': + """The crawler module, skipping when it was imported from another tree.""" + vendor_module = importlib.import_module(f'pcapkit.vendor.ieee802_11.{module}') + const_module = importlib.import_module(f'pcapkit.const.ieee802_11.{module}') + for found in (vendor_module, const_module): + resolved = pathlib.Path(found.__file__).resolve() + if ROOT not in resolved.parents: + self.skipTest(f'{found.__name__} was imported from {resolved}, which is outside ' + f'{ROOT}; install this checkout with `pip install -e .`') + return getattr(vendor_module, name) + + @staticmethod + def _vendor(klass: 'Any') -> 'Any': + """A crawler instance with the attributes ``__init__`` would set, without + the write ``__init__`` makes to the working tree.""" + vendor = klass.__new__(klass) + vendor.NAME = klass.__name__ + vendor.DOCS = klass.__doc__ + vendor.record = vendor.count(vendor.request()) + return vendor + + def test_regeneration_reproduces_the_committed_constant_files(self) -> None: + for module, name in CRAWLERS: + with self.subTest(crawler=name): + vendor = self._vendor(self._crawler(module, name)) + generated = _normalize(vendor.context(vendor.request())) + path = ROOT / 'pcapkit' / 'const' / 'ieee802_11' / f'{module}.py' + self.assertEqual(generated, path.read_text(encoding='utf-8'), + f'regenerating {path.relative_to(ROOT)} would change it; run ' + f'`python -m pcapkit.vendor.ieee802_11.{module}` and commit the result') + + def test_no_crawler_has_a_link(self) -> None: + for module, name in CRAWLERS: + with self.subTest(crawler=name): + klass = self._crawler(module, name) + self.assertIsNone(klass.LINK) + self.assertIs(self._vendor(klass)._request(), # pylint: disable=protected-access + importlib.import_module(klass.__module__).DATA) + + def test_every_subtype_value_resolves(self) -> None: + """Every 4-bit value is a member or a reserved range, so none raises.""" + for module, name in CRAWLERS[1:]: + with self.subTest(registry=name): + registry = getattr(importlib.import_module(f'pcapkit.const.ieee802_11.{module}'), name) + for value in range(16): + self.assertEqual(int(registry(value)), value) + with self.assertRaises(ValueError): + registry(16) + + +if __name__ == '__main__': + unittest.main()