From e05e7a447ef54e9511e87876a5bdda13012ce9a1 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Sun, 1 Nov 2026 23:36:44 -0500 Subject: [PATCH] refactor(analysis): decode DTLS handshakes in DTLS.analyze_handshake (#2622) - DTLS.analyze_handshake(header, body), after read_handshake, takes over the analysis module's private decoder: rebuild the message unfragmented, read it, keep a body that does not decode raw. That refusal is quiet (CaughtByCaller), where it logged at CRITICAL and installed the hooks. - dtls_handshake calls it, and the reassembly Deferred binds __protocol_type__'s, as IP and TCP do; one without it fails on completion. - reassembly-integration.rst records the ruling; protocol-family-grouping.rst counts the explicit CaughtByCaller under #2588's rule. - Tests pin the delegation, a subclass override, pickling, the quiet fallback, and that protocols import only registry.protocols from foundation. Closes #2622. --- .../conventions/protocol-family-grouping.rst | 7 + .../conventions/reassembly-integration.rst | 52 +++- .../pcapkit/foundation/analysis/dtls.rst | 4 +- .../pcapkit/foundation/analysis/index.rst | 3 +- .../pcapkit/protocols/application/dtls.rst | 4 +- pcapkit/foundation/analysis/dtls.py | 58 +---- pcapkit/foundation/reassembly/data/data.py | 6 +- pcapkit/foundation/reassembly/data/dtls.py | 10 +- pcapkit/foundation/reassembly/dtls.py | 25 +- pcapkit/protocols/application/dtls.py | 52 +++- ...test_dtls_analysis_delegation_2622_unit.py | 226 ++++++++++++++++++ ...est_protocols_import_boundary_2622_unit.py | 119 +++++++++ 12 files changed, 487 insertions(+), 79 deletions(-) create mode 100644 tests/foundation/reassembly/test_dtls_analysis_delegation_2622_unit.py create mode 100644 tests/project/test_protocols_import_boundary_2622_unit.py diff --git a/docs/source/contributing/conventions/protocol-family-grouping.rst b/docs/source/contributing/conventions/protocol-family-grouping.rst index c51c5d0a00..d10cc7393a 100644 --- a/docs/source/contributing/conventions/protocol-family-grouping.rst +++ b/docs/source/contributing/conventions/protocol-family-grouping.rst @@ -137,6 +137,13 @@ flow :class:`~pcapkit.utilities.exceptions.BaseError`'s quiet mode exists for, a installing a hook from an error the library caught itself is a process-wide side effect. +A parse whose refusal a fallback absorbs without +:func:`~pcapkit.corekit.decorators.beholder` follows the same rule through an explicit +``CaughtByCaller(ProtocolError)`` around it, as +:meth:`DTLS.analyze_handshake ` +keeps a body that does not decode raw -- an application of :issue:`2588`'s rule rather +than a ruling of its own. + **The block spans the whole call to the next layer**, not the refusal alone. Every :exc:`~pcapkit.utilities.exceptions.ProtocolError` built while that call is on the stack is quiet, at any depth, unless an inner diff --git a/docs/source/contributing/conventions/reassembly-integration.rst b/docs/source/contributing/conventions/reassembly-integration.rst index 44c19d6344..5d0e40332d 100644 --- a/docs/source/contributing/conventions/reassembly-integration.rst +++ b/docs/source/contributing/conventions/reassembly-integration.rst @@ -38,6 +38,30 @@ zero-fills to merge a segment. It bounds the memory a capture can have zero-fill :data:`~pcapkit.utilities.config.MAX_ZERO_PAD_LENGTH` does for a field, and ``-1`` lifts it to give the uncapped reassembly point 4 asks for. Ruled on :issue:`2546`. +Parse Ownership +~~~~~~~~~~~~~~~ + +**The protocol class owns the parse of a reassembled outcome**, and +:mod:`pcapkit.foundation.analysis` is a public layer over it. Ruled on :issue:`2622`: + +* The parse is a method of the protocol class: + :meth:`Protocol.analyze ` for IPv4 and + IPv6, :meth:`Transport.analyze ` + for TCP, and + :meth:`DTLS.analyze_handshake ` + for a DTLS handshake message. +* Each :mod:`pcapkit.foundation.analysis` function is a public wrapper that calls that + method and holds no parsing of its own. +* The reassembly :class:`~pcapkit.foundation.reassembly.data.data.Deferred` calls the + same method, on the reassembly's own ``__protocol_type__``. +* Protocol code never imports :mod:`pcapkit.foundation` to do its own work: the + dependency runs from ``foundation`` to the protocols. What the protocol base does + import from :mod:`pcapkit.foundation.registry.protocols` registers a class rather + than parsing. :file:`tests/project/test_protocols_import_boundary_2622_unit.py` fails + on any other import of :mod:`pcapkit.foundation` under :file:`pcapkit/protocols/` + made outside a ``TYPE_CHECKING`` block, the rest of + :mod:`pcapkit.foundation.registry` included. + Compliance, Measured ~~~~~~~~~~~~~~~~~~~~~ @@ -52,7 +76,8 @@ function on an already-parsed frame, feeding the result to the reassembly instan :class:`~pcapkit.foundation.extraction.Extractor` holds; for DTLS that function is :func:`pcapkit.toolkit.pcap.dtls_reassembly` (:issue:`2496`). :meth:`DTLS.read_handshake ` -is the parser method the rule allows. +and :meth:`DTLS.analyze_handshake ` +are the parser methods the rule allows. **No engine meets point 2.** No single :mod:`pcapkit.toolkit` helper enumerates which output engines support a given reassembly engine. Support is established ad hoc, per @@ -69,21 +94,22 @@ datagram's payload by its payload protocol type, through datagram's by its ports, through :meth:`Transport.analyze `; and :func:`~pcapkit.foundation.analysis.dtls.dtls_handshake` decodes a reassembled -DTLS handshake message (:issue:`2496`). The owner chose on :issue:`2581` to give IP -and TCP modules of their own, each function named for the datagram its engine -reassembles, rather than leave their outcomes to those dispatchers with no analysis -method, which kept the rule true for DTLS alone. +DTLS handshake message (:issue:`2496`), through +:meth:`DTLS.analyze_handshake ` +(:issue:`2622`). The owner chose on :issue:`2581` to give IP and TCP modules of their +own, each function named for the datagram its engine reassembles, rather than leave +their outcomes to those dispatchers with no analysis method, which kept the rule true +for DTLS alone. **Where the rule is silent, DTLS takes the IP/TCP shape** (:issue:`2531`). IPv4, IPv6, TCP and DTLS reassembly each set ``__protocol_type__``, and each reports its parsed outcome as a datagram's ``packet``, deferred to first read by :class:`~pcapkit.foundation.reassembly.data.data.Deferred`. Each ``Deferred`` runs the parse behind its engine's analysis function, so a ``packet`` is what that function -returns. IP and TCP bind that parse to ``__protocol_type__`` and call its ``analyze``, -so a subclass naming another protocol has its ``packet`` parsed by that one -(:issue:`2581`); DTLS decodes with -:meth:`DTLS.read_handshake ` -whatever ``__protocol_type__`` names. The engines differ in what an incomplete outcome -holds. IP and TCP parse a contiguous payload whether or not it is complete, as -``strict=False`` reports one, and hold :data:`None` only for the received runs -``strict=True`` reports; DTLS holds :data:`None` for any incomplete message. +returns. Each binds that parse to ``__protocol_type__``, calling its ``analyze`` for +IP and TCP (:issue:`2581`) and its ``analyze_handshake`` for DTLS (:issue:`2622`), so a +subclass naming another protocol has its ``packet`` parsed by that one. The engines +differ in what an incomplete outcome holds. IP and TCP parse a contiguous payload +whether or not it is complete, as ``strict=False`` reports one, and hold :data:`None` +only for the received runs ``strict=True`` reports; DTLS holds :data:`None` for any +incomplete message. diff --git a/docs/source/pcapkit/foundation/analysis/dtls.rst b/docs/source/pcapkit/foundation/analysis/dtls.rst index 75ce3cdecd..b8c6d6e7e5 100644 --- a/docs/source/pcapkit/foundation/analysis/dtls.rst +++ b/docs/source/pcapkit/foundation/analysis/dtls.rst @@ -7,6 +7,8 @@ DTLS Handshake-Message Analysis :mod:`pcapkit.foundation.analysis.dtls` contains :func:`~pcapkit.foundation.analysis.dtls.dtls_handshake` only, which decodes a DTLS handshake message that :class:`~pcapkit.foundation.reassembly.dtls.DTLS` -reassembled (:rfc:`6347#section-4.2.3`). +reassembled (:rfc:`6347#section-4.2.3`). The decode itself is +:meth:`DTLS.analyze_handshake +` (:issue:`2622`). .. autofunction:: pcapkit.foundation.analysis.dtls.dtls_handshake diff --git a/docs/source/pcapkit/foundation/analysis/index.rst b/docs/source/pcapkit/foundation/analysis/index.rst index e14cd4d60e..03da0bdf9c 100644 --- a/docs/source/pcapkit/foundation/analysis/index.rst +++ b/docs/source/pcapkit/foundation/analysis/index.rst @@ -7,7 +7,8 @@ Reassembled Payload Analysis :mod:`pcapkit.foundation.analysis` parses what a :mod:`pcapkit.foundation.reassembly` engine reassembled into a usable result, so that a caller does not interpret the raw reassembled octets itself -(:ref:`reassembly-integration`). +(:ref:`reassembly-integration`). Each function is a public wrapper: the parse +itself is a method of the protocol class, which the function calls. .. versionadded:: 1.6.0 diff --git a/docs/source/pcapkit/protocols/application/dtls.rst b/docs/source/pcapkit/protocols/application/dtls.rst index 8f718586c7..a0961f3358 100644 --- a/docs/source/pcapkit/protocols/application/dtls.rst +++ b/docs/source/pcapkit/protocols/application/dtls.rst @@ -128,7 +128,8 @@ Known Limitations and its :attr:`~pcapkit.protocols.data.application.dtls.DTLSHandshake.body` kept as raw octets. ``Extractor(reassembly=True, dtls=True)`` reassembles the message, and :func:`~pcapkit.foundation.analysis.dtls.dtls_handshake` decodes it -with :meth:`~pcapkit.protocols.application.dtls.DTLS.read_handshake`. +with :meth:`~pcapkit.protocols.application.dtls.DTLS.analyze_handshake`, which +reads it with :meth:`~pcapkit.protocols.application.dtls.DTLS.read_handshake`. **A unified-header record with a connection ID is not decoded** (:rfc:`9147#section-4.1`, :issue:`2467`). Its length is negotiated, not carried @@ -161,6 +162,7 @@ ones on the wire. .. automethod:: make .. automethod:: unpack .. automethod:: read_handshake + .. automethod:: analyze_handshake .. automethod:: __length_hint__ diff --git a/pcapkit/foundation/analysis/dtls.py b/pcapkit/foundation/analysis/dtls.py index c1fc367f5f..ccbc7f1c11 100644 --- a/pcapkit/foundation/analysis/dtls.py +++ b/pcapkit/foundation/analysis/dtls.py @@ -7,7 +7,9 @@ :mod:`pcapkit.foundation.analysis.dtls` contains :func:`~pcapkit.foundation.analysis.dtls.dtls_handshake` only, which decodes a DTLS handshake message that :class:`~pcapkit.foundation.reassembly.dtls.DTLS` -reassembled (:rfc:`6347#section-4.2.3`). +reassembled (:rfc:`6347#section-4.2.3`). The decode itself is +:meth:`DTLS.analyze_handshake +` (:issue:`2622`). .. versionadded:: 1.6.0 @@ -15,14 +17,12 @@ from typing import TYPE_CHECKING, cast from pcapkit.protocols.application.dtls import DTLS -from pcapkit.protocols.data.application.dtls import DTLSHandshake as Data_DTLSHandshake -from pcapkit.utilities.exceptions import ProtocolError if TYPE_CHECKING: from typing import Optional - from pcapkit.const.tls.handshake_type import HandshakeType as Enum_HandshakeType from pcapkit.foundation.reassembly.data.dtls import Datagram as DTLS_Datagram + from pcapkit.protocols.data.application.dtls import DTLSHandshake as Data_DTLSHandshake __all__ = ['dtls_handshake'] @@ -31,10 +31,10 @@ def dtls_handshake(datagram: 'DTLS_Datagram') -> 'Optional[Data_DTLSHandshake]': """Parse a DTLS handshake message reassembled by :class:`~pcapkit.foundation.reassembly.dtls.DTLS`. - The message is read as if it had arrived unfragmented, ``fragment_offset`` - 0 and ``fragment_length`` its ``length`` (:rfc:`6347#section-4.2.3`), by - :meth:`DTLS.read_handshake - `. So a + The message is decoded by :meth:`DTLS.analyze_handshake + `, which reads + it as if it had arrived unfragmented, ``fragment_offset`` 0 and + ``fragment_length`` its ``length`` (:rfc:`6347#section-4.2.3`). So a ``ClientHello``, ``ServerHello`` or ``EncryptedExtensions`` body decodes as an unfragmented one does, whatever its length, and any other body stays raw octets. @@ -49,45 +49,13 @@ def dtls_handshake(datagram: 'DTLS_Datagram') -> 'Optional[Data_DTLSHandshake]': Note: :attr:`Datagram.packet ` - holds the same result, decoded on its first read. + holds the same result, decoded on its first read, unless the + reassembly's ``__protocol_type__`` names another protocol, whose + ``analyze_handshake`` then decodes it instead. """ if not datagram.completed: return None # NOTE: a complete message is always one contiguous run. - return _read_handshake((datagram.msg_type, datagram.length, datagram.message_seq), - cast('bytes', datagram.body)) - - -def _read_handshake(header: 'tuple[Enum_HandshakeType, int, int]', body: 'bytes') -> 'Data_DTLSHandshake': - """Decode a complete handshake message from its header and body. - - :func:`dtls_handshake` runs this, and so does the - :class:`~pcapkit.foundation.reassembly.data.data.Deferred` that - :class:`~pcapkit.foundation.reassembly.dtls.DTLS` stores as a complete - message's ``packet``. - - Args: - header: The message's ``(msg_type, length, message_seq)``. - body: The reassembled body. - - Returns: - The message, unfragmented, its body raw if it does not decode. - - """ - msg_type, length, message_seq = header - message = (bytes([int(msg_type)]) + length.to_bytes(3, 'big') - + message_seq.to_bytes(2, 'big') + bytes(3) # fragment_offset 0 - + length.to_bytes(3, 'big') + body) - try: - return DTLS.read_handshake(message) - except ProtocolError: - return Data_DTLSHandshake( - msg_type=msg_type, - length=length, - body=body, - message_seq=message_seq, - fragment_offset=0, - fragment_length=length, - is_fragmented=False, - ) + return DTLS.analyze_handshake((datagram.msg_type, datagram.length, datagram.message_seq), + cast('bytes', datagram.body)) diff --git a/pcapkit/foundation/reassembly/data/data.py b/pcapkit/foundation/reassembly/data/data.py index 45b8fd8d25..3fc2eaf840 100644 --- a/pcapkit/foundation/reassembly/data/data.py +++ b/pcapkit/foundation/reassembly/data/data.py @@ -177,7 +177,9 @@ class Deferred(Generic[_KT]): ``Deferred[tuple[HandshakeType, int, int]]`` for a complete message. It passes the message's ``(msg_type, length, message_seq)``, its handshake header as if unfragmented (:rfc:`6347#section-4.2.3`), to the decoder - :func:`~pcapkit.foundation.analysis.dtls.dtls_handshake` runs, which reads + :func:`~pcapkit.foundation.analysis.dtls.dtls_handshake` runs, + :meth:`DTLS.analyze_handshake + `, which reads the body by its :class:`~pcapkit.const.tls.handshake_type.HandshakeType`. Those are the cases the type parameter is constrained to. IP's and TCP's are @@ -215,7 +217,7 @@ class Deferred(Generic[_KT]): analyze: The analyser to call: the parse :mod:`pcapkit.foundation.analysis` runs for the engine, bound to the reassembly object's protocol for IP and TCP, whose ``analyze`` - it calls, and the handshake decoder for DTLS. + it calls, and that protocol's ``analyze_handshake`` for DTLS. proto: What the analyser dispatches the payload on -- the payload protocol type for IP, the port pair for TCP, the handshake header for DTLS, as above. diff --git a/pcapkit/foundation/reassembly/data/dtls.py b/pcapkit/foundation/reassembly/data/dtls.py index 75a8728938..8f75f6ff80 100644 --- a/pcapkit/foundation/reassembly/data/dtls.py +++ b/pcapkit/foundation/reassembly/data/dtls.py @@ -111,10 +111,12 @@ class Datagram(DeferredPacket, Info, Generic[_AT]): #: Reassembled message body, undecoded. body: 'bytes | tuple[bytes, ...]' #: Decoded message, as :func:`~pcapkit.foundation.analysis.dtls.dtls_handshake` - #: returns it: decoded on first read, not at construction time, and - #: :data:`None` for an incomplete message. A - #: :class:`~pcapkit.foundation.reassembly.data.data.Deferred` may be passed in - #: its place, and reading this attribute then runs it and keeps the result. + #: returns it unless the reassembly's ``__protocol_type__`` names another + #: protocol, whose ``analyze_handshake`` then decodes it. Decoded on first + #: read, not at construction time, and :data:`None` for an incomplete + #: message. A :class:`~pcapkit.foundation.reassembly.data.data.Deferred` may + #: be passed in its place, and reading this attribute then runs it and keeps + #: the result. packet: 'Optional[Data_DTLSHandshake]' if TYPE_CHECKING: diff --git a/pcapkit/foundation/reassembly/dtls.py b/pcapkit/foundation/reassembly/dtls.py index 493f3731f0..3a59bf2935 100644 --- a/pcapkit/foundation/reassembly/dtls.py +++ b/pcapkit/foundation/reassembly/dtls.py @@ -16,7 +16,6 @@ """ from typing import TYPE_CHECKING, Generic -from pcapkit.foundation.analysis.dtls import _read_handshake from pcapkit.foundation.reassembly.data.data import Completion, Deferred from pcapkit.foundation.reassembly.data.dtls import (_AT, Buffer, BufferID, Datagram, DatagramID, Fragment, HoleDescriptor, Packet) @@ -51,9 +50,11 @@ class DTLS(ReassemblyBase[Packet[_AT], Datagram[_AT], BufferID, Buffer], Generic :class:`~pcapkit.foundation.extraction.Extractor` feeds it through :func:`pcapkit.toolkit.pcap.dtls_reassembly`, which reads the 4-tuple off the frame, and :func:`~pcapkit.foundation.analysis.dtls.dtls_handshake` - decodes what it reassembles; a complete message's + decodes what it reassembles. A complete message's :attr:`~pcapkit.foundation.reassembly.data.dtls.Datagram.packet` holds - that decode, run on first read. + the same decode, run on first read. Both call the protocol's + :meth:`~pcapkit.protocols.application.dtls.DTLS.analyze_handshake`; the + ``packet`` calls that of :attr:`__protocol_type__` (:issue:`2622`). Example: >>> from pcapkit.foundation.reassembly.dtls import DTLS @@ -74,7 +75,12 @@ class DTLS(ReassemblyBase[Packet[_AT], Datagram[_AT], BufferID, Buffer], Generic #: Protocol name of current reassembly object. __protocol_name__ = 'DTLS' - #: Protocol of current reassembly object. + #: Protocol of current reassembly object. An extension point: a protocol + #: named here need only define ``analyze_handshake``, with the signature of + #: :meth:`DTLS.analyze_handshake + #: `, and is read + #: for it when a message completes, so one without it raises + #: :exc:`AttributeError` there. __protocol_type__ = DTLS_Protocol # No ``__timeout__``: RFC 6347 section 4.2.3 and RFC 9147 section 5.5 give @@ -243,16 +249,19 @@ def _datagram_from_fragment(self, frag: 'Fragment', bufid: 'BufferID', message_s Returns: The reassembled (or partially reassembled) message. Its ``packet`` - is a deferred :func:`~pcapkit.foundation.analysis.dtls.dtls_handshake` - decode if the message is complete, and :data:`None` otherwise, as - that function returns. + is the decode :func:`~pcapkit.foundation.analysis.dtls.dtls_handshake` + runs, by this reassembly's own + :attr:`protocol `, + deferred to its first read, if the message is complete, and + :data:`None` otherwise, as that function returns. """ packet = None # type: Optional[Deferred[tuple[Enum_HandshakeType, int, int]]] if completion: body = bytes(frag.raw) payload = body # type: 'bytes | tuple[bytes, ...]' - packet = Deferred(_read_handshake, (frag.msg_type, frag.length, message_seq), body) + packet = Deferred(self.protocol.analyze_handshake, + (frag.msg_type, frag.length, message_seq), body) elif self._flag_s: payload = self._received_runs(frag) else: diff --git a/pcapkit/protocols/application/dtls.py b/pcapkit/protocols/application/dtls.py index 36ecade375..bdb16bfd2b 100644 --- a/pcapkit/protocols/application/dtls.py +++ b/pcapkit/protocols/application/dtls.py @@ -84,8 +84,8 @@ ``Extractor(reassembly=True, dtls=True)`` reassembles the message (:class:`~pcapkit.foundation.reassembly.dtls.DTLS`), and :func:`~pcapkit.foundation.analysis.dtls.dtls_handshake` decodes it with -:meth:`DTLS.read_handshake`. Only the first record of each datagram is -reassembled. +:meth:`DTLS.analyze_handshake`, which reads it with :meth:`DTLS.read_handshake`. +Only the first record of each datagram is reassembled. **A unified-header record with a connection ID is not decoded** (:rfc:`9147#section-4.1`). Its length is negotiated, not carried in the @@ -112,11 +112,12 @@ from pcapkit.protocols.data.application.dtls import DTLS as Data_DTLS from pcapkit.protocols.data.application.dtls import DTLSCIDCiphertext as Data_DTLSCIDCiphertext from pcapkit.protocols.data.application.dtls import DTLSCiphertext as Data_DTLSCiphertext +from pcapkit.protocols.data.application.dtls import DTLSHandshake as Data_DTLSHandshake from pcapkit.protocols.data.application.tls import ConnectionId as Data_ConnectionId from pcapkit.protocols.schema.application.dtls import DTLS as Schema_DTLS from pcapkit.protocols.schema.application.dtls import DTLSCIDCiphertext as Schema_DTLSCIDCiphertext from pcapkit.protocols.schema.application.dtls import DTLSCiphertext as Schema_DTLSCiphertext -from pcapkit.utilities.exceptions import ProtocolError +from pcapkit.utilities.exceptions import CaughtByCaller, ProtocolError if TYPE_CHECKING: from enum import IntEnum as StdlibEnum @@ -125,7 +126,7 @@ from aenum import IntEnum as AenumEnum from typing_extensions import Literal - from pcapkit.protocols.data.application.dtls import DTLSHandshake as Data_DTLSHandshake + from pcapkit.const.tls.handshake_type import HandshakeType as Enum_HandshakeType from pcapkit.protocols.protocol import ProtocolBase from pcapkit.protocols.schema.schema import Schema @@ -617,6 +618,49 @@ def read_handshake(cls, message: 'bytes') -> 'Data_DTLSHandshake': 'messages where exactly one is expected') return cast('Data_DTLSHandshake', messages[0]) + @classmethod + def analyze_handshake(cls, header: 'tuple[Enum_HandshakeType, int, int]', + body: 'bytes') -> 'Data_DTLSHandshake': + """Analyse a reassembled handshake message from its header and body. + + :func:`~pcapkit.foundation.analysis.dtls.dtls_handshake` runs this, and + so does the :class:`~pcapkit.foundation.reassembly.data.data.Deferred` + that :class:`~pcapkit.foundation.reassembly.dtls.DTLS` stores as a + complete message's ``packet`` (:issue:`2622`). + + The message is read by :meth:`read_handshake` as if it had arrived + unfragmented, ``fragment_offset`` 0 and ``fragment_length`` its + ``length`` (:rfc:`6347#section-4.2.3`). A body that does not decode + as its ``msg_type`` stays raw octets. That refusal is handled here, so + it is quiet (:issue:`2588`): nothing is logged and no exception hook + installed. + + Args: + header: The message's ``(msg_type, length, message_seq)``. + body: The reassembled body. + + Returns: + The message, unfragmented, its body raw if it does not decode. + + """ + msg_type, length, message_seq = header + message = (bytes([int(msg_type)]) + length.to_bytes(3, 'big') + + message_seq.to_bytes(2, 'big') + bytes(3) # fragment_offset 0 + + length.to_bytes(3, 'big') + body) + try: + with CaughtByCaller(ProtocolError): + return cls.read_handshake(message) + except ProtocolError: + return Data_DTLSHandshake( + msg_type=msg_type, + length=length, + body=body, + message_seq=message_seq, + fragment_offset=0, + fragment_length=length, + is_fragmented=False, + ) + ########################################################################## # Data models. ########################################################################## diff --git a/tests/foundation/reassembly/test_dtls_analysis_delegation_2622_unit.py b/tests/foundation/reassembly/test_dtls_analysis_delegation_2622_unit.py new file mode 100644 index 0000000000..8e1166869e --- /dev/null +++ b/tests/foundation/reassembly/test_dtls_analysis_delegation_2622_unit.py @@ -0,0 +1,226 @@ +# -*- coding: utf-8 -*- +"""DTLS handshake analysis is a method of the DTLS protocol (#2622). + +The owner ruled on #2622 that the protocol class owns the parse of a reassembled +outcome, and that :mod:`pcapkit.foundation.analysis` and the reassembly's deferred +``packet`` only call it. For DTLS that method is +:meth:`DTLS.analyze_handshake `: + +* :func:`~pcapkit.foundation.analysis.dtls.dtls_handshake` returns what the method + returns, called on :class:`~pcapkit.protocols.application.dtls.DTLS` itself; +* a datagram's ``packet`` is the method bound to the reassembly's own + ``__protocol_type__``, so a DTLS subclass overriding it decodes the ``packet``, a + protocol without it raises :exc:`AttributeError` once a message completes, and the + binding survives every pickle protocol; +* a body that does not decode stays raw quietly, as a handled refusal is (#2588), + while :meth:`DTLS.read_handshake` on the same octets stays loud. + +Every capture is built in memory, as PCAP and as PCAP-NG; no sample capture is read. + +""" + +from __future__ import annotations + +import operator +import pickle +import sys +import threading +import unittest +from typing import TYPE_CHECKING +from unittest import mock + +from tests._support import reimport_once_per_class +from tests.foundation.reassembly.test_dtls_handshake_reassembly_2440_unit import ( + _client_hello_body, _dtls_handshake, _dtls_record, _extract, _frame, _pcap, _pcapng, _split) +from tests.utilities._harness import capture + +if TYPE_CHECKING: + from typing import Any + +#: Test tier (#1958): pickling and the exception hooks, which differ by interpreter. +__tier__ = 'ALL' + +#: A ``ClientHello`` body, which decodes. +BODY = _client_hello_body(cookie=b'\x01\x02\x03') +#: A ``ClientHello`` body cut short inside ``random``, which does not. +BAD_BODY = b'\xfe\xfd' + bytes(7) +#: Their message sequence number. +MESSAGE_SEQ = 6 +#: The containers each case runs over. +CONTAINERS = (('pcap', _pcap), ('pcapng', _pcapng)) + + +def _records(body: 'bytes') -> 'list[bytes]': + """``body`` as a ``ClientHello`` in two ``handshake`` records.""" + return [_dtls_record(22, _dtls_handshake(1, body[fo:fo + length], message_seq=MESSAGE_SEQ, + fragment_offset=fo, fragment_length=length, + total_length=len(body)), seq=num) + for num, (fo, length) in enumerate(_split(body, (len(body) // 2,)), 1)] + + +def _held(datagram: 'Any') -> 'Any': + """``datagram``'s ``packet`` as stored, without resolving it.""" + return datagram.__dict__[datagram.__map__.get('packet', 'packet')] + + +def _header(body: 'bytes') -> 'tuple[Any, int, int]': + """The ``(msg_type, length, message_seq)`` of :func:`_records` ``(body)``.""" + from pcapkit.const.tls.handshake_type import HandshakeType + + return (HandshakeType.client_hello, len(body), MESSAGE_SEQ) + + +class _Reassembled(unittest.TestCase): + """Builds the one message a capture of :func:`_records` reassembles.""" + + def _datagram(self, write: 'Any', body: 'bytes' = BODY) -> 'Any': + extractor = _extract(self, write([_frame(record) for record in _records(body)]), + reassembly=True, dtls=True) + datagram, = extractor.reassembly.dtls + self.assertTrue(datagram.completed) + return datagram + + +class DelegationTests(_Reassembled): + """``dtls_handshake`` and the deferred ``packet`` both call the protocol method.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_dtls_handshake_returns_the_protocol_methods_result(self) -> None: + from pcapkit.foundation.analysis import dtls_handshake + from pcapkit.protocols.application.dtls import DTLS + + result = object() + for container, write in CONTAINERS: + with self.subTest(container=container): + datagram = self._datagram(write) + with mock.patch.object(DTLS, 'analyze_handshake', return_value=result) as analyze: + self.assertIs(dtls_handshake(datagram), result) + analyze.assert_called_once_with(_header(BODY), BODY) + + def test_a_dtls_subclass_overriding_the_method_decodes_the_packet(self) -> None: + from pcapkit.foundation.analysis import dtls_handshake + from pcapkit.foundation.reassembly.dtls import DTLS as DTLS_Reassembly + from pcapkit.protocols.application.dtls import DTLS + from pcapkit.protocols.data.application.dtls import DTLS as Data_DTLS + from pcapkit.protocols.data.application.tls import ClientHello + from pcapkit.protocols.schema.application.dtls import DTLS as Schema_DTLS + + class TaggedDTLS(DTLS, schema=Schema_DTLS, data=Data_DTLS): + """A DTLS protocol whose handshake analysis is tagged as its own.""" + + @classmethod + def analyze_handshake(cls, header: 'Any', body: 'bytes') -> 'Any': # type: ignore[override] + return ('tagged', header, body) + + for container, write in CONTAINERS: + with self.subTest(container=container): + with mock.patch.object(DTLS_Reassembly, '__protocol_type__', TaggedDTLS): + datagram = self._datagram(write) + self.assertEqual(datagram.packet, ('tagged', _header(BODY), BODY)) + # the public function decodes with DTLS itself + self.assertIsInstance(dtls_handshake(datagram).body, ClientHello) + + def test_a_protocol_type_without_the_method_fails_when_a_message_completes(self) -> None: + from pcapkit.const.tls.handshake_type import HandshakeType + from pcapkit.foundation.reassembly.data.dtls import Packet + from pcapkit.foundation.reassembly.dtls import DTLS as DTLS_Reassembly + + class Unrelated: # pylint: disable=too-few-public-methods + """A ``__protocol_type__`` with no ``analyze_handshake``.""" + + class Engine(DTLS_Reassembly): + __protocol_type__ = Unrelated + + half = len(BODY) // 2 + engine = Engine() + for num, (offset, fragment) in enumerate(((0, BODY[:half]), (half, BODY[half:])), 1): + info = Packet(bufid=('192.0.2.1', 1234, '198.51.100.1', 5684, 0), num=num, + msg_type=HandshakeType.client_hello, length=len(BODY), + message_seq=MESSAGE_SEQ, fragment_offset=offset, + fragment_length=len(fragment), payload=fragment, timestamp=float(num)) + if num == 1: + engine(info) # the first half completes nothing, so nothing is read + continue + with self.assertRaisesRegex(AttributeError, 'analyze_handshake'): + engine(info) + + def test_the_packet_is_the_bound_method_across_pickling(self) -> None: + from pcapkit.foundation.reassembly.data.data import Deferred + from pcapkit.protocols.application.dtls import DTLS + + for container, write in CONTAINERS: + for protocol in range(pickle.HIGHEST_PROTOCOL + 1): + with self.subTest(container=container, protocol=protocol): + datagram = self._datagram(write) + restored = pickle.loads(pickle.dumps(datagram, protocol=protocol)) + for held in (_held(datagram), _held(restored)): + self.assertIsInstance(held, Deferred) + self.assertEqual(held.analyze, DTLS.analyze_handshake) + self.assertEqual(restored.packet, datagram.packet) + + +class QuietRefusalTests(_Reassembled): + """A body that does not decode stays raw, and its refusal logs and hooks nothing.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + # NOTE: imported before the sentinels go in, since importing pcapkit + # outside development mode installs tbtrim's hook itself. + import pcapkit # pylint: disable=import-outside-toplevel,unused-import + saved = sys.excepthook, threading.excepthook + + def restore() -> None: + sys.excepthook, threading.excepthook = saved + + self.addCleanup(restore) + + def sentinel(*args: 'object') -> None: + sys.__excepthook__(*args) # type: ignore[arg-type] + + def thread_sentinel(args: 'object') -> None: # pylint: disable=unused-argument + pass + + self.sentinels = sentinel, thread_sentinel + sys.excepthook, threading.excepthook = self.sentinels + + def _quietly(self, decode: 'Any', *args: 'Any') -> 'Any': + """``decode(*args)``, asserting it logs nothing and leaves both hooks alone.""" + from pcapkit.utilities.logging import logger + + with capture(logger) as recorder: + result = decode(*args) + self.assertEqual(recorder.messages, []) + self.assertIs(sys.excepthook, self.sentinels[0]) + self.assertIs(threading.excepthook, self.sentinels[1]) + return result + + def test_an_undecodable_body_stays_raw_quietly(self) -> None: + from pcapkit.foundation.analysis import dtls_handshake + from pcapkit.protocols.application.dtls import DTLS + from pcapkit.utilities.exceptions import ProtocolError + from pcapkit.utilities.logging import logger + + for container, write in CONTAINERS: + with self.subTest(container=container): + datagram = self._datagram(write, BAD_BODY) + results = (self._quietly(operator.attrgetter('packet'), datagram), + self._quietly(dtls_handshake, datagram), + self._quietly(DTLS.analyze_handshake, _header(BAD_BODY), BAD_BODY)) + for message in results: + self.assertEqual((message.body, message.message_seq, message.fragment_offset, + message.fragment_length, message.is_fragmented), + (BAD_BODY, MESSAGE_SEQ, 0, len(BAD_BODY), False)) + + # reading the same octets directly, with nothing to handle the refusal, stays loud + octets = _dtls_handshake(1, BAD_BODY, message_seq=MESSAGE_SEQ, fragment_offset=0, + fragment_length=len(BAD_BODY)) + with capture(logger) as recorder: + with self.assertRaises(ProtocolError): + DTLS.read_handshake(octets) + self.assertEqual([level for level, _ in recorder.messages], ['CRITICAL']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/project/test_protocols_import_boundary_2622_unit.py b/tests/project/test_protocols_import_boundary_2622_unit.py new file mode 100644 index 0000000000..1705e66793 --- /dev/null +++ b/tests/project/test_protocols_import_boundary_2622_unit.py @@ -0,0 +1,119 @@ +# -*- coding: utf-8 -*- +"""Protocol code imports nothing from :mod:`pcapkit.foundation` to do its own work (#2622). + +The owner ruled on #2622 that the protocol class owns the parse of a reassembled +outcome, and :mod:`pcapkit.foundation.analysis` only calls it: the dependency runs +from ``foundation`` to the protocols. So no module under :file:`pcapkit/protocols/` +imports :mod:`pcapkit.foundation`, except where the import does no protocol work: + +* :mod:`pcapkit.foundation.registry.protocols` and any module under it, which the + protocol base's registration hooks reach to register a class -- the rest of + :mod:`pcapkit.foundation.registry` is not exempt; +* an import inside an ``if TYPE_CHECKING:`` block, which only an annotation reads. + +Imports are found by +:func:`tests.project.test_utilities_import_boundary_1735_unit.imported_names`, so a +lazy, relative or literal dynamic import counts as well. The rule is written down on +:file:`docs/source/contributing/conventions/reassembly-integration.rst`. + +This module is unit-tier and reads no capture. + +""" +from __future__ import annotations + +import ast +import unittest + +from tests.project.test_utilities_import_boundary_1735_unit import ROOT, _module_name, imported_names + +#: The protocol package the rule governs. +PROTOCOLS = ROOT / 'pcapkit' / 'protocols' + +#: The package protocol code may not import from. +FOUNDATION = 'pcapkit.foundation' + +#: The part of it the protocol base's registration hooks reach, which the rule +#: allows, with any module under it. +REGISTRATION = 'pcapkit.foundation.registry.protocols' + + +def _within(name: 'str', package: 'str') -> 'bool': + """Whether ``name`` is ``package`` or a module under it.""" + return name == package or name.startswith(f'{package}.') + + +def _type_checking_lines(source: 'str') -> 'set[int]': + """Every line inside the body of an ``if TYPE_CHECKING:`` block of ``source``.""" + lines = set() # type: set[int] + for node in ast.walk(ast.parse(source)): + if not isinstance(node, ast.If): + continue + test = node.test + name = test.attr if isinstance(test, ast.Attribute) else getattr(test, 'id', None) + if name != 'TYPE_CHECKING': + continue + for stmt in node.body: + lines.update(range(stmt.lineno, (stmt.end_lineno or stmt.lineno) + 1)) + return lines + + +def violations(source: 'str', module: 'str', is_package: 'bool' = False) -> 'list[tuple[int, str]]': + """Every import of :data:`FOUNDATION` in ``source`` that the rule does not allow.""" + typing_only = _type_checking_lines(source) + return [(line, name) for line, name in imported_names(source, module, is_package) + if _within(name, FOUNDATION) and not _within(name, REGISTRATION) + and line not in typing_only] + + +class ProtocolsImportBoundaryTests(unittest.TestCase): + """:mod:`pcapkit.protocols` does no work through :mod:`pcapkit.foundation`.""" + + def test_the_walk_found_the_package(self) -> None: + names = {path.relative_to(PROTOCOLS).as_posix() for path in PROTOCOLS.rglob('*.py')} + self.assertIn('protocol.py', names) + self.assertIn('application/dtls.py', names) + + def test_no_protocol_module_imports_foundation_to_do_its_work(self) -> None: + found = [] # type: list[str] + for path in sorted(PROTOCOLS.rglob('*.py')): + source = path.read_text(encoding='utf-8') + for line, name in violations(source, _module_name(path), path.name == '__init__.py'): + found.append(f'{path.relative_to(ROOT).as_posix()}:{line}: {name}') + self.assertEqual(found, [], + 'these modules under pcapkit/protocols/ import pcapkit.foundation, ' + 'which #2622 rules out: the protocol class owns the parse, and ' + 'pcapkit.foundation only calls it:\n' + '\n'.join(found)) + + def test_the_scanner_tells_the_allowed_imports_apart(self) -> None: + source = '\n'.join([ + 'from typing import TYPE_CHECKING', # 1 + 'from pcapkit.foundation.analysis.dtls import dtls_handshake', # 2 + 'from ...foundation.reassembly import dtls', # 3 + 'from pcapkit import foundation', # 4 + 'from pcapkit.foundation.registry.protocols import register', # 5 + 'if TYPE_CHECKING:', # 6 + ' from pcapkit.foundation.engines.pcapng import Context', # 7 + 'else:', # 8 + ' import pcapkit.foundation.extraction', # 9 + 'def f():', # 10 + ' from pcapkit.foundation.registry.protocols.tcp import r', # 11 + ' importlib.import_module("pcapkit.foundation.traceflow")', # 12 + ' from pcapkit.corekit import fields', # 13 + ' from pcapkit.foundation.registry.foundation import e', # 14 + ' from pcapkit.foundation.registry import protocols', # 15 + ' from pcapkit.foundation.registry.protocolsx import x', # 16 + ]) + self.assertEqual(violations(source, 'pcapkit.protocols.application.dtls'), [ + (2, 'pcapkit.foundation.analysis.dtls'), + (3, 'pcapkit.foundation.reassembly'), + (4, 'pcapkit.foundation'), + (9, 'pcapkit.foundation.extraction'), + (12, 'pcapkit.foundation.traceflow'), + (14, 'pcapkit.foundation.registry.foundation'), + (15, 'pcapkit.foundation.registry'), + (16, 'pcapkit.foundation.registry.protocolsx'), + ]) + + +if __name__ == '__main__': + unittest.main()