diff --git a/docs/source/contributing/conventions/index.rst b/docs/source/contributing/conventions/index.rst index 09d30a59bf..2f04feb66a 100644 --- a/docs/source/contributing/conventions/index.rst +++ b/docs/source/contributing/conventions/index.rst @@ -17,8 +17,9 @@ House Conventions wrappers of :mod:`pcapkit.foundation.registry`; * :ref:`extension-header-subclassing` -- a protocol class hierarchy; * :ref:`protocol-family-grouping` -- how a protocol family's base and members - spell :meth:`~pcapkit.protocols.protocol.Protocol.id`, and how a protocol class - is named after its module; + spell :meth:`~pcapkit.protocols.protocol.Protocol.id`, how a protocol class + is named after its module, and how quietly a next-layer fallback keeps a + refused layer as :class:`~pcapkit.protocols.misc.raw.Raw`; * :ref:`protocol-member-order` -- the order of members in a protocol class body; * :ref:`protocol-layer-placement` -- which subpackage a dissector belongs in; * :ref:`process` -- the repository rather than any of its code; diff --git a/docs/source/contributing/conventions/protocol-family-grouping.rst b/docs/source/contributing/conventions/protocol-family-grouping.rst index 313b9fa75a..50ae80307a 100644 --- a/docs/source/contributing/conventions/protocol-family-grouping.rst +++ b/docs/source/contributing/conventions/protocol-family-grouping.rst @@ -72,6 +72,64 @@ dispatcher gives :class:`~pcapkit.protocols.application.httpv2.HTTPv2` that its then refuses, such as a preface and three octets, are :class:`~pcapkit.protocols.misc.raw.Raw` with the parser's ``error``. +.. _protocol-family-refusals: + +Handled Refusals +~~~~~~~~~~~~~~~~ + +**A refusal a next-layer fallback handles is quiet**, for every protocol and not only +a family's members (:issue:`2588`). The fallback is +:func:`~pcapkit.corekit.decorators.beholder`. It decorates +:meth:`Protocol._import_next_layer ` +and the overrides that parse a next layer themselves. +:meth:`Application._import_next_layer +` and +:meth:`TLS._import_next_layer ` +are not decorated: they delegate to decorated methods, the base's or TLS's +``_import_next_record``. The decorated call runs inside a +:class:`~pcapkit.utilities.exceptions.CaughtByCaller` block naming +:exc:`~pcapkit.utilities.exceptions.ProtocolError`. A refused layer, the HTTP/2 case +above or a GRE header of a nonzero version, is then recorded on the ``error`` of the +:class:`~pcapkit.protocols.misc.raw.Raw` that replaces it, and nowhere else: nothing +reaches the ``pcapkit`` logger at any level, and neither :func:`sys.excepthook` nor +:func:`threading.excepthook` is replaced. A refusal the parser handles is the control +flow :class:`~pcapkit.utilities.exceptions.BaseError`'s quiet mode exists for, and +installing a hook from an error the library caught itself is a process-wide side +effect. + +**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 +:class:`~pcapkit.utilities.exceptions.CaughtByCaller` says otherwise: + +* one a parser below catches itself, such as the ``supported_versions`` peek of + :meth:`SSL._tls_negotiates ` + or TLS's SSLv2 trial, which is then recorded nowhere; +* one reporting library state rather than the octets, such as a + :class:`~pcapkit.corekit.module.ModuleDescriptor` entry whose class its module has + since lost -- by a later removal, or by a write to ``__proto__`` directly, since + :func:`~pcapkit.foundation.registry.protocols.register_tcp` refuses such an entry -- + which ends as the ``Raw`` layer's ``error``; +* one raised by an inner fallback's recovery, since the block of the fallback around + it is still active. + +What stays loud: + +* **Building the refusing layer directly.** ``GRE(...)`` or ``IPv4(...)`` on the same + octets raises an error that logs at :data:`logging.CRITICAL` and, outside development + mode, installs the hooks. +* **Any other error class.** An error the fallback absorbs is as loud as it was + constructed, and :func:`~pcapkit.corekit.decorators.beholder` logs it at + :data:`logging.WARNING` as well. +* **A refusal built under an inner** ``CaughtByCaller()``. A block naming no class, + such as the one :meth:`~pcapkit.corekit.enum.EnumRegistry.register` enters, makes + errors loud again inside the fallback's. +* **The outermost fallback's recovery.** Reading the payload and building the ``Raw`` + run after the block has ended, so under the outermost + :func:`~pcapkit.corekit.decorators.beholder` of a parse an error raised there reaches + the caller loud. Each block restores the one around it on exit, so none outlives + that parse. + .. _protocol-family-shapes: Member Shapes diff --git a/docs/source/pcapkit/utilities/exceptions.rst b/docs/source/pcapkit/utilities/exceptions.rst index c4cbe8875d..cf051b062a 100644 --- a/docs/source/pcapkit/utilities/exceptions.rst +++ b/docs/source/pcapkit/utilities/exceptions.rst @@ -33,6 +33,10 @@ An error is also quiet when it is constructed inside a classes. That is how :mod:`pcapkit` marks as control flow an error raised by code it calls but does not own, such as a generated ``_validate_value`` override, which cannot know to pass ``quiet=True`` itself (:issue:`1845`). +:func:`~pcapkit.corekit.decorators.beholder` runs the whole call to a next layer in +such a block for :exc:`~pcapkit.utilities.exceptions.ProtocolError`, so a refusal it +keeps as :class:`~pcapkit.protocols.misc.raw.Raw` is recorded on that layer's ``error`` +alone (:issue:`2588`). .. versionchanged:: 1.5.0 diff --git a/examples/generators/pcapng.py b/examples/generators/pcapng.py index 61a88fa3c6..a01daf3b4c 100644 --- a/examples/generators/pcapng.py +++ b/examples/generators/pcapng.py @@ -132,11 +132,13 @@ identically at both ends, and that the block chain covers the file exactly. So the complaints above are pcapkit's readings, not malformed fixtures. -The complaints about one packet are expected rather than a defect: -``test.pcapng`` carries a deliberately truncated packet (``captured_len`` below -``original_len``, to exercise snapshot-length handling), and dissecting it -reports ``packet length < 0: -2`` and an IPv4 header that runs past the end of -the data, which is simply what a snapped frame looks like to a dissector. +One packet is cut short on purpose rather than by a defect: ``test.pcapng`` +carries a deliberately truncated packet (``captured_len`` below +``original_len``, to exercise snapshot-length handling), whose IPv4 header runs +past the end of the data, which is simply what a snapped frame looks like to a +dissector. The frame keeps that header as ``Raw``, whose ``error`` says so, and +logs nothing for it (GitHub issue #2588), so verifying the fixture notes only +the ``EOF reached`` its extraction ends on. Usage ----- @@ -979,11 +981,14 @@ class _LogCapture(logging.Handler): pcapkit reports a parse complaint on its ``pcapkit`` logger, and a complaint raised as a warning goes through :mod:`warnings` as well, where - the application's filters decide its fate. Only the logger sees all of - them: :func:`~pcapkit.corekit.decorators.beholder` logs a dissector - failure it recovers from without issuing a warning at all. So the logger - is what this listens to. Duplicates are dropped, because one complaint per block in a capture - with many packets is not that many pieces of information. + the application's filters decide its fate. The logger sees more of them: + :func:`~pcapkit.corekit.decorators.beholder` logs a dissector failure it + recovers from without issuing a warning at all -- other than a + :exc:`~pcapkit.utilities.exceptions.ProtocolError`, a refusal it keeps + quiet and records only on the replacing ``Raw`` layer's ``error`` (GitHub + issue #2588). So the logger is what this listens to. Duplicates are + dropped, because one complaint per block in a capture with many packets is + not that many pieces of information. """ diff --git a/pcapkit/corekit/decorators.py b/pcapkit/corekit/decorators.py index 672e5b4ab0..f4a05e5352 100644 --- a/pcapkit/corekit/decorators.py +++ b/pcapkit/corekit/decorators.py @@ -31,7 +31,8 @@ import traceback from typing import TYPE_CHECKING, cast -from pcapkit.utilities.exceptions import StreamEOFError, StructError, stacklevel +from pcapkit.utilities.exceptions import (CaughtByCaller, ProtocolError, StreamEOFError, + StructError, stacklevel) from pcapkit.utilities.logging import DEVMODE, VERBOSE, get_logger if TYPE_CHECKING: @@ -113,10 +114,30 @@ def beholder( This decorator function is designed for decorating *instance methods*. This decorator calls the decorated function. Should any exception be - raised, it logs the error and re-parses the current layer's payload as + raised, it re-parses the current layer's payload as :class:`~pcapkit.protocols.misc.raw.Raw` -- or as :class:`~pcapkit.protocols.misc.null.NoPayload`, if the error is an - end-of-file :exc:`~pcapkit.utilities.exceptions.StructError`. + end-of-file :exc:`~pcapkit.utilities.exceptions.StructError` -- whose + ``error`` is the exception's message. + + The decorated call runs inside a + :class:`~pcapkit.utilities.exceptions.CaughtByCaller` block naming + :exc:`~pcapkit.utilities.exceptions.ProtocolError` (:issue:`2588`). The + block spans the whole call, so every such error built while it runs is + quiet, at any depth and whether or not the code that built it catches it, + unless an inner :class:`~pcapkit.utilities.exceptions.CaughtByCaller` says + otherwise: the next layer refusing its octets, a parser's own trial, a + registry entry whose class its module has since lost. One that reaches + here is recorded on ``error`` and is not logged here either. Any other + exception is logged here at :data:`logging.WARNING`, and is as loud as it + was constructed. + + The recovery runs after the block has ended. Under the outermost + :func:`beholder` of a parse, a + :exc:`~pcapkit.utilities.exceptions.ProtocolError` it raises therefore + reaches the caller loud; under any other, the block of the + :func:`beholder` around it is still active, so that error is quiet, and + becomes the enclosing replacement's ``error``. When :meth:`Protocol._parse_next_layer ` held the @@ -138,6 +159,12 @@ def beholder( Logs the exception it absorbs, and prints its traceback in verbose mode. + .. versionchanged:: 1.6.0 + + A :exc:`~pcapkit.utilities.exceptions.ProtocolError` the decorated + function builds is quiet, and one this decorator absorbs is not logged + here either (:issue:`2588`). + :param func: decorated function :meta decorator: """ @@ -158,7 +185,10 @@ def behold(*args: 'P.args', **kwargs: 'P.kwargs') -> 'R_beholder': # call method token = _beholding.set(True) try: - return func(*args, **kwargs) + # NOTE: The whole call is quiet for a ProtocolError, which the ``except`` below + # records; the recovery runs after it, loud unless an outer block is active (#2588). + with CaughtByCaller(ProtocolError): + return func(*args, **kwargs) finally: _beholding.reset(token) except Exception as exc: @@ -168,12 +198,14 @@ def behold(*args: 'P.args', **kwargs: 'P.kwargs') -> 'R_beholder': from pcapkit.protocols.misc.raw import Raw as protocol # type: ignore[assignment] # isort: skip # pylint: disable=import-outside-toplevel # error = traceback.format_exc(limit=1).strip().rsplit(os.linesep, maxsplit=1)[-1] - # log error - logger.warning(str(exc), stack_info=DEVMODE, stacklevel=stacklevel()) + # NOTE: Keyed on the class, not on quietness: a quiet end-of-file StructError is still + # logged here, and a ProtocolError built loud has logged at CRITICAL already (#2588). + if not isinstance(exc, ProtocolError): + logger.warning(str(exc), stack_info=DEVMODE, stacklevel=stacklevel()) - if VERBOSE: - logger.error('The following error occurred while parsing the packet:') - traceback.print_exc() + if VERBOSE: + logger.error('The following error occurred while parsing the packet:') + traceback.print_exc() # NOTE: ``self._get_payload()`` rather than # ``self.__header__.get_payload()``, which it wraps. The two agree diff --git a/pcapkit/protocols/internet/ipv6.py b/pcapkit/protocols/internet/ipv6.py index 8795f51dd7..2dd6827bc5 100644 --- a/pcapkit/protocols/internet/ipv6.py +++ b/pcapkit/protocols/internet/ipv6.py @@ -566,7 +566,7 @@ def _decode_next_layer(self, ipv6: 'Data_IPv6', proto: 'Optional[int]' = None, # A layer with no ``next`` field ends the walk, as RFC 8200 section 4 # has a node discard the packet on a malformed header (#2477). # It is not recorded as an extension header. - # A Raw is reused as the upper layer, so its error logs once. + # A Raw is reused as the upper layer, so the header is not parsed again. if not hasattr(info, 'next'): from pcapkit.protocols.misc.raw import Raw # isort: skip # pylint: disable=import-outside-toplevel # Exact type on purpose: Raw subclasses exist, so isinstance would over-match. diff --git a/tests/corekit/test_decorators.py b/tests/corekit/test_decorators.py index f7cc519ec9..205a7cc838 100644 --- a/tests/corekit/test_decorators.py +++ b/tests/corekit/test_decorators.py @@ -1,11 +1,14 @@ from __future__ import annotations import io +import logging import sys +import threading import unittest from tests._support import (bootstrap_core_modules, install_fake_payload_protocols, isolate_modules) +from tests.utilities._harness import capture #: Test tier (#1958): exercises metaclass, ABC or enum machinery, which differs by #: interpreter. @@ -544,6 +547,170 @@ def test_beholder_defaults_length_when_argument_is_missing(self) -> None: self.assertIsInstance(result, Raw) self.assertIsNone(result.length) + ########################################################################## + # beholder: a refusal it handles is quiet (#2588). Each error is built + # inside the decorated method, where the block declaring it is. + ########################################################################## + + def _sentinel_hooks(self) -> 'tuple[object, object]': + """Install stand-ins for both exception hooks, put back after the test.""" + 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: + pass + + sys.excepthook, threading.excepthook = sentinel, thread_sentinel + return sentinel, thread_sentinel + + def test_beholder_keeps_a_refusal_it_handles_quiet(self) -> None: + """A refusal is kept on ``error``, logs nothing at any level, and touches neither hook.""" + exceptions = self.exceptions + Raw, _ = self._payload_stand_ins() + hooks = self._sentinel_hooks() + + class DemoProtocol: + def _get_payload(self) -> bytes: + return b'refused-bytes' + + @self.decorators.beholder + def decode(self, proto, length=None): + raise exceptions.ProtocolError('refused') + + with capture(logging.getLogger('pcapkit')) as recorder: + result = DemoProtocol().decode(1, 13) + + self.assertIsInstance(result, Raw) + self.assertEqual((result.file, result.length, result.error), (b'refused-bytes', 13, 'refused')) + self.assertEqual(recorder.messages, []) + self.assertIs(sys.excepthook, hooks[0]) + self.assertIs(threading.excepthook, hooks[1]) + + def test_beholder_quiets_only_the_refusal_class(self) -> None: + """Another error stays loud and logged, and so does a refusal declared loud inside the block.""" + exceptions = self.exceptions + Raw, _ = self._payload_stand_ins() + self._sentinel_hooks() + + class DemoProtocol: + def _get_payload(self) -> bytes: + return b'raw-bytes' + + @self.decorators.beholder + def decode(self, proto, length=None): + try: + raise exceptions.ProtocolError('caught inside') + except exceptions.ProtocolError: + pass + with exceptions.CaughtByCaller(): + exceptions.ProtocolError('declared loud') + raise exceptions.StructError('broken') + + with capture(logging.getLogger('pcapkit')) as recorder: + result = DemoProtocol().decode(1, 9) + + self.assertIsInstance(result, Raw) + self.assertEqual(result.error, 'broken') + self.assertEqual(recorder.messages, [('CRITICAL', 'ProtocolError: declared loud'), + ('CRITICAL', 'StructError: broken'), + ('WARNING', 'broken')]) + + def test_beholder_recovery_error_stays_loud_and_reaches_the_caller(self) -> None: + """The outermost recovery runs outside every block, so a refusal it raises is loud and propagates.""" + exceptions = self.exceptions + self._payload_stand_ins() + self._sentinel_hooks() + + class DemoProtocol: + def _get_payload(self) -> bytes: + raise exceptions.ProtocolError('no payload') + + @self.decorators.beholder + def decode(self, proto, length=None): + raise exceptions.ProtocolError('refused') + + with capture(logging.getLogger('pcapkit')) as recorder: + with self.assertRaises(exceptions.ProtocolError) as context: + DemoProtocol().decode(1, 3) + + self.assertEqual(str(context.exception), 'no payload') + self.assertEqual(recorder.messages, [('CRITICAL', 'ProtocolError: no payload')]) + self.assertEqual(exceptions._caught_here.get(), ()) # pylint: disable=protected-access + + def test_beholder_inner_recovery_error_is_quiet_under_an_outer_block(self) -> None: + """An inner recovery runs inside the outer block, so its refusal is quiet and the outer ``error``.""" + exceptions = self.exceptions + Raw, _ = self._payload_stand_ins() + self._sentinel_hooks() + beholder = self.decorators.beholder + + class Inner: + def _get_payload(self) -> bytes: + raise exceptions.ProtocolError('no inner payload') + + @beholder + def decode(self, proto, length=None): + raise exceptions.ProtocolError('inner refused') + + class Outer: + def _get_payload(self) -> bytes: + return b'outer-bytes' + + @beholder + def decode(self, proto, length=None): + return Inner().decode(2, 11) + + with capture(logging.getLogger('pcapkit')) as recorder: + result = Outer().decode(1, 11) + + self.assertIsInstance(result, Raw) + self.assertEqual((result.file, result.length, result.error), (b'outer-bytes', 11, 'no inner payload')) + self.assertEqual(recorder.messages, []) + self.assertEqual(exceptions._caught_here.get(), ()) # pylint: disable=protected-access + + def test_beholder_nested_blocks_end_with_the_outer_call(self) -> None: + """An inner refusal is quiet, the outer method is back in its own block, and none outlives it.""" + exceptions = self.exceptions + Raw, _ = self._payload_stand_ins() + self._sentinel_hooks() + beholder = self.decorators.beholder + seen = [] + + class Inner: + def _get_payload(self) -> bytes: + return b'inner-bytes' + + @beholder + def decode(self, proto, length=None): + raise exceptions.ProtocolError('inner refused') + + class Outer: + def _get_payload(self) -> bytes: + return b'outer-bytes' + + @beholder + def decode(self, proto, length=None): + inner = Inner().decode(2, 11) + seen.append(exceptions._caught_here.get()) # pylint: disable=protected-access + return inner + + with capture(logging.getLogger('pcapkit')) as recorder: + result = Outer().decode(1, 11) + exceptions.ProtocolError('after the outer call') + + self.assertIsInstance(result, Raw) + self.assertEqual((result.file, result.error), (b'inner-bytes', 'inner refused')) + self.assertEqual(seen, [(exceptions.ProtocolError,)]) + self.assertEqual(exceptions._caught_here.get(), ()) # pylint: disable=protected-access + self.assertEqual(recorder.messages, [('CRITICAL', 'ProtocolError: after the outer call')]) + if __name__ == '__main__': unittest.main() diff --git a/tests/protocols/internet/test_ipv6_ext_unit.py b/tests/protocols/internet/test_ipv6_ext_unit.py index cc0d23d5f7..1dd15abd3e 100644 --- a/tests/protocols/internet/test_ipv6_ext_unit.py +++ b/tests/protocols/internet/test_ipv6_ext_unit.py @@ -7,6 +7,7 @@ import warnings from tests._support import reimport_once_per_class +from tests.utilities._harness import capture RUNTIME_DEPS = ('tbtrim', 'aenum', 'chardet', 'dictdumper') HAS_RUNTIME = all(importlib.util.find_spec(name) is not None for name in RUNTIME_DEPS) @@ -245,29 +246,41 @@ def test_failed_dedicated_parse_is_raw_and_ends_the_walk(self) -> None: self.assertEqual(IPv6.from_data(ipv6.info).data, raw) self.assertEqual(IPv6.from_data(ipv6.info.to_dict()).data, raw) - def test_failed_dedicated_parse_is_logged_once(self) -> None: + def test_failed_dedicated_parse_runs_once(self) -> None: """GitHub issue #2477: the ``Raw`` the walk ends on is the upper layer, - not parsed again, so its error is logged once and no - ``SchemaWarning`` is left from the header it replaced. + not parsed again, so the header's parser runs once and no + ``SchemaWarning`` is left from the header it replaced. The refusal is + handled, so it logs nothing (#2588). """ + from unittest import mock + from pcapkit.const.ipv6.extension_header import ExtensionHeader as EH + from pcapkit.const.ipv6.option import Option from pcapkit.const.reg.transtype import TransType + from pcapkit.protocols.internet.hopopt import HOPOPT from pcapkit.protocols.internet.ipv6 import IPv6 from pcapkit.protocols.misc.raw import Raw + from pcapkit.utilities.logging import logger udp = int(TransType.UDP) - for form, hopopt in (('malformed', bytes([udp, 0, 0x05, 10]) + bytes(4) + _udp_bytes(b'ok')), - ('truncated', bytes([udp, 0, 1]))): + truncated = (f'Field options has an option that runs past the end of the data: ' + f'{Option.PadN!r} at offset 0 of 6, with only 1 octet(s) of it present') + for form, hopopt, error in ( + ('malformed', bytes([udp, 0, 0x05, 10]) + bytes(4) + _udp_bytes(b'ok'), + 'HOPOPT: [OptNo 5] invalid format'), + ('truncated', bytes([udp, 0, 1]), truncated), + ): raw = _ipv6_bytes(int(EH.HOPOPT), hopopt) with self.subTest(form=form): - with warnings.catch_warnings(record=True) as caught, \ - self.assertLogs('pcapkit', level='WARNING') as logs: + with warnings.catch_warnings(record=True) as caught, capture(logger) as logs, \ + mock.patch.object(HOPOPT, '__post_init__', autospec=True, + side_effect=HOPOPT.__post_init__) as parses: warnings.simplefilter('always') ipv6 = IPv6(io.BytesIO(raw), len(raw)) self.assertIs(type(ipv6.payload), Raw) - error = ipv6.payload.info.error - self.assertEqual([(rec.levelname, error in rec.getMessage()) for rec in logs.records], - [('CRITICAL', True), ('WARNING', True)]) + self.assertEqual(ipv6.payload.info.error, error) + self.assertEqual(parses.call_count, 1) + self.assertEqual(logs.messages, []) self.assertEqual([w.category.__name__ for w in caught], []) def test_failed_ah_parse_is_raw_and_ends_the_walk(self) -> None: @@ -276,13 +289,15 @@ def test_failed_ah_parse_is_raw_and_ends_the_walk(self) -> None: from pcapkit.const.reg.transtype import TransType from pcapkit.protocols.internet.ipv6 import IPv6 from pcapkit.protocols.misc.raw import Raw + from pcapkit.utilities.logging import logger ah = bytes([int(TransType.UDP), 0]) + bytes(6) # Payload Len 0: a negative ICV length body = ah + _udp_bytes(b'ok') raw = _ipv6_bytes(int(EH.AH), body) - with self.assertLogs('pcapkit', level='WARNING'): + with capture(logger) as logs: ipv6 = IPv6(io.BytesIO(raw), len(raw)) + self.assertEqual(logs.messages, []) # a handled refusal is quiet (#2588) self.assertEqual(list(ipv6.extension_headers.items(multi=True)), []) self.assertIs(type(ipv6.payload), Raw) self.assertEqual(bytes(ipv6.payload), body) diff --git a/tests/protocols/test_handled_refusal_quiet_2588_unit.py b/tests/protocols/test_handled_refusal_quiet_2588_unit.py new file mode 100644 index 0000000000..2d11c1ef81 --- /dev/null +++ b/tests/protocols/test_handled_refusal_quiet_2588_unit.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +"""GitHub issue #2588: a next-layer refusal the parent's fallback handles is quiet. + +When :func:`~pcapkit.corekit.decorators.beholder` keeps a refused next layer as +:class:`~pcapkit.protocols.misc.raw.Raw`, the refusal's +:exc:`~pcapkit.utilities.exceptions.ProtocolError` logs nothing on the +``pcapkit`` logger, at any level, and leaves :data:`sys.excepthook` and +:data:`threading.excepthook` as they were. The ``Raw`` layer's ``error`` still +records it, and the frame rebuilds byte for byte. Building the refused layer +directly, with no fallback to handle it, stays loud. + +:mod:`pcapkit` is imported inside each test, after +:func:`~tests._support.reimport_once_per_class`. + +""" + +import struct +import sys +import threading +import unittest + +from tests._support import reimport_once_per_class +from tests.utilities._harness import capture + +#: Test tier (#1958): exercises threading, locking or warning and exception hooks, which +#: differ by interpreter. +__tier__ = 'ALL' + +#: An IPv4 header (DF clear, protocol UDP) and an 8-octet UDP header. +IPV4 = (bytes.fromhex('4500001c00010000401100000a0000010a000002') + + bytes.fromhex('3039003500080000')) +#: A GRE header of version 1, which :rfc:`2784` refuses, carrying :data:`IPV4`. +GRE_V1 = struct.pack('>HH', 1, 0x0800) + IPV4 +#: A ``LINUX_SLL`` frame whose ``ARPHRD_IPGRE`` payload is :data:`GRE_V1`. +SLL_GRE_V1 = struct.pack('>HHH8sH', 0, 778, 0, b'', 0x0800) + GRE_V1 +#: :data:`IPV4` with its version nibble set to 5. +IPV4_V5 = b'\x55' + IPV4[1:] +#: An Ethernet frame whose IPv4 payload is :data:`IPV4_V5`. +ETH_IPV4_V5 = bytes(12) + b'\x08\x00' + IPV4_V5 +#: A TCP header whose data offset, 60 octets, runs past its 20. +TCP_LONG = struct.pack('>HHIIBBHHH', 1, 2, 0, 0, 0xF0, 0x02, 0, 0, 0) +#: An Ethernet frame carrying a valid IPv4 header whose TCP payload is :data:`TCP_LONG`. +ETH_IPV4_TCP_LONG = (bytes(12) + b'\x08\x00' + + bytes.fromhex('4500002800010000400600000a0000010a000002') + TCP_LONG) + + +class TestHandledRefusalIsQuiet(unittest.TestCase): + """Pin that a refusal a next-layer fallback handles logs nothing 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: + pass + + self.sentinels = sentinel, thread_sentinel + sys.excepthook, threading.excepthook = self.sentinels + + def assert_hooks_untouched(self) -> None: + self.assertIs(sys.excepthook, self.sentinels[0]) + self.assertIs(threading.excepthook, self.sentinels[1]) + + def assert_quiet_fallback(self, klass, frame: 'bytes', chain: 'str', depth: 'int', # type: ignore[no-untyped-def] + error: 'str') -> None: + """``frame`` parses, keeping the layer ``depth`` down as ``Raw`` with ``error``, quietly.""" + from pcapkit.protocols.misc.raw import Raw + from pcapkit.utilities.logging import logger + + with capture(logger) as recorder: + parsed = klass(frame, len(frame)) + rebuilt = [klass.from_data(data).data for data in (parsed.info, parsed.info.to_dict())] + self.assertEqual(recorder.messages, []) + self.assert_hooks_untouched() + + layer = parsed + for _ in range(depth): + layer = layer.payload + self.assertIs(type(layer), Raw) + self.assertEqual(layer.info.error, error) + self.assertEqual(str(parsed.protochain), chain) + self.assertEqual(parsed.data, frame) + self.assertEqual(rebuilt, [frame, frame]) + + def assert_direct_construction_is_loud(self, klass, data: 'bytes', # type: ignore[no-untyped-def] + error: 'str') -> None: + """Building the refused layer itself raises, logs at ``CRITICAL`` and, outside devmode, hooks.""" + from pcapkit.utilities.exceptions import ProtocolError + from pcapkit.utilities.logging import DEVMODE, logger + + with capture(logger) as recorder: + with self.assertRaises(ProtocolError) as context: + klass(data, len(data)) + self.assertEqual(str(context.exception), error) + self.assertEqual(recorder.messages, [('CRITICAL', f'ProtocolError: {error}')]) + if not DEVMODE: + self.assertIsNot(sys.excepthook, self.sentinels[0]) + self.assertIsNot(threading.excepthook, self.sentinels[1]) + + def test_refused_gre_under_linux_sll_is_quiet(self) -> None: + """GRE refuses version 1; LinuxSLL's fallback keeps it as ``Raw``, and ``GRE(...)`` stays loud.""" + from pcapkit.protocols.link.gre import GRE + from pcapkit.protocols.link.linux_sll1 import LinuxSLL1 + + self.assert_quiet_fallback(LinuxSLL1, SLL_GRE_V1, 'LINUX_SLL1:Raw', 1, + 'GRE: invalid version: 1') + self.assert_direct_construction_is_loud(GRE, GRE_V1, 'GRE: invalid version: 1') + + def test_refused_ipv4_under_ethernet_is_quiet(self) -> None: + """IPv4 refuses version 5; Ethernet's fallback keeps it as ``Raw``, and ``IPv4(...)`` stays loud.""" + from pcapkit.protocols.internet.ipv4 import IPv4 + from pcapkit.protocols.link.ethernet import Ethernet + + self.assert_quiet_fallback(Ethernet, ETH_IPV4_V5, 'Ethernet:Internet_Protocol_version_4', 1, + '[IPv4] invalid version: 5') + self.assert_direct_construction_is_loud(IPv4, IPV4_V5, '[IPv4] invalid version: 5') + + def test_nested_refusal_is_quiet_and_the_block_ends_with_the_frame(self) -> None: + """TCP refused under IPv4 under Ethernet is quiet; once the frame is parsed, nothing is quieted.""" + from pcapkit.protocols.link.ethernet import Ethernet + from pcapkit.protocols.transport.tcp import TCP + from pcapkit.utilities import exceptions + + error = 'TCP: header length 60 runs past the end of the data (20 octets)' + self.assert_quiet_fallback(Ethernet, ETH_IPV4_TCP_LONG, 'Ethernet:IPv4:TCP', 2, error) + self.assertEqual(exceptions._caught_here.get(), ()) # pylint: disable=protected-access + self.assert_direct_construction_is_loud(TCP, TCP_LONG, error) + + +if __name__ == '__main__': + unittest.main()