diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dace3030f..c71128d385 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ Wave 2 of the roadmap, per the contributing docs' enhancement-proposal page: the protocol pairs that wanted a shared abstract base, and the two that simply had no dissector at all. +- **Added** -- `Netlink`, a dissector for the fixed 16-octet `nlmsghdr` framing of Linux's own kernel/userspace IPC transport (the kernel's own `netlink(7)` man page; [RFC 3549 Section 2.3.2](https://datatracker.ietf.org/doc/html/rfc3549#section-2.3.2)). `LinuxSLL`/ `LinuxSLL1`/ `LinuxSLL2` dispatch to it for the `ARPHRD_NETLINK` case instead of falling back to `Raw`. The byte order the header was written in is ambiguous -- Netlink is kernel/userspace IPC, not wire format -- and is guessed the way Wireshark's own netlink dissector does, by `nlmsg_byteorder()`. Per-family message payloads (routing table entries and the like) and multiple messages packed into one capture are out of scope for this first cut; every message's payload is kept as `Raw`, unconditionally. One of [#2460](https://github.com/JarryShaw/PyPCAPKit/issues/2460)'s five `arphrd_type` follow-ups to get a dissector (`FRAD` is tracked separately on [#2499](https://github.com/JarryShaw/PyPCAPKit/pull/2499)); `IPGRE`/`IP6GRE` and `IEEE80211_RADIOTAP` remain `Raw`. - **Added** -- `FRAD`, a dissector for Frame Relay's multiprotocol encapsulation ([RFC 2427](https://datatracker.ietf.org/doc/html/rfc2427), obsoleting [RFC 1490](https://datatracker.ietf.org/doc/html/rfc1490)). `LinuxSLL`/ `LinuxSLL1`/ `LinuxSLL2` dispatch to it for the `ARPHRD_FRAD` case instead of falling back to `Raw`; the direct NLPID and SNAP-encapsulated forms both dispatch onward, to IPv4/IPv6 and (via the EtherType registry every Ethernet/VLAN/LinuxSLL frame shares) ARP among others. The first of [#2460](https://github.com/JarryShaw/PyPCAPKit/issues/2460)'s five `arphrd_type` follow-ups to get a dissector; the other four (`NETLINK`, `IPGRE`/`IP6GRE`, `IEEE80211_RADIOTAP`) remain `Raw`. --- diff --git a/docs/source/changelog/1.6.0.rst b/docs/source/changelog/1.6.0.rst index e8b637dfbd..089ee82396 100644 --- a/docs/source/changelog/1.6.0.rst +++ b/docs/source/changelog/1.6.0.rst @@ -5,6 +5,23 @@ Wave 2 of the roadmap, per the contributing docs' enhancement-proposal page: the protocol pairs that wanted a shared abstract base, and the two that simply had no dissector at all. +* **Added** -- :class:`~pcapkit.protocols.link.netlink.Netlink`, a dissector for + the fixed 16-octet ``nlmsghdr`` framing of Linux's own kernel/userspace IPC + transport (the kernel's own ``netlink(7)`` man page; :rfc:`3549#section-2.3.2`). + :class:`~pcapkit.protocols.link.linux_sll.LinuxSLL`/ + :class:`~pcapkit.protocols.link.linux_sll1.LinuxSLL1`/ + :class:`~pcapkit.protocols.link.linux_sll2.LinuxSLL2` dispatch to it for the + ``ARPHRD_NETLINK`` case instead of falling back to + :class:`~pcapkit.protocols.misc.raw.Raw`. The byte order the header was written + in is ambiguous -- Netlink is kernel/userspace IPC, not wire format -- and is + guessed the way Wireshark's own netlink dissector does, by + :func:`~pcapkit.protocols.link.netlink.nlmsg_byteorder`. Per-family message + payloads (routing table entries and the like) and multiple messages packed into + one capture are out of scope for this first cut; every message's payload is kept + as :class:`~pcapkit.protocols.misc.raw.Raw`, unconditionally. One of + :issue:`2460`'s five ``arphrd_type`` follow-ups to get a dissector (``FRAD`` is + tracked separately on :pr:`2499`); ``IPGRE``/``IP6GRE`` and + ``IEEE80211_RADIOTAP`` remain :class:`~pcapkit.protocols.misc.raw.Raw`. * **Added** -- :class:`~pcapkit.protocols.link.frad.FRAD`, a dissector for Frame Relay's multiprotocol encapsulation (:rfc:`2427`, obsoleting :rfc:`1490`). :class:`~pcapkit.protocols.link.linux_sll.LinuxSLL`/ diff --git a/docs/source/pcapkit/protocols/link/index.rst b/docs/source/pcapkit/protocols/link/index.rst index 93fa5d01c8..a955f7cd2c 100644 --- a/docs/source/pcapkit/protocols/link/index.rst +++ b/docs/source/pcapkit/protocols/link/index.rst @@ -31,6 +31,7 @@ link layer, with detailed implementation and methods. linux_sll1 linux_sll2 loopback + netlink vlan c_tag s_tag diff --git a/docs/source/pcapkit/protocols/link/netlink.rst b/docs/source/pcapkit/protocols/link/netlink.rst new file mode 100644 index 0000000000..0544718aa3 --- /dev/null +++ b/docs/source/pcapkit/protocols/link/netlink.rst @@ -0,0 +1,77 @@ +Netlink - Linux Netlink Socket Framing +======================================= + +.. module:: pcapkit.protocols.link.netlink + +:mod:`pcapkit.protocols.link.netlink` contains +:class:`~pcapkit.protocols.link.netlink.Netlink` +only, which implements the extractor for the ``struct nlmsghdr`` framing of +Linux's own kernel/user IPC transport, as seen on a +``LINKTYPE_LINUX_SLL``/``LINKTYPE_LINUX_SLL2`` capture whose ``arphrd_type`` +is ``ARPHRD_NETLINK`` (an ``nlmon`` interface). Its structure is described as +below: + +.. table:: + + ====== ==== ======================= ================================ + Octets Bits Name Description + ====== ==== ======================= ================================ + 0 0 ``netlink.nlmsg_len`` Message Length (header included) + 4 32 ``netlink.nlmsg_type`` Message Type + 6 48 ``netlink.nlmsg_flags`` Message Flags + 8 64 ``netlink.nlmsg_seq`` Sequence Number + 12 96 ``netlink.nlmsg_pid`` Sender Port ID + ====== ==== ======================= ================================ + +Only this fixed 16-octet header is dissected; per-family message payloads and +multiple messages packed into one capture are out of scope for this first +cut -- see the module docstring's *Scope* section. The byte order the header +was written in is ambiguous (Netlink is kernel/userspace IPC, not wire +format) and is guessed by :func:`nlmsg_byteorder`; either way it is kept as +``netlink.byteorder``. + +.. autoclass:: pcapkit.protocols.link.netlink.Netlink + :no-members: + :show-inheritance: + + .. autoproperty:: name + .. autoproperty:: alias + .. autoproperty:: length + .. autoproperty:: protocol + + .. automethod:: id + + .. automethod:: read + .. automethod:: make + + .. automethod:: __length_hint__ + .. automethod:: __index__ + + .. automethod:: _make_data + + .. autoattribute:: __proto__ + :no-value: + +.. autofunction:: pcapkit.protocols.link.netlink.nlmsg_byteorder + +Header Schemas +-------------- + +.. module:: pcapkit.protocols.schema.link.netlink + +.. autoclass:: pcapkit.protocols.schema.link.netlink.Netlink + :members: + :show-inheritance: + +Data Models +----------- + +.. module:: pcapkit.protocols.data.link.netlink + +.. autoclass:: pcapkit.protocols.data.link.netlink.Netlink + :members: + :show-inheritance: + +.. rubric:: Footnotes + +.. [*] :manpage:`netlink(7)`; :rfc:`3549#section-2.3.2` diff --git a/pcapkit/__init__.py b/pcapkit/__init__.py index 30b0a2036b..73ed9522cf 100644 --- a/pcapkit/__init__.py +++ b/pcapkit/__init__.py @@ -112,7 +112,7 @@ 'Raw', # Raw Packet 'ARP', 'C_Tag', 'Ethernet', 'FRAD', 'InARP', 'L2TP', # Link Layer - 'L2TPv2', 'LinuxSLL', 'LinuxSLL1', 'LinuxSLL2', 'Loopback', 'S_Tag', 'VLAN', + 'L2TPv2', 'LinuxSLL', 'LinuxSLL1', 'LinuxSLL2', 'Loopback', 'Netlink', '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 e56b24c991..1e15646e43 100644 --- a/pcapkit/all.py +++ b/pcapkit/all.py @@ -122,7 +122,7 @@ 'NoPayload', # No Payload 'Raw', # Raw Packet 'ARP', 'C_Tag', 'Ethernet', 'FRAD', 'InARP', 'L2TP', 'L2TPv2', 'LinuxSLL', 'LinuxSLL1', - 'LinuxSLL2', 'Loopback', 'S_Tag', 'VLAN', # Link Layer + 'LinuxSLL2', 'Loopback', 'Netlink', 'S_Tag', 'VLAN', # Link Layer 'AH', 'ESP', 'ICMP', 'ICMPv4', 'ICMPv6', 'IGMP', 'IGMPv1', 'IGMPv2', 'IGMPv3', 'IP', 'IPsec', 'IPv4', 'IPv6', 'IPX', # Internet Layer diff --git a/pcapkit/protocols/__init__.py b/pcapkit/protocols/__init__.py index 3121984b73..aed5183b95 100644 --- a/pcapkit/protocols/__init__.py +++ b/pcapkit/protocols/__init__.py @@ -51,7 +51,8 @@ # Link Layer 'ARP', 'C_Tag', 'Ethernet', 'FRAD', 'InARP', 'L2TP', 'L2TPv2', 'LinuxSLL', 'LinuxSLL1', - 'LinuxSLL2', 'Loopback', 'S_Tag', 'VLAN', + 'LinuxSLL2', 'Loopback', 'Netlink', '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 fe22251fbd..5e0e257819 100644 --- a/pcapkit/protocols/data/__init__.py +++ b/pcapkit/protocols/data/__init__.py @@ -74,6 +74,9 @@ # BSD Loopback Encapsulation 'Loopback', + # Netlink Socket Framing + 'Netlink', + # 802.1Q/802.1ad VLAN Tag Types 'VLAN', 'VLAN_TCI', diff --git a/pcapkit/protocols/data/link/__init__.py b/pcapkit/protocols/data/link/__init__.py index e846d8d6aa..dfa831ee91 100644 --- a/pcapkit/protocols/data/link/__init__.py +++ b/pcapkit/protocols/data/link/__init__.py @@ -23,6 +23,9 @@ # BSD Loopback Encapsulation from pcapkit.protocols.data.link.loopback import Loopback +# Netlink Socket Framing +from pcapkit.protocols.data.link.netlink import Netlink + # 802.1Q/802.1ad VLAN Tag Types from pcapkit.protocols.data.link.vlan import TCI as VLAN_TCI from pcapkit.protocols.data.link.vlan import VLAN @@ -46,6 +49,9 @@ # BSD Loopback Encapsulation 'Loopback', + # Netlink Socket Framing + 'Netlink', + # 802.1Q/802.1ad VLAN Tag Types 'VLAN', 'VLAN_TCI', ] diff --git a/pcapkit/protocols/data/link/netlink.py b/pcapkit/protocols/data/link/netlink.py new file mode 100644 index 0000000000..aa9d1db13c --- /dev/null +++ b/pcapkit/protocols/data/link/netlink.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +"""data models for Linux Netlink socket framing""" + +from typing import TYPE_CHECKING + +from pcapkit.corekit.infoclass import info_final +from pcapkit.protocols.data.protocol import Protocol + +if TYPE_CHECKING: + from typing_extensions import Literal + +__all__ = ['Netlink'] + + +@info_final +class Netlink(Protocol): + """Data model for Linux Netlink socket framing (``nlmsghdr``).""" + + #: Message length, in octets, including this 16-octet header -- as + #: written, in whichever byte order :attr:`byteorder` records. + nlmsg_len: 'int' + #: Message type; ``1``-``4`` are the generic control values + #: (``NLMSG_NOOP``/``NLMSG_ERROR``/``NLMSG_DONE``/``NLMSG_OVERRUN``) + #: shared by every Netlink family, ``16`` and above are family-specific + #: and not interpreted by this class. + nlmsg_type: 'int' + #: Message flags; their bit meanings depend on ``nlmsg_type`` and on + #: whether the message is a request, so they are not decoded further. + nlmsg_flags: 'int' + #: Sequence number. + nlmsg_seq: 'int' + #: Sender port ID; ``0`` when sent by the kernel. + nlmsg_pid: 'int' + #: Byte order the header was written in. + byteorder: "Literal['big', 'little']" + + if TYPE_CHECKING: + def __init__(self, nlmsg_len: 'int', nlmsg_type: 'int', nlmsg_flags: 'int', # pylint: disable=unused-argument,super-init-not-called,multiple-statements + nlmsg_seq: 'int', nlmsg_pid: 'int', # pylint: disable=unused-argument + byteorder: "Literal['big', 'little']") -> 'None': ... # pylint: disable=unused-argument diff --git a/pcapkit/protocols/link/__init__.py b/pcapkit/protocols/link/__init__.py index 9d1ef08ac2..13acae8865 100644 --- a/pcapkit/protocols/link/__init__.py +++ b/pcapkit/protocols/link/__init__.py @@ -19,6 +19,7 @@ from pcapkit.protocols.link.ethernet import Ethernet from pcapkit.protocols.link.frad import FRAD from pcapkit.protocols.link.loopback import Loopback +from pcapkit.protocols.link.netlink import Netlink # Linux "Cooked" Capture Encapsulation Family from pcapkit.protocols.link.linux_sll import LinuxSLL @@ -43,5 +44,6 @@ # Link Layer Protocols 'ARP', 'C_Tag', 'Ethernet', 'FRAD', 'InARP', 'L2TP', 'L2TPv2', 'LinuxSLL', 'LinuxSLL1', - 'LinuxSLL2', 'Loopback', 'S_Tag', 'VLAN', + 'LinuxSLL2', 'Loopback', 'Netlink', 'S_Tag', 'VLAN', + ] diff --git a/pcapkit/protocols/link/linux_sll.py b/pcapkit/protocols/link/linux_sll.py index 3b938e0cad..cf40d8b196 100644 --- a/pcapkit/protocols/link/linux_sll.py +++ b/pcapkit/protocols/link/linux_sll.py @@ -150,8 +150,11 @@ def _import_next_layer(self, proto: 'int', length: 'Optional[int]' = None, *, # `) Returns: - Instance of next layer: :class:`~pcapkit.protocols.link.frad.FRAD` - when ``arphrd_type`` is :attr:`ARPHRD.FRAD `; + Instance of next layer: :class:`~pcapkit.protocols.link.netlink.Netlink` + when ``arphrd_type`` is :attr:`ARPHRD.NETLINK + `; or + :class:`~pcapkit.protocols.link.frad.FRAD` when ``arphrd_type`` is + :attr:`ARPHRD.FRAD `; otherwise :class:`~pcapkit.protocols.misc.raw.Raw`, recording ``proto``, for every other ``arphrd_type`` in :data:`_ARPHRD_NOT_ETHERTYPE`. @@ -164,6 +167,12 @@ def _import_next_layer(self, proto: 'int', length: 'Optional[int]' = None, *, # if not min(length, len(file_)): return super()._import_next_layer(proto, length, packet=packet) # type: ignore[misc,call-arg] + if arphrd_type == ARPHRD.NETLINK: + from pcapkit.protocols.link.netlink import Netlink # pylint: disable=import-outside-toplevel # isort: skip + return self._parse_next_layer(Netlink, file_, length, alias=proto, packet=packet, + layer=self._exlayer, protocol=self._exproto, + __context__=self._exctx) + if arphrd_type == ARPHRD.FRAD: from pcapkit.protocols.link.frad import FRAD # pylint: disable=import-outside-toplevel return self._parse_next_layer(FRAD, file_, length, alias=proto, packet=packet, diff --git a/pcapkit/protocols/link/netlink.py b/pcapkit/protocols/link/netlink.py new file mode 100644 index 0000000000..3fbc7fef00 --- /dev/null +++ b/pcapkit/protocols/link/netlink.py @@ -0,0 +1,358 @@ +# -*- coding: utf-8 -*- +"""Netlink - Linux Netlink Socket Framing +============================================ + +.. module:: pcapkit.protocols.link.netlink + +:mod:`pcapkit.protocols.link.netlink` contains +:class:`~pcapkit.protocols.link.netlink.Netlink` only, which implements the +extractor for the ``struct nlmsghdr`` framing [*]_ of Linux's own kernel/user +IPC transport, as seen on a ``LINKTYPE_LINUX_SLL``/``LINKTYPE_LINUX_SLL2`` +capture whose ``arphrd_type`` is ``ARPHRD_NETLINK`` (an ``nlmon`` interface) +-- :class:`~pcapkit.protocols.link.linux_sll.LinuxSLL` dispatches here +instead of falling back to :class:`~pcapkit.protocols.misc.raw.Raw` for that +one case; see :data:`~pcapkit.protocols.link.linux_sll._ARPHRD_NOT_ETHERTYPE`. +Netlink is the kernel's own IPC mechanism (routing table dumps, interface +notifications and the like), not a "next protocol over this link" the way +every other ``arphrd_type`` exception is -- there is no next-layer dispatch +table here at all, every message's payload is kept as +:class:`~pcapkit.protocols.misc.raw.Raw`. + +.. table:: + + ====== ==== ======================= ================================ + Octets Bits Name Description + ====== ==== ======================= ================================ + 0 0 ``netlink.nlmsg_len`` Message Length (header included) + 4 32 ``netlink.nlmsg_type`` Message Type + 6 48 ``netlink.nlmsg_flags`` Message Flags + 8 64 ``netlink.nlmsg_seq`` Sequence Number + 12 96 ``netlink.nlmsg_pid`` Sender Port ID + ====== ==== ======================= ================================ + +Scope +----- + +Only the fixed 16-octet ``nlmsghdr`` framing is dissected -- ``nlmsg_len``, +``nlmsg_type``, ``nlmsg_flags``, ``nlmsg_seq`` and ``nlmsg_pid``. Two things a +complete Netlink dissector would eventually need are deliberately left out of +this first cut: + +* **Per-family message payloads.** What a given ``nlmsg_type`` means, and how + its payload is laid out, is defined independently by each Netlink family + (``NETLINK_ROUTE``, ``NETLINK_GENERIC``, ...) and is a large surface of its + own -- routing table entries, link attributes (``rtattr``/``nlattr`` TLV + chains), and so on. The payload is kept as + :class:`~pcapkit.protocols.misc.raw.Raw`, unconditionally. +* **Multiple messages packed into one read.** The kernel's own netlink(7) + page documents that a single read from a netlink socket, and so a single + captured frame, commonly carries more than one ``nlmsghdr`` message back to + back (:rfc:`3549#section-2.3.2` numbers the header fields; it does not + itself restate the "one read, several messages" framing convention, which + is :manpage:`netlink(7)`'s). This class dissects only the *first* message's + header; everything after it -- that message's own payload, and any sibling + messages the frame may carry -- is kept together as the undissected + :class:`~pcapkit.protocols.misc.raw.Raw` payload, exactly as if it were one + larger opaque blob. Splitting the frame on every ``nlmsg_len`` boundary is + future work, tracked on :issue:`2460`. + +Byte Order +---------- + +Netlink is a kernel/userspace IPC mechanism, not a protocol with octets +specified for the wire -- its messages are written in the *capturing host's* +native byte order, and neither :manpage:`netlink(7)`, :rfc:`3549` nor +tcpdump.org's `LINKTYPE_NETLINK`_ page record a marker for which order that +was. :func:`nlmsg_byteorder` resolves it with the same heuristic Wireshark's +own netlink dissector uses (``packet-netlink.c``, ``dissect_netlink()``, +comment: *"We do not know the endianness of the capture host, we have to +guess."*): decode ``nlmsg_len`` both ways and keep whichever reading comes +closer to the number of octets actually available, since a genuine message +length is normally well under the size either misreading would imply. See +:func:`~pcapkit.protocols.link.loopback.family_byteorder` for the same +problem solved the same way for ``LINKTYPE_NULL``. + +.. [*] :manpage:`netlink(7)`; :rfc:`3549#section-2.3.2` + +.. _LINKTYPE_NETLINK: https://www.tcpdump.org/linktypes/LINKTYPE_NETLINK.html + +.. versionadded:: 1.6.0 + +""" +import collections +import sys +from typing import TYPE_CHECKING + +from pcapkit.corekit.module import ModuleDescriptor +from pcapkit.protocols.data.link.netlink import Netlink as Data_Netlink +from pcapkit.protocols.link.link import Link +from pcapkit.protocols.schema.link.netlink import Netlink as Schema_Netlink +from pcapkit.utilities.exceptions import ProtocolError, UnsupportedCall + +if TYPE_CHECKING: + from typing import Any, DefaultDict, NoReturn, Optional, Type + + from typing_extensions import Literal + + from pcapkit.protocols.protocol import ProtocolBase + from pcapkit.protocols.schema.schema import Schema + +__all__ = ['Netlink', 'nlmsg_byteorder'] + + +def nlmsg_byteorder(nlmsg_len: 'bytes', length: 'int') -> "Literal['big', 'little']": + """Guess the byte order a Netlink message header was written in. + + Args: + nlmsg_len: The 4 raw octets of ``nlmsg_len``. + length: Octets available from the start of this header to the end of + the frame -- ``nlmsg_len`` counts the header itself + (:rfc:`3549#section-2.3.2`: *"the length of the message in + bytes, including the header"*), so this is the quantity to + compare against, not the payload alone. + + Returns: + ``'big'`` or ``'little'``, whichever reading of ``nlmsg_len`` comes + closer to ``length``; ties favour ``'little'``, the overwhelmingly + common case (x86/ARM hosts). + + Note: + This is the heuristic Wireshark's own netlink dissector uses + (``packet-netlink.c``, ``dissect_netlink()``): a genuine message + length is normally well under the size either misreading of 4 + arbitrary octets would imply, so the reading closer to the octets + actually on hand is overwhelmingly likely to be the real one. + + """ + len_be = int.from_bytes(nlmsg_len, 'big') + len_le = int.from_bytes(nlmsg_len, 'little') + if abs(len_be - length) < abs(len_le - length): + return 'big' + return 'little' + + +class Netlink(Link[Data_Netlink, Schema_Netlink], + schema=Schema_Netlink, data=Data_Netlink): + """This class implements Linux Netlink socket framing. + + Every message's payload is kept as + :class:`~pcapkit.protocols.misc.raw.Raw`, unconditionally -- see the + module docstring's *Scope* section for why. :attr:`self.__proto__ + ` is kept, as an empty + registry, only so :meth:`register` works the same way every other + :class:`~pcapkit.protocols.link.link.Link` subclass's does. + + """ + + ########################################################################## + # Defaults. + ########################################################################## + + #: Protocol index mapping for decoding next layer. Deliberately empty -- + #: see the class docstring -- so every key, including the ``nlmsg_type`` + #: this class dispatches with, resolves through the + #: :class:`~collections.defaultdict` fallback to + #: :class:`~pcapkit.protocols.misc.raw.Raw`. + __proto__ = collections.defaultdict( + lambda: ModuleDescriptor('pcapkit.protocols.misc.raw', 'Raw'), + ) # type: DefaultDict[int, ModuleDescriptor[ProtocolBase] | Type[ProtocolBase]] + + ########################################################################## + # Properties. + ########################################################################## + + @property + def name(self) -> 'Literal["Netlink"]': + """Name of current protocol.""" + return 'Netlink' + + @property + def alias(self) -> 'Literal["Netlink"]': + """Acronym of corresponding protocol.""" + return 'Netlink' + + @property + def length(self) -> 'Literal[16]': + """Header length of current protocol.""" + return 16 + + @property + def protocol(self) -> 'int': # type: ignore[override] + """Message type of the dissected ``nlmsghdr``.""" + return self._info.nlmsg_type + + ########################################################################## + # Methods. + ########################################################################## + + @classmethod + def id(cls) -> 'tuple[Literal["Netlink"]]': + """Index ID of the protocol.""" + return ('Netlink',) + + def read(self, length: 'Optional[int]' = None, *, + byteorder: "Optional[Literal['big', 'little']]" = None, + **kwargs: 'Any') -> 'Data_Netlink': # pylint: disable=unused-argument + """Read Linux Netlink socket framing. + + Structure of the ``nlmsghdr`` this class dissects, the first message + in the frame: + + .. code-block:: text + + 0 1 2 3 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | nlmsg_len | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | nlmsg_type | nlmsg_flags | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | nlmsg_seq | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | nlmsg_pid | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + + Args: + length: Length of packet data. + byteorder: Byte order to read the header in, ahead of guessing it + from :func:`nlmsg_byteorder`. :meth:`make` is handed the one + :attr:`~pcapkit.protocols.protocol.Protocol.info` holds by + :meth:`from_data `, + and it reaches here too, so a rebuild reads the header as it + was parsed rather than guessing again. + **kwargs: Arbitrary keyword arguments. + + Returns: + Parsed packet data. + + Raises: + ProtocolError: If ``byteorder`` is neither ``'big'`` nor + ``'little'``. + + """ + if length is None: + length = len(self) + schema = self.__header__ + + if byteorder is None: + _byte = nlmsg_byteorder(schema.nlmsg_len, length) + elif byteorder in ('big', 'little'): + _byte = byteorder + else: + raise ProtocolError(f'{self.alias}: invalid byte order: {byteorder!r}') + + netlink = Data_Netlink( + nlmsg_len=int.from_bytes(schema.nlmsg_len, _byte), + nlmsg_type=int.from_bytes(schema.nlmsg_type, _byte), + nlmsg_flags=int.from_bytes(schema.nlmsg_flags, _byte), + nlmsg_seq=int.from_bytes(schema.nlmsg_seq, _byte), + nlmsg_pid=int.from_bytes(schema.nlmsg_pid, _byte), + byteorder=_byte, + ) + return self._decode_next_layer(netlink, netlink.nlmsg_type, length - self.length) + + def make(self, + nlmsg_type: 'int' = 0, + nlmsg_flags: 'int' = 0, + nlmsg_seq: 'int' = 0, + nlmsg_pid: 'int' = 0, + nlmsg_len: 'Optional[int]' = None, + byteorder: "Literal['big', 'little']" = sys.byteorder, + payload: 'bytes | ProtocolBase | Schema' = b'', + **kwargs: 'Any') -> 'Schema_Netlink': + """Make (construct) packet data. + + Args: + nlmsg_type: Message type. + nlmsg_flags: Message flags. + nlmsg_seq: Sequence number. + nlmsg_pid: Sender port ID. + nlmsg_len: Message length, header included; computed from the + header and ``payload`` when omitted. + byteorder: Byte order to write the header in, by default this + host's, as a genuine capture is written in the capturing + host's own order. + payload: Payload data. + **kwargs: Arbitrary keyword arguments. + + Returns: + Constructed packet data. + + Raises: + ProtocolError: If ``byteorder`` is neither ``'big'`` nor + ``'little'``; if ``nlmsg_type`` or ``nlmsg_flags`` is not an + :class:`int` (a :class:`bool` included) or does not fit in 16 + bits; if ``nlmsg_seq`` or ``nlmsg_pid`` is not an :class:`int` + (a :class:`bool` included) or does not fit in 32 bits; or if + ``nlmsg_len`` is given and does not fit in 32 bits. + + """ + if byteorder not in ('big', 'little'): + raise ProtocolError(f'{self.alias}: invalid byte order: {byteorder!r}') + for name, value in (('nlmsg_type', nlmsg_type), ('nlmsg_flags', nlmsg_flags)): + if not isinstance(value, int) or isinstance(value, bool) or not 0 <= value <= 0xFFFF: + raise ProtocolError(f'{self.alias}: invalid {name}: {value!r}') + for name, value in (('nlmsg_seq', nlmsg_seq), ('nlmsg_pid', nlmsg_pid)): + if not isinstance(value, int) or isinstance(value, bool) or not 0 <= value <= 0xFFFF_FFFF: + raise ProtocolError(f'{self.alias}: invalid {name}: {value!r}') + + if nlmsg_len is None: + nlmsg_len = 16 + len(payload) + elif (not isinstance(nlmsg_len, int) or isinstance(nlmsg_len, bool) + or not 0 <= nlmsg_len <= 0xFFFF_FFFF): + raise ProtocolError(f'{self.alias}: invalid nlmsg_len: {nlmsg_len!r}') + + return Schema_Netlink( + nlmsg_len=nlmsg_len.to_bytes(4, byteorder), + nlmsg_type=nlmsg_type.to_bytes(2, byteorder), + nlmsg_flags=nlmsg_flags.to_bytes(2, byteorder), + nlmsg_seq=nlmsg_seq.to_bytes(4, byteorder), + nlmsg_pid=nlmsg_pid.to_bytes(4, byteorder), + payload=payload, + ) + + ########################################################################## + # Data models. + ########################################################################## + + def __length_hint__(self) -> 'Literal[16]': + """Return an estimated length for the object.""" + return 16 + + @classmethod + def __index__(cls) -> 'NoReturn': # pylint: disable=invalid-index-returned + """Numeral registry index of the protocol. + + Raises: + UnsupportedCall: This protocol is reached only through + :class:`~pcapkit.protocols.link.linux_sll.LinuxSLL`'s + ``arphrd_type`` dispatch, not through a link-layer header + type of its own. + + """ + raise UnsupportedCall(f'{cls.__name__!r} object cannot be interpreted as an integer') + + ########################################################################## + # Utilities. + ########################################################################## + + @classmethod + def _make_data(cls, data: 'Data_Netlink') -> 'dict[str, Any]': # type: ignore[override] + """Create key-value pairs from ``data`` for protocol construction. + + Args: + data: protocol data + + Returns: + Key-value pairs for protocol construction. + + """ + return { + 'nlmsg_type': data.nlmsg_type, + 'nlmsg_flags': data.nlmsg_flags, + 'nlmsg_seq': data.nlmsg_seq, + 'nlmsg_pid': data.nlmsg_pid, + 'nlmsg_len': data.nlmsg_len, + 'byteorder': data.byteorder, + 'payload': cls._make_payload(data), + } diff --git a/pcapkit/protocols/schema/__init__.py b/pcapkit/protocols/schema/__init__.py index a645628c6a..89da76182d 100644 --- a/pcapkit/protocols/schema/__init__.py +++ b/pcapkit/protocols/schema/__init__.py @@ -31,6 +31,7 @@ 'L2TP', 'LinuxSLL1', 'LinuxSLL2', 'Loopback', + 'Netlink', 'VLAN', 'VLAN_TCI', # Internet Layer Protocols diff --git a/pcapkit/protocols/schema/link/__init__.py b/pcapkit/protocols/schema/link/__init__.py index c48b6f6e87..63ebfce6b7 100644 --- a/pcapkit/protocols/schema/link/__init__.py +++ b/pcapkit/protocols/schema/link/__init__.py @@ -8,6 +8,7 @@ from pcapkit.protocols.schema.link.linux_sll1 import LinuxSLL1 from pcapkit.protocols.schema.link.linux_sll2 import LinuxSLL2 from pcapkit.protocols.schema.link.loopback import Loopback +from pcapkit.protocols.schema.link.netlink import Netlink from pcapkit.protocols.schema.link.vlan import TCI as VLAN_TCI from pcapkit.protocols.schema.link.vlan import VLAN @@ -18,5 +19,6 @@ 'L2TP', 'LinuxSLL1', 'LinuxSLL2', 'Loopback', + 'Netlink', 'VLAN', 'VLAN_TCI', ] diff --git a/pcapkit/protocols/schema/link/netlink.py b/pcapkit/protocols/schema/link/netlink.py new file mode 100644 index 0000000000..909beefdff --- /dev/null +++ b/pcapkit/protocols/schema/link/netlink.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# mypy: disable-error-code=assignment +"""header schema for Linux Netlink socket framing + +Every field of ``nlmsghdr`` is kept as raw octets rather than unpacked by a +fixed byte order -- the byte order itself is ambiguous (see +:func:`~pcapkit.protocols.link.netlink.nlmsg_byteorder`) and has to be +resolved before any of the fields can be read as integers. + +""" + +from typing import TYPE_CHECKING + +from pcapkit.corekit.fields.misc import PayloadField +from pcapkit.corekit.fields.strings import BytesField +from pcapkit.protocols.schema.schema import Schema, schema_final + +__all__ = ['Netlink'] + +if TYPE_CHECKING: + from pcapkit.protocols.protocol import ProtocolBase + + +@schema_final +class Netlink(Schema): + """Header schema for Linux Netlink socket framing (``nlmsghdr``).""" + + #: Message length, including this header; 4 octets, byte order ambiguous. + nlmsg_len: 'bytes' = BytesField(length=4) + #: Message type; 2 octets, byte order ambiguous. + nlmsg_type: 'bytes' = BytesField(length=2) + #: Message flags; 2 octets, byte order ambiguous. + nlmsg_flags: 'bytes' = BytesField(length=2) + #: Sequence number; 4 octets, byte order ambiguous. + nlmsg_seq: 'bytes' = BytesField(length=4) + #: Sender port ID; 4 octets, byte order ambiguous. + nlmsg_pid: 'bytes' = BytesField(length=4) + #: Payload. + payload: 'bytes' = PayloadField() + + if TYPE_CHECKING: + def __init__(self, nlmsg_len: 'bytes', nlmsg_type: 'bytes', nlmsg_flags: 'bytes', # pylint: disable=unused-argument,super-init-not-called,multiple-statements + nlmsg_seq: 'bytes', nlmsg_pid: 'bytes', # pylint: disable=unused-argument + payload: 'bytes | ProtocolBase | Schema') -> 'None': ... # pylint: disable=unused-argument diff --git a/tests/project/test_rfc_constants_one_home_1739_unit.py b/tests/project/test_rfc_constants_one_home_1739_unit.py index 905dc5697c..c9d81c1469 100644 --- a/tests/project/test_rfc_constants_one_home_1739_unit.py +++ b/tests/project/test_rfc_constants_one_home_1739_unit.py @@ -431,6 +431,10 @@ 'pcapkit/protocols/link/linux_sll1.py': ((65535,), "the max value of LINUX_SLL's own " '2-octet link_addr_length field, not an IP total ' 'length'), + 'pcapkit/protocols/link/netlink.py': ((16, 65535), "the nlmsghdr fixed header length (16 " + 'octets, not a PCAP record header) and the max value ' + "of its 16-bit nlmsg_type/nlmsg_flags fields (not " + 'an IPv4 total length)'), 'pcapkit/protocols/schema/link/ethernet.py': ((6,), 'a MAC address is 6 octets'), 'pcapkit/protocols/schema/link/l2tp.py': ((6, 12), 'an L2TP header field offset, and the ' 'version bit-field'), diff --git a/tests/protocols/link/test_linux_sll1_unit.py b/tests/protocols/link/test_linux_sll1_unit.py index 07148bca60..7b476b2d9d 100644 --- a/tests/protocols/link/test_linux_sll1_unit.py +++ b/tests/protocols/link/test_linux_sll1_unit.py @@ -73,17 +73,15 @@ def test_ethernet_arphrd_dispatches_by_ethertype(self) -> None: self._assert_rebuilds(parsed) def test_the_non_ethertype_arphrds_are_kept_raw(self) -> None: - """``NETLINK``, ``IPGRE``, ``IP6GRE`` and ``IEEE80211_RADIOTAP``, per - tcpdump.org's LINUX_SLL page, never have ``protocol_type`` looked up as an - EtherType, even though 0x0800 is IPv4's. ``FRAD`` is the exception: see - :meth:`test_frad_arphrd_dispatches_to_frad`. + """The generic ``arphrd_type``s that never have ``protocol_type`` looked + up as an EtherType are kept raw; ``NETLINK`` and ``FRAD`` are the + exceptional dispatch cases, covered by their dedicated tests. """ from pcapkit.const.linux.arphrd import ARPHRD from pcapkit.protocols.link.linux_sll1 import LinuxSLL1 from pcapkit.protocols.misc.raw import Raw - for arphrd in (ARPHRD.NETLINK, ARPHRD.IPGRE, ARPHRD.IP6GRE, - ARPHRD.IEEE80211_RADIOTAP): + for arphrd in (ARPHRD.IPGRE, ARPHRD.IP6GRE, ARPHRD.IEEE80211_RADIOTAP): with self.subTest(arphrd=arphrd): # protocol_type 0x0800 is IPv4's EtherType, but these # hardware types never carry an EtherType in protocol_type. @@ -98,6 +96,28 @@ def test_the_non_ethertype_arphrds_are_kept_raw(self) -> None: self.assertEqual(parsed.data, raw) self._assert_rebuilds(parsed) + def test_netlink_arphrd_dispatches_to_netlink(self) -> None: + """``ARPHRD_NETLINK`` dispatches to + :class:`~pcapkit.protocols.link.netlink.Netlink`, not + :class:`~pcapkit.protocols.misc.raw.Raw` (:issue:`2500`), even though + it too is in :data:`~pcapkit.protocols.link.linux_sll._ARPHRD_NOT_ETHERTYPE`. + """ + import struct + + from pcapkit.const.linux.arphrd import ARPHRD + from pcapkit.protocols.link.linux_sll1 import LinuxSLL1 + from pcapkit.protocols.link.netlink import Netlink + + nlmsg = struct.pack(' None: """GitHub issue #2460: ``ARPHRD_FRAD`` dispatches to :class:`~pcapkit.protocols.link.frad.FRAD` rather than falling back to @@ -221,7 +241,7 @@ def test_registered_override_is_reached_as_from_ethernet(self) -> None: from pcapkit.protocols.internet.ipv4 import IPv4 from pcapkit.protocols.link.link import Link from pcapkit.protocols.link.linux_sll1 import LinuxSLL1 - from pcapkit.protocols.misc.raw import Raw + from pcapkit.protocols.link.netlink import Netlink registry = Link.__dict__['__proto__'] had_original = 0x1234 in registry @@ -238,9 +258,11 @@ def test_registered_override_is_reached_as_from_ethernet(self) -> None: self.assertEqual(parsed.protochain.chain, 'LINUX_SLL1:IPv4:UDP') self._assert_rebuilds(parsed) + # A registered override on Link.__proto__ does not leak into + # NETLINK's dispatch -- it never looks EtherType up at all. raw = _sll(0x1234, ARPHRD.NETLINK) + IPV4 parsed = LinuxSLL1(raw, len(raw)) - self.assertIsInstance(parsed.payload, Raw) + self.assertIsInstance(parsed.payload, Netlink) finally: if had_original: registry[0x1234] = original @@ -339,7 +361,7 @@ def test_full_extraction_dissects_an_any_capture(self) -> None: raw = (_header(0, 1, bytes.fromhex('001122334455'), 6, 0x0800) + IPV4, # loopback (772): not Ethernet, not one of the exceptions. _header(2, 772, b'', 0, 0x0800) + IPV4, - # NETLINK (824): one of the exceptions, stays Raw. + # NETLINK (824): one of the exceptions, dispatches to Netlink. _header(2, ARPHRD.NETLINK, b'', 0, 0x0800) + IPV4) with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, 'any.pcap') @@ -350,7 +372,7 @@ def test_full_extraction_dissects_an_any_capture(self) -> None: extractor = pcapkit.extract(fin=path, nofile=True, store=True) self.assertEqual([frame.protochain.chain for frame in extractor.frame], - ['LINUX_SLL1:IPv4:UDP', 'LINUX_SLL1:IPv4:UDP', 'LINUX_SLL1:Raw']) + ['LINUX_SLL1:IPv4:UDP', 'LINUX_SLL1:IPv4:UDP', 'LINUX_SLL1:Netlink:Raw']) for frame in extractor.frame: self._assert_rebuilds(frame, num=frame._fnum, header=frame._ghdr) diff --git a/tests/protocols/link/test_linux_sll2_unit.py b/tests/protocols/link/test_linux_sll2_unit.py index 6161728e42..4cf6a747bd 100644 --- a/tests/protocols/link/test_linux_sll2_unit.py +++ b/tests/protocols/link/test_linux_sll2_unit.py @@ -75,16 +75,15 @@ def test_ethernet_arphrd_dispatches_by_ethertype(self) -> None: self._assert_rebuilds(parsed) def test_the_non_ethertype_arphrds_are_kept_raw(self) -> None: - """``NETLINK``, ``IPGRE``, ``IP6GRE`` and ``IEEE80211_RADIOTAP``, per - tcpdump.org's LINUX_SLL2 page, are kept raw. ``FRAD`` is the - exception: see :meth:`test_frad_arphrd_dispatches_to_frad`. + """The generic ``arphrd_type``s that never have ``protocol_type`` looked + up as an EtherType are kept raw; ``NETLINK`` and ``FRAD`` are the + exceptional dispatch cases, covered by their dedicated tests. """ from pcapkit.const.linux.arphrd import ARPHRD from pcapkit.protocols.link.linux_sll2 import LinuxSLL2 from pcapkit.protocols.misc.raw import Raw - for arphrd in (ARPHRD.NETLINK, ARPHRD.IPGRE, ARPHRD.IP6GRE, - ARPHRD.IEEE80211_RADIOTAP): + for arphrd in (ARPHRD.IPGRE, ARPHRD.IP6GRE, ARPHRD.IEEE80211_RADIOTAP): with self.subTest(arphrd=arphrd): raw = _header(0x0800, 0, 0, arphrd, 2, b'', 0) + IPV4 parsed = LinuxSLL2(raw, len(raw)) @@ -97,6 +96,28 @@ def test_the_non_ethertype_arphrds_are_kept_raw(self) -> None: self.assertEqual(parsed.data, raw) self._assert_rebuilds(parsed) + def test_netlink_arphrd_dispatches_to_netlink(self) -> None: + """``ARPHRD_NETLINK`` dispatches to + :class:`~pcapkit.protocols.link.netlink.Netlink`, not + :class:`~pcapkit.protocols.misc.raw.Raw` (:issue:`2500`), even though + it too is in :data:`~pcapkit.protocols.link.linux_sll._ARPHRD_NOT_ETHERTYPE`. + """ + import struct + + from pcapkit.const.linux.arphrd import ARPHRD + from pcapkit.protocols.link.linux_sll2 import LinuxSLL2 + from pcapkit.protocols.link.netlink import Netlink + + nlmsg = struct.pack(' None: """GitHub issue #2460: ``ARPHRD_FRAD`` dispatches to :class:`~pcapkit.protocols.link.frad.FRAD` rather than falling back to @@ -209,7 +230,7 @@ def test_registered_override_is_reached_as_from_ethernet(self) -> None: from pcapkit.protocols.internet.ipv4 import IPv4 from pcapkit.protocols.link.link import Link from pcapkit.protocols.link.linux_sll2 import LinuxSLL2 - from pcapkit.protocols.misc.raw import Raw + from pcapkit.protocols.link.netlink import Netlink registry = Link.__dict__['__proto__'] had_original = 0x1234 in registry @@ -226,9 +247,11 @@ def test_registered_override_is_reached_as_from_ethernet(self) -> None: self.assertEqual(parsed.protochain.chain, 'LINUX_SLL2:IPv4:UDP') self._assert_rebuilds(parsed) + # A registered override on Link.__proto__ does not leak into + # NETLINK's dispatch -- it never looks EtherType up at all. raw = _sll(0x1234, ARPHRD.NETLINK) + IPV4 parsed = LinuxSLL2(raw, len(raw)) - self.assertIsInstance(parsed.payload, Raw) + self.assertIsInstance(parsed.payload, Netlink) finally: if had_original: registry[0x1234] = original @@ -326,7 +349,7 @@ def test_full_extraction_dissects_an_any_capture(self) -> None: raw = (_header(0x0800, 0, 1, 1, 0, bytes.fromhex('001122334455'), 6) + IPV4, # loopback (772): not Ethernet, not one of the exceptions. _header(0x0800, 0, 1, 772, 2, b'', 0) + IPV4, - # NETLINK (824): one of the exceptions, stays Raw. + # NETLINK (824): one of the exceptions, dispatches to Netlink. _header(0x0800, 0, 1, ARPHRD.NETLINK, 2, b'', 0) + IPV4) with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, 'any2.pcap') @@ -337,7 +360,7 @@ def test_full_extraction_dissects_an_any_capture(self) -> None: extractor = pcapkit.extract(fin=path, nofile=True, store=True) self.assertEqual([frame.protochain.chain for frame in extractor.frame], - ['LINUX_SLL2:IPv4:UDP', 'LINUX_SLL2:IPv4:UDP', 'LINUX_SLL2:Raw']) + ['LINUX_SLL2:IPv4:UDP', 'LINUX_SLL2:IPv4:UDP', 'LINUX_SLL2:Netlink:Raw']) for frame in extractor.frame: self._assert_rebuilds(frame, num=frame._fnum, header=frame._ghdr) diff --git a/tests/protocols/link/test_netlink_unit.py b/tests/protocols/link/test_netlink_unit.py new file mode 100644 index 0000000000..848c2d23d6 --- /dev/null +++ b/tests/protocols/link/test_netlink_unit.py @@ -0,0 +1,229 @@ +# -*- coding: utf-8 -*- +"""GitHub issue #2500: Linux Netlink socket framing (``nlmsghdr``). + +:class:`~pcapkit.protocols.link.netlink.Netlink` dissects the ``arphrd_type`` +``ARPHRD_NETLINK`` payload that +:class:`~pcapkit.protocols.link.linux_sll.LinuxSLL` previously kept whole as +:class:`~pcapkit.protocols.misc.raw.Raw` (see +:data:`~pcapkit.protocols.link.linux_sll._ARPHRD_NOT_ETHERTYPE`). Covers the +16-octet ``nlmsghdr`` fields, the byte-order heuristic +:func:`~pcapkit.protocols.link.netlink.nlmsg_byteorder` resolves (see +:rfc:`3549#section-2.3.2`), and byte-exact round trips. The NETLINK-dispatch +side of ``LinuxSLL1``/``LinuxSLL2`` is covered in their own test modules. + +Each test imports :mod:`pcapkit` itself, after +:func:`~tests._support.reimport_once_per_class`. + +""" + +import struct +import unittest + +from tests._support import reimport_once_per_class + + +def _nlmsghdr(byteorder: str, nlmsg_type: int = 0, nlmsg_flags: int = 0, + nlmsg_seq: int = 0, nlmsg_pid: int = 0, payload: bytes = b'', + nlmsg_len: 'int | None' = None) -> bytes: + """Pack a bare ``nlmsghdr`` plus ``payload`` in the given byte order.""" + if nlmsg_len is None: + nlmsg_len = 16 + len(payload) + fmt = ('<' if byteorder == 'little' else '>') + 'IHHII' + return struct.pack(fmt, nlmsg_len, nlmsg_type, nlmsg_flags, nlmsg_seq, nlmsg_pid) + payload + + +class TestNetlink(unittest.TestCase): + """Pin the Netlink layer, alone.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def _assert_rebuilds(self, layer, **kwargs) -> None: # type: ignore[no-untyped-def] + """``layer`` rebuilds byte-exactly from its info, and from ``info.to_dict()``.""" + for data in (layer.info, layer.info.to_dict()): + with self.subTest(layer=type(layer).__name__, data=type(data).__name__): + self.assertEqual(type(layer).from_data(data, **kwargs).data.hex(), layer.data.hex()) + + def test_little_endian_header_decodes_every_field(self) -> None: + from pcapkit.protocols.link.netlink import Netlink + + raw = _nlmsghdr('little', nlmsg_type=3, nlmsg_flags=0x0101, nlmsg_seq=42, + nlmsg_pid=1234, payload=b'\xaa\xbb\xcc\xdd') + parsed = Netlink(raw, len(raw)) + self.assertEqual(parsed.info.nlmsg_len, 20) + self.assertEqual(parsed.info.nlmsg_type, 3) + self.assertEqual(parsed.info.nlmsg_flags, 0x0101) + self.assertEqual(parsed.info.nlmsg_seq, 42) + self.assertEqual(parsed.info.nlmsg_pid, 1234) + self.assertEqual(parsed.info.byteorder, 'little') + self.assertEqual(parsed.protocol, 3) + self.assertEqual((parsed.name, parsed.alias, parsed.length), ('Netlink', 'Netlink', 16)) + self.assertEqual(type(parsed.payload).__name__, 'Raw') + self.assertEqual(parsed.payload.data, b'\xaa\xbb\xcc\xdd') + self.assertEqual(parsed.protochain.chain, 'Netlink:Raw') + self.assertEqual(parsed.data, raw) + self._assert_rebuilds(parsed) + + def test_big_endian_header_is_detected_by_heuristic(self) -> None: + """A length field that only makes sense read big-endian is read + that way, per :func:`~pcapkit.protocols.link.netlink.nlmsg_byteorder`. + """ + from pcapkit.protocols.link.netlink import Netlink + + raw = _nlmsghdr('big', nlmsg_type=16, nlmsg_flags=5, nlmsg_seq=7, + nlmsg_pid=0, payload=b'\x01\x02\x03\x04\x05\x06') + parsed = Netlink(raw, len(raw)) + self.assertEqual(parsed.info.byteorder, 'big') + self.assertEqual(parsed.info.nlmsg_len, 22) + self.assertEqual(parsed.info.nlmsg_type, 16) + self.assertEqual(parsed.data, raw) + self._assert_rebuilds(parsed) + + def test_heuristic_matches_wireshark_on_both_byteorders_of_the_same_payload(self) -> None: + """The same field values, packed each way, are read back the same + way they were packed -- the heuristic is not biased toward one + reading regardless of the octets. + """ + from pcapkit.protocols.link.netlink import Netlink + + for byteorder in ('little', 'big'): + with self.subTest(byteorder=byteorder): + raw = _nlmsghdr(byteorder, nlmsg_type=1, nlmsg_flags=0, nlmsg_seq=99, + nlmsg_pid=500, payload=bytes(32)) + parsed = Netlink(raw, len(raw)) + self.assertEqual(parsed.info.byteorder, byteorder) + self.assertEqual(parsed.info.nlmsg_seq, 99) + self.assertEqual(parsed.info.nlmsg_pid, 500) + self._assert_rebuilds(parsed) + + def test_payload_is_always_raw_regardless_of_type(self) -> None: + """Per-family payload decoding is out of scope: every ``nlmsg_type``, + including the generic control values, resolves to + :class:`~pcapkit.protocols.misc.raw.Raw`. + """ + from pcapkit.protocols.link.netlink import Netlink + from pcapkit.protocols.misc.raw import Raw + + # NLMSG_NOOP, NLMSG_ERROR, NLMSG_DONE, NLMSG_OVERRUN, and a + # family-specific value (RTM_NEWLINK = 16, NETLINK_ROUTE's own). + for nlmsg_type in (1, 2, 3, 4, 16): + with self.subTest(nlmsg_type=nlmsg_type): + raw = _nlmsghdr('little', nlmsg_type=nlmsg_type, payload=b'\x00\x00\x00\x00') + parsed = Netlink(raw, len(raw)) + self.assertEqual(parsed.info.nlmsg_type, nlmsg_type) + self.assertIsInstance(parsed.payload, Raw) + self.assertEqual(parsed.protochain.chain, 'Netlink:Raw') + self._assert_rebuilds(parsed) + + def test_zero_length_payload_is_no_payload(self) -> None: + from pcapkit.protocols.link.netlink import Netlink + from pcapkit.protocols.misc.null import NoPayload + + raw = _nlmsghdr('little', nlmsg_type=3) + parsed = Netlink(raw, len(raw)) + self.assertEqual(parsed.info.nlmsg_len, 16) + self.assertIsInstance(parsed.payload, NoPayload) + self.assertEqual(parsed.data, raw) + self._assert_rebuilds(parsed) + + def test_make_then_parse(self) -> None: + from pcapkit.protocols.link.netlink import Netlink + + made = Netlink(nlmsg_type=3, nlmsg_flags=0x0101, nlmsg_seq=1, nlmsg_pid=0, + byteorder='little', payload=b'\xaa\xbb\xcc\xdd').data + self.assertEqual(made, _nlmsghdr('little', nlmsg_type=3, nlmsg_flags=0x0101, + nlmsg_seq=1, nlmsg_pid=0, payload=b'\xaa\xbb\xcc\xdd')) + + parsed = Netlink(made, len(made)) + self.assertEqual(parsed.info.nlmsg_type, 3) + self.assertEqual(type(parsed.payload).__name__, 'Raw') + self._assert_rebuilds(parsed) + + def test_make_computes_nlmsg_len_from_payload(self) -> None: + from pcapkit.protocols.link.netlink import Netlink + + made = Netlink(payload=bytes(10)) + self.assertEqual(made.info.nlmsg_len, 26) + + def test_make_defaults(self) -> None: + import sys + + from pcapkit.protocols.link.netlink import Netlink + + made = Netlink() + self.assertEqual(made.data, struct.pack( + ('<' if sys.byteorder == 'little' else '>') + 'IHHII', 16, 0, 0, 0, 0)) + + def test_make_explicit_byteorder_round_trips(self) -> None: + from pcapkit.protocols.link.netlink import Netlink + + for byteorder in ('little', 'big'): + with self.subTest(byteorder=byteorder): + made = Netlink(nlmsg_type=5, byteorder=byteorder, payload=b'\x01\x02') + self.assertEqual(made.data, _nlmsghdr(byteorder, nlmsg_type=5, payload=b'\x01\x02')) + parsed = Netlink(made.data, len(made.data)) + self.assertEqual(parsed.info.byteorder, byteorder) + self.assertEqual(parsed.info.nlmsg_type, 5) + + def test_make_rejects_invalid_byteorder(self) -> None: + from pcapkit.protocols.link.netlink import Netlink + from pcapkit.utilities.exceptions import ProtocolError + + with self.assertRaises(ProtocolError): + Netlink(byteorder='middle') # type: ignore[arg-type] + + def test_make_rejects_what_does_not_fit(self) -> None: + from pcapkit.protocols.link.netlink import Netlink + from pcapkit.utilities.exceptions import ProtocolError + + for kwargs in ( + {'nlmsg_type': -1}, {'nlmsg_type': 0x1_0000}, {'nlmsg_type': True}, + {'nlmsg_flags': -1}, {'nlmsg_flags': 0x1_0000}, + {'nlmsg_seq': -1}, {'nlmsg_seq': 0x1_0000_0000}, + {'nlmsg_pid': -1}, {'nlmsg_pid': 0x1_0000_0000}, + {'nlmsg_len': -1}, {'nlmsg_len': 0x1_0000_0000}, {'nlmsg_len': True}, + ): + with self.subTest(**kwargs), self.assertRaises(ProtocolError): + Netlink(**kwargs) + + def test_make_explicit_nlmsg_len_is_written_as_given(self) -> None: + """An explicit ``nlmsg_len`` is written as given, not recomputed -- + the same reason :meth:`UDP.make ` + keeps an explicit ``total_length`` (:issue:`1155`). + """ + from pcapkit.protocols.link.netlink import Netlink + + made = Netlink(nlmsg_len=999, payload=b'\x01\x02') + self.assertEqual(made.info.nlmsg_len, 999) + + def test_id_and_index(self) -> None: + from pcapkit.protocols.link.netlink import Netlink + from pcapkit.utilities.exceptions import UnsupportedCall + + self.assertEqual(Netlink.id(), ('Netlink',)) + with self.assertRaises(UnsupportedCall): + Netlink.__index__() + + def test_truncated_header_rebuilds_as_captured(self) -> None: + """A capture ending inside the header must not blow up the parse, and + rebuilds to exactly the octets captured. See + ``tests/protocols/test_short_read_roundtrip_unit.py`` for the sweep + that cuts this (and every other) family at every offset. + """ + import warnings + + from pcapkit.protocols.link.netlink import Netlink + + raw = _nlmsghdr('little', nlmsg_type=3, payload=b'\xaa\xbb\xcc\xdd') + for keep in (1, 2, 3, 4, 8, 15): + with self.subTest(keep=keep): + cut = raw[:keep] + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + parsed = Netlink(cut, len(cut)) + self.assertEqual(parsed.data, cut) + self.assertEqual(Netlink.from_data(parsed.info).data, cut) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/protocols/test_short_read_roundtrip_unit.py b/tests/protocols/test_short_read_roundtrip_unit.py index 22592a5cd4..6bf84a42a6 100644 --- a/tests/protocols/test_short_read_roundtrip_unit.py +++ b/tests/protocols/test_short_read_roundtrip_unit.py @@ -52,6 +52,7 @@ def _ipv6(code: int, body: bytes) -> bytes: ('l2tpv2', 'link.l2tpv2:L2TPv2', {}, bytes.fromhex('fff2000e00000000000000000000')), ('loopback-null', 'link.loopback:Loopback', {}, bytes.fromhex('020000004500')), ('loopback-loop', 'link.loopback:Loopback', {'alias': 108}, bytes.fromhex('000000024500')), + ('netlink', 'link.netlink:Netlink', {}, struct.pack('