diff --git a/tests/protocols/application/test_ftp_http1_raw_unit.py b/tests/protocols/application/test_ftp_http1_raw_unit.py index 7dd5ea3aa3..2b3e86d793 100644 --- a/tests/protocols/application/test_ftp_http1_raw_unit.py +++ b/tests/protocols/application/test_ftp_http1_raw_unit.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- """FTP and HTTP/1.* keep what they normalise. C.f. #1302, #1303, #1333. -- #1302: HTTP/1.* field values decoded with a guessed charset were encoded back - as UTF-8. The field lines are now kept as received (``raw_header``). -- #1303: a multi-line FTP reply arriving whole in one segment did not parse. The - lines after the first are now kept as received (``lines``). +- #1302: HTTP/1.* field values decoded with a guessed charset must not be + encoded back as UTF-8. The field lines are kept as received (``raw_header``). +- #1303: a multi-line FTP reply arriving whole in one segment parses. The lines + after the first are kept as received (``lines``). - #1333: the FTP command case and the HTTP/1.* whitespace -- around start-line tokens, around the field colon, and in ``obs-fold`` -- were normalised on rebuild. The parsed values stay normalised for lookup, and the original diff --git a/tests/protocols/application/test_ftp_http1_roundtrip_unit.py b/tests/protocols/application/test_ftp_http1_roundtrip_unit.py index 80c0c02097..0df4e0793b 100644 --- a/tests/protocols/application/test_ftp_http1_roundtrip_unit.py +++ b/tests/protocols/application/test_ftp_http1_roundtrip_unit.py @@ -10,7 +10,7 @@ one. - #1241: text decoded with a detected charset was encoded back as UTF-8, so non-UTF-8 octets in FTP arguments and HTTP/1.* start lines changed on rebuild. - The charset is now recorded on the data whenever UTF-8 would not reproduce the + The charset is recorded on the data whenever UTF-8 would not reproduce the octets. """ diff --git a/tests/protocols/application/test_ftp_http_unassigned_code_1443_unit.py b/tests/protocols/application/test_ftp_http_unassigned_code_1443_unit.py index 3c7f1365ae..fd1b11f026 100644 --- a/tests/protocols/application/test_ftp_http_unassigned_code_1443_unit.py +++ b/tests/protocols/application/test_ftp_http_unassigned_code_1443_unit.py @@ -1,13 +1,14 @@ # -*- coding: utf-8 -*- """FTP reply codes and HTTP status codes parse and rebuild for every three digits. -GitHub issue #1443: :class:`~pcapkit.const.ftp.return_code.ReturnCode` resolved -only 100 to 659 and :class:`~pcapkit.const.http.status_code.StatusCode` only -100 to 599, so a reply such as ``999 hi`` or a status line such as -``HTTP/1.1 999 X`` raised :exc:`~pcapkit.utilities.exceptions.EnumValueError` -and could not be parsed at all. Both wire fields are three digits, so every -value from 000 to 999 now resolves, to ``Unassigned`` inside the registry's -range and ``Unknown`` outside it, and the packet rebuilds byte for byte. +GitHub issue #1443: guards against +:class:`~pcapkit.const.ftp.return_code.ReturnCode` resolving only 100 to 659 and +:class:`~pcapkit.const.http.status_code.StatusCode` only 100 to 599, which made a +reply such as ``999 hi`` or a status line such as ``HTTP/1.1 999 X`` raise +:exc:`~pcapkit.utilities.exceptions.EnumValueError` and fail to parse at all. +Both wire fields are three digits, so every value from 000 to 999 resolves, to +``Unassigned`` inside the registry's range and ``Unknown`` outside it, and the +packet rebuilds byte for byte. ``make()`` also writes the code as three digits, so ``099`` keeps its zero. Every case builds its own octets in memory and reads no capture. Classes are diff --git a/tests/protocols/application/test_ftp_unit.py b/tests/protocols/application/test_ftp_unit.py index 23c6f313e1..913c9abf8d 100644 --- a/tests/protocols/application/test_ftp_unit.py +++ b/tests/protocols/application/test_ftp_unit.py @@ -116,20 +116,20 @@ def test_command_get_is_case_insensitive(self) -> None: # form raised too. self.assertIs(Command('retr'), Command.RETR) - # GitHub issue #860: a genuinely unknown command no longer registers + # GitHub issue #860: a genuinely unknown command does not register # at all -- the owner's ruling is that ``get``/``_missing_`` have no # way to supply ``feat``/``desc``/``type``/``conf``, so minting one # would register a permanently hollowed-out member; only # ``register()`` can do that properly. The *value* keeps the - # caller's own casing (unchanged from before #860, and the same - # convention :class:`~pcapkit.const.ftp.command.FEATCode` already - # used) -- only the *name* is canonicalised -- so a repeated call - # with the same casing is *equal* but never *identical* (nothing is - # cached to be identical to any more), while a different-case call - # is a genuinely different value and is correctly *not* equal: on - # ``main`` minting's cache silently returned the first casing seen - # for every later call regardless of case, which is exactly the - # "registered enum out of an unrecognised value" #860 removes. + # caller's own casing (the same convention + # :class:`~pcapkit.const.ftp.command.FEATCode` uses) -- only the + # *name* is canonicalised -- so a repeated call with the same casing + # is *equal* but never *identical* (nothing is cached to be + # identical to), while a different-case call is a genuinely + # different value and is correctly *not* equal. A minting cache + # would return the first casing seen for every later call + # regardless of case, which is exactly the "registered enum out of + # an unrecognised value" #860 rules out. unknown = Command.get('xyzw') self.assertEqual(unknown._name_, 'XYZW') self.assertEqual(unknown, 'xyzw') @@ -146,7 +146,8 @@ def test_command_get_is_case_insensitive(self) -> None: def test_ftp_read_parses_a_lowercase_request(self) -> None: """The case-insensitivity is reachable from wire data: ``ftp.py`` compiles ``FTP_REQUEST`` with :data:`re.I` and passes the match - verbatim, so a lowercase request used to crash the parse -- #582. + verbatim, so the lookup must accept a lowercase request rather than + crash the parse -- #582. """ from pcapkit.const.ftp.command import Command from pcapkit.protocols.application.ftp import FTP, Type diff --git a/tests/protocols/application/test_http_from_data_1154_unit.py b/tests/protocols/application/test_http_from_data_1154_unit.py index c82883ecda..9bcab4666f 100644 --- a/tests/protocols/application/test_http_from_data_1154_unit.py +++ b/tests/protocols/application/test_http_from_data_1154_unit.py @@ -1,19 +1,20 @@ # -*- coding: utf-8 -*- """``HTTP.from_data`` rebuilds the message it was parsed from. C.f. #1154. -:meth:`HTTP._make_data ` -chose the versioned class from a ``version`` key that neither HTTP/1.* nor -HTTP/2 data carries, so every ``HTTP.from_data(http.info)`` raised -``ProtocolError: invalid HTTP version: 0``. It now dispatches on the class of -the data, and passes the ``version`` that :meth:`HTTP.make -` dispatches on. +Guards against :meth:`HTTP._make_data +` choosing the versioned +class from a ``version`` key that neither HTTP/1.* nor HTTP/2 data carries, +which made every ``HTTP.from_data(http.info)`` raise ``ProtocolError: invalid +HTTP version: 0``. It dispatches on the class of the data, and passes the +``version`` that :meth:`HTTP.make ` +dispatches on. Behind that, :meth:`httpv2.HTTP._make_data -` returned the frame's -``length``, which ``ProtocolBase.__init__`` takes as the number of octets to -parse back -- the payload alone, nine short of the frame -- so every HTTP/2 -``from_data`` raised as well. ``make`` computes the Length itself, so the key -is no longer returned. +` must not return the +frame's ``length``, which ``ProtocolBase.__init__`` takes as the number of +octets to parse back -- the payload alone, nine short of the frame -- so every +HTTP/2 ``from_data`` would raise as well. ``make`` computes the Length itself, +so the key is not returned. The frames are the Scapy-built ones of ``test_http_guess_http2_1141_unit.LEGAL_FRAMES``, embedded as bytes. @@ -93,7 +94,7 @@ def test_dispatcher_rebuilds_http2_frames(self) -> None: self._round_trip(HTTP, bytes.fromhex(octets)) def test_httpv2_rebuilds_its_own_frames(self) -> None: - """``httpv2.HTTP.from_data`` no longer parses back only Length octets.""" + """``httpv2.HTTP.from_data`` parses back the whole frame, not only Length octets.""" from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 for name, octets in HTTP2_FRAMES: diff --git a/tests/protocols/application/test_http_guess_http2_1141_unit.py b/tests/protocols/application/test_http_guess_http2_1141_unit.py index 23e2e6deb1..0bf558e820 100644 --- a/tests/protocols/application/test_http_guess_http2_1141_unit.py +++ b/tests/protocols/application/test_http_guess_http2_1141_unit.py @@ -1,18 +1,18 @@ # -*- coding: utf-8 -*- """HTTP's version guess takes a bare frame for HTTP/2 only if a sender could write it. C.f. #1141. -Since #1138 an HTTP/2 frame's Length counts the payload alone -(:rfc:`9113#section-4.1`), so any nine octets whose Length fits the buffer are -self-consistent. :meth:`HTTP._guess_version +An HTTP/2 frame's Length counts the payload alone (:rfc:`9113#section-4.1`, +#1138), so any nine octets whose Length fits the buffer are self-consistent. +:meth:`HTTP._guess_version ` falls through to an -HTTP/2 trial parse when neither the preface nor an HTTP/1 start line matches, and -that trial accepted most zero-heavy binary: nine zero octets came back as -``HTTP/2 type=0``, a ``DATA`` frame on stream 0, which :rfc:`9113#section-6.1` -makes a protocol error. +HTTP/2 trial parse when neither the preface nor an HTTP/1 start line matches. A +trial that accepts any self-consistent header would take most zero-heavy binary: +nine zero octets would come back as ``HTTP/2 type=0``, a ``DATA`` frame on stream +0, which :rfc:`9113#section-6.1` makes a protocol error. -The guess now requires a frame type :rfc:`9113` defines, no flag that type +The guess requires a frame type :rfc:`9113` defines, no flag that type leaves undefined, a clear reserved bit, and a stream that type may be sent on. -Explicit ``version=2`` parsing is not affected, and is asserted unchanged here. +Explicit ``version=2`` parsing is not subject to the guess, as asserted here too. The legal frames in :data:`LEGAL_FRAMES` were built with Scapy 2.7.0's :mod:`scapy.contrib.http2` (``H2Frame`` over each frame class) and are embedded @@ -70,8 +70,9 @@ ('ORIGIN, not an RFC 9113 type', '0000000c0000000000'), ) -#: The seeded fuzz. Pre-fix, this corpus produced 566 HTTP/2 labels, of which -#: 249 were ``DATA`` (most on stream 0) and 317 frames of unknown type. +#: The seeded fuzz. Measured without the guess's checks, this corpus produced +#: 566 HTTP/2 labels, of which 249 were ``DATA`` (most on stream 0) and 317 +#: frames of unknown type. FUZZ_SEED = 1141 FUZZ_COUNT = 5000 PRE_FIX_HTTP2_LABELS = 566 @@ -111,7 +112,7 @@ def test_nine_zero_octets_are_not_guessed_as_http2(self) -> None: self._guess(bytes(9)) def test_explicit_version_still_parses_nine_zero_octets(self) -> None: - """Explicit HTTP/2 parsing stays RFC-exact: only the guess is tightened.""" + """Explicit HTTP/2 parsing is RFC-exact: the checks apply to the guess only.""" from pcapkit.protocols.application.http import HTTP http = HTTP(bytes(9), 9, version=2) diff --git a/tests/protocols/application/test_http_runtime.py b/tests/protocols/application/test_http_runtime.py index fdfe80d363..056b9985c2 100644 --- a/tests/protocols/application/test_http_runtime.py +++ b/tests/protocols/application/test_http_runtime.py @@ -104,13 +104,14 @@ def test_malformed_http_payload_falls_back_to_raw(self) -> None: self.assertEqual(tcp.payload.name, 'Unknown') # The registry key that selected the protocol whose parse then failed -- - # TCP port 80 here. It used to be :data:`None`, because ``beholder`` was - # the one path to :class:`Raw` that did not forward ``alias``; that made a - # *registered* number less informative than an unregistered one, which - # reaches Raw through ``_import_next_layer`` and keeps its enumeration. + # TCP port 80 here. Guards against it reading :data:`None`, as it would if + # ``beholder`` did not forward ``alias`` on its path to :class:`Raw`; that + # would make a *registered* number less informative than an unregistered + # one, which reaches Raw through ``_import_next_layer`` and keeps its + # enumeration. # ``Data_Raw.protocol`` is "the original enumeration of this protocol", and # 80 is what this payload arrived as, so the value belongs here. The - # protochain still reads ``Raw``: a plain :obj:`int` has no ``name`` to + # protochain reads ``Raw`` all the same: a plain :obj:`int` has no ``name`` to # render, and only an enumeration key -- SCTP's payload protocol # identifier, say -- shows through. self.assertEqual(tcp.payload.info.protocol, 80) diff --git a/tests/protocols/application/test_http_test_start_line_1519_unit.py b/tests/protocols/application/test_http_test_start_line_1519_unit.py index 1c0ef887ed..7e0e7e79ae 100644 --- a/tests/protocols/application/test_http_test_start_line_1519_unit.py +++ b/tests/protocols/application/test_http_test_start_line_1519_unit.py @@ -3,11 +3,10 @@ The predicate :meth:`HTTP._guess_version ` uses to recognise an -HTTP/1.* start line was ``httpv1._test_start_line``, imported lazily by the -dispatcher in ``http.py``. It is now -:func:`~pcapkit.protocols.application.http.test_start_line`, and the patterns it +HTTP/1.* start line is :func:`~pcapkit.protocols.application.http.test_start_line`, +not a private ``httpv1._test_start_line`` imported lazily, and the patterns it shares with the HTTP/1.* parser -- ``_RE_METHOD``, ``_RE_VERSION``, -``_RE_STATUS`` and ``_HTTP2_PREFACE_HEADER`` -- moved with it, so ``httpv1.py`` +``_RE_STATUS`` and ``_HTTP2_PREFACE_HEADER`` -- live beside it, so ``httpv1.py`` imports them from ``http.py`` rather than keeping a copy of its own. The predicate is imported inside each test, never at module level: pytest diff --git a/tests/protocols/application/test_http_unit.py b/tests/protocols/application/test_http_unit.py index cb42561e37..fa6e878d04 100644 --- a/tests/protocols/application/test_http_unit.py +++ b/tests/protocols/application/test_http_unit.py @@ -176,18 +176,18 @@ def make(**kwargs): def test_http_make_dispatches_to_real_versioned_classes(self) -> None: """Regression test for GH-452. - ``HTTP.make`` used to dispatch with ``protocol.make(**kwargs)``, where - ``protocol`` is the imported *class* -- an unbound call, since neither - ``HTTPv1.make`` nor ``HTTPv2.make`` is declared ``staticmethod`` or - ``classmethod`` on the real class (both are plain ``def make(self, - ...)``, matching the abstract ``ProtocolBase.make``). Every real call - therefore raised ``TypeError: make() missing 1 required positional - argument: 'self'``. + Guards against ``HTTP.make`` dispatching with + ``protocol.make(**kwargs)``, where ``protocol`` is the imported *class* + -- an unbound call, since neither ``HTTPv1.make`` nor ``HTTPv2.make`` is + declared ``staticmethod`` or ``classmethod`` on the real class (both are + plain ``def make(self, ...)``, matching the abstract + ``ProtocolBase.make``). Every real call would then raise ``TypeError: + make() missing 1 required positional argument: 'self'``. This deliberately exercises the **real** ``HTTPv1``/``HTTPv2`` classes rather than a fake with a ``staticmethod`` ``make`` -- ``test_http_read_make_and_guess_version_delegation_paths`` above uses - exactly such a fake, which is what let the original defect through: + exactly such a fake, which is how that defect gets through: a ``staticmethod`` absorbs an unbound call the same as a bound one, so a fake-based assertion passes whether or not ``self`` is actually threaded through. @@ -223,33 +223,17 @@ def test_http_construction_reaches_the_versioned_make_callee(self) -> None: :meth:`ProtocolBase.pack` calls ``target.make(**kwargs)`` on :meth:`~ProtocolBase._packing_target`, which while constructing is the instance itself -- a *bound* call on the - outer ``HTTP`` instance, which is what actually reaches the inner, - previously-unbound ``protocol.make(**kwargs)``. So the reachable - reproduction is ``HTTP(version=1, ...)``, not ``HTTP.make(...)``: - - - on the unfixed code, this raises ``TypeError: HTTP.make() missing - 1 required positional argument: 'self'`` -- the *same* exception - text as the wrong reproduction, but reached legitimately this time. - - on the fixed code, the constructed packet is read back and rejected, - as ``ProtocolError: HTTP: invalid format`` -- proving the callee was - actually reached, which "no ``TypeError``" alone would not. - - The asserted message was ``'HTTP/1: invalid format'`` until #787. - Nothing about *this* regression test changed: the exception type, and - the fact that reaching it at all proves the versioned ``make`` ran, are - as they were. What moved is which line raises. ``httpv1.HTTP`` now - reports a malformed message as ``ProtocolError`` itself, so - ``HTTP.read``'s ``except ProtocolError: raise`` re-raises it unchanged - instead of ``except ValueError`` re-labelling it with the version it had - dispatched on. The bare message is also what this path *already* gave - whenever ``_read_http_header`` rejected a start line it could not - recognise, so #787 makes one message of two rather than introducing a - new one. - - Since #1041 the read-back succeeds, so the callee is proven reached by - its output instead: ``make`` builds ``b'GET / HTTP/1.1\\r\\n\\r\\n'``, - a field-less request, and ``_read_http_header`` now accepts zero field - lines (:rfc:`9112#section-2.1`) where it used to reject them. + outer ``HTTP`` instance, which is what actually reaches the inner + ``protocol.make(**kwargs)`` dispatch. So the reachable reproduction is + ``HTTP(version=1, ...)``, not ``HTTP.make(...)``. Without the fix it + raises ``TypeError: HTTP.make() missing 1 required positional argument: + 'self'`` -- the *same* exception text as the wrong reproduction, but + reached legitimately. + + The callee is proven reached by its output, which "no ``TypeError``" + alone would not prove: ``make`` builds ``b'GET / HTTP/1.1\\r\\n\\r\\n'``, + a field-less request, and ``_read_http_header`` accepts zero field lines + (:rfc:`9112#section-2.1`, #1041), so the read-back succeeds. """ from pcapkit.protocols.application.http import HTTP @@ -260,13 +244,13 @@ def test_http_construction_reaches_the_versioned_make_callee(self) -> None: def test_http_read_explicit_version_uses_same_buffer_as_guess(self) -> None: """Regression test for GH-447. - The explicit ``version=`` path passed ``self._file`` -- already - drained by the outer read -- instead of ``self._data``, so it built - the sub-protocol from a short or empty buffer while - ``_guess_version``, built from the same bytes, worked fine. This pins - the fix by asserting *which* buffer object reaches the sub-protocol - constructor, for both ``version=1`` and ``version=2``, rather than - merely that a call succeeds. + Guards against the explicit ``version=`` path passing ``self._file`` + -- already drained by the outer read -- instead of ``self._data``, + which builds the sub-protocol from a short or empty buffer while + ``_guess_version``, built from the same bytes, works fine. Pinned by + asserting *which* buffer object reaches the sub-protocol constructor, + for both ``version=1`` and ``version=2``, rather than merely that a + call succeeds. """ from pcapkit.protocols.application.http import HTTP @@ -326,20 +310,17 @@ def test_http_read_explicit_version_wraps_malformed_payload(self) -> None: self.assertIsInstance(ctx.exception.__cause__, ValueError) def test_http_read_explicit_version_still_wraps_a_bare_value_error(self) -> None: - """``HTTP.read``'s ``except ValueError`` net stays, and is pinned here. - - #787 stops ``httpv1.HTTP`` from raising a bare :class:`ValueError` at - all, and that wrapper is what used to catch it -- so after the fix no - real payload reaches it, and the test above that used to cover it - (``..._wraps_malformed_payload``) now gets its chained ``__cause__`` from - ``httpv1.HTTP`` instead. Left in place rather than deleted, because the - guarantee is the explicit path's and not one delegate's: a - :class:`ValueError` out of *either* versioned class, from any site #787 - did not touch, must still reach the caller as a - :class:`~pcapkit.utilities.exceptions.ProtocolError`. Pinned with a - stand-in that raises one, since nothing in the library does any more -- - an untested branch is how a net like this comes to look redundant and - gets removed. + """``HTTP.read``'s ``except ValueError`` net is kept, and is pinned here. + + ``httpv1.HTTP`` does not raise a bare :class:`ValueError` (#787), so no + real payload reaches that net, and ``..._wraps_malformed_payload`` above + gets its chained ``__cause__`` from ``httpv1.HTTP``. The net is kept + because the guarantee is the explicit path's and not one delegate's: a + :class:`ValueError` out of *either* versioned class must reach the + caller as a :class:`~pcapkit.utilities.exceptions.ProtocolError`. Pinned + with a stand-in that raises one, since nothing in the library does -- an + untested branch is how a net like this comes to look redundant and gets + removed. """ from pcapkit.protocols.application.http import HTTP @@ -360,8 +341,7 @@ def test_http_read_explicit_version_still_wraps_a_bare_value_error(self) -> None self.assertIsInstance(ctx.exception.__cause__, ValueError) # A ``ProtocolError`` from the delegate is re-raised as it is, not - # re-labelled -- which is why #787's normalisation changes the message - # the two tests named in its commit assert. + # re-labelled with the version it was dispatched on. with mock.patch('pcapkit.protocols.application.httpv1.HTTP', mock.Mock(side_effect=ProtocolError('HTTP: invalid format'))): with self.assertRaises(ProtocolError) as ctx: @@ -372,9 +352,10 @@ def test_http_read_explicit_version_still_wraps_a_bare_value_error(self) -> None def test_http_make_data_delegates_to_httpv1(self) -> None: """The version comes from the class of the parsed data (#1154). - This used to drive ``_make_data`` with a stand-in carrying a ``version`` - key, which no parsed HTTP/1.* data has, so it pinned the lookup that made - every real ``from_data`` raise ``invalid HTTP version: 0``. + Driven with real parsed data rather than a stand-in carrying a + ``version`` key, which no parsed HTTP/1.* data has: such a stand-in pins + the lookup that makes every real ``from_data`` raise ``invalid HTTP + version: 0``. """ from pcapkit.const.http.method import Method @@ -410,44 +391,27 @@ def test_http_make_data_delegates_to_httpv2(self) -> None: self.assertIs(values['frame'], data) def test_http_guess_version_falls_through_to_the_http2_arm(self) -> None: - """``_guess_version`` must reach its second arm, which it never did (#787). - - This test used to be ``..._propagates_malformed_payload_value_error`` - and asserted the opposite -- that these bytes raise :class:`ValueError` - -- because that is what the unfixed code did: ``httpv1.HTTP`` failed - with a bare :class:`ValueError`, which ``contextlib.suppress( - ProtocolError)`` does not catch, so the exception left ``_guess_version`` - from the *first* arm and the HTTP/2 arm below it was dead code. - - The expectation is inverted rather than deleted because the old one - pinned the defect as though it were the contract. Note the assertion - would still have *passed* untouched -- + """``_guess_version`` must reach its second arm (#787). + + Guards against ``httpv1.HTTP`` failing with a bare :class:`ValueError`, + which ``contextlib.suppress(ProtocolError)`` does not catch: the + exception would leave ``_guess_version`` from the *first* arm, and the + HTTP/2 arm below it would be dead code. ``assertRaises(ValueError)`` + cannot tell the two apart -- :class:`~pcapkit.utilities.exceptions.ProtocolError` is a - :class:`ValueError` subclass, so ``assertRaises(ValueError)`` cannot tell - a normalised protocol error from a stray stdlib one -- had these - particular bytes still raised at all. + :class:`ValueError` subclass -- so the arms entered are recorded. What is asserted is that the second arm was *entered*, not the answer it - gave -- and, as of #799, the answer for *these* bytes is itself - ``ProtocolError`` rather than ``version='2'``. An earlier revision of - this test pinned ``version == '2'`` here and #799's review caught that - as exactly the trap this docstring already warned against: "not"'s - three octets read as a 24-bit declared length of 7,237,492, wildly past - the fifteen actually available, and #799 now requires the declared - length to be consistent with the buffer -- so arm 2 is entered - (dead-code-ness stays fixed) but correctly declines this payload too, - and both arms failing is what drives ``_guess_version`` to its own - ``ProtocolError``. Real HTTP/2 wire bytes -- self-consistently declared - -- are pinned to ``version == '2'`` by - ``test_guess_version_reaches_http2_on_the_connection_preface``, which is - where that assertion belongs. - - Since #1141 the HTTP/2 arm runs only on a frame header a sender could - write, and ``b'not http at all'`` (type ``0x20``) is turned away before - it. So the payload is now a ``DATA`` frame header on stream 1 that - declares 255 octets of payload, with six behind it. That header passes - the plausibility test, so arm 2 is entered, and the declared length then - makes the arm decline it, as "not"'s did. + gave: the answer for *these* bytes is itself ``ProtocolError`` rather + than ``version='2'``. The payload is a ``DATA`` frame header on stream 1 + that declares 255 octets of payload, with six behind it. That header + passes the plausibility test the HTTP/2 arm applies first (#1141), so + arm 2 is entered, and the declared length, which must be consistent with + the buffer (#799), then makes the arm decline it. Both arms failing is + what drives ``_guess_version`` to its own ``ProtocolError``. Real HTTP/2 + wire bytes -- self-consistently declared -- are pinned to ``version == + '2'`` by ``test_guess_version_reaches_http2_on_the_connection_preface``, + which is where that assertion belongs. """ from pcapkit.protocols.application.http import HTTP @@ -474,8 +438,8 @@ def spy(*args: object, **kwargs: object) -> object: HTTP._guess_version(http, len(http._data)) # Both arms ran, in order: the first was tried and failed, and the - # second was reached -- which is exactly what it never used to be -- - # even though it goes on to decline these particular bytes too. + # second was reached, even though it goes on to decline these particular + # bytes too. self.assertEqual(entered, ['httpv1', 'httpv2']) def test_guess_version_reaches_http2_on_the_connection_preface(self) -> None: @@ -483,35 +447,22 @@ def test_guess_version_reaches_http2_on_the_connection_preface(self) -> None: ``b'PRI * HTTP/2.0\\r\\n\\r\\nSM\\r\\n\\r\\n'`` plus a SETTINGS frame is what an HTTP/2 connection opens with, and ``httpv2.HTTP`` parses the - *frame* (on its own) and reports ``version='2'``. Through the proxy it - raised ``ValueError: not enough values to unpack (expected 2, got 1)`` - instead, from ``httpv1.HTTP`` -- so the guess could not reach the answer - its own second arm already had. + *frame* (on its own) and reports ``version='2'``. Guards against the + guess raising ``ValueError: not enough values to unpack (expected 2, got + 1)`` from ``httpv1.HTTP`` instead, which keeps it from the answer its own + second arm has. Asserted against the explicit ``version=2`` path rather than on its own, because "``read()`` and ``read(version=2)`` agree" is the property #787 - is about: the explicit path worked throughout, and only the guess did - not. - - The preface itself is asserted separately, and *not* prepended to the - frame in one buffer as the original #787 reproduction did. That - construction only ever "worked" by exploiting the exact defect #799 - fixes: ``httpv2.HTTP`` has no notion of the preface at all, so it read - the preface's own ASCII octets as a frame header -- ``b'PRI'`` as a - 23-bit-plus declared length of 5,265,993 -- and pre-#799 the guard - never checked that against the real buffer, so it "succeeded" reporting - a length backed by nothing. #799's ``schema.length > length`` check - rejects that inconsistency on both the guess and the explicit path - alike, which is correct: recognising the preface and skipping past it - is a real gap (tracked as #800), but reading it as if it were binary - framing was never a fix for that gap, only an accident #799 closes. - - #800 has since closed that gap, so the one-buffer construction is - asserted here on the *explicit* path only: ``read(version=2)`` still has - no notion of the preface and must still refuse to read it as framing, - while the guess path now identifies the preface by prefix compare and - parses the frame after it. That half moved to - ``test_guess_version_identifies_the_http2_connection_preface`` below. + is about. + + The preface prepended to the frame in one buffer is asserted here on the + *explicit* path only: ``read(version=2)`` skips the preface (#1242) + rather than reading its ASCII octets as a frame header, which would make + ``b'PRI'`` a declared length of 5,265,993 that the ``schema.length > + length`` check (#799) refuses. The guess path's handling of the preface + is pinned by ``test_guess_version_identifies_the_http2_connection_preface`` + below. """ import io @@ -553,17 +504,15 @@ def test_guess_version_identifies_the_http2_connection_preface(self) -> None: ``b'PRI * HTTP/2.0\\r\\n\\r\\nSM\\r\\n\\r\\n'`` followed by a SETTINGS frame is what every HTTP/2 connection opens with - (:rfc:`9113#section-3.4`), and before this change the dispatcher had no - notion of it: the HTTP/1 arm declined it and the HTTP/2 arm read the + (:rfc:`9113#section-3.4`). Without the prefix compare the dispatcher has + no notion of it: the HTTP/1 arm declines it and the HTTP/2 arm reads the preface's own ASCII as a frame header, ``b'PRI'`` becoming a declared - length of 5,265,993. #799/#802 then made that inconsistency a refusal, - which was right in itself but left a real HTTP/2 connection opening - reported as not-HTTP at all -- measured on ``f046b38f8``/``4530424df``, - both the bare preface and preface-plus-SETTINGS raised ``unknown HTTP - version``. + length of 5,265,993, which the consistency check of #799/#802 refuses -- + so a real HTTP/2 connection opening, bare preface or + preface-plus-SETTINGS, would raise ``unknown HTTP version``. Three things are asserted, because "it answers 2" alone would also be - true of the accident this replaces: + true of a parse that consumed the preface as framing: * the answer is ``version='2'``; * the parsed frame is *the SETTINGS frame*, byte-identical to reading @@ -626,11 +575,11 @@ def test_guess_version_reports_a_preface_with_no_frame_as_such(self) -> None: per packet and has no representation for a frameless segment, so there is nothing to return and the payload is refused. - What the fix buys here is the *diagnosis*, which is why the message is - asserted rather than merely the exception type: on ``4530424df`` this - answered ``unknown HTTP version``, indistinguishable from genuine - garbage, and it now says which of the two it was. Reporting a version - with no data would be the dishonest alternative. + What is pinned here is the *diagnosis*, which is why the message is + asserted rather than merely the exception type: ``unknown HTTP + version`` would be indistinguishable from genuine garbage, and the + message says which of the two it was. Reporting a version with no data + would be the dishonest alternative. """ import io @@ -655,20 +604,19 @@ def test_guess_version_reports_a_preface_with_no_frame_as_such(self) -> None: # And a preface followed by a frame that trips the #805 residual -- a # sixteen-octet GOAWAY, whose fixed ``stream`` and ``error`` fields alone - # want eight octets after the header -- must come back catchable. #805 - # is now fixed at its actual root by #811: ``FieldBase.length`` raises - # ``ProtocolError`` itself on a negative resolved length, instead of - # letting a bare :exc:`struct.error` escape from :func:`struct.calcsize`. + # want eight octets after the header -- must come back catchable. + # ``FieldBase.length`` raises ``ProtocolError`` itself on a negative + # resolved length (#811), instead of letting a bare :exc:`struct.error` + # escape from :func:`struct.calcsize`. # So the ``debug`` field -- whose length is ``pkt['__length__']`` minus # the fixed ``stream``/``error`` octets, negative once the declared frame # length outruns this 16-octet buffer -- raises that ``ProtocolError`` # directly, and ``http.py``'s dispatcher (``except ProtocolError: raise``) # passes it through unchanged rather than wrapping it as # ``'HTTP/2: invalid format'``, exactly as it does for the - # '9-octet frame header' case above. What #814 needed preserved survives - # this: it is still a catchable ``BaseError``, still not a bare - # :exc:`struct.error`, and still chained to the original one via - # ``__cause__`` -- only the message is now the more specific one #811 + # '9-octet frame header' case above. As #814 requires, it is a catchable + # ``BaseError``, not a bare :exc:`struct.error`, and chained to the + # original one via ``__cause__``, with the specific message #811 # provides. import struct @@ -688,19 +636,17 @@ def test_guess_version_reports_a_preface_with_no_frame_as_such(self) -> None: def test_guess_version_does_not_classify_text_as_http2(self) -> None: """Garbage text must never come back HTTP/2. - ``b'foo bar baz\\r\\nX: y\\r\\n\\r\\n'`` answered ``version='2'`` on - ``main`` before #802 -- the headline wrong answer of #800 -- because the - HTTP/2 arm accepted any buffer of nine octets or more and ``b'foo'`` read - as a declared length of 6,712,175 that nothing checked. + Guards against ``b'foo bar baz\\r\\nX: y\\r\\n\\r\\n'`` answering + ``version='2'`` -- the headline wrong answer of #800 -- as it does when + the HTTP/2 arm accepts any buffer of nine octets or more and ``b'foo'`` + reads as a declared length of 6,712,175 that nothing checks. - Honest about what closed it: #802's ``schema.length > length`` check - already refuses this, so this case passes on ``4530424df`` too and is a - *regression guard* rather than a fix demonstration. It is worth pinning - here all the same, because after #800 the answer no longer depends on - that guard at all: text does not match the preface and does not match an - HTTP/1 start line, so it is never positively identified as anything, and - the fall-through is the only route left to it. Both defences are asserted - so that loosening either one is noticed. + Two defences refuse it. #802's ``schema.length > length`` check refuses + the inconsistent length, and independently of that check (#800) text + does not match the preface and does not match an HTTP/1 start line, so + it is never positively identified as anything, and the fall-through is + the only route left to it. Both defences are asserted so that loosening + either one is noticed. """ import io @@ -728,19 +674,18 @@ def test_guess_version_does_not_classify_text_as_http2(self) -> None: def test_guess_version_commits_to_http1_once_the_start_line_says_so(self) -> None: """An identified HTTP/1 message is not re-tried as HTTP/2 (#800). - This is the direction that matters for #682, which is about to route 231 - real HTTP/1 frames through this dispatcher. Before the fix, an HTTP/1 - message the HTTP/1 parser refused was handed to the HTTP/2 arm, which - accepts any self-consistent buffer of nine octets or more -- so the - answer for a malformed HTTP/1 message depended on what its first three - ASCII octets happened to spell as a 24-bit length. Now the start line - decides the version and the HTTP/1 parser's own verdict is the answer. + This is the direction that matters for the real HTTP/1 frames #682 + routes through this dispatcher. Guards against an HTTP/1 message the + HTTP/1 parser refuses being handed to the HTTP/2 arm, which accepts any + self-consistent buffer of nine octets or more -- so the answer for a + malformed HTTP/1 message would depend on what its first three ASCII + octets happen to spell as a 24-bit length. The start line decides the + version and the HTTP/1 parser's own verdict is the answer. Asserted on the message, because the exception *type* is - ``ProtocolError`` either way: on ``4530424df`` these payloads came back - ``unknown HTTP version`` (both arms having declined), and they now come - back ``HTTP: invalid format`` from the version that was actually - identified. + ``ProtocolError`` either way: a payload both arms declined comes back + ``unknown HTTP version``, while these come back ``HTTP: invalid format`` + from the version that was actually identified. """ import io @@ -900,11 +845,11 @@ def test_guess_version_keeps_an_upgrade_h2c_exchange_on_http1(self) -> None: self.assertEqual(upgraded.info.header['Upgrade'], 'h2c') def test_guess_version_still_prefers_http1_for_http1_bytes(self) -> None: - """HTTP/1 is tried first and must still win, request and response alike. + """HTTP/1 is tried first and must win, request and response alike. - The arms are ordered, so making the second reachable is only safe if the - first still claims everything it used to -- which is what stops #787's - fix from relabelling ordinary HTTP/1 traffic as HTTP/2. + The arms are ordered, so a reachable second arm is only safe if the first + claims every HTTP/1 message -- which is what stops ordinary HTTP/1 + traffic being relabelled as HTTP/2 (#787). """ import io @@ -927,7 +872,7 @@ def test_guess_version_still_prefers_http1_for_http1_bytes(self) -> None: self.assertEqual(guessed.info, explicit.info) def test_guess_version_still_reports_an_unknown_http_version(self) -> None: - """Bytes neither arm accepts must still raise ``unknown HTTP version``. + """Bytes neither arm accepts must raise ``unknown HTTP version``. ``_guess_version``'s closing ``raise`` is only reachable when *both* arms fail with :class:`~pcapkit.utilities.exceptions.ProtocolError`, so @@ -938,12 +883,12 @@ def test_guess_version_still_reports_an_unknown_http_version(self) -> None: A frame is used as the probe rather than short garbage because it exercises the intended route: both arms declining with - ``ProtocolError``. Short garbage now arrives here too, by way of the + ``ProtocolError``. Short garbage arrives here too, by way of the :exc:`struct.error` that ``_guess_version`` suppresses alongside ``ProtocolError``, and ``test_guess_version_reports_unknown_version_for_a_short_payload`` - covers that separately -- it used to escape uncaught, which is the second - leak of #787's shape. + covers that separately -- it guards against that error escaping + uncaught, the second leak of #787's shape. """ import io @@ -960,28 +905,28 @@ def test_guess_version_still_reports_an_unknown_http_version(self) -> None: def test_guess_version_reports_unknown_version_for_a_short_payload(self) -> None: """A payload too short for an HTTP/2 frame header must not leak :exc:`struct.error`. - Making the HTTP/2 arm reachable routed a class of payload that used to - stop at the HTTP/1 arm into ``httpv2.HTTP`` for the first time, and under - nine octets that class fails *inside* the schema machinery rather than in - ``read``: ``FieldBase.length`` hands :func:`struct.calcsize` a template - built from a negative length, and the resulting :exc:`struct.error` - derives straight from :exc:`Exception`. It is therefore not a - ``ProtocolError``, not a :exc:`ValueError`, and not + With the HTTP/2 arm reachable (#787), a payload the HTTP/1 arm declines + goes on into ``httpv2.HTTP``. Guards against a payload under nine octets + failing *inside* the schema machinery rather than in ``read``: + :func:`struct.calcsize` handed a template built from a negative length + raises :exc:`struct.error`, which derives straight from + :exc:`Exception`. That is not a ``ProtocolError``, not a + :exc:`ValueError`, and not :exc:`~pcapkit.utilities.exceptions.StructError`, so none of ``_guess_version``'s suppression, ``read``'s ``except ValueError`` net, - nor a caller's ``except BaseError`` saw it -- it left the proxy bare. + nor a caller's ``except BaseError`` would see it -- it would leave the + proxy bare. Asserted on all three of those nets, because "it raises something" is not - the property that was missing; "a caller who catches protocol errors - catches this" is. + the property at stake; "a caller who catches protocol errors catches + this" is. - The underlying defect is ``httpv2.HTTP``'s, and predates #787: the same - :exc:`struct.error` comes out of ``HTTPv2`` constructed directly and out - of ``HTTP(..., version=2)``, on the base revision as much as on this one. - What #787 changed is that the *guess* path reaches it, so the fix belongs - at this dispatcher's boundary, where both routes into a versioned parser - already normalise their failures. Both routes are pinned here for that - reason. + ``httpv2.HTTP.unpack`` refuses a buffer under nine octets with + ``ProtocolError`` before the schema layer runs (#799), so that + :exc:`struct.error` is what the guard prevents rather than what such a + payload meets. Both routes into ``httpv2.HTTP`` -- the guess and + ``HTTP(..., version=2)`` -- reach the same short-buffer path, so both are + pinned here. """ import io @@ -999,7 +944,7 @@ def test_guess_version_reports_unknown_version_for_a_short_payload(self) -> None with self.assertRaises(ProtocolError) as ctx: HTTP(io.BytesIO(raw), len(raw), **kwargs) - # The nets that used to miss it. + # The nets a bare struct.error would miss. self.assertIsInstance(ctx.exception, ValueError) self.assertIsInstance(ctx.exception, BaseError) self.assertNotIsInstance(ctx.exception, struct.error) @@ -1021,9 +966,8 @@ def test_httpv1_read_raises_protocol_error_for_a_malformed_message(self) -> None bare :class:`ValueError`, and the reproduction only reaches the second. Asserted on ``httpv1.HTTP`` directly rather than through the proxy - because that is the difference between the two candidate fixes: the - contract now holds for every caller of ``httpv1.HTTP``, not only for - ``_guess_version``'s first arm. + because the contract holds for every caller of ``httpv1.HTTP``, not only + for ``_guess_version``'s first arm. ``__cause__`` is asserted because the chain is what keeps the underlying unpacking error reportable, and what keeps @@ -1039,7 +983,7 @@ def test_httpv1_read_raises_protocol_error_for_a_malformed_message(self) -> None cases = ( # No header/body separator at all -- ``read``'s own unpacking. ('no separator', b'not http at all'), - # The HTTP/2 preface is no longer refused by an unpacking (#1041) -- + # The HTTP/2 preface is refused by name, not by an unpacking (#1041) -- # ``test_httpv1_still_refuses_the_http2_connection_preface`` covers it. # A start line of fewer than three whitespace-separated tokens. ('two-token start line', b'GET /\r\nHost: example.com\r\n\r\n'), @@ -1059,9 +1003,10 @@ def test_httpv1_reads_a_request_with_no_header_fields(self) -> None: :rfc:`9112#section-2.1` gives ``*( field-line CRLF )``, so zero fields is legal. The header block is then the start line alone, with no CRLF in - it, which ``_read_http_header`` used to refuse as malformed. Asserted - through the dispatcher too: ``test_start_line`` mirrored that refusal, - so ``HTTP`` answered ``unknown HTTP version`` for the same bytes. + it, which ``_read_http_header`` must not refuse as malformed. Asserted + through the dispatcher too: were ``test_start_line`` to mirror such a + refusal, ``HTTP`` would answer ``unknown HTTP version`` for the same + bytes. """ import io @@ -1108,9 +1053,9 @@ def test_httpv1_reads_a_response_with_no_header_fields_and_a_body(self) -> None: def test_httpv1_make_read_round_trips_a_message_with_no_header_fields(self) -> None: """``make`` emits zero fields as ``start-line CRLF CRLF``; ``read`` takes it back (#1041). - ``make`` already produced those bytes when given no ``headers``, so - constructing ``HTTPv1(method='GET', uri='/', http_version='1.1')`` raised - from its own ``read``. The response is also round-tripped through + ``make`` produces those bytes when given no ``headers``, so constructing + ``HTTPv1(method='GET', uri='/', http_version='1.1')`` exercises its own + ``read`` on them. The response is also round-tripped through ``_make_data``, so an empty parsed header dict is shown to rebuild the same bytes. The empty-bodied request's ``from_data`` round trip is :meth:`test_httpv1_from_data_round_trips_a_message_with_an_empty_body` @@ -1141,10 +1086,10 @@ def test_httpv1_from_data_round_trips_a_message_with_an_empty_body(self) -> None """``from_data(info)`` rebuilds a message whose body is empty (#1050). ``read`` records an empty body as :obj:`None`, and that stays the public - ``info.body`` value; ``_make_data`` used to pass it straight to ``make``, - which concatenated it onto the header bytes and raised ``TypeError``. - The response with a body is a regression guard: it round-tripped before - the fix and must keep doing so. + ``info.body`` value. Guards against ``_make_data`` passing it straight to + ``make``, which would concatenate it onto the header bytes and raise + ``TypeError``. The response with a body pins the non-empty case + alongside. """ import io @@ -1171,11 +1116,10 @@ def test_httpv1_still_refuses_the_http2_connection_preface(self) -> None: The preface's header block, once split at the first CRLFCRLF, is ``PRI * HTTP/2.0`` -- a start line with zero fields, which ``_RE_METHOD`` - and ``_RE_VERSION`` both accept. Refusing every CRLF-less header used to - be what turned it away; it is now refused by name. A regression guard: - this held before the fix and must hold after it, to the same message, in - the parser and the predicate alike, and the dispatcher's answers for a - complete and a truncated preface are unchanged. + and ``_RE_VERSION`` both accept. It is refused by name, so accepting a + CRLF-less header does not let it through. Pinned to the same message in + the parser and the predicate alike, along with the dispatcher's answers + for a complete and a truncated preface. """ import io @@ -1205,13 +1149,13 @@ def test_httpv1_refuses_a_field_line_with_no_colon(self) -> None: """A colon-less field line must raise ``ProtocolError``, not :exc:`IndexError`. ``re.split(rb'\\s*:\\s*', field, maxsplit=1)`` returns a *one*-element - list for a field line with no colon in it, so ``item[1]`` raised - :exc:`IndexError`. That is the same defect #787 is about, one loop + list for a field line with no colon in it, so ``item[1]`` would raise + :exc:`IndexError`. That would be the same defect #787 is about, one loop further on and wearing a different exception type: an :exc:`IndexError` - is neither a :exc:`ValueError` nor a ``ProtocolError``, so it escaped - ``HTTP._guess_version``'s suppression exactly as the bare - :exc:`ValueError` did and left the HTTP/2 arm dead for this whole class - of input as well. + is neither a :exc:`ValueError` nor a ``ProtocolError``, so it would + escape ``HTTP._guess_version``'s suppression exactly as a bare + :exc:`ValueError` does and leave the HTTP/2 arm dead for this whole + class of input as well. Refused rather than skipped: dropping the line would hand back a message whose header fields are quietly not the ones on the wire, which is worse @@ -1243,15 +1187,15 @@ def test_httpv1_unfolds_an_obs_fold_continuation_line(self) -> None: :rfc:`9112#section-5.2` allows a field value to continue on the next line when that line begins with SP or HTAB, and prescribes replacing each such ``obs-fold`` with a space before interpreting the value. Deprecated, but - present in real captures, and both of the ways it used to come out were - wrong: + present in real captures, and a parser that splits each line on its colon + gets it wrong both ways: - * a continuation carrying no colon reached ``item[1]`` and raised - :exc:`IndexError` -- so an ordinary, legal HTTP/1.1 request aborted the - guess and, once refused, would have been relabelled HTTP/2; - * a continuation that happened to contain a colon was *worse*: it parsed + * a continuation carrying no colon reaches ``item[1]`` and raises + :exc:`IndexError` -- so an ordinary, legal HTTP/1.1 request aborts the + guess and, once refused, would be relabelled HTTP/2; + * a continuation that happens to contain a colon is *worse*: it parses silently into a spurious extra field, so ``X-Long: a`` folded over - ``b: c`` yielded two fields rather than one value of ``a b: c``, with + ``b: c`` yields two fields rather than one value of ``a b: c``, with nothing raised to say so. The second is the reason this is unfolded rather than merely refused. @@ -1281,7 +1225,7 @@ def test_httpv1_unfolds_an_obs_fold_continuation_line(self) -> None: proto = HTTPv1(io.BytesIO(raw), len(raw)) self.assertEqual(list(proto.info.header.items()), fields) - # The colon-bearing continuation, which used to mis-parse in silence. + # The colon-bearing continuation, which a per-line split mis-parses in silence. raw = b'GET / HTTP/1.1\r\nX-Long: a\r\n b: c\r\nHost: e\r\n\r\n' proto = HTTPv1(io.BytesIO(raw), len(raw)) self.assertEqual(list(proto.info.header.items()), @@ -1293,17 +1237,16 @@ def test_httpv1_unfold_replaces_the_whole_obs_fold_including_the_ows(self) -> No The production is ``obs-fold = OWS CRLF RWS`` (:rfc:`9112#section-5.2`) and a recipient replaces *the whole* obs-fold with one or more SP -- so the optional whitespace before the CRLF belongs to the fold, not to the - field value. Right-stripping only the continuation left that OWS behind: - ``X: a \\t`` folded over ``\\tb`` unfolded to ``'a \\t b'`` where the - literal ``X: a b`` gives ``'a b'``, four of the five pairs below - disagreed, and a HTAB survived inside the value where the RFC prescribes - SP. + field value. Right-stripping only the continuation would leave that OWS + behind: ``X: a \\t`` folded over ``\\tb`` would unfold to ``'a \\t b'`` + where the literal ``X: a b`` gives ``'a b'``, and a HTAB would survive + inside the value where the RFC prescribes SP. Asserted as agreement between each folded message and the literal message it is *defined to mean*, rather than against a hand-written expected value, because that equivalence is the whole content of the RFC's remedy - -- and pinning it on one input, as this test first did, is what let the - trailing-OWS forms through. + -- and pinning it on one input is what lets the trailing-OWS forms + through. """ import io @@ -1356,27 +1299,19 @@ def fields(raw: bytes) -> object: def test_httpv1_never_lets_a_bare_exception_escape(self) -> None: """The invariant behind #787: ``httpv1.HTTP`` fails only as ``ProtocolError``. - This is what makes the set of payloads whose proxy answer changed - *derivable* rather than something to enumerate by example. That set is - exactly - - {payloads ``httpv1`` refused with a bare exception on base} - INTERSECT {payloads ``httpv2`` accepts} - INTERSECT {payloads ``httpv1`` still refuses} - - and the bare-exception sites were precisely four: the missing + A bare exception out of ``httpv1`` escapes ``_guess_version``'s + suppression, so every site that raises one adds a class of payload the + dispatcher mislabels. The sites guarded here are the missing ``\\r\\n\\r\\n`` in ``read``, a header with no CRLF, a start line of fewer than three whitespace-separated tokens, and ``item[1]`` on a colon-less - field line. A fifth such site would add a fifth class nobody predicted, - which is how that count grew by one on each review round while it was - being found by search. + field line -- and finding such sites by search is how one gets missed. So rather than pin the classes, this pins the property that bounds them: over a battery spanning every branch of ``read`` and - ``_read_http_header`` -- including the four converted sites and the four - classes that already raised ``ProtocolError`` before the fix -- nothing - escapes that is not a ``ProtocolError``. A new bare-raising site fails - here, and no consequence table has to be re-derived by hand. + ``_read_http_header`` -- including those sites and the paths that raise + ``ProtocolError`` by design -- nothing escapes that is not a + ``ProtocolError``. A new bare-raising site fails here, and no + consequence table has to be re-derived by hand. """ import io @@ -1385,7 +1320,7 @@ def test_httpv1_never_lets_a_bare_exception_escape(self) -> None: from pcapkit.utilities.exceptions import ProtocolError payloads = ( - # The four converted sites. + # Sites that would raise a bare exception without the #787 conversion. ('read: no CRLFCRLF', b'not http at all'), ('read: no CRLFCRLF, long', b'abcdefghijklmnopqrstuvwxyz0123456789'), ('header: no CRLF (preface)', b'PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n'), @@ -1393,7 +1328,7 @@ def test_httpv1_never_lets_a_bare_exception_escape(self) -> None: ('start line: one token', b'PRI\r\nHost: e\r\n\r\n'), ('field line: no colon', b'GET / HTTP/1.1\r\nNoColonHere\r\n\r\n'), ('field line: orphan continuation', b'GET / HTTP/1.1\r\n orphan\r\n\r\n'), - # Already ``ProtocolError`` before the fix -- these must stay so. + # Paths that raise ``ProtocolError`` by design. ('start line: unrecognised 3 tokens', b'FOO BAR BAZ\r\nHost: e\r\n\r\n'), ('start line: non-numeric status', b'HTTP/1.1 XXX OK\r\nHost: e\r\n\r\n'), ('start line: four-digit status', b'HTTP/1.1 2000 OK\r\nHost: e\r\n\r\n'), @@ -1407,7 +1342,7 @@ def test_httpv1_never_lets_a_bare_exception_escape(self) -> None: ('valid request', b'GET / HTTP/1.1\r\nHost: e\r\n\r\n'), ('valid response', b'HTTP/1.1 200 OK\r\nServer: s\r\n\r\n'), ('valid folded request', b'GET / HTTP/1.1\r\nX: a\r\n b\r\nHost: e\r\n\r\n'), - # Field-less, refused as a CRLF-less header until #1041. + # Field-less: zero field lines are legal (#1041). ('valid field-less request', b'GET / HTTP/1.0\r\n\r\n'), ('valid field-less response', b'HTTP/1.1 200 OK\r\n\r\n'), ) @@ -1467,18 +1402,18 @@ def test_guess_version_does_not_suppress_struct_error_on_the_http1_arm(self) -> stop rather than a tidier failure. ``unknown HTTP version`` is only the best case, needing arm 2 to decline as well. - #799 closed the *outer*-header slice of the class this suppression - exists for (``httpv2.HTTP.unpack`` now rejects a buffer under nine - octets before the schema layer runs), and #805 closed the *inner* - slice (a resolved field length going negative now raises - ``ProtocolError`` rather than a bare :exc:`struct.error`), but neither - retired the suppression: it stays as defence in depth for any - ``__length__``-keyed field #805 did not individually verify, across - the schema modules that share the pattern. See + The *outer*-header slice of the class this suppression exists for is + refused by ``httpv2.HTTP.unpack``, which rejects a buffer under nine + octets before the schema layer runs (#799), and the *inner* slice by + ``FieldBase.length``, where a resolved field length going negative + raises ``ProtocolError`` rather than a bare :exc:`struct.error` (#805). + The suppression stays regardless, as defence in depth for any + ``__length__``-keyed field #805 did not individually verify, across the + schema modules that share the pattern. See ``test_guess_version_reports_unknown_version_for_a_short_payload`` for - the outer-header case #799 closed, and + the outer-header case, and ``test_guess_version_no_longer_leaks_a_bare_struct_error_for_an_inner_field_shortfall`` - below for the inner one #805 closed. + below for the inner one. It is also unnecessary: nothing reaches a :exc:`struct.error` through ``httpv1.HTTP``. Nine byte patterns over lengths 0-24, on both the direct @@ -1519,25 +1454,24 @@ def test_guess_version_does_not_suppress_struct_error_on_the_http1_arm(self) -> HTTP(io.BytesIO(raw), len(raw)) def test_guess_version_no_longer_leaks_a_bare_struct_error_for_an_inner_field_shortfall(self) -> None: - """A buffer that clears nine octets used to still crash one field further in. + """A buffer that clears nine octets must not crash one field further in. #799's ``httpv2.HTTP.unpack`` guard only protects the fixed nine-octet *outer* header. A ``GOAWAY`` frame's own fixed ``stream`` (4 octets) and ``error`` (4 octets) fields consume eight more octets before ``debug`` is even reached, so a sixteen-octet buffer -- nine for the header, seven for the rest -- drives ``pkt['__length__']`` to ``-1`` at ``debug``. - Pre-#805, that raised a bare :exc:`struct.error` straight through - ``httpv2.HTTP``; #805 closed the actual root, generic - ``FieldBase.length`` (``struct.calcsize`` on a negative-count template), - shared by many schema modules, not something httpv2-specific -- - so direct construction now raises :exc:`ProtocolError` too. - - ``_guess_version``'s last arm keeps suppressing :exc:`struct.error` - alongside ``ProtocolError`` regardless: narrowing it, as an earlier - revision of this fix did, regressed the direct ``HTTP()`` guess path - for a *different* input class (the sub-nine-octet outer-header one), - and #805 does not touch every ``__length__``-keyed field in every one - of those modules, so the suppression stays as defence in depth. + Guards against that raising a bare :exc:`struct.error` straight through + ``httpv2.HTTP``. The root is generic ``FieldBase.length`` + (``struct.calcsize`` on a negative-count template), shared by many + schema modules, not something httpv2-specific, and it is handled there + (#805) -- so direct construction raises :exc:`ProtocolError` too. + + ``_guess_version``'s last arm suppresses :exc:`struct.error` alongside + ``ProtocolError`` regardless: #805 does not touch every + ``__length__``-keyed field in every one of those modules, so the + suppression stays as defence in depth. Narrowing it was tried and broke + the direct ``HTTP()`` guess path for the sub-nine-octet outer-header class. This test drives real wire bytes through the actual guess path -- no ``mock.patch`` -- specifically because a mocked fault cannot see a regression in the *un-mocked* route the fault is meant to stand in for. @@ -1554,8 +1488,8 @@ def test_guess_version_no_longer_leaks_a_bare_struct_error_for_an_inner_field_sh raw = b'\x00\x00\x15\x07\x00\x00\x00\x00\x00' + b'\xff' * 7 self.assertEqual(len(raw), 16) - # Direct construction now raises ProtocolError, not a bare - # struct.error -- #805's fix, pinned here so a regression is noticed. + # Direct construction raises ProtocolError, not a bare struct.error + # (#805). import struct with self.assertRaises(ProtocolError) as direct_ctx: HTTPv2(io.BytesIO(raw), len(raw)) @@ -1644,29 +1578,24 @@ def test_httpv1_id_make_data_and_request_construction(self) -> None: proto._read_http_header(b'BAD nope nope\r\nHost: example') def test_method_get_is_now_case_sensitive(self) -> None: - """``Method.get`` used to test the upper-cased key, so a mixed-case - method resolved to the same registered member regardless of its own - casing -- #583, item 1, at the time framed the same way as #582's fix - in :mod:`pcapkit.const.ftp.command` (``Command.get`` stays that way, per - RFC 959 Section 5.3). GitHub issue #896 retired that - for ``Method`` specifically: RFC 9110 Section 9.1 makes the HTTP - method *token* case-sensitive -- "standardized methods are defined in - all-uppercase US-ASCII letters" by convention, not by requirement -- - so a request whose method token is ``get`` is a distinct, - non-standardised method from ``GET``, and conflating the two through - ``.get()`` was the defect. - - This replaces the class's own former pin of the opposite behaviour - (same method name, opposite body) rather than adding a second test - beside it, since the two claims cannot both be true. + """``Method.get`` must not resolve a mixed-case method to the registered + member of another casing -- GitHub issue #896. + + RFC 9110 Section 9.1 makes the HTTP method *token* case-sensitive -- + "standardized methods are defined in all-uppercase US-ASCII letters" by + convention, not by requirement -- so a request whose method token is + ``get`` is a distinct, non-standardised method from ``GET``, and + conflating the two through ``.get()`` is the defect. ``Command.get`` in + :mod:`pcapkit.const.ftp.command` differs on purpose: it folds case, per + RFC 959 Section 5.3 (#582, #583 item 1). """ from pcapkit.const.http.method import Method - # The exact registered casing still resolves. + # The exact registered casing resolves. self.assertIs(Method.get('GET'), Method.GET) self.assertTrue(Method.get('GET').safe) - # A differently-cased token no longer resolves to the registered + # A differently-cased token does not resolve to the registered # member -- each becomes its own unregistered member instead, # carrying the caller's own casing on its value (same convention # :meth:`Method._unregistered_member` documents) and never @@ -1684,27 +1613,26 @@ def test_method_get_is_now_case_sensitive(self) -> None: self.assertEqual(len(Method.__members__), before) # The constructor path (``Method(...)`` -> ``_missing_``) is a - # separate mechanism from ``.get()`` and #896 does not touch it -- - # it still folds case, exactly as before. + # separate mechanism from ``.get()``, outside #896's scope, and it + # folds case. self.assertIs(Method('Get'), Method.GET) self.assertEqual([name for name in Method._member_map_ if name.upper() == 'GET'], ['GET']) - # GitHub issue #860: a genuinely unknown method no longer registers + # GitHub issue #860: a genuinely unknown method does not register # at all -- the owner's ruling is that ``get``/``_missing_`` have no # way to supply ``safe``/``idempotent``, so minting one would # register a permanently hollowed-out member; only ``register()`` # can do that properly. The *value* keeps the caller's own casing - # (unchanged from before #860, and the same convention - # :class:`~pcapkit.const.ftp.command.FEATCode` already used) -- - # only the *name* is canonicalised -- so a repeated call with the - # same casing is *equal* but never *identical* (nothing is cached - # to be identical to any more), while a different-case call is a - # genuinely different value and is correctly *not* equal: on - # ``main`` minting's cache silently returned the first casing seen - # for every later call regardless of case, which is exactly the - # "registered enum out of an unrecognised value" #860 removes. + # (the same convention :class:`~pcapkit.const.ftp.command.FEATCode` + # uses) -- only the *name* is canonicalised -- so a repeated call + # with the same casing is *equal* but never *identical* (nothing is + # cached to be identical to), while a different-case call is a + # genuinely different value and is correctly *not* equal. A minting + # cache would return the first casing seen for every later call + # regardless of case, which is exactly the "registered enum out of + # an unrecognised value" #860 rules out. unknown = Method.get('frob') self.assertEqual(unknown._name_, 'FROB') self.assertEqual(unknown, 'frob') @@ -1717,8 +1645,9 @@ def test_method_get_is_now_case_sensitive(self) -> None: self.assertNotIn('FROB', Method.__members__) def test_httpv1_method_regex_is_anchored(self) -> None: - """``_RE_METHOD`` was unanchored and :func:`re.match` anchors only at the - start, so it prefix-matched ``b'Get'`` down to ``b'G'`` -- #583, item 2. + """``_RE_METHOD`` must be anchored: :func:`re.match` anchors only at the + start, so an unanchored pattern prefix-matches ``b'Get'`` down to + ``b'G'`` -- #583, item 2. Method tokens are case-sensitive per :rfc:`9110#section-9.1`, so ``Get`` is not ``GET`` and must not be accepted as one. @@ -1738,8 +1667,8 @@ def test_httpv1_method_regex_is_anchored(self) -> None: self.assertEqual(match.group('method') if match else None, expected) def test_httpv1_read_header_uses_the_captured_method(self) -> None: - """``httpv1.py`` handed the whole ``para1`` to ``Method.get`` rather than - the captured group, so a prefix match let a bad token through -- #583. + """``httpv1.py`` must hand ``Method.get`` the captured group, not the whole + ``para1``, or a prefix match lets a bad token through -- #583. Both halves are asserted together because either alone still gives a wrong answer: normalising ``Method.get`` alone would parse ``b'Get'`` as @@ -1756,8 +1685,8 @@ def test_httpv1_read_header_uses_the_captured_method(self) -> None: self.assertIs(header.method, Method.GET) self.assertEqual(header.uri, '/index.html') - # A mixed- or lower-case token is not a registered method and no longer - # masquerades as a prefix of one; it is a malformed request line. + # A mixed- or lower-case token is not a registered method and must not + # masquerade as a prefix of one; it is a malformed request line. for raw in (b'Get / HTTP/1.1\r\nHost: example.test', b'get / HTTP/1.1\r\nHost: example.test'): with self.subTest(raw=raw): @@ -1768,29 +1697,28 @@ def test_httpv1_read_header_uses_the_captured_method(self) -> None: self.assertEqual([name for name in Method._member_map_ if name.upper() == 'GET'], ['GET']) - # The response path is untouched. + # The response path parses as usual. response, _ = proto._read_http_header(b'HTTP/1.1 404 Not Found\r\nServer: example') self.assertEqual(response.status, 404) def test_httpv1_status_regex_is_anchored(self) -> None: - """``_RE_STATUS`` carried the same unanchored-prefix defect as - ``_RE_METHOD``, and it escaped as the wrong exception type. + """``_RE_STATUS`` must be anchored like ``_RE_METHOD``, or a malformed + status escapes as the wrong exception type -- #583. - Found while auditing ``_RE_METHOD``'s siblings for #583. The pattern is - only a guard -- the value comes from ``int(para2)`` on the *raw* token -- - so a prefix match let a malformed status past the guard and then out of - ``int()`` as a bare ``ValueError``, where ``_read_http_header`` documents - ``ProtocolError``. :rfc:`9112#section-4` gives ``status-code = 3DIGIT``, - exactly three, so anchoring is what the grammar says. + The pattern is only a guard -- the value comes from ``int(para2)`` on + the *raw* token -- so a prefix match would let a malformed status past + the guard and then out of ``int()`` as a bare ``ValueError``, where + ``_read_http_header`` documents ``ProtocolError``. :rfc:`9112#section-4` + gives ``status-code = 3DIGIT``, exactly three, so anchoring is what the + grammar says. The citation is RFC 9112, not RFC 9110: the production belongs to HTTP/1.1's ``status-line = HTTP-version SP status-code SP [ reason-phrase ]``, and :rfc:`9110#section-15` covers what the codes *mean* plus the IANA registry. RFC 9112 section 4 says so itself -- - "HTTP's core status codes are defined in Section 15 of [HTTP]". A - cross-review caught the first draft citing 9110 for the grammar. + "HTTP's core status codes are defined in Section 15 of [HTTP]". - Measured before the fix: + Measured with an unanchored pattern: ``b'HTTP/1.1 200x OK'`` -> ``ValueError: invalid literal for int() with base 10: b'200x'``, and ``b'HTTP/1.1 2000 OK'`` -> ``ValueError: 2000 is not a valid StatusCode``. @@ -2086,12 +2014,12 @@ def test_httpv2_read_validates_common_frame_header_rules(self) -> None: def test_httpv2_truncated_frame_is_rejected_uniformly_regardless_of_declared_length(self) -> None: """#799: a buffer of four real octets must refuse *every* declared length. - ``HTTP.read`` in ``httpv2.py`` used to read ``if schema.length < 9:`` -- the declared - 24-bit length off the wire, never the buffer's -- so a frame whose real - buffer held only four octets still parsed whenever the declared value - happened to clear nine and (for ``SETTINGS``) land on a multiple of six - past it. Measured pre-fix, sweeping the declared length against exactly - this four-octet ``SETTINGS`` buffer:: + Guards against a length guard of ``if schema.length < 9:`` -- the + declared 24-bit length off the wire, never the buffer's -- under which a + frame whose real buffer holds only four octets parses whenever the + declared value happens to clear nine and (for ``SETTINGS``) land on a + multiple of six past it. Measured with that guard, sweeping the declared + length against exactly this four-octet ``SETTINGS`` buffer:: declared=0 -> ProtocolError declared=9 -> PARSED length=9 declared=5 -> ProtocolError declared=15 -> PARSED length=15 @@ -2103,8 +2031,8 @@ def test_httpv2_truncated_frame_is_rejected_uniformly_regardless_of_declared_len The parses are exactly the declared lengths congruent to 3 (mod 6) -- i.e. ``(declared - 9) % 6 == 0`` -- which is ``_read_http_settings``'s own *unrelated* structural check on the declared value, not evidence the - buffer held what was declared. Every one of the nine cases below must - now come back ``ProtocolError`` uniformly. + buffer held what was declared. Every case below must come back + ``ProtocolError`` uniformly. """ import io @@ -2160,13 +2088,13 @@ def truncated_frame(declared: int, type_: int, buflen: int) -> bytes: def test_httpv2_declared_length_exceeding_the_buffer_is_rejected(self) -> None: """#799 blocker 2: a declared length beyond the buffer must not parse. - Before this check, a frame whose declared length cleared nine on its - own -- regardless of how far past the real buffer it reached -- still - passed the guard and reported the declared, attacker-controlled value - as if the capture actually contained it. Measured pre-fix, identical on - base and the first revision of #802: a nine-octet buffer declaring - 16777215 parsed and reported ``length=16777215``, overstated by - 16,777,206 octets, at every one of these buffer sizes. + Guards against a frame whose declared length clears nine on its own -- + regardless of how far past the real buffer it reaches -- passing the + guard and reporting the declared, attacker-controlled value as if the + capture actually contained it. Measured without this check: a + nine-octet buffer declaring 16777215 parsed and reported + ``length=16777215``, overstated by 16,777,206 octets, at every one of + these buffer sizes. """ import io @@ -2203,7 +2131,7 @@ def test_httpv2_boundary_buffer_exactly_nine_octets(self) -> None: Nine is the header's own size, so a ``DATA`` frame declaring zero (an empty payload) backed by exactly nine real octets is the smallest legitimate HTTP/2 frame there is. One octet short of that must still be - refused -- pinning that the fix's ``>= 9`` is not an off-by-one ``> 9``. + refused -- pinning that the guard's ``>= 9`` is not an off-by-one ``> 9``. """ import io @@ -2222,7 +2150,7 @@ def test_httpv2_boundary_buffer_exactly_nine_octets(self) -> None: HTTPv2(io.BytesIO(one_short), len(one_short)) def test_httpv2_well_formed_frame_still_parses(self) -> None: - """The fix must not refuse a frame whose buffer backs its declared length. + """The guard must not refuse a frame whose buffer backs its declared length. A ``DATA`` frame declaring 12 (its payload, the nine header octets excluded) backed by all 21 real octets is exactly the well-formed case @@ -2243,15 +2171,15 @@ def test_httpv2_well_formed_frame_still_parses(self) -> None: def test_httpv2_unpack_rejects_a_short_buffer_before_the_schema_layer_crashes(self) -> None: """A buffer under nine octets must not reach :func:`struct.calcsize`. - Before this guard existed at ``unpack``, a buffer too short to hold the - fixed nine-octet header sent ``Schema.unpack`` into arithmetic that - builds a *negative*-length struct template for a frame's payload field + Without this guard at ``unpack``, a buffer too short to hold the fixed + nine-octet header sends ``Schema.unpack`` into arithmetic that builds a + *negative*-length struct template for a frame's payload field (``pkt['__length__']`` is decremented by each field's nominal width - regardless of how many octets the buffer actually had), and - :func:`struct.calcsize` raised a bare :exc:`struct.error` for it -- + regardless of how many octets the buffer actually has), and + :func:`struct.calcsize` raises a bare :exc:`struct.error` for it -- neither a ``ProtocolError`` nor caught by anything downstream. Pinned directly on ``HTTP.unpack`` rather than only on ``read``, since ``read`` - never runs for these buffers: the crash happened one call earlier. + never runs for these buffers: the crash would happen one call earlier. """ import io @@ -2504,27 +2432,28 @@ def test_httpv2_frame_constructors_cover_all_frame_types_and_branches(self) -> N def test_settings_frame_settings_field_wraps_item_schema(self) -> None: """Regression test for GH-459. - ``SettingsFrame.settings`` used to pass the bare ``SettingPair`` - *class* as ``ListField``'s ``item_type``, where every sibling + Guards against ``SettingsFrame.settings`` passing the bare + ``SettingPair`` *class* as ``ListField``'s ``item_type``, where every + sibling (``tcp.py``'s ``SACK.sack``, ``hip.py``, ``mh.py``'s ``CGAParametersOption.parameters``, ``sctp.py``'s ``gap_blocks``/``dup_tsn``) wraps its schema item in a :class:`~pcapkit.corekit.fields.misc.SchemaField`. A bare ``SchemaMeta`` is not a field instance, so ``ListField.unpack``'s - schema branch -- ``field = self._item_type(packet)`` -- constructed a - ``SettingPair`` from the packet *dict* instead of configuring a + schema branch -- ``field = self._item_type(packet)`` -- would construct + a ``SettingPair`` from the packet *dict* instead of configuring a per-item field, and the ``isinstance(self._item_type, SchemaField)`` - check that picks the schema branch was ``False`` for a bare class in - the first place, so it fell through to the plain-field branch and - failed there instead: ``field.length`` does not exist on a + check that picks the schema branch is ``False`` for a bare class in the + first place, so it would fall through to the plain-field branch and + fail there instead: ``field.length`` does not exist on a ``SettingPair`` instance. This is a unit-level check on the field wiring, not an end-to-end - ``HTTPv2`` round trip. The round trip itself now passes -- there is + ``HTTPv2`` round trip. The round trip itself passes -- there is no ``EXPECTED_FAILURES`` entry for ``httpv2-frame/SETTINGS``, and the generator's own round trip for it reports ``OK``. What a round trip never exercises is a bare ``SettingsFrame(...).pack()`` called - with no enclosing packet: that still raises ``KeyError: 'flags'``, + with no enclosing packet: that raises ``KeyError: 'flags'``, from ``FrameType.post_process``'s ``packet['flags'][name]`` lookup, which has no ``flags`` to read without a real header above it. That is an unsupported invocation, not a defect in the round trip. @@ -2534,7 +2463,7 @@ def test_settings_frame_settings_field_wraps_item_schema(self) -> None: field = SettingsFrame.__fields__['settings'] - # The type-check the ``# type: ignore[arg-type]`` used to silence: + # The type-check a ``# type: ignore[arg-type]`` would silence: # ``item_type`` must be a field instance, not the schema class itself. self.assertIsInstance(field._item_type, SchemaField) self.assertIs(field._item_type.schema, SettingPair) diff --git a/tests/protocols/application/test_httpv2_frame_length_1121_unit.py b/tests/protocols/application/test_httpv2_frame_length_1121_unit.py index 22e15f37a6..6be495f8f9 100644 --- a/tests/protocols/application/test_httpv2_frame_length_1121_unit.py +++ b/tests/protocols/application/test_httpv2_frame_length_1121_unit.py @@ -1,10 +1,9 @@ # -*- coding: utf-8 -*- """HTTP/2 frame Length counts the payload only, per :rfc:`9113#section-4.1`. -GitHub issue #1121. ``HTTP.make`` wrote Length as payload + 9 and the readers -checked the same, so every RFC-conformant frame with a fixed-size payload was -rejected, and ``_read_http_priority`` checked ``!= 9`` and so rejected even the -14 that ``make`` wrote for it. +GitHub issue #1121. Guards against ``HTTP.make`` writing Length as payload + 9 +and the readers checking the same, under which every RFC-conformant frame with a +fixed-size payload is rejected. The fixtures below are real HTTP/2 octets, not a re-statement of pcapkit's own arithmetic: each one is byte-identical to what scapy's independent @@ -95,7 +94,7 @@ def test_parsed_frames_rebuild_to_the_same_octets(self) -> None: self.assertEqual(bytes(HTTPv2(type=info.type, sid=info.sid, frame=info)), raw) def test_the_old_header_inclusive_length_is_rejected(self) -> None: - """A fixed-size frame declaring payload + 9 is now a size error.""" + """A fixed-size frame declaring payload + 9 is a size error.""" from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 from pcapkit.utilities.exceptions import ProtocolError diff --git a/tests/protocols/application/test_httpv2_negative_length_unit.py b/tests/protocols/application/test_httpv2_negative_length_unit.py index 56fe4a6543..dcd9a7a861 100644 --- a/tests/protocols/application/test_httpv2_negative_length_unit.py +++ b/tests/protocols/application/test_httpv2_negative_length_unit.py @@ -3,17 +3,17 @@ GitHub issue #805. :meth:`~pcapkit.protocols.schema.schema.Schema.unpack` decrements ``packet['__length__']`` by each field's *nominal* width -regardless of how many octets the buffer actually had, and a field whose own +regardless of how many octets the buffer actually has, and a field whose own ``length=lambda pkt: pkt['__length__']``-style callback resolves to that negative remainder builds a struct template such as ``'-5s'``. -:func:`struct.calcsize` cannot size that and raised a bare +:func:`struct.calcsize` cannot size that and raises a bare :exc:`struct.error` for it -- not a :exc:`ProtocolError`, not even a :exc:`ValueError`, uncatchable by ordinary caller code. -Scope, per the issue's own correction of its body: the fix belongs at the +Scope, per the issue's own correction of its body: the guard belongs at the *resolved field length* -- :attr:`~pcapkit.corekit.fields.field.FieldBase.length` --- not at the running-counter warning (``pcapkit/protocols/schema/schema.py`` -around ``packet['__length__'] < 0``), which this module does not touch. See +-- not at the running-counter warning (``packet['__length__'] < 0`` in +:meth:`~pcapkit.protocols.schema.schema.Schema.unpack`). See :mod:`tests.corekit.test_fields_field`'s ``FieldBaseLengthNegativeResolvedLengthTests`` for the property-level unit tests and the unaffected non-negative control. @@ -73,7 +73,7 @@ def test_goaway_at_sixteen_octets_raises_protocolerror_not_structerror(self) -> ``stream``+``error`` alone are eight fixed octets, so a 16-octet buffer (7 octets of frame-specific payload after the 9-octet header) drives ``debug``'s ``length=lambda pkt: pkt['__length__']`` to -1. - Pre-fix: ``struct.error: bad char in struct format``. + Unguarded: ``struct.error: bad char in struct format``. """ from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 from pcapkit.utilities.exceptions import ProtocolError @@ -135,10 +135,10 @@ def test_an_over_padded_data_frame_raises_protocolerror(self) -> None: def test_a_well_formed_goaway_frame_still_parses_cleanly(self) -> None: """Control: a ``GOAWAY`` frame whose buffer matches its declared length. - The fix is a ``try``/``except struct.error`` around the same - :func:`struct.calcsize` call as before, so a resolved length that - never goes negative must parse identically to pre-fix -- no - exception, no warning. + The guard is a ``try``/``except struct.error`` around the + :func:`struct.calcsize` call, so a resolved length that never goes + negative must parse as it would unguarded -- no exception, no + warning. """ from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 diff --git a/tests/protocols/application/test_httpv2_payload_length_unit.py b/tests/protocols/application/test_httpv2_payload_length_unit.py index 1ac828f289..0e322afbd3 100644 --- a/tests/protocols/application/test_httpv2_payload_length_unit.py +++ b/tests/protocols/application/test_httpv2_payload_length_unit.py @@ -1,13 +1,14 @@ # -*- coding: utf-8 -*- """HTTP/2 payload lengths, asserted against the octets that went onto the wire. -GitHub issue #668. Three payload length callbacks in -:mod:`pcapkit.protocols.schema.application.httpv2` put the conditional -expression in the wrong place, so an **unpadded** ``DATA``, ``HEADERS`` or -``PUSH_PROMISE`` frame parsed with its whole payload silently discarded. +GitHub issue #668. Guards against three payload length callbacks in +:mod:`pcapkit.protocols.schema.application.httpv2` putting the conditional +expression in the wrong place, under which an **unpadded** ``DATA``, +``HEADERS`` or ``PUSH_PROMISE`` frame parses with its whole payload silently +discarded. -What the grouping did ---------------------- +What the grouping does +---------------------- A conditional expression binds looser than ``-``, so:: @@ -16,15 +17,15 @@ groups as ``(pkt['__length__'] - pkt['pad_len']) if pkt['flags']['bit_3'] else 0`` -- the whole subtraction is the ``if`` arm and the ``else`` arm is a bare ``0``. ``0`` reaches :class:`~pcapkit.corekit.fields.strings.BytesField` as "read no -octets at all", so every frame *without* the ``PADDED`` flag read its payload as -``b''``. The intent was to subtract nothing rather than to read nothing:: +octets at all", so every frame *without* the ``PADDED`` flag reads its payload as +``b''``. The intent is to subtract nothing rather than to read nothing:: length=lambda pkt: pkt['__length__'] - (pkt['pad_len'] if pkt['flags']['bit_3'] else 0) ``__length__`` is the *remaining* declared length at the field, so the unpadded arm wants ``__length__`` itself, which is what subtracting a zero padding length -gives. Padding is rare in HTTP/2, so the broken arm was the common one: nothing -raised, nothing warned, and ``info.data`` was simply empty. +gives. Padding is rare in HTTP/2, so the broken arm is the common one: nothing +raises, nothing warns, and ``info.data`` is simply empty. Why the arithmetic comes out ---------------------------- @@ -42,23 +43,23 @@ Note also that the two expressions are *equal whenever ``PADDED`` is set* (``(A - B) if T else 0`` and ``A - (B if T else 0)`` are both ``A - B`` for -truthy ``T``), so only the unpadded arm could ever have changed. The padded +truthy ``T``), so only the unpadded arm differs between them. The padded cases below are here to prove that from behaviour rather than from the shape of the conditional, and to catch a "fix" that repairs the unpadded arm by breaking the padded one. -It broke construction as well as parsing ---------------------------------------- +It breaks construction as well as parsing +----------------------------------------- :class:`~pcapkit.corekit.fields.strings.BytesField` consults its ``length`` -callback on the *pack* path too, so the ``else 0`` arm did not merely discard a -payload on read -- it declined to write one. Measured pre-fix, +callback on the *pack* path too, so the ``else 0`` arm does not merely discard a +payload on read -- it declines to write one. Measured with that grouping, ``bytes(HTTP(type=Frame.DATA, sid=1, frame={'data': DATA_PAYLOAD}))`` packed ``000015000000000001``: nine octets of header whose length field declares **21**, with the body absent. A reader walking a stream by that length field is handed a frame twelve octets shorter than it claims, which desynchronises everything after it. #668 is filed as a parse-side defect and the construct side -needed no code change, so +needs no code change of its own, so :class:`HTTPv2ConstructedFrameDeclaresWhatItWritesUnitTests` asserts that half rather than leaving it implied. @@ -80,12 +81,11 @@ Each of the three frames has a "frame of only padding" case, where ``pad_len`` accounts for the whole payload area and the payload is legitimately empty. Those -are not padding of the test matrix: a cross-review on a second model found that -with only the ``DATA`` one present -- every other fixture carrying a non-empty -payload -- the module was satisfied by a wrong fix returning -``max(computed, 1)`` for the two ``fragment`` fields, which passed all fifteen -tests while leaking a padding octet into the fragment and swallowing a -``SchemaWarning: packet length < 0: -1``. ``0`` is a legitimate answer from +are not padding of the test matrix: with only the ``DATA`` one present -- every +other fixture carrying a non-empty payload -- the module would be satisfied by a +wrong fix returning ``max(computed, 1)`` for the two ``fragment`` fields, which +leaks a padding octet into the fragment and swallows a ``SchemaWarning: packet +length < 0: -1``. ``0`` is a legitimate answer from these callbacks and has to be asserted as one, at the wire and at the callback both; :meth:`HTTPv2PayloadLengthCallbackUnitTests.\ test_the_padded_arm_reaches_zero_and_is_not_clamped` is the latter. @@ -97,25 +97,25 @@ :attr:`~pcapkit.protocols.schema.application.httpv2.UnassignedFrame.data` and :attr:`~pcapkit.protocols.schema.application.httpv2.GoawayFrame.debug` use the plain ``length=lambda pkt: pkt['__length__']`` with no conditional, and all -three carried their payload correctly the whole time. +three carry their payload correctly with or without the defect. :class:`HTTPv2PlainLengthFormControlUnitTests` asserts them, because they are -what shows the field machinery was never at fault -- it was the conditional's +what shows the field machinery is not at fault -- it is the conditional's grouping alone. -Why the round-trip suite could not see it ----------------------------------------- +Why the round-trip suite cannot see it +-------------------------------------- :mod:`tests.protocols.test_option_roundtrip_unit` drives every HTTP/2 frame type, but through ``make`` -> parse -> ``make``, and ``make`` writes the length -field from ``HTTP._make_http_length``. Both sides therefore agreed on an empty -payload and the octets matched. Its generator also passes no ``data`` or +field from ``HTTP._make_http_length``. Both sides therefore agree on an empty +payload and the octets match. Its generator also passes no ``data`` or ``fragment`` argument for these three frames (measured: ``kwargs={}`` for ``httpv2-frame/DATA``, ``/HEADERS`` and ``/PUSH_PROMISE``), so the payload it -round-trips is ``b''`` and there was nothing for the callback to lose. +round-trips is ``b''`` and there is nothing for the callback to lose. :meth:`tests.protocols.application.test_http_unit.HTTPUnitTests.test_httpv2_frame_readers_cover_successful_frames` drives the readers with hand-built schema stubs rather than wire bytes, so the -length callback never ran there at all. This module reads wire octets, which is -the gap -- and asserts the packed octets too, which no existing test did either. +length callback never runs there at all. This module reads wire octets, which is +the gap -- and asserts the packed octets too. This module is unit tier: it builds its own octets and reads no capture. @@ -245,9 +245,9 @@ class HTTPv2DataFramePayloadLengthUnitTests(FramePayloadMixin, unittest.TestCase def test_unpadded_data_frame_keeps_its_whole_payload(self) -> None: """An unpadded ``DATA`` frame's payload survives the parse. - This is #668's headline case. Pre-fix the callback returned ``0`` here - and ``info.data`` came back ``b''`` -- measured on the octets this test - builds, which declare twelve payload octets and carried them. + This is #668's headline case. With the defect the callback returns ``0`` + here and ``info.data`` comes back ``b''`` -- measured on the octets this + test builds, which declare twelve payload octets and carry them. """ raw = http2_frame_bytes(self.TYPE, self.END_STREAM, 1, DATA_PAYLOAD) @@ -282,7 +282,7 @@ def test_a_padded_data_frame_of_only_padding_reads_an_empty_payload(self) -> Non """``pad_len`` accounting for the whole payload leaves ``data`` empty. The boundary of the padded arm: here ``__length__ - pad_len`` is exactly - ``0``, which is the one input for which the pre-fix and post-fix + ``0``, which is the one input for which the defective and correct expressions agree on an empty payload -- and the only shape under which ``b''`` is the right answer. @@ -311,8 +311,8 @@ class HTTPv2HeadersFramePayloadLengthUnitTests(FramePayloadMixin, unittest.TestC def test_unpadded_headers_frame_keeps_its_header_block_fragment(self) -> None: """An unpadded ``HEADERS`` frame's fragment survives the parse. - Pre-fix ``info.fragment`` came back ``b''``, which is what an HPACK - decoder would have been handed in place of the header block. + Guards against ``info.fragment`` coming back ``b''``, which is what an + HPACK decoder would be handed in place of the header block. """ raw = http2_frame_bytes(self.TYPE, self.END_HEADERS, 1, FRAGMENT) @@ -425,7 +425,7 @@ def test_unpadded_push_promise_frame_keeps_its_fragment(self) -> None: The promised stream identifier is asserted too: it sits between ``pad_len`` and the fragment, so reading it correctly and the fragment - as ``b''`` is exactly the pre-fix behaviour, and asserting only the + as ``b''`` is exactly the defect's behaviour, and asserting only the fragment would not show that the four octets before it were accounted for. @@ -479,8 +479,8 @@ class HTTPv2PlainLengthFormControlUnitTests(FramePayloadMixin, unittest.TestCase ``ContinuationFrame.fragment``, ``UnassignedFrame.data`` and ``GoawayFrame.debug`` are written ``length=lambda pkt: pkt['__length__']``, - and all three carried their payload correctly before #668 was fixed as well - as after. That is what localises the defect to the conditional's grouping + and all three carry their payload correctly whether or not the #668 grouping + defect is present. That is what localises the defect to the conditional's grouping rather than to :class:`~pcapkit.corekit.fields.strings.BytesField`, ``__length__``'s bookkeeping, or the frame dispatch -- all of which these frames use identically. @@ -592,8 +592,8 @@ def packet(self, *, padded: 'bool') -> 'dict[str, Any]': def test_the_unpadded_arm_returns_the_remaining_length(self) -> None: """With ``PADDED`` clear the callback returns ``__length__``, not ``0``. - This is #668 stated as a number: pre-fix all three returned ``0`` here, - which is why an unpadded frame's payload was dropped. + This is #668 stated as a number: with the defect all three return ``0`` + here, which is why an unpadded frame's payload is dropped. """ packet = self.packet(padded=False) @@ -611,10 +611,10 @@ def test_the_unpadded_arm_does_not_consult_pad_len(self) -> None: ``pad_len`` is :data:`~pcapkit.corekit.sentinels.NO_VALUE` when ``PADDED`` is clear, so the conditional has to short-circuit around it. - It does so in the fixed form because the conditional *is* the right - operand of the subtraction. This does not discriminate the fix from the - defect -- the broken grouping short-circuited too, just around the wrong - thing -- but it does pin the shape against a rewrite that reaches for + It does so in the correct form because the conditional *is* the right + operand of the subtraction. This does not discriminate the correct + grouping from the defective one -- the broken grouping short-circuits + too, just around the wrong thing -- but it does pin the shape against a rewrite that reaches for ``pad_len`` unconditionally, e.g. ``__length__ - int(pkt['pad_len'] or 0)``, which raises on the value the field machinery actually supplies. @@ -684,15 +684,15 @@ class HTTPv2ConstructedFrameDeclaresWhatItWritesUnitTests(unittest.TestCase): :class:`~pcapkit.corekit.fields.strings.BytesField` consults its ``length`` callback on the construct path as well as the parse path, so the ``else 0`` - arm did not merely discard a payload on read -- it declined to *write* one. - Measured pre-fix: ``bytes(HTTP(type=Frame.DATA, sid=1, frame={'data': body}))`` + arm does not merely discard a payload on read -- it declines to *write* one. + Measured with that grouping: ``bytes(HTTP(type=Frame.DATA, sid=1, frame={'data': body}))`` packed ``000015000000000001``, nine octets of header whose length field declares **21**, with the twelve octets of body absent. Any reader walking a stream by that length field is handed a frame that is twelve octets shorter than it claims, which desynchronises everything after it. - The issue frames #668 as a parse-side defect and the construct side needed no - code change, so this is asserted here rather than left implied by the parse + The issue frames #668 as a parse-side defect and the construct side needs no + code change of its own, so this is asserted here rather than left implied by the parse tests -- it is the half of the defect that reaches other people's tooling. """ @@ -733,20 +733,20 @@ def assertDeclaresWhatItWrites(self, raw: 'bytes', payload: 'bytes') -> 'None': self.assertIn(payload, raw, 'the payload octets must reach the wire') def test_an_unpadded_data_frame_writes_the_body_it_declares(self) -> None: - """``DATA`` -- packed nine octets against a declared 21 pre-fix.""" + """``DATA`` -- the defect packs nine octets against a declared 21.""" raw = self.build(type=0x00, sid=1, frame={'data': DATA_PAYLOAD}) self.assertDeclaresWhatItWrites(raw, DATA_PAYLOAD) def test_an_unpadded_headers_frame_writes_the_fragment_it_declares(self) -> None: - """``HEADERS`` -- packed nine octets against a declared 29 pre-fix.""" + """``HEADERS`` -- the defect packs nine octets against a declared 29.""" raw = self.build(type=0x01, sid=1, frame={'fragment': FRAGMENT}) self.assertDeclaresWhatItWrites(raw, FRAGMENT) def test_an_unpadded_push_promise_frame_writes_the_fragment_it_declares(self) -> None: - """``PUSH_PROMISE`` -- packed thirteen octets against a declared 33 pre-fix. + """``PUSH_PROMISE`` -- the defect packs thirteen octets against a declared 33. Thirteen rather than nine because the promised stream identifier sits - ahead of the fragment and was written; it is asserted here so that a + ahead of the fragment and is written; it is asserted here so that a regression cannot lose the fragment while keeping the frame's length field self-consistent. @@ -757,12 +757,12 @@ def test_an_unpadded_push_promise_frame_writes_the_fragment_it_declares(self) -> self.assertIn((7).to_bytes(4, 'big'), raw) def test_an_unpadded_continuation_frame_is_the_control(self) -> None: - """``CONTINUATION`` packed its fragment correctly pre-fix as well. + """``CONTINUATION`` packs its fragment correctly with or without the defect. The plain-form control for the construct path, matching :class:`HTTPv2PlainLengthFormControlUnitTests` on the parse path: its - length field matched what it wrote throughout, which is what shows the - construct machinery was never the problem either. + length field matches what it writes either way, which is what shows the + construct machinery is not the problem either. """ raw = self.build(type=0x09, sid=1, frame={'fragment': FRAGMENT}) diff --git a/tests/protocols/application/test_layer_placement_unit.py b/tests/protocols/application/test_layer_placement_unit.py index ffd10ae429..0c56abd5b4 100644 --- a/tests/protocols/application/test_layer_placement_unit.py +++ b/tests/protocols/application/test_layer_placement_unit.py @@ -9,8 +9,7 @@ Link Layer chapter at §2.3.2. The rule and its citations are written down at :doc:`/contributing/conventions/protocol-layer-placement`. -Three separate things are pinned here, because each broke independently while the -move was being made: +Three separate things are pinned here, because each can break independently: #. the ``__layer__`` values and the base classes, including the **order** of RARP's two bases -- ``ARP``'s chain reaches ``Link``, which owns ``__layer__``, so @@ -94,7 +93,7 @@ def test_ospf_and_rarp_report_the_application_layer(self) -> None: self.assertEqual(RARP(rarp_request()).layer, 'Application') def test_arp_and_inarp_stay_on_the_link_layer(self) -> None: - """:rfc:`1122#section-2.3.2` keeps ARP where it is; only RARP moved.""" + """:rfc:`1122#section-2.3.2` keeps ARP on the link layer; RARP is not.""" from pcapkit.protocols.link.arp import ARP, InARP self.assertEqual(ARP.__layer__, 'Link') @@ -153,7 +152,7 @@ def test_the_public_re_exports_are_unaffected_by_the_move(self) -> None: self.assertIn(name, pcapkit.protocols.__all__) def test_the_names_left_the_link_subpackage(self) -> None: - """A pure move: no deprecated re-export at the old path, by ruling.""" + """No deprecated re-export under ``link``, by ruling.""" import pcapkit.protocols.application import pcapkit.protocols.link @@ -197,12 +196,12 @@ def test_dispatch_keys_did_not_move_with_the_modules(self) -> None: self.assertIs(getattr(importlib.import_module(entry.module), entry.name), cls) def test_ospf_leaves_links_registry_behind_and_rarp_keeps_it(self) -> None: - """``Link`` sets names of its own; OSPF loses its EtherType machinery. + """``Link`` sets names of its own; OSPF has none of its EtherType machinery. ``__proto__``, its shipped copy ``_shipped_proto``, ``register`` and ``_read_protos`` are ``Link``'s EtherType machinery, and OSPF takes each of them from ``ProtocolBase`` instead. The first assertion pins a subset - of ``Link``'s own names. The loss is inert because a ``-1`` lookup misses in either + of ``Link``'s own names. The absence is inert because a ``-1`` lookup misses in either registry and falls back to :class:`~pcapkit.protocols.misc.raw.Raw`. """ from pcapkit.protocols.application.ospf import OSPF @@ -287,13 +286,14 @@ def extract_ipv4_ospf(self, **kwargs): return extraction.frame[0] def test_layer_link_extraction_of_a_headerless_ipv4_capture(self) -> None: - """Inert for the protochain, though the termination flag moves. Measured. - - ``_sigterm`` on the OSPF instance was ``True`` under ``layer='link'`` while - OSPF reported ``'Link'``, and is ``False`` now. The extracted result does - not change, because OSPF dispatches its body on the ``-1`` sentinel and - both the ``_sigterm`` branch of ``_import_next_layer`` and - ``ProtocolBase.__proto__``'s default resolve to + """Inert for the protochain, though the termination flag differs. Measured. + + ``_sigterm`` on the OSPF instance is ``False`` under ``layer='link'``, as + OSPF reports ``'Application'``; were OSPF on the link layer it would be + ``True``. The extracted result is the same either way, because OSPF + dispatches its body on the ``-1`` sentinel and both the ``_sigterm`` + branch of ``_import_next_layer`` and ``ProtocolBase.__proto__``'s default + resolve to :class:`~pcapkit.protocols.misc.raw.Raw`. A ``link`` extraction of a capture with no Ethernet header never reaches a link protocol at all. """ @@ -308,7 +308,7 @@ def test_layer_link_extraction_of_a_headerless_ipv4_capture(self) -> None: self.assertFalse(ospf._sigterm, "'link' must no longer terminate at OSPF") def test_layer_application_extraction_now_terminates_at_ospf(self) -> None: - """The flip side: ``layer='application'`` now matches where it did not.""" + """The flip side: ``layer='application'`` matches at OSPF.""" frame = self.extract_ipv4_ospf(layer='application') self.assertEqual(str(frame.protochain), 'IPv4:OSPFv2:Raw') diff --git a/tests/protocols/application/test_ngap_unit.py b/tests/protocols/application/test_ngap_unit.py index be13982ac4..b31f451a1f 100644 --- a/tests/protocols/application/test_ngap_unit.py +++ b/tests/protocols/application/test_ngap_unit.py @@ -122,13 +122,13 @@ def test_criticality_resolves_names_and_rejects_a_fourth_value(self) -> None: def test_unknown_procedure_and_ie_resolve_without_raising_or_aliasing(self) -> None: """GitHub issue #880: an in-range value neither enumeration names must report rather than explode, but two *different* unrecognised values - must never alias onto the same member the way the old shared-``-1`` - ``extend_enum`` mint used to. Since GitHub issue #880's fix, + must never alias onto the same member, as a shared-``-1`` + ``extend_enum`` mint would make them. :class:`~pcapkit.const.ngap.procedure_code.ProcedureCode` and :class:`~pcapkit.const.ngap.protocol_ie.ProtocolIE` answer such a value with a throwaway, non-registering member (:meth:`pcapkit. corekit.enum.EnumRegistry._unregistered_member`) named ``Unassigned`` - -- bare, not value-suffixed, matching every other converted registry + -- bare, not value-suffixed, matching every other non-minting registry under :mod:`pcapkit.const` (see :mod:`tests.const. test_const_enum_no_mint`) -- rather than a permanent, value-suffixed member sharing one mutable default with every other diff --git a/tests/protocols/application/test_ospf_auth_dict_roundtrip_unit.py b/tests/protocols/application/test_ospf_auth_dict_roundtrip_unit.py index ddff12303d..2b88f58ff3 100644 --- a/tests/protocols/application/test_ospf_auth_dict_roundtrip_unit.py +++ b/tests/protocols/application/test_ospf_auth_dict_roundtrip_unit.py @@ -7,7 +7,7 @@ restore it, since the field is declared ``bytes | CryptographicAuthentication``, so :meth:`OSPF._make_encrypt_auth ` has to accept -that :obj:`dict`; it used to raise ``invalid type for auth_data``. +that :obj:`dict` rather than raise ``invalid type for auth_data``. Every case builds its own octets in memory and reads no capture. @@ -74,7 +74,7 @@ def test_from_data_rebuilds_object_form_byte_for_byte(self) -> None: self.assertEqual(OSPF.from_data(parsed.info).data, raw) def test_to_dict_flattens_cryptographic_authentication(self) -> None: - # The premise of the fix: the dict form really does carry a plain + # The premise of the dict branch: the dict form really does carry a plain # mapping -- the OrderedMultiDict ``to_dict()`` writes (#1484). from pcapkit.corekit.multidict import OrderedMultiDict from pcapkit.protocols.application.ospf import OSPF diff --git a/tests/protocols/application/test_ospf_cryptographic_authentication_unit.py b/tests/protocols/application/test_ospf_cryptographic_authentication_unit.py index 5a39a41ad9..614fa33b04 100644 --- a/tests/protocols/application/test_ospf_cryptographic_authentication_unit.py +++ b/tests/protocols/application/test_ospf_cryptographic_authentication_unit.py @@ -57,7 +57,7 @@ def test_misspelled_name_is_gone(self) -> None: def test_ospf_is_grouped_with_the_application_layer(self) -> None: # The groups in these __all__ literals are runs of names: OSPF has to sit # inside the application run, which FTP opens and HTTPv2 closes, and not - # between the link-layer names it used to share a run with. + # among the link-layer names. for package in self._packages(): names = package.__all__ application = names[names.index('FTP'):names.index('HTTPv2_ContinuationFrame') + 1] diff --git a/tests/protocols/application/test_ospf_unit.py b/tests/protocols/application/test_ospf_unit.py index 085d17d9c0..2e0ce42a2d 100644 --- a/tests/protocols/application/test_ospf_unit.py +++ b/tests/protocols/application/test_ospf_unit.py @@ -157,9 +157,9 @@ def test_ospf_id_numbers_rejects_a_bool(self) -> None: Latent rather than live: nothing in this module calls ``_make_id_numbers`` -- ``OSPF.make`` builds ``router_id``/``area_id`` from its own arguments rather than through this helper -- so only a unit test (this one, - and the pre-existing one above) reaches it. It is fixed alongside the three - live sites anyway, so that it does not resurface the moment a future caller - reaches it. Measured before the fix: + and the one above) reaches it. It is guarded alongside the three live sites + anyway, so that it does not resurface the moment a future caller reaches it. + Measured without the guard: .. code-block:: text diff --git a/tests/protocols/transport/test_sctp_hostname_nul_unit.py b/tests/protocols/transport/test_sctp_hostname_nul_unit.py index c045032f92..8e567c7948 100644 --- a/tests/protocols/transport/test_sctp_hostname_nul_unit.py +++ b/tests/protocols/transport/test_sctp_hostname_nul_unit.py @@ -1,9 +1,9 @@ """#1482 -- an SCTP Host Name parameter without a NUL terminator rebuilds as captured. :rfc:`9260#section-3.3.2.1.4` requires the host name to be null-terminated. -The reader accepts a name as captured, but the maker rejected an unterminated -one, so ``from_data(info)`` raised on a packet that parsed. The terminator -check now applies only to a ``name`` keyword, not to a parsed record. +The reader accepts a name as captured, so a maker that rejected an unterminated +one would make ``from_data(info)`` raise on a packet that parsed. The terminator +check applies only to a ``name`` keyword, not to a parsed record. """ from __future__ import annotations diff --git a/tests/protocols/transport/test_sctp_roundtrip_unit.py b/tests/protocols/transport/test_sctp_roundtrip_unit.py index aa24077283..b5c42118f1 100644 --- a/tests/protocols/transport/test_sctp_roundtrip_unit.py +++ b/tests/protocols/transport/test_sctp_roundtrip_unit.py @@ -1,12 +1,13 @@ """#1221 and #1222 -- SCTP chunks rebuild byte-exactly through ``from_data``. -#1221: the chunk makers wrote the flags octet back as zero, the DATA and T-bit -flag schemas mapped only the defined bits, and the Invalid Stream Identifier -cause wrote its reserved field back as zero. The raw octets are now kept. +#1221: guards against the chunk makers writing the flags octet back as zero, the +DATA and T-bit flag schemas mapping only the defined bits, and the Invalid Stream +Identifier cause writing its reserved field back as zero. The raw octets are +kept. #1222: :rfc:`9260#section-3.2` excludes the last parameter's (or cause's) -padding from the chunk length, but the makers counted it. The canonical length -is now computed, and a parsed chunk that counted the final padding keeps it. +padding from the chunk length, so the makers must not count it. The canonical +length is computed, and a parsed chunk that counted the final padding keeps it. """ from __future__ import annotations diff --git a/tests/protocols/transport/test_sctp_stray_octets_unit.py b/tests/protocols/transport/test_sctp_stray_octets_unit.py index 7b8d777313..498d45ba59 100644 --- a/tests/protocols/transport/test_sctp_stray_octets_unit.py +++ b/tests/protocols/transport/test_sctp_stray_octets_unit.py @@ -3,12 +3,12 @@ GitHub issue #1468: SCTP has no packet length field, so octets left after the last whole chunk -- fewer than a chunk header, or a chunk whose declared length -runs past the data -- cannot be told apart from a chunk cut short. They made -:class:`~pcapkit.protocols.transport.sctp.SCTP` raise ``ProtocolError: Field -chunks has an option that runs past the end of the data``, so the packet had no -round trip at all. +runs past the data -- cannot be told apart from a chunk cut short. Guards +against them making :class:`~pcapkit.protocols.transport.sctp.SCTP` raise +``ProtocolError: Field chunks has an option that runs past the end of the +data``, which leaves the packet no round trip at all. -Per the #1458 ruling they are now recorded rather than rejected: the chunk list +Per the #1458 ruling they are recorded rather than rejected: the chunk list stops at the last whole chunk and the rest is kept as ``trailer``, as IPv4, IPv6 (#1209), L2TPv2 and OSPF (#1455) do. :meth:`make` writes a ``trailer`` after the chunks. @@ -48,7 +48,7 @@ 'chunk length below its header': (COMMON + COOKIE_ACK + struct.pack('!BBH', 11, 0, 2), 1, struct.pack('!BBH', 11, 0, 2)), 'under a chunk header': (COMMON + b'ab', 0, b'ab'), - # formerly rejected by tests/protocols/test_declared_length_overrun_unit.py + # recorded as stray octets, not rejected as a declared-length overrun 'INIT past the data': (COMMON + INIT_CUT, 0, INIT_CUT), } diff --git a/tests/protocols/transport/test_sctp_unit.py b/tests/protocols/transport/test_sctp_unit.py index 2c3ee5cc27..e37d26106e 100644 --- a/tests/protocols/transport/test_sctp_unit.py +++ b/tests/protocols/transport/test_sctp_unit.py @@ -41,9 +41,9 @@ def bitfield_packs_zero_bits() -> bool: so a flag word constructed with any bit clear does not survive a round trip. That is a library-wide defect in :mod:`pcapkit.corekit`, not an SCTP one, and - it is being fixed separately. The tests that need clear bits on the - *construction* side are gated on this probe so that they start running of - their own accord once the fix lands. Parsing is unaffected -- + it is tracked separately. The tests that need clear bits on the + *construction* side are gated on this probe so that they run of their own + accord wherever :mod:`pcapkit.corekit` packs clear bits correctly. Parsing is unaffected -- ``BitField.post_process`` is correct -- so the wire-conformance and scapy cross-check tests below exercise mixed flags unconditionally. @@ -80,9 +80,8 @@ def _packet(raw: bytes): def _proto_registry(): """Restore ``SCTP.__proto__`` on the way out, defaults included. - ``SCTP.__proto__.clear()`` was a sound teardown while the registry - started empty. It is not one now: NGAP is registered on PPIDs 60 and 66 - by default, and clearing the registry leaves every later test in the + ``SCTP.__proto__.clear()`` is not a sound teardown: NGAP is registered + on PPIDs 60 and 66 by default, and clearing the registry leaves every later test in the process running against a registry that no import repopulates -- an order-dependent failure that only appears in whichever test happens to run second. @@ -939,7 +938,7 @@ def test_ppid_dispatch_hook(self) -> None: 'SCTP:PayloadProtocolIdentifier_3GPP_NG_Application_Protocol') # A PPID registered over the default dispatches to the new class. The - # overwrite warning is expected here -- 60 is no longer a free slot -- + # overwrite warning is expected here -- 60 is not a free slot -- # and is asserted rather than allowed to litter the test output. with self._proto_registry(): with mock.patch.object(sctp_module, 'warn') as warned: @@ -1064,9 +1063,9 @@ class Other(Raw): # would already be the overwrite this test means to trigger on the # *second* call, and the assertion on the call count would pass for the # wrong reason. The second call also has to use a genuinely different - # class from the first: GitHub issue #718 added an identity guard here - # (see ``test_register_stays_quiet_on_same_object_reregistration``), so - # replaying the first call's exact object would no longer warn. + # class from the first: GitHub issue #718's identity guard (see + # ``test_register_stays_quiet_on_same_object_reregistration``) keeps a + # replay of the first call's exact object quiet. with self._proto_registry(): SCTP.register(4243, Raw) with mock.patch.object(sctp_module, 'warn') as warned: @@ -1077,12 +1076,12 @@ class Other(Raw): def test_register_stays_quiet_on_same_object_reregistration(self) -> None: """GitHub issue #718: replaying a call with the identical class is a no-op. - Before the fix the guard fired on presence alone, so the *second* of - two calls that register the exact same class under the exact same - code -- what this test performs -- warned about an overwrite that - never happened. Mirrors the identity guard :func:`register_protocol - ` already - applies. + Guards against the overwrite warning firing on presence alone, under + which the *second* of two calls that register the exact same class + under the exact same code -- what this test performs -- warns about an + overwrite that never happens. Mirrors the identity guard + :func:`register_protocol + ` applies. """ from pcapkit.protocols.misc.raw import Raw diff --git a/tests/protocols/transport/test_sctp_unpadded_final_param_unit.py b/tests/protocols/transport/test_sctp_unpadded_final_param_unit.py index 2784b0c026..978d4638f3 100644 --- a/tests/protocols/transport/test_sctp_unpadded_final_param_unit.py +++ b/tests/protocols/transport/test_sctp_unpadded_final_param_unit.py @@ -1,10 +1,10 @@ """#1479 -- a final SCTP parameter or error cause sent without its padding parses and rebuilds. :rfc:`9260#section-3.2` lets the last chunk of a packet omit its padding, and in -a chunk carrying a parameter or cause list that is the last item's padding. The -nested list's span counted that padding whether or not it was there, so the last -item read past the data and parsing raised :exc:`ProtocolError`. The span is now -clamped to the octets present. +a chunk carrying a parameter or cause list that is the last item's padding. A +nested list span that counted that padding whether or not it was there would make +the last item read past the data and parsing raise :exc:`ProtocolError`. The span +is clamped to the octets present. """ from __future__ import annotations diff --git a/tests/protocols/transport/test_sctp_unpadded_last_chunk_unit.py b/tests/protocols/transport/test_sctp_unpadded_last_chunk_unit.py index c84e9650a1..c5262adc3e 100644 --- a/tests/protocols/transport/test_sctp_unpadded_last_chunk_unit.py +++ b/tests/protocols/transport/test_sctp_unpadded_last_chunk_unit.py @@ -1,10 +1,10 @@ """#1474 -- a last SCTP chunk captured without (all of) its padding rebuilds as captured. :rfc:`9260#section-3.2` pads every chunk to four octets, but the last chunk of a -packet may arrive with fewer pad octets, or none. Parsing already read what was -there; the rebuild zero-filled the rest, so 17 octets in gave 20 out. The chunk's -``padding`` now records the octets as captured whenever they are short, and the -rebuild writes exactly those. +packet may arrive with fewer pad octets, or none. Parsing reads what is there; +guards against the rebuild zero-filling the rest, under which 17 octets in give +20 out. The chunk's ``padding`` records the octets as captured whenever they are +short, and the rebuild writes exactly those. """ from __future__ import annotations diff --git a/tests/protocols/transport/test_tcp_http_dispatch_unit.py b/tests/protocols/transport/test_tcp_http_dispatch_unit.py index f5c61bee58..1e5b56f3d3 100644 --- a/tests/protocols/transport/test_tcp_http_dispatch_unit.py +++ b/tests/protocols/transport/test_tcp_http_dispatch_unit.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- """TCP's HTTP ports dispatch through the version-identifying proxy. C.f. #682. -:attr:`TCP.__proto__ ` bound -:class:`pcapkit.protocols.application.httpv1.HTTP` directly for ports 80 and -8080, so a segment on either port was HTTP/1 by assertion of the port number: -an HTTP/2 payload there was refused by the HTTP/1 parser and reported as -:class:`~pcapkit.protocols.misc.raw.Raw`. Both versions share those ports on the +Guards against :attr:`TCP.__proto__ ` +binding :class:`pcapkit.protocols.application.httpv1.HTTP` directly for ports 80 +and 8080, which makes a segment on either port HTTP/1 by assertion of the port +number: an HTTP/2 payload there would be refused by the HTTP/1 parser and +reported as :class:`~pcapkit.protocols.misc.raw.Raw`. Both versions share those ports on the wire -- :rfc:`9113#section-3.1` keeps the cleartext form on ``http`` -- so the port cannot decide the version and the payload has to, which is what :class:`pcapkit.protocols.application.http.HTTP` does. @@ -86,15 +86,15 @@ def test_tcp_and_udp_agree_on_the_http_ports(self) -> None: def test_http2_on_the_http_ports_decodes_as_http2_and_http1_still_as_http1(self) -> None: """Both versions reach their own parser from a port-80/8080 segment. - The HTTP/2 half is what the repoint buys: before it, these nine octets - decoded as ``TCP:Raw``, because ``httpv1.HTTP`` refused them and - :func:`~pcapkit.corekit.decorators.beholder` turned the refusal into - ``Raw``. The HTTP/1.1 half is the guard on the 231 HTTP/1.1 frames in the - fixture corpus that this change routes through + The HTTP/2 half pins the binding to the proxy: bound to ``httpv1.HTTP``, + these nine octets decode as ``TCP:Raw``, because ``httpv1.HTTP`` refuses + them and :func:`~pcapkit.corekit.decorators.beholder` turns the refusal + into ``Raw``. The HTTP/1.1 half guards the HTTP/1.1 frames in the + fixture corpus, which these ports route through :meth:`HTTP._guess_version - ` for the first - time: they have to come back HTTP/1.1, and both halves are asserted here - so neither can be satisfied at the other's expense. + `: they have to + come back HTTP/1.1, and both halves are asserted here so neither can be + satisfied at the other's expense. """ from pcapkit.protocols.transport.tcp import TCP diff --git a/tests/protocols/transport/test_tcp_mptcp_addaddr_unit.py b/tests/protocols/transport/test_tcp_mptcp_addaddr_unit.py index db1e109575..b3fe6e7bc6 100644 --- a/tests/protocols/transport/test_tcp_mptcp_addaddr_unit.py +++ b/tests/protocols/transport/test_tcp_mptcp_addaddr_unit.py @@ -1,61 +1,48 @@ # -*- coding: utf-8 -*- """``TCP._make_mptcp_addaddr`` builds an ``ADD_ADDR`` option that packs. GitHub issue #541. -Filed measurement: ``t._make_mptcp_addaddr(MPTCPOption.ADD_ADDR, addr_id=1, addr='1.2.3.4')`` -warned ``UnknownFieldWarning`` twice -- once for ``kind``, once for ``length`` -- then -constructed a schema whose ``.pack()`` raised ``KeyError: 'length'`` from the ``port`` -field's condition in :class:`~pcapkit.protocols.schema.transport.tcp.MPTCPAddAddress`: - -.. code-block:: python - - port: 'int' = ConditionalField( - UInt16Field(), - lambda pkt: pkt['length'] in (10, 22), - ) - -Root cause: :class:`~pcapkit.protocols.schema.transport.tcp.MPTCP`, the base class every -Multipath TCP subtype schema inherits, declared ``kind`` and ``length`` only under +Guards against :class:`~pcapkit.protocols.schema.transport.tcp.MPTCP`, the base class +every Multipath TCP subtype schema inherits, declaring ``kind`` and ``length`` only under :data:`~typing.TYPE_CHECKING` -- annotations for a type checker, not real -:class:`~pcapkit.corekit.fields.field.FieldBase` descriptors. So ``Schema.__update__`` -rejected both keyword arguments as unknown and dropped them, and by the time ``pack()`` -reached ``port``'s lambda, ``length`` was never in the packet mapping at all. The ``port`` -predicate reading ``pkt['length']`` is not an independent circularity: ``length`` is -supplied by the caller (the arithmetic differs by IP version and by whether ``port`` is -given, but it does not depend on ``port`` having already been packed), so once ``length`` -is a real field that lands before ``port`` in field order, the predicate reads a value -that is already there. Fixed by declaring real ``kind``/``length`` fields on ``MPTCP`` -itself, mirroring how :class:`~pcapkit.protocols.schema.transport.tcp.Option` already -does it for every non-Multipath TCP option -- see ``MPTCP`` in -:mod:`pcapkit.protocols.schema.transport.tcp`. - -That one change also fixes construction for every other ``_make_mptcp_*`` helper in -:mod:`pcapkit.protocols.transport.tcp` that passes ``kind=``/``length=`` to its schema -the same way (``_make_mptcp_unknown``, ``_make_mptcp_capable``, ``_make_join_syn``, -``_make_join_synack``, ``_make_join_ack``, ``_make_mptcp_dss``, ``_make_mptcp_remove``, -``_make_mptcp_prio``, ``_make_mptcp_fail`` and ``_make_mptcp_fastclose``, alongside -``_make_mptcp_addaddr`` itself -- eleven in total), but this module only covers -``ADD_ADDR``, the case the issue was filed against. It asserts on packed bytes, not -merely on successful construction, since construction already succeeded before this fix -and only ``pack()`` failed. - -The same missing fields broke *unpacking*, not only packing, and for the identical -reason: with no real ``kind``/``length`` fields on the base class, ``MPTCPAddAddress``'s -own leading field (``test``, the subtype/IP-version octet) started reading one octet too -early -- the ``kind`` octet itself (``0x1e``) -- rather than the third octet of the -option. That is an off-by-two in field alignment, not a wire-format change: the octets a -correct sender already puts on the wire were always right, only this library's reading of -them was shifted. Measured on the pre-fix code with the hand-built, spec-correct wire -octets :data:`ADD_ADDR_SPEC_OCTETS` and :data:`MP_PRIO_SPEC_OCTETS` below (neither built -through any ``_make_mptcp_*`` maker, so the pack and parse directions cannot cancel each -other's bug out): ``0x1e`` decoded as subtype ``1``/version ``14``, an IP version nothing -accepts, so :class:`TCPMPTCPUnpackUnitTests.test_add_addr_spec_octets_parse_correctly` -raised ``FieldError: TCP: [OptNo 30] 3 invalid IP version`` before this fix; MP_PRIO has -no such guard, so :class:`TCPMPTCPUnpackUnitTests.test_mp_prio_spec_octets_parse_correctly` -instead ran the misaligned bytes as far as ``MPTCPPriority.addr_id``'s own -``pkt['length']``-gated :class:`~pcapkit.corekit.fields.misc.ConditionalField` and raised -``KeyError: 'length'`` -- the same exception #541 was filed against, reached from the -opposite direction. Both parse correctly after this fix, every field exactly as the -octets were built. +:class:`~pcapkit.corekit.fields.field.FieldBase` descriptors. ``Schema.__update__`` then +rejects both keyword arguments as unknown and drops them: as filed, +``t._make_mptcp_addaddr(MPTCPOption.ADD_ADDR, addr_id=1, addr='1.2.3.4')`` warned +``UnknownFieldWarning`` twice -- once for ``kind``, once for ``length`` -- then constructed a +schema whose ``.pack()`` raised ``KeyError: 'length'`` from the ``port`` field's +``pkt['length']``-gated condition, because ``length`` was never in the packet mapping at all. +That predicate is not an independent circularity: ``length`` is supplied by the caller (the +arithmetic differs by IP version and by whether ``port`` is given, but it does not depend on +``port`` having already been packed), so with ``length`` a real field that lands before +``port`` in field order, the predicate reads a value that is already there. ``MPTCP`` +declares real ``kind``/``length`` fields itself, mirroring how +:class:`~pcapkit.protocols.schema.transport.tcp.Option` does it for every non-Multipath TCP +option -- see ``MPTCP`` in :mod:`pcapkit.protocols.schema.transport.tcp`. + +Every other ``_make_mptcp_*`` helper in :mod:`pcapkit.protocols.transport.tcp` passes +``kind=``/``length=`` to its schema the same way (``_make_mptcp_unknown``, +``_make_mptcp_capable``, ``_make_join_syn``, ``_make_join_synack``, ``_make_join_ack``, +``_make_mptcp_dss``, ``_make_mptcp_remove``, ``_make_mptcp_prio``, ``_make_mptcp_fail`` and +``_make_mptcp_fastclose``), but this module only covers ``ADD_ADDR``, the case the issue was +filed against. It asserts on packed bytes, not merely on successful construction, since +construction succeeds even with the fields missing and only ``pack()`` fails. + +The same missing fields break *unpacking*, not only packing, and for the identical reason: +with no real ``kind``/``length`` fields on the base class, ``MPTCPAddAddress``'s own leading +field (``test``, the subtype/IP-version octet) starts reading one octet too early -- the +``kind`` octet itself (``0x1e``) -- rather than the third octet of the option. That is an +off-by-two in field alignment, not a wire-format change: the octets a correct sender puts on +the wire are right, only the reading of them is shifted. The hand-built, spec-correct wire +octets :data:`ADD_ADDR_SPEC_OCTETS` and :data:`MP_PRIO_SPEC_OCTETS` below pin it (neither is +built through any ``_make_mptcp_*`` maker, so the pack and parse directions cannot cancel +each other's bug out). Misaligned, ``0x1e`` decodes as subtype ``1``/version ``14``, an IP +version nothing accepts, so +:class:`TCPMPTCPUnpackUnitTests.test_add_addr_spec_octets_parse_correctly` would raise +``FieldError: TCP: [OptNo 30] 3 invalid IP version``; MP_PRIO has no such guard, so +:class:`TCPMPTCPUnpackUnitTests.test_mp_prio_spec_octets_parse_correctly` would instead run +the misaligned bytes as far as ``MPTCPPriority.addr_id``'s own ``pkt['length']``-gated +:class:`~pcapkit.corekit.fields.misc.ConditionalField` and raise ``KeyError: 'length'`` -- +the same exception #541 was filed against, reached from the opposite direction. Both parse +every field exactly as the octets were built. """ from __future__ import annotations @@ -187,7 +174,7 @@ def test_ipv6_with_port(self) -> None: self.assertEqual(packed[-2:], bytes.fromhex('01bb')) def test_opt_argument_round_trips_through_the_maker(self) -> None: - """Passing a previously-parsed ``opt`` takes the same path as the keyword form. + """Passing an already-parsed ``opt`` takes the same path as the keyword form. ``_make_mptcp_addaddr`` reads ``addr_id``/``addr``/``port`` off ``opt`` instead of the keyword arguments when one is given, so this exercises that branch against the @@ -241,12 +228,12 @@ class TCPMPTCPUnpackUnitTests(unittest.TestCase): def test_add_addr_spec_octets_parse_correctly(self) -> None: """RFC 8684 section 3.4.1's IPv4 ``ADD_ADDR``, addr_id=1, 192.0.2.1. - Pre-fix this raised ``FieldError: TCP: [OptNo 30] 3 invalid IP version`` -- - ``MPTCPAddAddress.test`` read the ``kind`` octet ``0x1e`` (``0001 1110``) as its - own subtype/version octet, decoding ``version=14``, which - :func:`~pcapkit.protocols.schema.transport.tcp.mptcp_add_address_selector` - then rejected outright since it was neither 4 nor 6. The selector now - takes the address family from the option length instead (#1217). + Guards against ``FieldError: TCP: [OptNo 30] 3 invalid IP version``, raised when + ``MPTCPAddAddress.test`` reads the ``kind`` octet ``0x1e`` (``0001 1110``) as its + own subtype/version octet, decoding ``version=14``, which a selector keyed on the + version would reject outright since it is neither 4 nor 6. + :func:`~pcapkit.protocols.schema.transport.tcp.mptcp_add_address_selector` takes + the address family from the option length instead (#1217). """ from pcapkit.const.tcp.option import Option as Enum_Option @@ -266,8 +253,8 @@ def test_add_addr_spec_octets_parse_correctly(self) -> None: def test_mp_prio_spec_octets_parse_correctly(self) -> None: """RFC 8684 section 3.3.8's ``MP_PRIO`` with the optional Address ID, backup set. - Pre-fix this raised ``KeyError: 'length'``: ``MP_PRIO`` carries no IP-version - guard the way ``ADD_ADDR`` does, so the same off-by-two misalignment ran on + Guards against ``KeyError: 'length'``: ``MP_PRIO`` carries no IP-version guard + the way ``ADD_ADDR`` does, so the same off-by-two misalignment would run on through to ``MPTCPPriority.addr_id``'s own ``ConditionalField(UInt8Field(), lambda pkt: pkt['length'] == 4)`` -- the same exception #541 was filed against, reached by parsing rather than by packing. diff --git a/tests/protocols/transport/test_tcp_mptcp_capable_length_unit.py b/tests/protocols/transport/test_tcp_mptcp_capable_length_unit.py index 93125d2bf8..00e29f63d3 100644 --- a/tests/protocols/transport/test_tcp_mptcp_capable_length_unit.py +++ b/tests/protocols/transport/test_tcp_mptcp_capable_length_unit.py @@ -1,45 +1,31 @@ # -*- coding: utf-8 -*- -"""MP_CAPABLE's length and its receiver's-key predicate agreed on neither RFC 8684 form. +"""MP_CAPABLE's length and its receiver's-key predicate agree on every RFC 8684 form. -GitHub issue #567, found while fixing #541 and widened during #565's cross-review. +GitHub issue #567, with the remaining lengths from #1042. -Two independent defects, and both had to move together --------------------------------------------------------- +Three sites that must agree +--------------------------- :rfc:`8684` section 3.1 gives ``MP_CAPABLE`` as **12** octets without the receiver's key and -**20** octets with it. - -``TCP._make_mptcp_capable`` (``pcapkit/protocols/transport/tcp.py``) wrote -``length=20 if rkey is None else 32`` -- both branches wrong, and the no-key branch writing -the value RFC 8684 assigns to the *other* case. - -Independently, ``MPTCPCapable.rkey`` (``pcapkit/protocols/schema/transport/tcp.py``) was - -.. code-block:: python - - rkey: 'int' = ConditionalField( - UInt64Field(), - lambda pkt: pkt['length'] != 32, - ) - --- the receiver's key field packed for every length *except* 32, i.e. dropped for exactly the -length the (also wrong) maker used to signal "key present". Fixing only the maker would have -made a 20-octet, key-present option fail this predicate (``20 != 32`` is true, so the field -would misfire as present regardless -- but a *correct* 12-octet, key-absent option satisfies -``12 != 32`` too, so the key field would still pack, appending 8 phantom octets). Fixing only -the predicate without the maker would leave both of the maker's wrong lengths (20, 32) on the -wire. Neither half alone closes the gap; both are fixed in the same change here, to -``length=12 if rkey is None else 20`` and ``lambda pkt: pkt['length'] == 20`` respectively. - -A third site shared the same wrong constants and would otherwise have re-broken this the -moment #566 let construction reach it: ``TCP._read_mptcp_capable`` rejected anything but -``schema.length in (20, 32)``, and read ``rkey`` only ``if schema.length == 32``. Fixed -alongside the other two, to ``(12, 20)`` and ``== 20``. - -Filed measurement, pre-fix (all three sites still at the old constants): a maker call with -``rkey=None`` packed a *20*-octet option (the value RFC 8684 assigns to the with-key form) -carrying no key octets, and a maker call with an explicit ``rkey`` packed a *32*-octet option -whose last 12 octets are entirely unaccounted for by the RFC 8684 figure. +**20** octets with it. Three sites encode that split -- the maker +``TCP._make_mptcp_capable``, the predicate on ``MPTCPCapable.rkey``, and the reader +``TCP._read_mptcp_capable`` -- and the tests here guard each against its own wrong constant: + +* a maker writing ``length=20 if rkey is None else 32`` -- both branches wrong, and the no-key + branch writing the value RFC 8684 assigns to the *other* case. Measured with all three sites + wrong, ``rkey=None`` packed a *20*-octet option carrying no key octets, and an explicit + ``rkey`` packed a *32*-octet option whose last 12 octets are entirely unaccounted for by the + RFC 8684 figure; +* a predicate of ``pkt['length'] != 32``, which packs the receiver's key for every length + *except* 32, i.e. drops it for exactly the length such a maker uses to signal "key present", + while a *correct* 12-octet, key-absent option satisfies ``12 != 32`` too, so the key field + would pack, appending 8 phantom octets; +* a reader accepting only ``schema.length in (20, 32)`` and reading ``rkey`` only at 32, which + rejects the correct forms as soon as construction reaches it (#566). + +Correcting the maker alone leaves the predicate packing phantom key octets, and correcting the +predicate alone leaves both of the maker's wrong lengths (20, 32) on the wire, so the sites are +pinned together. Coverage -------- @@ -51,8 +37,8 @@ ``Schema_TCP(options=..., ...)``, bypassing the makers entirely, so the *parse* direction is pinned independently of whatever the *pack* direction happens to produce. -#1042 widened the 12/20 split to all five lengths figure 4 defines -- 4, 12, 20, 22 and 24 --- in :class:`TCPMPTCPCapableAllLengthsUnitTests`. +:class:`TCPMPTCPCapableAllLengthsUnitTests` covers all five lengths figure 4 defines -- 4, +12, 20, 22 and 24 (#1042). """ from __future__ import annotations @@ -137,9 +123,9 @@ class TCPMPTCPCapableMakerUnitTests(unittest.TestCase): def test_no_key_packs_twelve_octets(self) -> None: """``rkey=None`` packs 12 octets: RFC 8684 figure 4, the key-absent form. - Pre-fix this packed **20** octets (the with-key value) and carried no key octets at - all -- the maker's own ``length=20 if rkey is None else 32`` read backwards relative - to RFC 8684. + Guards against packing **20** octets (the with-key value) with no key octets at all, + as a maker writing ``length=20 if rkey is None else 32`` -- backwards relative to RFC + 8684 -- does. """ packed = make_capable(skey=0x0102030405060708, rkey=None) @@ -151,10 +137,10 @@ def test_no_key_packs_twelve_octets(self) -> None: def test_with_key_packs_twenty_octets(self) -> None: """An explicit ``rkey`` packs 20 octets: RFC 8684 figure 4, the key-present form. - Pre-fix this packed **32** octets, the last 12 of which are unaccounted for by RFC - 8684 -- the maker wrote ``length=32``, and pre-#567's ``rkey`` predicate - (``pkt['length'] != 32``) then *dropped* the key for this exact length, so those 12 - extra octets were not even the key it claimed to carry. + Guards against packing **32** octets, the last 12 of which are unaccounted for by RFC + 8684: a maker writing ``length=32`` beside an ``rkey`` predicate of + ``pkt['length'] != 32`` *drops* the key for this exact length, so those 12 extra + octets would not even be the key it claims to carry (#567). """ packed = make_capable(skey=0x0102030405060708, rkey=0x1112131415161718) @@ -195,9 +181,9 @@ class TCPMPTCPCapableParseUnitTests(unittest.TestCase): def test_no_key_spec_octets_parse_correctly(self) -> None: """RFC 8684 figure 4's 12-octet form parses, with ``rkey`` absent. - Pre-#566/#567 this raised ``ProtocolError: TCP: [OptNo 30] invalid format`` from - ``_read_mptcp_capable``'s ``schema.length not in (20, 32)`` guard -- 12 satisfied - neither of the two (both wrong) accepted values. + Guards against ``ProtocolError: TCP: [OptNo 30] invalid format`` from a + ``schema.length not in (20, 32)`` guard in ``_read_mptcp_capable`` (#566, #567) -- + 12 satisfies neither of those two (both wrong) accepted values. """ from pcapkit.const.tcp.mp_tcp_option import MPTCPOption as Enum_MPTCPOption @@ -218,9 +204,9 @@ def test_no_key_spec_octets_parse_correctly(self) -> None: def test_with_key_spec_octets_parse_correctly(self) -> None: """RFC 8684 figure 4's 20-octet form parses, with ``rkey`` present. - Pre-#566/#567, ``schema.length not in (20, 32)`` did accept 20 -- but - ``rkey=schema.rkey if schema.length == 32 else None`` then discarded it anyway, since - 20 is not 32, so the receiver's key silently vanished rather than being reported. + Guards against the receiver's key silently vanishing: a ``schema.length not in (20, + 32)`` guard accepts 20, but ``rkey=schema.rkey if schema.length == 32 else None`` + then discards the key anyway, since 20 is not 32 (#566, #567). """ from pcapkit.const.tcp.mp_tcp_option import MPTCPOption as Enum_MPTCPOption @@ -327,10 +313,10 @@ def test_with_key_round_trip_is_twenty_octets(self) -> None: class TCPMPTCPCapableAllLengthsUnitTests(unittest.TestCase): """All five MP_CAPABLE lengths of :rfc:`8684` section 3.1 parse and round-trip (#1042). - Before #1042, ``_read_mptcp_capable`` accepted only ``length in (12, 20)``, so the SYN - form (4) and both first-data-ACK forms (22, 24) raised ``ProtocolError: TCP: [OptNo 30] - invalid format``; the schema also read ``skey`` unconditionally and had no Data-Level - Length or Checksum field. + Guards against ``_read_mptcp_capable`` accepting only ``length in (12, 20)``, under which + the SYN form (4) and both first-data-ACK forms (22, 24) raise ``ProtocolError: TCP: [OptNo + 30] invalid format``, and against a schema that reads ``skey`` unconditionally and has no + Data-Level Length or Checksum field. """ diff --git a/tests/protocols/transport/test_tcp_mptcp_error_message_unit.py b/tests/protocols/transport/test_tcp_mptcp_error_message_unit.py index 6ac307c25e..b18243f89c 100644 --- a/tests/protocols/transport/test_tcp_mptcp_error_message_unit.py +++ b/tests/protocols/transport/test_tcp_mptcp_error_message_unit.py @@ -1,27 +1,23 @@ -"""#649 -- the four MPTCP error messages that carried a doubled separator. +"""#649 -- the MPTCP error messages carry a single separator. -Four :exc:`~pcapkit.utilities.exceptions.ProtocolError` messages in the MPTCP -option handlers were spelled ``f'{self.alias}: : [OptNo …]'``, so they rendered +Guards against :exc:`~pcapkit.utilities.exceptions.ProtocolError` messages in +the MPTCP option handlers spelled ``f'{self.alias}: : [OptNo …]'``, which render with an empty field between the protocol alias and the option number:: TCP: : [OptNo 30] 1: invalid flags combination The defect is presentational -- the exception type, the option number and the -subtype were always right, and nothing downstream parses these strings -- but it -is the string a user sees when an MP_JOIN or DSS option is rejected, and an empty -field reads as a value that failed to interpolate. - -The correct form is not a matter of taste. When #649 was fixed, the same file -spelled the same prefix without the doubling in 28 other places, all -``f'{self.alias}: [OptNo {schema.kind}] …'``, so the four were outliers against a -house form in their own module; the fix moved them into it and the count became -32. Two sibling test modules already quoted the single-separator form in their -docstrings, which is further evidence of what was intended. - -Every message here is asserted **in full** rather than by substring. That is the -point of the change: a substring assertion on ``'invalid flags combination'`` -passes both before and after it, so the two places that already exercise these -messages could not have caught this, and needed no edit -- +subtype are right either way, and nothing downstream parses these strings -- but +it is the string a user sees when an MP_JOIN or DSS option is rejected, and an +empty field reads as a value that failed to interpolate. + +The correct form is not a matter of taste: every other message in the same file +spells the prefix ``f'{self.alias}: [OptNo {schema.kind}] …'``, so a doubled one +is an outlier against a house form in its own module. + +Every message here is asserted **in full** rather than by substring. A substring +assertion on ``'invalid flags combination'`` matches both forms, so the two +places that also exercise these messages cannot catch the doubling -- * ``TCPMPTCPJoinFlagOrderingUnitTests.test_neither_syn_nor_ack_raises_protocol_error`` in ``test_tcp_mptcp_join_flag_ordering_unit.py``, which asserts @@ -32,12 +28,6 @@ ``assertRaisesRegex(ProtocolError, 'invalid flags combination')`` against ``_read_mptcp_join``. -Two, not one: both match a substring that straddles the defect, and both still -pass unchanged. Neither module is touched by this change. - -All four lines arrived together in ``3bba8a1748`` (2023-04-10) and were untouched -until #649. - """ from __future__ import annotations @@ -79,8 +69,8 @@ #: :attr:`MPTCPOption.DSS `. DSS_MESSAGE = 'TCP: [OptNo 30] 2: missing required fields' -#: What the four messages read before the fix, kept as a literal so each test can -#: assert it is gone rather than only that the new form is present. +#: The doubled prefix the defect renders, kept as a literal so each test can +#: assert its absence rather than only the presence of the correct form. DOUBLED_PREFIX = 'TCP: : ' @@ -180,7 +170,7 @@ def test_make_mptcp_dss_messages(self) -> None: """Both of ``_make_mptcp_dss``'s guards, which are two lines, not one. The two carry the identical message, so asserting the string once would - not show that both were fixed. They are reached separately instead: + not show that both are correct. They are reached separately instead: * ``dsn`` given and ``ssn``/``dl_len``/``checksum`` absent -- the data-level fields a DSN requires are missing. @@ -206,12 +196,10 @@ def test_make_mptcp_dss_messages(self) -> None: def test_no_doubled_separator_survives_anywhere_in_the_module(self) -> None: """The source-level sweep, so a fifth site cannot be added unnoticed. - The four were found by grepping for the pattern rather than by reading the - one that was reported, and three of the four came from that grep. A test - that pins only the four strings would not notice a fifth appearing, so - the absence is asserted over the whole file -- and the single-separator - count is asserted too, since deleting the four lines outright would - satisfy an absence check on its own. + A test that pins only the known strings would not notice another + appearing, so the absence is asserted over the whole file -- and the + single-separator count is asserted too, since deleting the offending + lines outright would satisfy an absence check on its own. """ import pcapkit.protocols.transport.tcp as tcp_module @@ -221,21 +209,20 @@ def test_no_doubled_separator_survives_anywhere_in_the_module(self) -> None: self.assertNotIn("f'{self.alias}: : ", source) self.assertEqual(source.count("f'{self.alias}: : "), 0) - # 28 before the fix, 32 after: the four joined the house form rather than - # being removed. A guard against "fixed" by deletion. + # The exact count of the single-separator form: the #649 messages join + # the house form rather than being removed. A guard against "fixed" by + # deletion. # # NOTE TO A FUTURE EDITOR: this count is deliberately exact, and it is - # therefore a tripwire on the whole module rather than on the four lines + # therefore a tripwire on the whole module rather than on the lines # #649 was about. Adding or removing *any* `f'{self.alias}: [OptNo …]'` - # message anywhere in this ~3000-line file will fail this assertion for a - # reason that has nothing to do with the doubled separator. That is - # intended -- the alternative, asserting only the absence, passes if the - # four lines are deleted outright -- so when you legitimately change the - # number of such messages, update the number here rather than loosening - # the assertion. The one above it, on the doubled form, is the one that - # must stay at 0 forever. 33 since #1042 added the MP_CAPABLE maker's, - # 34 since #1215 added the DSS maker's 4-octet width check, 35 since - # #1217 added the ADD_ADDR maker's HMAC check. + # message anywhere in the file will fail this assertion for a reason + # that has nothing to do with the doubled separator. That is intended + # -- the alternative, asserting only the absence, passes if the + # offending lines are deleted outright -- so when you legitimately + # change the number of such messages, update the number here rather + # than loosening the assertion. The one above it, on the doubled form, + # is the one that must stay at 0 forever. self.assertEqual(source.count("f'{self.alias}: [OptNo"), 35) diff --git a/tests/protocols/transport/test_tcp_mptcp_join_flag_ordering_unit.py b/tests/protocols/transport/test_tcp_mptcp_join_flag_ordering_unit.py index 97d56a7c40..639474038e 100644 --- a/tests/protocols/transport/test_tcp_mptcp_join_flag_ordering_unit.py +++ b/tests/protocols/transport/test_tcp_mptcp_join_flag_ordering_unit.py @@ -1,15 +1,17 @@ # -*- coding: utf-8 -*- """``TCP.make`` resolves the connection flags before it builds the options. -GitHub issue #587, found while reviewing #585's CI. Distinct from #576/#585, which are -about MPTCP option *length* arithmetic; this one is a statement-ordering defect in -:meth:`TCP.make ` and needs its own fix. +GitHub issue #587. Distinct from #576/#585, which are about MPTCP option *length* +arithmetic; this one is a statement-ordering defect in +:meth:`TCP.make `. -Root cause ----------- +The ordering +------------ -:meth:`~pcapkit.protocols.transport.tcp.TCP.make` built the options first and assigned -the flags afterwards: +:meth:`TCP._make_mptcp_join `, +reached from ``_make_tcp_options``, *branches on* ``self._flags`` to pick which of the +three MP_JOIN layouts to emit, so :meth:`~pcapkit.protocols.transport.tcp.TCP.make` has +to assign the flags before it builds the options. Built the other way round: .. code-block:: python @@ -17,22 +19,15 @@ ... self._flags = _flag -but :meth:`TCP._make_mptcp_join -`, reached from -``_make_tcp_options``, *branches on that attribute* to pick which of the three MP_JOIN -layouts to emit. So on a fresh instance the attribute did not exist yet and construction -died outright:: +a fresh instance has no such attribute yet, and construction dies outright:: AttributeError: 'TCP' object has no attribute '_flags' -The parse path has the identical pair of branches in ``_read_mptcp_join`` and is fine, -because :meth:`~pcapkit.protocols.transport.tcp.TCP.read` assigns ``self._flags`` -before it parses the options. Only the make path was -inverted. - -The fix is the hoist: the ``flags`` dict and the ``_flag`` accumulation move above the -``_make_tcp_options`` call. The ``offset`` computation stays where it is, being the one -statement in that block that genuinely depends on the options' ``total_length``. +The parse path has the identical pair of branches in ``_read_mptcp_join``, and +:meth:`~pcapkit.protocols.transport.tcp.TCP.read` assigns ``self._flags`` before it +parses the options. In ``make`` the ``flags`` dict and the ``_flag`` accumulation sit +above the ``_make_tcp_options`` call; the ``offset`` computation stays below it, being the +one statement in that block that genuinely depends on the options' ``total_length``. Why the branch is not the thing to remove ------------------------------------------ @@ -50,12 +45,12 @@ ACK figure 7 24 subtype and 12 reserved bits 2, full HMAC 20 ============== ========= ======== ================================================== -Those octet counts are the ones #585 corrected at the makers, and they are re-asserted -here from the same figures, because a layout chosen correctly and then sized wrongly is -no better than the crash. +Those octet counts are the makers' own (#585), and they are re-asserted here from the +same figures, because a layout chosen correctly and then sized wrongly is no better than +the crash. -Why a zero initialisation is not the fix ------------------------------------------ +Why a zero initialisation is not enough +---------------------------------------- The obvious alternative -- give ``_flags`` a class-level default of zero so the attribute always exists -- is worse than the crash, and @@ -64,9 +59,9 @@ :meth:`Protocol.pack ` is public and calls ``make``, so an instance that has already *parsed* a segment can be asked to build a different one. That instance's ``_flags`` is already set -- to the parsed segment's flags --- so a zero default never comes into play for it, and pre-fix it silently built the -option for the wrong segment. Measured pre-fix on a parsed MP_JOIN-SYN instance asked to -pack an MP_JOIN-ACK segment:: +-- so a zero default never comes into play for it, and with the options built first it +silently builds the option for the wrong segment. Measured that way on a parsed +MP_JOIN-SYN instance asked to pack an MP_JOIN-ACK segment:: c3500050 00000001 00000000 8010 2000 0000 0000 1e0c 10 00 00000000 00000000 @@ -76,40 +71,36 @@ replaced by an all-zero phantom token and nonce. Nothing raises. A receiver gets a self-inconsistent segment. -Post-fix the same call emits ``1e18 10 00`` followed by the 20 HMAC octets: figure 7, -length 24. +Built in the right order, the same call emits ``1e18 10 00`` followed by the 20 HMAC +octets: figure 7, length 24. -So the crash was the *benign* symptom and the ordering is the defect. Hoisting fixes both -outcomes at once; a zero default fixes only the fresh-instance crash, and converts the -flagless case from a crash into a spurious error while leaving the silent corruption -exactly as it was. +So the crash is the *benign* symptom and the ordering is the defect. Resolving the flags +first prevents both outcomes; a zero default prevents only the fresh-instance crash, and +converts the flagless case from a crash into a spurious error while leaving the silent +corruption exactly as it is. -The second, smaller fix in the same block ------------------------------------------- +The flagless seed +------------------ -Hoisting made one branch reachable for the first time: an MP_JOIN asked for on a segment -with neither SYN nor ACK set. ``_make_mptcp_join`` ends in a -:exc:`~pcapkit.utilities.exceptions.ProtocolError` for exactly that case, but it could -not be reached, because the accumulator was seeded with ``cast('Enum_Flags', 0)`` -- -and :func:`typing.cast` is a runtime no-op, so with no flag set ``self._flags`` stayed -the plain :class:`int` ``0`` and the first membership test raised instead:: +An MP_JOIN asked for on a segment with neither SYN nor ACK set ends in +``_make_mptcp_join``'s :exc:`~pcapkit.utilities.exceptions.ProtocolError`. Reaching it +needs a real flagless seed for the accumulator: seeded with ``cast('Enum_Flags', 0)`` -- +and :func:`typing.cast` is a runtime no-op -- ``self._flags`` would stay the plain +:class:`int` ``0`` with no flag set, and the first membership test would raise instead:: TypeError: argument of type 'int' is not a container or iterable Seeding with ``Enum_Flags(0)`` -- a real, flagless :class:`aenum.IntFlag` member, which -still compares equal to ``0`` and still ORs as before -- lets the library's own -documented error surface. :class:`TCPMPTCPJoinFlagOrderingUnitTests` pins that it is a -``ProtocolError``, since a bare :exc:`TypeError` escaping the library would break the -in-library exception contract. +compares equal to ``0`` and ORs the same way -- lets the library's own documented error +surface. :class:`TCPMPTCPJoinFlagOrderingUnitTests` pins that it is a ``ProtocolError``, +since a bare :exc:`TypeError` escaping the library would break the in-library exception +contract. -The read path was checked for the same shape of problem and does **not** have it: +On the read path, :func:`~pcapkit.protocols.schema.transport.tcp.mptcp_data_selector` guards the flagless -case and raises -:exc:`~pcapkit.utilities.exceptions.FieldError` before ``_read_mptcp_join`` is reached at -all, so its own closing ``ProtocolError`` stays unreachable and its -``cast('Enum_Flags', 0)`` is left alone. Changing it would alter the ``connection`` value -the data model reports for every flagless parsed segment, which is not what #587 is -about. +case and raises :exc:`~pcapkit.utilities.exceptions.FieldError` before +``_read_mptcp_join`` is reached at all, and ``read`` seeds ``Enum_Flags(0)`` as well +(#616). Relationship to the rest of the suite -------------------------------------- @@ -119,19 +110,8 @@ in :mod:`tests.protocols.transport.test_tcp_mptcp_length_arithmetic_unit` call ``_make_join_syn``/``_make_join_synack``/``_make_join_ack`` directly on a ``TCP.__new__(TCP)``, which is precisely how they sidestep ``_make_mptcp_join`` and its -``self._flags`` read -- so they passed throughout, and none of them would have caught -this. The public path is the one #587 breaks, and the one nothing covered. - -:mod:`tests.protocols.transport.test_tcp_mptcp_subtype_unit` records the same gap from -the other side: its module docstring excludes MP_JOIN from #566's coverage because it -"fails independently with ``AttributeError: 'TCP' object has no attribute '_flags'``". -That exclusion is lifted by this fix, and the ``subtype`` assertion it could not make for -MP_JOIN is made below alongside the layout ones. - -:data:`tests.protocols.test_option_roundtrip_unit.EXPECTED_FAILURES` carried a -``'tcp-mptcp/MP_JOIN'`` entry recording this defect. It is deleted with this fix, which -is what that suite's own contract requires -- an entry whose defect is fixed turns it red -until the entry goes. +``self._flags`` read -- so none of them can catch an inverted order. The public path is +the one #587 is about. """ from __future__ import annotations @@ -145,18 +125,11 @@ #: Deliberately *not* a copy of :data:`examples.generators.options.TCP_BASE`, which the #: sibling modules in this directory reuse -- and, unlike them, this dict omits #: ``syn``/``ack`` altogether, since every case here passes those two explicitly to -#: select a layout, where a shared constant could not. Until #617, those sibling -#: modules' own copies of that mapping additionally passed ``'seq': 1`` and -#: ``'ack_flag': False``, neither of which was a parameter of ``make`` -- both were -#: silently swallowed by its ``**kwargs`` -- while their ``'ack': 0`` bound to ``ack``, -#: the *acknowledgement flag*, not to ``ack_no``. Measured pre-#617: -#: ``TCP(**TCP_BASE).info.seq`` read back ``0``, not the ``1`` the mapping appeared to -#: set. That was harmless where those modules used it, since none of them asserted -#: anything about the sequence number -- but #617 turns any keyword ``make`` does not -#: declare into ``UnsupportedCall`` instead of a silent no-op, so those mappings have -#: since been corrected to ``seq_no``/``ack_no``/``ack`` rather than merely tolerated. -#: This module dispatches on the ACK flag and always has, so it could never have -#: afforded to leave which-``ack``-is-which to inference, #617 or not. +#: select a layout, where a shared constant could not. The names matter: ``ack`` is the +#: *acknowledgement flag*, not ``ack_no``, and ``make`` raises ``UnsupportedCall`` for any +#: keyword it does not declare (#617), so a ``seq`` or an ``ack_flag`` would be refused +#: rather than silently swallowed by its ``**kwargs``. This module dispatches on the ACK +#: flag, so it cannot leave which-``ack``-is-which to inference. TCP_HEADER = { 'srcport': 50000, 'dstport': 80, 'seq_no': 1, 'ack_no': 0, 'ns': False, 'cwr': False, 'ece': False, 'urg': False, @@ -259,8 +232,8 @@ def assertJoinLayout(self, tcp: 'object', expected: 'bytes') -> 'None': # noqa: wrong: the option octets are what the figure draws, the option's own ``Length`` octet agrees with how many octets it actually occupies, and the header's ``offset`` accounts for them -- the last being what proves the ``offset`` - computation still sees the options it is supposed to count, after the hoist moved - statements around it. + computation sees the options it is supposed to count, with the flag resolution + placed ahead of the option build. Args: tcp: The constructed :class:`~pcapkit.protocols.transport.tcp.TCP` instance. @@ -280,29 +253,24 @@ def assertJoinLayout(self, tcp: 'object', expected: 'bytes') -> 'None': # noqa: class TCPMPTCPJoinFlagOrderingUnitTests(JoinLayoutMixin, unittest.TestCase): """Each of the three MP_JOIN layouts builds through the public constructor. - Pre-fix every one of these raised ``AttributeError: 'TCP' object has no attribute - '_flags'`` before returning anything, so there was no instance to assert on at all. + With the options built before the flags, every one of these raises ``AttributeError: + 'TCP' object has no attribute '_flags'`` before returning anything, leaving no instance + to assert on at all. """ def test_the_issue_reproduction_constructs(self) -> None: """#587's reproduction returns an instance instead of raising. - This used to keep ``seq=0, ack=0`` exactly as the issue filed it, since neither - is ``make``'s parameter name for the sequence and acknowledgement numbers and a - reader pasting the issue's own text would hit precisely this call. #617 removes - that option: ``seq`` is not declared by ``make``'s (or ``read``'s, ``pack``'s, - ``unpack``'s, ``__post_init__``'s, or ``__init__``'s) signature at all -- the - parameter is ``seq_no`` -- so where it used to be silently absorbed by ``make``'s - trailing ``**kwargs`` and leave the sequence number at its default, it now raises - :exc:`~pcapkit.utilities.exceptions.UnsupportedCall`, for a reason that has - nothing to do with what this test is pinning. ``ack=0`` needed no change: ``ack`` + The issue's own call, with ``seq`` spelled ``seq_no``: ``make`` (like ``read``, + ``pack``, ``unpack``, ``__post_init__`` and ``__init__``) declares no ``seq`` + parameter, and an undeclared keyword raises + :exc:`~pcapkit.utilities.exceptions.UnsupportedCall` (#617), for a reason that has + nothing to do with what this test is pinning. ``ack=0`` is kept as filed: ``ack`` *is* a real parameter -- the connection flag, not the acknowledgement number -- - and ``0`` is an accepted falsy value for it, which is exactly why the issue's - original text could leave it alone too. So only ``seq`` is renamed to ``seq_no`` - here; everything else, including the value ``0``, is unchanged, and the case - still returns an instance rather than raising ``AttributeError: 'TCP' object has - no attribute '_flags'``, which is what #587 is about. + and ``0`` is an accepted falsy value for it. The case returns an instance rather + than raising ``AttributeError: 'TCP' object has no attribute '_flags'``, which is + what #587 is about. """ from pcapkit.const.tcp.mp_tcp_option import MPTCPOption as Enum_MPTCPOption @@ -407,12 +375,12 @@ def test_three_layouts_are_three_distinct_lengths(self) -> None: def test_neither_syn_nor_ack_raises_protocol_error(self) -> None: """A flagless segment gets the library's own error, not a bare ``TypeError``. - ``_make_mptcp_join`` has always ended in this ``ProtocolError``, but the branch - was doubly unreachable: construction died on the missing attribute first, and - even past that the ``cast('Enum_Flags', 0)`` accumulator left ``self._flags`` as - a plain ``int``, on which ``Enum_Flags.SYN in self._flags`` raises ``TypeError: - argument of type 'int' is not a container or iterable``. Seeding with - ``Enum_Flags(0)`` is what lets the intended error through. + ``_make_mptcp_join`` ends in this ``ProtocolError``, and two things can keep a + caller from it: construction dying on a missing attribute first, and a + ``cast('Enum_Flags', 0)`` accumulator leaving ``self._flags`` as a plain ``int``, + on which ``Enum_Flags.SYN in self._flags`` raises ``TypeError: argument of type + 'int' is not a container or iterable``. Seeding with ``Enum_Flags(0)`` is what + lets the intended error through. """ from pcapkit.utilities.exceptions import ProtocolError @@ -428,8 +396,8 @@ def test_a_flagless_segment_without_mp_join_still_builds(self) -> None: The control on the previous case: seeding the accumulator with ``Enum_Flags(0)`` must not have made a flagless segment invalid in itself, and the flag octet it writes must still be zero. ``Enum_Flags(0)`` compares equal to ``0`` and ORs - exactly as the old ``cast('Enum_Flags', 0)`` did, so it is a drop-in for every - combination -- which is what makes the change safe for the six other flags this + exactly as a ``cast('Enum_Flags', 0)`` seed does, so it is a drop-in for every + combination -- which is what makes the seed safe for the six other flags this accumulator handles (``cwr``, ``ece``, ``urg``, ``psh``, ``rst``, ``fin``) as well as for SYN and ACK. @@ -492,8 +460,9 @@ class TCPMPTCPJoinStaleFlagsUnitTests(unittest.TestCase): """The case that rules out initialising ``_flags`` to zero. An instance that has parsed a segment already *has* ``_flags``, so no default value - would have changed its behaviour. Pre-fix it built the option for the segment it had - read rather than the one it was being asked to write, and did so silently. + would change its behaviour. With the options built before the flags, it builds the + option for the segment it read rather than the one it is asked to write, and does so + silently. """ @@ -501,7 +470,7 @@ def _parsed_join_syn(self) -> 'object': """A ``TCP`` instance that has parsed a real MP_JOIN-SYN segment. Returns: - The instance, whose ``_flags`` is now ``SYN`` from the wire. + The instance, whose ``_flags`` is ``SYN`` from the wire. """ from pcapkit.protocols.transport.tcp import TCP @@ -515,11 +484,12 @@ def _parsed_join_syn(self) -> 'object': def test_parsed_instance_packs_the_requested_layout(self) -> None: """``pack`` on a parsed MP_JOIN-SYN instance emits the ACK form when asked to. - Pre-fix this produced ``…8010…1e0c 10 00 00000000 00000000``: an ACK header - carrying figure 5's 12-octet SYN option, with the caller's 20-octet HMAC - discarded and an all-zero token and nonce invented in its place. No exception -- - which is why this outcome is worse than the ``AttributeError`` the same defect - produces on a fresh instance, and why a zero default is not the fix. + With the options built first, this produces ``…8010…1e0c 10 00 00000000 + 00000000``: an ACK header carrying figure 5's 12-octet SYN option, with the + caller's 20-octet HMAC discarded and an all-zero token and nonce invented in its + place. No exception -- which is why this outcome is worse than the + ``AttributeError`` the same defect produces on a fresh instance, and why a zero + default is not enough. """ from pcapkit.const.tcp.mp_tcp_option import MPTCPOption as Enum_MPTCPOption @@ -543,19 +513,19 @@ def test_parsed_instance_packs_the_requested_layout(self) -> None: def test_make_re_derives_the_flags_on_every_call(self) -> None: """``make`` recomputes ``_flags`` per call rather than inheriting the parse's. - **This case is not a regression guard for #587, and it passed on the unfixed - tree too.** It is kept, and named for what it actually establishes, because the - fix leans on this property: hoisting the flag resolution is only worth anything + **This case is not a regression guard for #587: it passes with either statement + order.** It is kept, and named for what it actually establishes, because the + ordering leans on this property: resolving the flags first is only worth anything if what it resolves describes the segment being *built*. If ``make`` instead - reused a previously stored value, the hoist would move the same wrong flags to an - earlier point and the defect above would survive it. + reused a previously stored value, resolving it earlier would only move the same + wrong flags to an earlier point and the defect above would survive. Adding an ``options`` argument would not turn this into a discriminator, which is - worth recording since it is the obvious thing to try. Pre-fix, ``make`` assigned - ``self._flags`` *after* building the options -- so by the time ``make`` returned, - the attribute held the new call's flags either way, and only the option octets - built in between were wrong. The assertion that separates the two trees is - therefore on those octets, and + worth recording since it is the obvious thing to try. With the options built + first, ``make`` assigns ``self._flags`` *after* building them -- so by the time + ``make`` returns, the attribute holds the new call's flags either way, and only + the option octets built in between are wrong. The assertion that separates the + two orders is therefore on those octets, and :meth:`test_parsed_instance_packs_the_requested_layout` is the case that makes it. @@ -580,7 +550,7 @@ def test_make_re_derives_the_flags_on_every_call(self) -> None: class TCPMakeStatementOrderUnitTests(unittest.TestCase): - """The ordering itself, pinned so a future refactor cannot silently re-invert it. + """The ordering itself, pinned so a future refactor cannot silently invert it. The behavioural cases above are the ones that matter, but they only fail for MP_JOIN. Any option maker that comes to read ``self._flags`` would be broken by the same @@ -607,8 +577,8 @@ def test_flags_are_assigned_before_the_options_are_built(self) -> None: def test_the_offset_still_follows_the_option_build(self) -> None: """``offset`` is computed after the options, since it counts their octets. - The half of the hoist that had to *stay* put. Moving it up with the flags would - have made the header's data offset describe an empty option area. + The statement that has to *stay* below the option build. Moving it up with the + flags would make the header's data offset describe an empty option area. """ from pcapkit.protocols.transport.tcp import TCP @@ -626,7 +596,7 @@ def test_the_flags_accumulator_is_a_real_enum_member(self) -> None: Pinned on the behaviour rather than on the source text, because what the option makers need is that ``Enum_Flags.SYN in self._flags`` does not raise for *any* flag combination -- including none at all, which is the case - ``cast('Enum_Flags', 0)`` left as a plain ``int``. + ``cast('Enum_Flags', 0)`` would leave as a plain ``int``. ``make`` is called on a bare ``TCP.__new__(TCP)`` rather than through the constructor, and that is load-bearing rather than a shortcut. ``Protocol``'s @@ -636,13 +606,10 @@ def test_the_flags_accumulator_is_a_real_enum_member(self) -> None: self._info = self.unpack(...) # -> read(), which sets _flags again so a fully constructed instance's ``_flags`` is whatever the **read** path left, - not what ``make`` computed. When this test was written the read path also seeded - with ``cast('Enum_Flags', 0)``, so a flagless ``TCP(...)`` reported ``_flags`` as - the plain ``int`` ``0``; #616 changed that seed to ``Enum_Flags(0)``, so both paths - now leave an ``Enum_Flags`` member. The re-parse still overwrites what ``make`` - assigned either way, and it cannot affect option construction, which has already - finished by then. Observing ``make`` alone is the only way to assert on the value - the option makers actually see. + not what ``make`` computed. Both paths seed ``Enum_Flags(0)`` (#597, #616), but + the re-parse overwrites what ``make`` assigned either way, and it cannot affect + option construction, which has already finished by then. Observing ``make`` + alone is the only way to assert on the value the option makers actually see. """ from pcapkit.const.tcp.flags import Flags as Enum_Flags @@ -661,12 +628,11 @@ def test_the_flags_accumulator_is_a_real_enum_member(self) -> None: class TCPMPTCPJoinReadPathUnitTests(unittest.TestCase): - """The control: the parse path dispatches as it always did. + """The control: the parse path dispatches each layout to its own reader. - #587 is a defect on the make path only, and the fix moves statements inside ``make``. - Nothing here should have changed, so these are regression guards rather than new - coverage -- they fail only if the hoist disturbed something it was not supposed to - touch. + #587 is a defect on the make path only, and its ordering lives inside ``make``. These + are regression guards rather than new coverage -- they fail only if reordering + ``make`` disturbs something it is not supposed to touch. """ @@ -692,17 +658,14 @@ def test_all_three_layouts_still_parse(self) -> None: self.assertEqual(data.connection, connection) def test_a_flagless_segment_is_rejected_by_the_schema_selector(self) -> None: - """The read path's own flagless guard is untouched, and fires before the reader. + """The read path's own flagless guard fires before the reader. ``mptcp_data_selector`` cannot choose an MP_JOIN schema with neither flag set, so it raises :exc:`~pcapkit.utilities.exceptions.FieldError` there. That is why - ``_read_mptcp_join``'s closing ``ProtocolError`` is unreachable from a caller, and - it is still unreachable: this guard is what #616 measured as identical either side - of changing the read path's seed. That unreachability was the original reason for - leaving the seed as ``cast('Enum_Flags', 0)``, and #616's reason for changing it - anyway -- a ``TypeError`` averted only by a guard in a different file is averted - fragilely. ``read`` seeds ``Enum_Flags(0)`` now, as ``make`` has since #597, so - calling the dispatcher directly on a flagless parsed segment reaches its + ``_read_mptcp_join``'s closing ``ProtocolError`` is unreachable from a caller. + ``read`` seeds ``Enum_Flags(0)`` all the same (#616), as ``make`` does (#597) -- + a ``TypeError`` averted only by a guard in a different file is averted fragilely + -- so calling the dispatcher directly on a flagless parsed segment reaches its ``ProtocolError`` rather than a bare ``TypeError``; see :meth:`tests.protocols.transport.test_tcp_udp_unit.TCPUDPUnitTests.test_a_flagless_segment_seeds_its_connection_flags_as_an_enum`. @@ -721,9 +684,9 @@ def test_a_flagless_segment_is_rejected_by_the_schema_selector(self) -> None: class TCPMakeOtherOptionsUnitTests(unittest.TestCase): """The other control: options that do not read ``self._flags`` are unaffected. - The hoist moves two statements past a call that reaches every option maker in the - registry, so the claim that it is behaviour-preserving for everything except MP_JOIN - needs at least one witness that is not MP_JOIN. + The flag resolution sits before a call that reaches every option maker in the + registry, so the claim that its position matters only to MP_JOIN needs at least one + witness that is not MP_JOIN. """ @@ -757,8 +720,8 @@ def capable(syn: 'bool', ack: 'bool') -> 'bytes': def test_a_plain_option_still_sets_the_header_offset(self) -> None: """A segment with a non-MPTCP option still counts it in ``offset``. - The ``offset`` computation is the statement the hoist stepped over, so this pins - that it still reads the ``total_length`` the option build produced rather than a + The ``offset`` computation is the statement that stays below the option build, so + this pins that it reads the ``total_length`` the option build produced rather than a stale or zero one. """ diff --git a/tests/protocols/transport/test_tcp_mptcp_length_arithmetic_unit.py b/tests/protocols/transport/test_tcp_mptcp_length_arithmetic_unit.py index d5c396367d..3b76fce042 100644 --- a/tests/protocols/transport/test_tcp_mptcp_length_arithmetic_unit.py +++ b/tests/protocols/transport/test_tcp_mptcp_length_arithmetic_unit.py @@ -59,7 +59,7 @@ #576 files the DSS defect against ``_make_mptcp_dss``'s length expression and suggests the fix "will also change what its maker needs to compute". Re-derived from figure 9, that -expression is **already correct**, and it is left untouched. Read as a base plus widening +expression is **correct**. Read as a base plus widening increments rather than one term per field: .. code-block:: python @@ -71,33 +71,28 @@ 2-octet Checksum) and ``m`` a further 4 to widen the DSN to 8. Every combination matches the figure, and all flags set gives 4 + 4 + 4 + 12 + 4 = 28. -What was wrong was the *schema* that expression describes. ``MPTCPDSS.ack`` and ``MPTCPDSS.dsn`` -sized themselves with ``NumberField(length=lambda pkt: 8 if pkt['flags']['a'] else 0)`` -- **0** -octets in the unextended case, where the figure says 4 -- so the option went onto the wire 4 -(or, with both fields unextended, 8) octets shorter than its own ``Length`` octet declared, and -the ``ack``/``dsn`` values the caller supplied were simply not present. Measured pre-fix with -the generator's own override arguments: 12 octets packed against a declared 20. - -A second defect sat behind the first: correcting the lambda to ``8 if ... else 4`` would still -not have packed *at the time*, because :class:`~pcapkit.corekit.fields.numbers.NumberField` -could not pack a callable length at all -- it called ``build_template`` once at ``__init__`` -with the placeholder length ``-1``, latching ``_need_process = True``, and nothing cleared that -when ``__call__`` later resolved the real length and rebuilt the template as ``>I``/``>Q``. -Measured on the 8-octet form, which the old lambda did reach: ``_make_mptcp_dss(DSS, ack=1 << -40)`` raised ``struct.error: required argument is not an integer``. #576 left that to -:mod:`pcapkit.corekit.fields.numbers`, and **#598 has since fixed it** by recomputing -``_need_process`` from the width in force rather than once from the placeholder, so a callable -length packs and unpacks both widths. The schema keeps selecting between -:class:`~pcapkit.corekit.fields.numbers.UInt32Field` and -:class:`~pcapkit.corekit.fields.numbers.UInt64Field`, which each fix ``__template__`` at class -level, through a :class:`~pcapkit.corekit.fields.misc.SwitchField` -- the pattern -:func:`~pcapkit.protocols.schema.transport.tcp.mptcp_add_address_selector` already uses in that +What can go wrong is the *schema* that expression describes. Guards against +``MPTCPDSS.ack`` and ``MPTCPDSS.dsn`` sizing themselves with ``NumberField(length=lambda pkt: +8 if pkt['flags']['a'] else 0)`` -- **0** octets in the unextended case, where the figure says +4 -- under which the option goes onto the wire 4 (or, with both fields unextended, 8) octets +shorter than its own ``Length`` octet declares, and the ``ack``/``dsn`` values the caller +supplied are simply not present. Measured that way with the generator's own override +arguments: 12 octets packed against a declared 20. + +A callable length also has to pack at all. :class:`~pcapkit.corekit.fields.numbers.NumberField` +builds its template once at ``__init__`` with the placeholder length ``-1``, and recomputes +``_need_process`` from the width in force rather than latching it from that placeholder +(#598), so a callable length packs and unpacks both widths; latched, the 8-octet form +``_make_mptcp_dss(DSS, ack=1 << 40)`` raised ``struct.error: required argument is not an +integer``. The schema selects between :class:`~pcapkit.corekit.fields.numbers.UInt32Field` +and :class:`~pcapkit.corekit.fields.numbers.UInt64Field`, which each fix ``__template__`` at +class level, through a :class:`~pcapkit.corekit.fields.misc.SwitchField` -- the pattern +:func:`~pcapkit.protocols.schema.transport.tcp.mptcp_add_address_selector` uses in that module -- for the narrower reason recorded in :func:`~pcapkit.protocols.schema.transport.tcp.mptcp_dss_ack_selector`'s own note, which is about :class:`~pcapkit.corekit.fields.misc.ConditionalField`'s condition-blind ``length`` and -not about wire absence. Swapping the two would be a behaviour change and is not made here. -:class:`TCPMPTCPDSSExtendedFieldsUnitTests` covers the 8-octet forms that could not be packed -before at all. +not about wire absence. Swapping the two would be a behaviour change. +:class:`TCPMPTCPDSSExtendedFieldsUnitTests` covers the 8-octet forms. MP_JOIN-SYN is not a defect either ----------------------------------- @@ -125,21 +120,11 @@ the blind spot :mod:`tests.protocols.test_option_roundtrip_unit`'s own docstring names ("a defect can leave the cycle closed"). -When this module was written MP_JOIN could not be built through the public ``TCP()`` -constructor at all -- ``_make_mptcp_join`` dispatches on ``self._flags``, which ``TCP.make`` -assigned *after* it had already built the options, so construction raised ``AttributeError: -'TCP' object has no attribute '_flags'``. That was a separate, already-recorded gap -(``tcp-mptcp/MP_JOIN`` in :data:`tests.protocols.test_option_roundtrip_unit.EXPECTED_FAILURES`, -with exactly that diagnosis) and was left alone here, which is why the MP_JOIN classes drive -the makers directly and reach the readers by parsing bytes, where ``_flags`` *is* set. - -**#587 has since hoisted that assignment above the option build**, so the constructor route is -open now and its ``EXPECTED_FAILURES`` entry is gone; +The MP_JOIN classes drive the makers directly and reach the readers by parsing bytes, where +``_flags`` *is* set, rather than going through the public ``TCP()`` constructor: that keeps +them a check on the *length arithmetic* of each form rather than on the dispatch. :mod:`tests.protocols.transport.test_tcp_mptcp_join_flag_ordering_unit` covers all three -layouts through ``TCP()`` proper. The classes here are still written against the makers, which -is what keeps them a check on the *length arithmetic* of each form rather than on the dispatch, -so they are left as they are -- but the reason is now choice rather than impossibility. C.f. -#587, #603. +layouts through ``TCP()`` proper. C.f. #587, #603. """ from __future__ import annotations @@ -266,8 +251,8 @@ class TCPMPTCPFastcloseLengthUnitTests(LengthInvariantMixin, unittest.TestCase): def test_maker_packs_twelve_octets(self) -> None: """``_make_mptcp_fastclose`` packs 12 octets, reserved octet included. - Pre-fix this packed **11** -- ``1e0c700000000000000009``, a declared 12 against an - 11-octet option -- because ``MPTCPFastclose`` had no reserved field and ``test`` was + Guards against packing **11** -- ``1e0c700000000000000009``, a declared 12 against + an 11-octet option -- as a schema with no reserved field would, ``test`` being then the only octet between ``length`` and ``key``, where figure 14 draws 4 subtype bits followed by 12 reserved ones. @@ -280,10 +265,10 @@ def test_maker_packs_twelve_octets(self) -> None: def test_spec_octets_parse_back(self) -> None: """Hand-built figure 14 octets parse, with the key intact. - Pre-fix ``_read_mptcp_fastclose`` required ``schema.length == 16``, so a - spec-correct 12-octet option off the wire raised ``ProtocolError: TCP: [OptNo 30] - invalid format`` -- and so did every option the maker produced, since it declared - the correct 12. + Guards against ``_read_mptcp_fastclose`` requiring ``schema.length == 16``, under + which a spec-correct 12-octet option off the wire raises ``ProtocolError: TCP: + [OptNo 30] invalid format`` -- and so does every option the maker produces, since + it declares the correct 12. """ from pcapkit.const.tcp.mp_tcp_option import MPTCPOption as Enum_MPTCPOption @@ -332,9 +317,9 @@ class TCPMPTCPJoinSYNACKLengthUnitTests(LengthInvariantMixin, unittest.TestCase) def test_maker_packs_sixteen_octets(self) -> None: """``_make_join_synack`` packs 16 octets and declares 16. - Pre-fix it declared **12** -- ``_make_join_syn``'s length for figure 5's SYN form -- - while packing the 16 octets figure 6 requires, since the HMAC here is 8 octets where - the SYN's token is 4. + Guards against declaring **12** -- ``_make_join_syn``'s length for figure 5's SYN + form -- while packing the 16 octets figure 6 requires, since the HMAC here is 8 + octets where the SYN's token is 4. """ packed = make('_make_join_synack', 'MP_JOIN', addr_id=5, hmac=b'\xDD' * 8, nonce=9) @@ -348,10 +333,10 @@ def test_maker_packs_sixteen_octets(self) -> None: def test_spec_octets_parse_back(self) -> None: """Hand-built figure 6 octets parse in a SYN/ACK segment. - Pre-fix ``_read_join_synack`` required ``schema.length == 20`` -- a value neither - figure 6 nor ``MPTCPJoinSYNACK`` produces, and one that contradicted this method's - own docstring figure -- so a spec-correct 16-octet option was rejected as an invalid - format. + Guards against ``_read_join_synack`` requiring ``schema.length == 20`` -- a value + neither figure 6 nor ``MPTCPJoinSYNACK`` produces, and one that contradicts the + reader's own docstring figure -- under which a spec-correct 16-octet option is + rejected as an invalid format. """ from pcapkit.const.tcp.mp_tcp_option import MPTCPOption as Enum_MPTCPOption @@ -368,12 +353,12 @@ def test_spec_octets_parse_back(self) -> None: self.assertEqual(data.nonce, 9) def test_reconstruction_keeps_the_parsed_hmac(self) -> None: - """Rebuilding from parsed data preserves ``hmac``, which used to be dropped. + """Rebuilding from parsed data preserves ``hmac``. - ``_make_join_synack``'s ``if opt is not None:`` branch set ``backup``, ``addr_id`` - and ``nonce`` -- the last of those twice, on two consecutive lines -- but never - ``hmac``, so reconstructing a parsed MP_JOIN-SYN/ACK silently substituted the - ``bytes(8)`` default for the truncated HMAC that was actually on the wire. The HMAC + Guards against ``_make_join_synack``'s ``if opt is not None:`` branch setting + ``backup``, ``addr_id`` and ``nonce`` but never ``hmac``, under which + reconstructing a parsed MP_JOIN-SYN/ACK silently substitutes the ``bytes(8)`` + default for the truncated HMAC that was actually on the wire. The HMAC is the entire authentication payload of this form of the option, so an all-zero substitute is not a cosmetic loss. @@ -399,9 +384,9 @@ class TCPMPTCPJoinACKLengthUnitTests(LengthInvariantMixin, unittest.TestCase): def test_maker_packs_twenty_four_octets(self) -> None: """``_make_join_ack`` packs 24 octets and declares 24. - Pre-fix it declared **8**, a third of the truth, while packing the 24 octets figure 7 - requires: the 160-bit HMAC alone is 20. ``_read_join_ack`` already required 24, so - nothing this maker produced could be parsed back. + Guards against declaring **8**, a third of the truth, while packing the 24 octets + figure 7 requires: the 160-bit HMAC alone is 20. ``_read_join_ack`` requires 24, so + nothing such a maker produces could be parsed back. """ packed = make('_make_join_ack', 'MP_JOIN', hmac=b'\xBB' * 20) @@ -412,8 +397,8 @@ def test_maker_packs_twenty_four_octets(self) -> None: def test_maker_output_satisfies_its_own_reader(self) -> None: """What the maker declares is what the reader accepts. - The two disagreed absolutely before the fix -- 8 against a guard of 24 -- so this is - the pairing that could not hold at all, rather than an off-by-one. + A maker declaring 8 disagrees absolutely with a guard of 24, so this is the pairing + that cannot hold at all under that defect, rather than an off-by-one. """ from pcapkit.const.tcp.mp_tcp_option import MPTCPOption as Enum_MPTCPOption @@ -437,9 +422,9 @@ def test_two_address_ids_pack_five_octets(self) -> None: """``addr_id=[1, 2]`` packs 5 octets and declares 5. Stated as its own test, not only as a case of the ``subTest`` sweep below, because - pytest 9.1.1 here has no ``pytest-subtests``: a failing ``subTest`` is reported but - its *parent* test still prints ``PASSED``, so a per-site regression is easier to read - from a test that fails outright. Pre-fix this packed ``1e04400102`` -- 5 octets + pytest counts a failing ``subTest`` as a failed subtest (``SUBFAILED``) while + counting its *parent* test as passed, so a per-site regression is easier to read + from a test that fails outright. Guards against packing ``1e04400102`` -- 5 octets declaring 4. """ @@ -451,10 +436,10 @@ def test_two_address_ids_pack_five_octets(self) -> None: def test_length_tracks_the_number_of_address_ids(self) -> None: """``3 + n`` for every ``n``, not a constant 4. - Pre-fix, measured: ``addr_id=[1, 2]`` packed 5 octets declaring 4, ``addr_id=[]`` - packed 3 declaring 4, and ``addr_id=[1, 2, 3, 4]`` packed 7 declaring 4. Only - ``addr_id=[1]`` -- which is what ``examples.generators.options``' fixture passes -- - happened to agree, which is why the round-trip suite never saw this. + Measured with a constant 4: ``addr_id=[1, 2]`` packed 5 octets declaring 4, + ``addr_id=[]`` packed 3 declaring 4, and ``addr_id=[1, 2, 3, 4]`` packed 7 declaring + 4. Only ``addr_id=[1]`` -- which is what ``examples.generators.options``' fixture + passes -- happens to agree, which is why the round-trip suite cannot see this. """ for addr_ids in ([], [1], [1, 2], [1, 2, 3, 4], list(range(1, 21))): @@ -470,7 +455,7 @@ def test_public_constructor_reports_every_address_id(self) -> None: The declared length is what :attr:`~pcapkit.protocols.schema.transport.tcp.MPTCPRemoveAddress.addr_id` sizes its - list from, as ``pkt['length'] - 3``, so the old constant 4 truncated the parse to a + list from, as ``pkt['length'] - 3``, so a constant 4 would truncate the parse to a single ID as well as mis-declaring the pack. """ @@ -497,9 +482,9 @@ class TCPMPTCPPriorityLengthUnitTests(LengthInvariantMixin, unittest.TestCase): def test_no_address_id_packs_three_octets(self) -> None: """``addr_id=None`` packs 3 octets with no Address ID at all. - Pre-fix this packed ``1e045000`` -- 4 octets, the last a phantom all-zero Address ID - no caller asked for. The constant ``length=4`` satisfied ``MPTCPPriority.addr_id``'s - own ``pkt['length'] == 4`` predicate, so declaring the legacy length *created* the + Guards against packing ``1e045000`` -- 4 octets, the last a phantom all-zero Address + ID no caller asked for. A constant ``length=4`` satisfies ``MPTCPPriority.addr_id``'s + own ``pkt['length'] == 4`` predicate, so declaring the legacy length *creates* the legacy field. """ @@ -529,12 +514,13 @@ def test_explicit_address_id_keeps_the_legacy_four_octet_form(self) -> None: self.assertEqual(packed, bytes([0x1E, 0x04, 0x50, 0x07])) def test_reconstruction_keeps_the_parsed_backup_flag(self) -> None: - """Rebuilding from parsed data preserves ``backup``, which used to be dropped. + """Rebuilding from parsed data preserves ``backup``. - ``_make_mptcp_prio``'s ``if opt is not None:`` branch set only ``addr_id``, so a - parsed MP_PRIO with ``B=1`` was rebuilt with ``B=0`` -- and ``B`` is the entire - payload of this option, so the rebuilt option said the opposite of the original. - The same shape as ``_make_join_synack``'s dropped ``hmac``. + Guards against ``_make_mptcp_prio``'s ``if opt is not None:`` branch setting only + ``addr_id``, under which a parsed MP_PRIO with ``B=1`` is rebuilt with ``B=0`` -- + and ``B`` is the entire payload of this option, so the rebuilt option would say the + opposite of the original. The same shape as a dropped ``hmac`` in + ``_make_join_synack``. """ from pcapkit.const.tcp.mp_tcp_option import MPTCPOption as Enum_MPTCPOption @@ -557,9 +543,9 @@ class TCPMPTCPDSSLengthUnitTests(LengthInvariantMixin, unittest.TestCase): def test_data_ack_only_packs_eight_octets(self) -> None: """``A`` set, ``a`` clear: head 4 + a 4-octet Data ACK = 8. - Pre-fix this packed ``1e082001`` -- 4 octets declaring 8 -- because ``MPTCPDSS.ack`` - sized itself ``8 if pkt['flags']['a'] else 0`` and so contributed nothing at all. - The ``ack`` value the caller passed was not on the wire. + Guards against packing ``1e082001`` -- 4 octets declaring 8 -- as an + ``MPTCPDSS.ack`` sized ``8 if pkt['flags']['a'] else 0`` does, contributing nothing + at all. The ``ack`` value the caller passed would not be on the wire. """ packed = make('_make_mptcp_dss', 'DSS', ack=1) @@ -570,8 +556,9 @@ def test_data_ack_only_packs_eight_octets(self) -> None: def test_mapping_only_packs_sixteen_octets(self) -> None: """``M`` set, ``m`` clear: head 4 + DSN 4 + SSN 4 + Data-Level Length 2 + Checksum 2. - Pre-fix ``MPTCPDSS.dsn`` contributed 0 octets here for the same reason as ``ack``, so - this packed 12 against a declared 16 and the ``dsn`` value was lost. + Guards against ``MPTCPDSS.dsn`` contributing 0 octets here for the same reason as + ``ack``, under which this packs 12 against a declared 16 and the ``dsn`` value is + lost. """ packed = make('_make_mptcp_dss', 'DSS', dsn=2, ssn=3, dl_len=4, checksum=b'\x00\x00') @@ -588,9 +575,9 @@ def test_generator_override_arguments_pack_twenty_octets(self) -> None: This is #576's filed measurement: ``ack=1, dsn=2, ssn=3, dl_len=4, checksum=b'\\x00\\x00'`` packed **12** octets against a declared 20, with neither - ``ack`` nor ``dsn`` on the wire. ``tcp-mptcp/DSS`` still read ``'OK'`` in the - round-trip suite, because construct -> parse -> reconstruct produced the same wrong - 12 octets every time and the suite only checks the cycle is self-consistent. + ``ack`` nor ``dsn`` on the wire. The round-trip suite cannot see that, because + construct -> parse -> reconstruct produces the same wrong 12 octets every time and + the suite only checks the cycle is self-consistent. """ packed = make('_make_mptcp_dss', 'DSS', ack=1, dsn=2, ssn=3, dl_len=4, @@ -610,8 +597,8 @@ def test_ack_and_dsn_survive_a_real_byte_round_trip(self) -> None: would prove nothing: ``TCP._make_mode_mp`` hands the schema it just built straight to the matching ``_read_mptcp_*``, so ``data.ack`` reads back the attribute the caller set regardless of how many octets the field packed. Measured -- that assertion passes - against the unfixed tree. Going out to bytes and back in is what exposes a field that - declared 4 octets and packed 0. + even with the defect present. Going out to bytes and back in is what exposes a field + that declares 4 octets and packs 0. """ from pcapkit.const.tcp.mp_tcp_option import MPTCPOption as Enum_MPTCPOption diff --git a/tests/protocols/transport/test_tcp_mptcp_length_unit.py b/tests/protocols/transport/test_tcp_mptcp_length_unit.py index 815a818271..c164a29ee3 100644 --- a/tests/protocols/transport/test_tcp_mptcp_length_unit.py +++ b/tests/protocols/transport/test_tcp_mptcp_length_unit.py @@ -1,20 +1,20 @@ # -*- coding: utf-8 -*- -"""Where the Multipath TCP option header keeps its length, which was off by seven bits. +"""Where the Multipath TCP option header keeps its length: bits 8-15, not seven bits early. GitHub issue #553. :class:`_MPTCP ` reads the option header through a :class:`~pcapkit.corekit.fields.misc.ForwardMatchField` over a 3-octet :class:`~pcapkit.corekit.fields.strings.BitField`, and that field's namespace -declared ``'length': (1, 8)`` -- eight bits starting one bit in. The entry has -to be ``(8, 8)``, and three independent pieces of evidence say so: +declares ``'length': (8, 8)``. Guards against ``(1, 8)`` -- eight bits starting +one bit in -- and three independent pieces of evidence say ``(8, 8)`` is right: * :rfc:`8684` section 3 lays every Multipath TCP option out as ``Kind`` (8 bits), ``Length`` (8 bits), ``Subtype`` (4 bits), then subtype-specific content. Numbering from the option's first octet, ``Length`` therefore occupies bits 8-15 and the subtype bits 16-19. * The sibling entry in the very same namespace, ``'subtype': (16, 4)``, is - already written against that numbering, and it decodes correctly. Two entries - in one namespace cannot be anchored differently -- :meth:`BitField.post_process + written against that numbering, and it decodes correctly. Two entries in one + namespace cannot be anchored differently -- :meth:`BitField.post_process ` slices both out of one big-endian bit string over the whole 3-octet window -- so ``subtype`` reading from bit 16 fixes the window's origin at ``kind`` and leaves bit 8 as the only @@ -26,13 +26,13 @@ ` -- so it is handed the option's first octet and nothing has consumed ``kind``/``length`` before it. -At ``(1, 8)`` the field straddled the low seven bits of ``kind`` and the high bit -of ``length``. For the 12-octet MP_CAPABLE of :rfc:`8684` figure 4, whose first -three octets are ``1e 0c 01``, that reads ``00111100`` where the length is -``00001100``: 60 instead of 12. +At ``(1, 8)`` the field would straddle the low seven bits of ``kind`` and the +high bit of ``length``. For the 12-octet MP_CAPABLE of :rfc:`8684` figure 4, +whose first three octets are ``1e 0c 01``, that reads ``00111100`` where the +length is ``00001100``: 60 instead of 12. -Why no existing test saw it ---------------------------- +Why a round trip cannot see it +------------------------------ The decoded length is used for one thing, ``SchemaField(length=pkt['test']['length'])`` in :func:`mptcp_data_selector @@ -40,26 +40,13 @@ nested subtype schema. Over-declaring that size does not fail loudly: the enclosing :class:`~pcapkit.corekit.fields.collections.OptionField` stops at the end-of-option-list marker or at the declared options length whichever comes -first, so the over-read is absorbed rather than reported. And the construction -path never evaluated the field at all -- when #553 was fixed, every -``tcp-mptcp/*`` case in -:data:`tests.protocols.test_option_roundtrip_unit.EXPECTED_FAILURES` failed before -reaching it, for reasons of its own that #553 did not touch. +first, so the over-read is absorbed rather than reported. So the assertion here is deliberately on the *decoded namespace* rather than on a -round trip. A full parse of an MP_CAPABLE option did not complete then either: -:class:`MPTCPCapable -` gated ``rkey`` on -``pkt['length'] != 32`` while the nested subtype schemas declared no ``length`` -field, so parsing raised ``KeyError: 'length'`` from -:file:`pcapkit/protocols/schema/transport/tcp.py` regardless of which bit offset -the header used. That was the structural defect behind #541 and the eight -``tcp-mptcp/*`` expected failures -- the subtype schemas inherit :class:`MPTCP -`, which declared ``kind`` and -``length`` only under :data:`~typing.TYPE_CHECKING` -- and it is not what #553 -is about. Tying this assertion to an end-to-end parse would have meant it could -not be written until that much larger change landed, and the bit offset would -have stayed wrong in the meantime. +round trip: a full parse can fail, or pass, for reasons that have nothing to do +with the bit offset -- the subtype schemas' own ``kind``/``length`` declarations +on :class:`MPTCP ` (#541), for one +-- and the offset is pinned independently of them. The field is read off the schema class, ``_MPTCP.__fields__['test']``, rather than rebuilt here, so the declaration under test is the one the library ships. @@ -123,8 +110,8 @@ def test_subtype_still_decodes_beside_the_corrected_length(self) -> 'None': """``subtype`` reads MP_CAPABLE, from the same window as ``length``. This is the entry that pins the window's origin, so it is asserted - rather than assumed: ``subtype`` was already correct at ``(16, 4)`` - before #553 and has to stay correct after it, which is what rules out + rather than assumed: ``subtype`` decodes correctly at ``(16, 4)`` + independently of #553's ``length`` entry, which is what rules out "fixing" the length by moving the window instead of the entry. """ @@ -169,7 +156,7 @@ def test_every_length_a_single_octet_can_hold_round_trips(self) -> 'None': The exhaustive form of the assertion above, and the one that leaves no room for an offset that happens to be right for the two lengths a hand-written case would pick. ``kind`` is held at ``1e``, whose low - seven bits are what the old offset mixed into the answer, so any + seven bits are what a ``(1, 8)`` offset mixes into the answer, so any residual dependence on it shows up as a mismatch. """ diff --git a/tests/protocols/transport/test_tcp_mptcp_subtype_unit.py b/tests/protocols/transport/test_tcp_mptcp_subtype_unit.py index 160a2bad5b..5b125c1721 100644 --- a/tests/protocols/transport/test_tcp_mptcp_subtype_unit.py +++ b/tests/protocols/transport/test_tcp_mptcp_subtype_unit.py @@ -1,17 +1,13 @@ # -*- coding: utf-8 -*- """``MPTCP.subtype`` is set for a schema built in memory, not only one parsed from bytes. -GitHub issue #566, found while fixing #541 -- the same defect the ``EXPECTED_FAILURES`` -table in :mod:`tests.protocols.test_option_roundtrip_unit` recorded for -``tcp-mptcp/MP_CAPABLE``, ``tcp-mptcp/ADD_ADDR``, ``tcp-mptcp/REMOVE_ADDR``, -``tcp-mptcp/MP_PRIO``, ``tcp-mptcp/DSS`` and ``tcp-mptcp/MP_FAIL`` until this fix, each with -a fragment of the form ``"'MPTCPCapable' object has no attribute 'subtype'"``. +GitHub issue #566. -Root cause ----------- +The construction path +--------------------- :class:`~pcapkit.protocols.schema.transport.tcp.MPTCP`, the base class every Multipath TCP -subtype schema inherits, declared ``subtype`` only under :data:`~typing.TYPE_CHECKING`: +subtype schema inherits, declares ``subtype`` only under :data:`~typing.TYPE_CHECKING`: .. code-block:: python @@ -19,50 +15,45 @@ #: MPTCP subtype. subtype: 'Enum_MPTCPOption' -so it was an annotation for a type checker, never a real attribute any construction path -set. The only code that ever assigned ``schema.subtype`` was -:meth:`_MPTCP.post_process `, -which runs once per option while parsing real bytes off the wire. Building an option through -:class:`~pcapkit.protocols.transport.tcp.TCP`'s convenience constructor -- +so it is an annotation for a type checker, not a field any schema packs. +:meth:`_MPTCP.post_process ` +assigns ``schema.subtype`` once per option while parsing real bytes off the wire. Building +an option through :class:`~pcapkit.protocols.transport.tcp.TCP`'s convenience constructor -- ``TCP(options=[(Enum_Option.Multipath_TCP, {'subtype': ..., **kwargs})], ...)`` -- never unpacks anything: the dispatcher (``TCP._make_mode_mp``) calls the matching ``_make_mptcp_*`` maker to build the schema in memory and then hands that same schema straight to the matching ``_read_mptcp_*`` to build the :class:`~pcapkit.protocols.data.transport.tcp.MPTCP` data -model -- and every one of those eleven ``_read_mptcp_*`` methods reads ``schema.subtype``. -Filed measurement, pre-fix: ``TCP(options=[(Enum_Option.Multipath_TCP, {'subtype': -Enum_MPTCPOption.MP_FAIL, 'dsn': 7})], ...)`` raised -``AttributeError: 'MPTCPFallback' object has no attribute 'subtype'``. +model -- and every ``_read_mptcp_*`` method reads ``schema.subtype``. So +:meth:`TCP._make_mode_mp `, the single +dispatcher every ``_make_mptcp_*`` maker returns through, sets ``schema.subtype = +subtype_val`` right after building the schema -- the same assignment +:meth:`_MPTCP.post_process` makes for a real unpack, made unconditionally. Without it, +``TCP(options=[(Enum_Option.Multipath_TCP, {'subtype': Enum_MPTCPOption.MP_FAIL, 'dsn': +7})], ...)`` raises ``AttributeError: 'MPTCPFallback' object has no attribute 'subtype'``. -Why a real field was not the fix ---------------------------------- +Why a real field is not the answer +----------------------------------- -#541 fixed the identical shape of problem for ``kind``/``length`` by declaring them as real -fields on ``MPTCP``, and the obvious next step is to do the same for ``subtype``. That would -be wrong here, and the difference is worth recording since this is the third -``TYPE_CHECKING``-only attribute this one class has had (``kind``, ``length`` in #541, -``subtype`` here) and a reader deserves to know the pattern was considered as a whole rather -than patched a third time by reflex. +``kind`` and ``length`` are real fields on ``MPTCP`` (#541), and the obvious next step is to +do the same for ``subtype``. That would be wrong here, and the difference is worth recording +so that a reader knows the pattern was considered as a whole rather than patched by reflex. ``kind`` and ``length`` are each the *sole* source of their own octet -- nothing else in any -subtype schema packs them, so a real field was the only way to get them onto the wire at all. -``subtype`` is not like that: every concrete subclass already encodes it as 4 bits of its own +subtype schema packs them, so a real field is the only way to get them onto the wire at all. +``subtype`` is not like that: every concrete subclass encodes it as 4 bits of its own ``test`` :class:`~pcapkit.corekit.fields.strings.BitField` (e.g. ``MPTCPCapable.test['subtype']``), which is what the wire format actually carries. Declaring a second, independent ``Field`` for the same 4 bits would either double-pack them under two names, or need a "derive this from another field, do not pack it yourself" kind of field -- which nothing in :mod:`pcapkit.corekit.fields` provides (every ``FieldBase`` subclass either contributes its own bytes or selects among nested schemas; none merely mirrors a sibling -field's decoded value into a second attribute). So ``MPTCP.subtype`` stays an annotation, and -the fix is on the *construction* path instead: :meth:`TCP._make_mode_mp -`, the single dispatcher every -``_make_mptcp_*`` maker returns through, now sets ``schema.subtype = subtype_val`` right after -building the schema -- the same assignment :meth:`_MPTCP.post_process` already makes for a -real unpack, just made unconditionally. +field's decoded value into a second attribute). So ``MPTCP.subtype`` stays an annotation, +populated on the *construction* path. That leaves no other ``TYPE_CHECKING``-only *data* attribute on ``MPTCP`` itself: the class -body has exactly ``kind``, ``length`` (both real fields since #541) and ``subtype`` (fixed -here by the construction path). The ``if TYPE_CHECKING: def __init__(...): ...`` blocks inside -the concrete subclasses below it are ordinary typed-signature stubs for the ``__init__`` that +body has ``kind`` and ``length`` (real fields) and ``subtype`` (set by the construction +path). The ``if TYPE_CHECKING: def __init__(...): ...`` blocks inside the concrete subclasses +below it are ordinary typed-signature stubs for the ``__init__`` that :class:`~pcapkit.protocols.schema.schema.Schema` synthesises at runtime, not latent fields, so they are not instances of this pattern. @@ -71,25 +62,16 @@ Every case here goes through the *public* ``TCP`` convenience constructor, not ``_make_mptcp_*`` directly, because that in-memory, no-byte-round-trip path is exactly what -exposed the defect. - -``MP_JOIN`` used to be left out, because it failed independently with ``AttributeError: -'TCP' object has no attribute '_flags'`` -- ``_make_mptcp_join`` reads ``self._flags`` to -pick between the three layouts of :rfc:`8684` section 3.2, and ``TCP.make`` assigned that -attribute only *after* it had built the options. That is a statement-ordering defect, not -this one, and #587 has since fixed it by hoisting the flag resolution above the option -build; the ``subtype`` assertion this module exists for is made for MP_JOIN below, once per -layout, since which layout gets built is exactly what the flags decide. The ordering defect -itself, the three layouts' octets, and the silent wrong-layout outcome that ruled out -fixing it with a zero-valued default are covered in +exposes the defect. + +``MP_JOIN`` is asserted once per layout, since ``_make_mptcp_join`` reads ``self._flags`` to +pick between the three layouts of :rfc:`8684` section 3.2, and which layout gets built is +exactly what the flags decide. The flag ordering itself (#587), the three layouts' octets, +and the silent wrong-layout outcome that rules out a zero-valued default are covered in :mod:`tests.protocols.transport.test_tcp_mptcp_join_flag_ordering_unit`. -``MP_FASTCLOSE`` *is* covered, as of #576. When this module was written it was left out: fixing -``subtype`` got its construction past the ``AttributeError`` and into a second, independent -defect -- its declared length agreed with neither its own schema nor its own parser -- so the -case here could only assert the *shape of the exception* that defect raised. #576 has since -corrected all three sites, so the assertion is made directly. The length arithmetic itself is -covered per option in +``MP_FASTCLOSE`` is asserted directly as well. Its length arithmetic (#576), like every +option's, is covered per option in :mod:`tests.protocols.transport.test_tcp_mptcp_length_arithmetic_unit`; what stays here is only the ``subtype`` question this module is about. @@ -160,10 +142,10 @@ def build_mptcp_option(subtype: 'object', *, header: 'Optional[dict[str, object] class TCPMPTCPSubtypeUnitTests(unittest.TestCase): """Every constructible MPTCP subtype round-trips ``.subtype`` through ``TCP()``. - Pre-fix, every one of these raised ``AttributeError: '' object has no - attribute 'subtype'`` -- construction never got as far as returning a - :class:`~pcapkit.protocols.transport.tcp.TCP` instance at all, so there is nothing to - assert on the resulting data model until the fix lands. + Without the construction path setting ``subtype``, every one of these raises + ``AttributeError: '' object has no attribute 'subtype'`` -- construction + never gets as far as returning a :class:`~pcapkit.protocols.transport.tcp.TCP` instance + at all, leaving no data model to assert on. """ @@ -235,12 +217,10 @@ def test_mp_fail_subtype_round_trips(self) -> None: def test_mp_join_subtype_round_trips_in_all_three_layouts(self) -> None: """``MP_JOIN`` built through ``TCP()`` reports its own subtype, whichever form. - This module used to exclude MP_JOIN altogether: ``_make_mptcp_join`` reads - ``self._flags`` to choose between the three layouts of :rfc:`8684` section 3.2, - and ``TCP.make`` assigned that attribute only after it had already built the - options, so every one of these raised ``AttributeError: 'TCP' object has no - attribute '_flags'`` before ``subtype`` could come into it. #587 hoists the flag - resolution above the option build. + ``_make_mptcp_join`` reads ``self._flags`` to choose between the three layouts of + :rfc:`8684` section 3.2, so ``TCP.make`` resolves the flags before it builds the + options (#587); otherwise every one of these would raise ``AttributeError: 'TCP' + object has no attribute '_flags'`` before ``subtype`` could come into it. All three forms are exercised rather than just the SYN one ``TCP_BASE`` selects, because the flags are what pick the layout: a regression that reached only one @@ -273,17 +253,10 @@ def test_mp_join_subtype_round_trips_in_all_three_layouts(self) -> None: def test_mp_fastclose_builds_and_reports_its_subtype(self) -> None: """``MP_FASTCLOSE`` builds through ``TCP()`` and reports its ``subtype``. - This was ``test_mp_fastclose_still_fails_but_no_longer_on_subtype`` when #579 - landed: it pinned that MP_FASTCLOSE no longer raised the ``subtype`` - ``AttributeError`` that #566 was about, while still raising ``ProtocolError: - TCP: [OptNo 30] invalid format`` from the *other* defect it named -- the - length disagreement filed as #576. - - #576 has since fixed that disagreement at all three sites (the parser's guard, - which required 16; the schema, which packed 11; and the maker, which already - declared the RFC's 12), so the construction now succeeds and the assertion this - module exists for -- that ``subtype`` survives construction -- can be made - directly instead of through the shape of an exception. + MP_FASTCLOSE's maker, schema and parser agree on the RFC's 12 octets (#576), so the + construction succeeds and the assertion this module exists for -- that + ``subtype`` survives construction -- is made directly rather than through the + shape of an exception. """ from pcapkit.const.tcp.mp_tcp_option import MPTCPOption as Enum_MPTCPOption diff --git a/tests/protocols/transport/test_tcp_roundtrip_unit.py b/tests/protocols/transport/test_tcp_roundtrip_unit.py index 0ba1b7183b..ab01bb0526 100644 --- a/tests/protocols/transport/test_tcp_roundtrip_unit.py +++ b/tests/protocols/transport/test_tcp_roundtrip_unit.py @@ -11,7 +11,7 @@ ``R``) were written back as zero; * #1218 -- the octets after an End of Option List option were dropped, and the Data Offset was recomputed from what was left, shrinking the header; they - are kept now, but only while the options still end with that option, so an + are kept, but only while the options still end with that option, so an edited option list still builds; * #1219 -- the User Timeout option lost its ``G`` bit, the maker choosing the granularity by the size of the value instead; the parsed unit is kept while diff --git a/tests/protocols/transport/test_tcp_sack_length_unit.py b/tests/protocols/transport/test_tcp_sack_length_unit.py index 09c438e72e..2a12f47fdc 100644 --- a/tests/protocols/transport/test_tcp_sack_length_unit.py +++ b/tests/protocols/transport/test_tcp_sack_length_unit.py @@ -1,15 +1,15 @@ # -*- coding: utf-8 -*- -"""The TCP SACK option's length constraint, which was documented but not checked. +"""The TCP SACK option's length constraint, documented and checked. GitHub issue #519 counted :meth:`TCP._read_mode_sack ` among its phantom ``Raises:`` clauses: the docstring promised ``ProtocolError: If length is **NOT** multiply of 8 plus 2`` and the body contained no such check. The tempting reading is that the clause was stale and should be deleted, which is -what the other five phantoms in that issue needed. +what the other phantoms in that issue needed. -It was the opposite. The clause was right and the check was missing, and three -pieces of evidence settle it: +It was the opposite. The clause is right and the check belongs in the body, and +three pieces of evidence settle it: * :rfc:`2018` gives the SACK option a 2-octet header followed by 8-octet left/right edge pairs, so a well-formed option's length really is ``8n + 2``. @@ -18,13 +18,12 @@ ` -- the method immediately above this one -- raises on ``schema.length != 2``, and :meth:`TCP._read_mode_echo ` - on ``schema.length != 6``, both with the same message. ``_read_mode_sack`` - was the one option reader documenting a length rule it never enforced. -* Nothing upstream enforced it either. A segment carrying ``kind=5, - length=11`` parsed clean before the fix: the schema field is + on ``schema.length != 6``, both with the same message. +* Nothing upstream enforces it either. The schema field is ``ListField(length=lambda pkt: pkt['length'] - 2, item_type=SchemaField(length=8, schema=SACKBlock))``, and a remainder is - simply not noticed. That is what :meth:`SACKLengthTests + simply not noticed, so without the check a segment carrying ``kind=5, + length=11`` parses clean. That is what :meth:`SACKLengthTests .test_invalid_sack_length_is_rejected` pins. So this module is the behavioural half of the #519 work. The other half is @@ -33,31 +32,25 @@ *this* docstring, which is the direction that actually lets a malformed packet through. -:meth:`SACKLengthTests.test_invalid_sack_length_is_rejected` used to accept -either :exc:`~pcapkit.utilities.exceptions.ProtocolError` or -:exc:`~pcapkit.utilities.exceptions.FieldValueError`, because which one arrived -depended on process state rather than on the packet -- filed as #525. The root -cause was :meth:`ListField.unpack +The exception is :exc:`~pcapkit.utilities.exceptions.ProtocolError` whatever +ran earlier in the process (#525). That depends on :meth:`ListField.unpack ` resolving -:class:`~pcapkit.corekit.fields.misc.SchemaField` through a *function-local* -import on every call, instead of once at module load: ordinarily a cheap -:data:`sys.modules` hit, but a class that has been popped from +:class:`~pcapkit.corekit.fields.misc.SchemaField` once at module load rather +than through a *function-local* import on every call: ordinarily such an import +is a cheap :data:`sys.modules` hit, but a class that has been popped from :data:`sys.modules` since this field was built (which is exactly what the ``#439`` ABC-cache regression tests do, in every case's ``setUp``/``tearDown``, to get a clean cache between them) comes back as a second, distinct class on the next call. ``isinstance(self._item_type, SchemaField)`` then answers :data:`False` for an item that plainly is one, ``ListField.unpack`` bills it by -its declared length instead of by what it actually consumed, and *that* is what -let the length check go negative and raise +its declared length instead of by what it actually consumed, and *that* lets +the length check go negative and raise :exc:`~pcapkit.utilities.exceptions.FieldValueError` from -:mod:`pcapkit.corekit.fields.collections` before ``_read_mode_sack`` was ever -reached. Hoisting the import to module level in that fix closed it: the -exception is now always :exc:`~pcapkit.utilities.exceptions.ProtocolError`, -regardless of what ran earlier in the process, and the assertion below is -narrowed to match. +:mod:`pcapkit.corekit.fields.collections` before ``_read_mode_sack`` is ever +reached. :meth:`SACKLengthTests.test_sack_exception_type_survives_a_sys_modules_purge` -pins the mechanism directly, by reproducing the exact -:data:`sys.modules` manipulation that used to flip it. +pins the mechanism directly, by reproducing that :data:`sys.modules` +manipulation. One deliberate limit, so a later reader does not think it an oversight: the check implements exactly the rule the docstring states, ``(length - 2) % 8 == @@ -144,18 +137,15 @@ def test_valid_sack_lengths_are_accepted(self) -> 'None': def test_invalid_sack_length_is_rejected(self) -> 'None': """A length that is not ``8n + 2`` is rejected. - Every one of these parsed without complaint before the #519 check was - added, which is the whole point of the case. ``11`` and ``14`` are the + Every one of these parses without complaint unless the #519 check is + in place, which is the whole point of the case. ``11`` and ``14`` are the interesting ones -- a remainder of 1 and of 4 -- because the block list happily consumes as many whole 8-octet items as it can find and silently ignores the tail. - This used to accept either :exc:`~pcapkit.utilities.exceptions.ProtocolError` - or :exc:`~pcapkit.utilities.exceptions.FieldValueError`, since which one - arrived depended on unrelated process state rather than on the packet - -- #525. That is now fixed at the root (see the module docstring and - :meth:`test_sack_exception_type_survives_a_sys_modules_purge` below), - so the exception is pinned to the single documented type: + The exception is pinned to the single documented type, independent of + unrelated process state -- #525, see the module docstring and + :meth:`test_sack_exception_type_survives_a_sys_modules_purge` below: :exc:`~pcapkit.utilities.exceptions.ProtocolError` from :meth:`TCP._read_mode_sack `, which is the @@ -172,8 +162,8 @@ def test_sack_exception_type_survives_a_sys_modules_purge(self) -> 'None': """The exception type does not depend on :data:`sys.modules` state. This pins the actual mechanism behind #525, rather than merely - recording the symptom. :meth:`ListField.unpack - ` used to resolve + recording the symptom. Guards against :meth:`ListField.unpack + ` resolving :class:`~pcapkit.corekit.fields.misc.SchemaField` through a function-local import, re-run on every call. That is an ordinary :data:`sys.modules` cache hit almost always -- except when something @@ -199,14 +189,14 @@ def test_sack_exception_type_survives_a_sys_modules_purge(self) -> 'None': :data:`sys.modules` manipulation alone is sufficient, and doing just that is what makes this a targeted regression test rather than a repeat of the same "run some other tests first" guess #525 opened - with. Before the fix, this exact removal made + with. With a function-local import, this exact removal makes :meth:`ListField.unpack ` re-import :mod:`pcapkit.corekit.fields.misc` on its next call and mint the mismatched second ``SchemaField`` class described above; asserting that :mod:`pcapkit.corekit.fields.misc` is *not* back in :data:`sys.modules` immediately after ``parse()`` returns is what - confirms the fix (a module-level import, resolved once) rather than - just a passing assertion for the wrong reason. + confirms a module-level import, resolved once, rather than just a + passing assertion for the wrong reason. """ self.assertIn( @@ -219,11 +209,10 @@ def test_sack_exception_type_survives_a_sys_modules_purge(self) -> 'None': parse(sack_option(11, 11 + 8)) self.assertRegex(str(caught.exception), 'invalid format') - # The fix hoisted the import to module level, so nothing during - # ``parse()`` re-triggers it: the module stays absent from - # ``sys.modules`` until this test restores it below. Before the - # fix, ``ListField.unpack``'s function-local import would have put - # it right back. + # The import is at module level, so nothing during ``parse()`` + # re-triggers it: the module stays absent from ``sys.modules`` + # until this test restores it below. A function-local import in + # ``ListField.unpack`` would put it right back. self.assertNotIn('pcapkit.corekit.fields.misc', sys.modules) finally: # NOTE: restore, rather than leave the module state disturbed for diff --git a/tests/protocols/transport/test_tcp_udp_unit.py b/tests/protocols/transport/test_tcp_udp_unit.py index 524a739b1c..5130bac4d8 100644 --- a/tests/protocols/transport/test_tcp_udp_unit.py +++ b/tests/protocols/transport/test_tcp_udp_unit.py @@ -35,14 +35,14 @@ def mptcp_option(opt: 'Any' = None, *, syn: 'bool' = False, ack: 'bool' = False, ` is what assigns that attribute -- as an :class:`aenum.IntFlag` member, resolved *before* ``_make_tcp_options`` runs. - Until #603 the MP_JOIN cases here wrote a plain :obj:`set` onto the attribute of a bare - ``object.__new__(TCP)`` instead. A :obj:`set` answers the ``in`` tests the dispatchers - use, so every branch ran and both TCP modules read 100% statement and branch coverage - -- while the attribute had neither the type nor the provenance production gives it. That - is how #587, an ordering defect that broke MP_JOIN construction for every caller, sat - behind that coverage number untouched, and how the ``cast('Enum_Flags', 0)`` no-op - behind it went unnoticed too. Going through ``TCP()`` means an ordering or type defect - fails a test here instead of passing one. + The cases go through ``TCP()`` rather than writing a plain :obj:`set` onto the attribute + of a bare ``object.__new__(TCP)`` (#603). A :obj:`set` answers the ``in`` tests the + dispatchers use, so every branch runs and both TCP modules read 100% statement and + branch coverage -- while the attribute has neither the type nor the provenance + production gives it. That is how an ordering defect such as #587, which breaks MP_JOIN + construction for every caller, or a ``cast('Enum_Flags', 0)`` no-op can sit behind that + coverage number untouched. Through ``TCP()``, an ordering or type defect fails a test + here instead of passing one. Each call constructs a **fresh** instance, because the point is that ``_flags`` is resolved from these very arguments rather than left over from an earlier call. @@ -938,11 +938,12 @@ def test_tcp_mptcp_constructors_cover_flag_branches(self) -> None: # NOTE: a segment with neither SYN nor ACK selects no MP_JOIN layout, and the # library's own ``ProtocolError`` is what a caller must get for it. Reaching that # through ``TCP()`` rather than through a hand-written ``proto._flags = set()`` - # makes this one assertion catch both defects #603 is about: revert #587's hoist - # and ``_flags`` does not exist yet, so this raises ``AttributeError``; restore the - # ``cast('Enum_Flags', 0)`` no-op and ``_flags`` is a plain ``int``, so - # ``Enum_Flags.SYN in self._flags`` raises ``TypeError``. A ``set`` gave the right - # answer for the wrong reason and hid both. + # makes this one assertion catch both defects #603 is about: if ``TCP.make`` + # built the options before resolving the flags (#587), ``_flags`` would not + # exist yet and this would raise ``AttributeError``; if the accumulator were + # seeded with the ``cast('Enum_Flags', 0)`` no-op, ``_flags`` would be a plain + # ``int`` and ``Enum_Flags.SYN in self._flags`` would raise ``TypeError``. A + # ``set`` gives the right answer for the wrong reason and hides both. with self.assertRaises(ProtocolError): mptcp_option(subtype=MPTCPOption.MP_JOIN) with self.assertRaises(ProtocolError): @@ -986,12 +987,10 @@ def mark(schema, length: int, subtype: MPTCPOption): self.assertEqual(proto._read_mode_mp(unknown, options=options).data, b'\x0a\x01') # NOTE: :rfc:`8684` section 3.1 gives MP_CAPABLE as 12 octets without the - # receiver's key and 20 octets with it (#567); these three cases used - # 20/32/12 respectively, the pre-#567 (wrong) split, and so were - # exercising -- and pinning -- the very defect #567 fixes rather than - # correct behaviour. Updated to 12/20, with the third case's length - # moved to 32, which is not an MP_CAPABLE length under either the old - # or the new split, so it stays a genuine rejection. + # receiver's key and 20 octets with it (#567), so the first two cases + # use 12 and 20. The rejection case uses 32, which is not an MP_CAPABLE + # length under the RFC or under the wrong 20/32 split #567 guards + # against, so it is a genuine rejection. capable_no_receiver = mark( MPTCPCapable(test={'subtype': MPTCPOption.MP_CAPABLE.value, 'version': 0}, flags={'req': 1, 'ext': 0, 'deny_join': 0, 'reserved': 0, 'hsa': 1}, skey=1, rkey=2), @@ -1022,13 +1021,9 @@ def mark(schema, length: int, subtype: MPTCPOption): # dispatchers read the same attribute with the same ``in`` tests, and ``make`` is # the public entry point that assigns it, so this installs the ``Enum_Flags`` # member production assigns instead of a ``set`` that merely answers ``in``. C.f. - # #603. Until #616 it *had* to be ``make`` rather than a parsed segment for the - # flagless case further down: ``read`` seeded its accumulator with - # ``cast('Enum_Flags', 0)``, a runtime no-op, so a flagless *parsed* instance - # carried a plain ``int`` and ``_read_mptcp_join`` raised ``TypeError`` instead of - # its documented ``ProtocolError``. #616 made ``read`` seed ``Enum_Flags(0)`` too, - # so either entry point serves now; ``make`` stays because these rows are about the - # dispatchers rather than about parsing, and + # #603. ``read`` seeds ``Enum_Flags(0)`` as ``make`` does (#616), so either entry + # point serves for the flagless case further down; ``make`` is used because these + # rows are about the dispatchers rather than about parsing, and # :meth:`test_a_flagless_segment_seeds_its_connection_flags_as_an_enum` below # covers the parsed side. Either way ``mptcp_data_selector`` rejects a flagless # MP_JOIN in the schema layer first, and @@ -1053,11 +1048,10 @@ def mark(schema, length: int, subtype: MPTCPOption): # NOTE: :rfc:`8684` section 3.2 figure 6 gives MP_JOIN-SYN/ACK as 16 # octets -- kind (1) + length (1) + subtype/flags (1) + addr_id (1) + # the truncated HMAC (8) + the nonce (4) -- which is also exactly what - # MPTCPJoinSYNACK packs. These two cases used 20 (accept) and 19 - # (reject), the pre-#576 guard's own wrong constant, and so pinned the - # defect rather than the RFC. The rejection case is now 20: the value the - # guard used to *require*, which no MP_JOIN form produces, so it stays a - # genuine rejection rather than an off-by-one near the correct length. + # MPTCPJoinSYNACK packs, so the accepted case uses 16. The rejection case + # uses 20, the wrong constant filed under #576, which no MP_JOIN form + # produces, so it is a genuine rejection rather than an off-by-one near + # the correct length. proto.make(syn=True, ack=True) join_synack = mark( MPTCPJoinSYNACK(test={'subtype': MPTCPOption.MP_JOIN.value, 'reserved': 0, 'backup': 0}, @@ -1169,11 +1163,9 @@ def mark(schema, length: int, subtype: MPTCPOption): # NOTE: :rfc:`8684` section 3.5 figure 14 gives MP_FASTCLOSE as 12 octets # -- kind (1) + length (1) + subtype-and-reserved (2) + the receiver's key - # (8) -- which is what MPTCPFastclose packs since #576 added its missing - # reserved octet. These two cases used 16 (accept) and 15 (reject), the - # pre-#576 guard's own wrong constant. The rejection case is now 16, the - # value the guard used to require and that the RFC never produces for this - # option. + # (8) -- which is what MPTCPFastclose packs, reserved octet included + # (#576). The rejection case uses 16, the wrong constant filed under + # #576, which the RFC never produces for this option. fastclose = mark( MPTCPFastclose(test={'subtype': MPTCPOption.MP_FASTCLOSE.value, 'reserved': 0}, key=123), 12, @@ -1440,10 +1432,10 @@ def test_a_truncated_option_still_parses_its_declared_length(self) -> None: The segment below sets a data offset of 7 -- 8 octets of option area -- for a reserved option kind (``0x4f``, ``Option.Reserved_79``) declaring ``length=12`` or ``length=32``, with only 6 data octets - behind it. The missing octets used to be read as zeros, 4 or 24 of - them, and rebuilt as if captured. :meth:`OptionField.unpack - ` now refuses - the option with :exc:`~pcapkit.utilities.exceptions.ProtocolError` + behind it. Guards against the missing octets being read as zeros, 4 + or 24 of them, and rebuilt as if captured. :meth:`OptionField.unpack + ` refuses the + option with :exc:`~pcapkit.utilities.exceptions.ProtocolError` instead (#1325). Inside an IPv4 datagram the segment is kept as the octets captured, a :class:`~pcapkit.protocols.misc.raw.Raw` payload carrying that error, and rebuilds byte for byte. @@ -1576,20 +1568,21 @@ def test_unregistered_mptcp_subtype_does_not_mutate_the_class_registry(self) -> def test_a_flagless_segment_seeds_its_connection_flags_as_an_enum(self) -> None: """A segment whose flags octet is all zero still parses to an ``Enum_Flags``. C.f. #616. - :meth:`TCP.read ` seeded its flag - accumulator with ``cast('Enum_Flags', 0)``. :func:`typing.cast` is a runtime - no-op -- it returns its second argument unchanged -- so the accumulator began - life as the plain :class:`int` ``0``. The ``|=`` in the loop below it is the - only thing that promotes it to an + Guards against :meth:`TCP.read ` + seeding its flag accumulator with ``cast('Enum_Flags', 0)``. + :func:`typing.cast` is a runtime no-op -- it returns its second argument + unchanged -- so the accumulator would begin life as the plain :class:`int` + ``0``. The ``|=`` in the loop is the only thing that promotes it to an :class:`~pcapkit.const.tcp.flags.Flags` member, and that never runs when no - flag bit is set, so a flagless segment left ``self._flags`` an :obj:`int`. - ``Enum_Flags.SYN in self._flags`` then raised ``TypeError: argument of type - 'int' is not a container or iterable`` rather than answering. Any flag at all - masked it, which is why it survived a file at 100% coverage. + flag bit is set, so a flagless segment would leave ``self._flags`` an + :obj:`int`. ``Enum_Flags.SYN in self._flags`` then raises ``TypeError: + argument of type 'int' is not a container or iterable`` rather than + answering. Any flag at all masks it, which is how it survives a file at 100% + coverage. The type assertions are the load-bearing ones. ``0 == Enum_Flags(0)`` is - :data:`True`, so ``assertEqual(proto.connection, 0)`` passes on both sides of - the fix and cannot discriminate -- and neither can a membership test alone, on + :data:`True`, so ``assertEqual(proto.connection, 0)`` passes with either seed + and cannot discriminate -- and neither can a membership test alone, on the flagful rows. Hence ``assertIs(type(...), Enum_Flags)``, which is what the :attr:`~pcapkit.protocols.transport.tcp.TCP.connection` property has always advertised it returns. @@ -1597,29 +1590,27 @@ def test_a_flagless_segment_seeds_its_connection_flags_as_an_enum(self) -> None: The last block pins the consequence rather than the type. ``_read_mptcp_join`` chooses between :rfc:`8684` section 3.2's three MP_JOIN layouts by testing ``self._flags`` for SYN and ACK, and falls through to the library's own - ``ProtocolError`` when neither is set. On an :obj:`int` it died on a bare + ``ProtocolError`` when neither is set. On an :obj:`int` it would die on a bare :exc:`TypeError` two lines earlier instead. That branch is not reachable from a caller -- :func:`~pcapkit.protocols.schema.transport.tcp.mptcp_data_selector` rejects a flagless MP_JOIN with a ``FieldError`` before the dispatcher runs -- - so this was latent, but latent only by virtue of a guard in a different file. + so the defect is latent, but latent only by virtue of a guard in a different + file. The instance is parsed through ``TCP(...)`` rather than having ``_flags`` written onto a bare ``object.__new__(TCP)``, for the reason #603 and #612 give in :func:`mptcp_option` above: a hand-written attribute proves nothing about what production puts there. - The closing block pins the one difference a consumer can see, so that it is a - recorded decision rather than a silent change: a flagless segment's - ``connection`` now dumps as the string ``'Flags::0 [0]'`` where it dumped as - the number ``0``. That is not a regression so much as the removal of an - inconsistency -- the field was a number for a flagless segment and a string for - every other one. + The closing block pins the one rendering a consumer can see, so that it is a + recorded decision: a flagless segment's ``connection`` dumps as the string + ``'Flags::0 [0]'``, not the number ``0``, so the field is a string for every + segment rather than a number for a flagless one alone. - That string read ``'Flags::None [0]'`` when this test was written, because - :func:`~pcapkit.dumpkit.common.make_dumper`'s hook interpolated ``o.name`` - without accounting for a nameless composite member. #648 has since guarded it, - so the name half is now the value's own decimal spelling -- the same spelling - the enumeration library already uses for an undeclared residue, as in - ``Flags(2049).name == 'ACK|1'``. + The name half of that string is the value's own decimal spelling -- the same + spelling the enumeration library uses for an undeclared residue, as in + ``Flags(2049).name == 'ACK|1'`` -- because + :func:`~pcapkit.dumpkit.common.make_dumper`'s hook guards a nameless composite + member rather than interpolating its ``o.name`` of ``None`` (#648). """ import struct @@ -1661,8 +1652,8 @@ def segment(flags_octet: 'int') -> 'bytes': self.assertEqual(Enum_Flags.SYN in proto.connection, bool(octet & 0x02)) self.assertEqual(Enum_Flags.ACK in proto.connection, bool(octet & 0x10)) - # The numeric value is unchanged by the fix, which is exactly why the - # type is what has to be asserted. + # The numeric value is the same with either seed, which is exactly why + # the type is what has to be asserted. self.assertEqual(int(proto.connection), int(expected)) self.assertEqual(proto.connection, int(expected)) @@ -1675,17 +1666,16 @@ def segment(flags_octet: 'int') -> 'bytes': with self.assertRaisesRegex(ProtocolError, 'invalid flags combination'): proto._read_mptcp_join(schema, options=DummyData()) # type: ignore[arg-type] - # The one place the change is observable to a consumer, pinned here so it cannot + # The one place the seed is observable to a consumer, pinned here so it cannot # drift silently. ``make_dumper``'s hook renders any enum member as # ``Type::name [value]``, and an ``aenum.IntFlag`` pseudo-member carrying no bits - # has ``name is None`` -- so a flagless segment dumps as a *string* where it used - # to dump as the number ``0``. Note what that replaced: ``connection`` was a JSON - # *number* for a flagless segment and a *string* for every other one, so the field - # is consistently typed now rather than switching type with the flags. + # has ``name is None`` -- so a flagless segment dumps as a *string*, not as the + # number ``0``, and ``connection`` is consistently typed rather than switching + # type with the flags. # - # The name half of that string was the literal ``None`` until #648 guarded the - # interpolation; it is now the value's own decimal spelling, so the rendering is - # ``'Flags::0 [0]'``. Both halves are asserted here because this segment is the + # The name half of that string would be the literal ``None`` without #648's + # guard on the interpolation; it is the value's own decimal spelling, so the + # rendering is ``'Flags::0 [0]'``. Both halves are asserted here because this segment is the # only wire-reachable producer of a nameless member in the library, which makes # this the test that notices if the guard is ever removed. # ``self`` is reached only by the fallback ``super()`` call at the end of the hook, diff --git a/tests/protocols/transport/test_transport_unit.py b/tests/protocols/transport/test_transport_unit.py index b5e9b8e0e5..588a43b2ba 100644 --- a/tests/protocols/transport/test_transport_unit.py +++ b/tests/protocols/transport/test_transport_unit.py @@ -52,8 +52,8 @@ def __index__(cls) -> int: with self.assertRaises(UnsupportedCall): Transport.register(1, Raw) - # RegistryError is (BaseError, TypeError), so the bare TypeError this - # used to raise is still caught by anyone who was catching it. + # RegistryError is (BaseError, TypeError), so anyone catching a bare + # TypeError here still catches it. with self.assertRaises(RegistryError): DummyTransport.register(81, object) # type: ignore[arg-type] with self.assertRaises(TypeError): @@ -65,11 +65,10 @@ def __index__(cls) -> int: class Other(Raw): pass - # GitHub issue #718 added an identity guard, so registering the same - # ``Raw`` object port 80 already holds -- what this used to do -- - # would now be a silent no-op; see + # GitHub issue #718's identity guard makes registering the same ``Raw`` + # object port 80 already holds a silent no-op; see # ``test_register_stays_quiet_on_same_object_reregistration`` for - # that case. A genuinely different class is needed to still warn. + # that case. A genuinely different class is needed to warn. with mock.patch('pcapkit.protocols.transport.transport.warn') as warn: DummyTransport.register(80, Other) warn.assert_called_once() @@ -77,13 +76,11 @@ class Other(Raw): def test_register_stays_quiet_on_same_object_reregistration(self) -> None: """GitHub issue #718: replaying a call with the identical class is a no-op. - Before the fix the guard fired on presence alone, so registering port - 80's ``Raw`` incumbent with the exact same ``Raw`` object -- what - ``test_register_validates_protocols_and_abstract_base`` used to do -- - warned about an overwrite that never happened. Matches the identity - guard :func:`register_protocol - ` already - applies. + Guards against the overwrite warning firing on presence alone, under + which registering port 80's ``Raw`` incumbent with the exact same + ``Raw`` object warns about an overwrite that never happens. Matches + the identity guard :func:`register_protocol + ` applies. """ from pcapkit.protocols.misc.raw import Raw @@ -380,9 +377,9 @@ def test_decode_next_layer_forwards_the_lower_port_when_neither_is_registered(se :meth:`ProtocolBase._import_next_layer ` as ``alias``, which is what :class:`~pcapkit.protocols.misc.raw.Raw` records - as ``Data_Raw.protocol``. This used to pass :obj:`None`, so TCP and UDP - anonymised every payload they could not dispatch -- unlike SCTP and IPv4, - which both keep the identifier the packet arrived with. The lower port is + as ``Data_Raw.protocol``. Guards against passing :obj:`None`, under which + TCP and UDP anonymise every payload they cannot dispatch -- unlike SCTP + and IPv4, which both keep the identifier the packet arrived with. The lower port is the one carried, matching the lookup's own primary key. """ diff --git a/tests/protocols/transport/test_udp_trailer_kept_unit.py b/tests/protocols/transport/test_udp_trailer_kept_unit.py index 6297bf3edd..7919bbbd1b 100644 --- a/tests/protocols/transport/test_udp_trailer_kept_unit.py +++ b/tests/protocols/transport/test_udp_trailer_kept_unit.py @@ -2,12 +2,12 @@ """Octets captured past a UDP datagram's own Length field survive a rebuild. GitHub issue #1461: :class:`~pcapkit.protocols.transport.udp.UDP` reads its -Length field, but the payload ran to the end of the capture and nothing kept -what lay past an empty one, so -:meth:`~pcapkit.protocols.protocol.ProtocolBase.from_data` dropped it: a header -of Length 8 followed by two octets rebuilt as 8 octets of its 10. +Length field. Guards against the payload running to the end of the capture with +nothing keeping what lies past an empty one, under which +:meth:`~pcapkit.protocols.protocol.ProtocolBase.from_data` drops it: a header of +Length 8 followed by two octets would rebuild as 8 octets of its 10. -The payload now stops at Length and the rest is ``info.trailer``, as IPv4, IPv6 +The payload stops at Length and the rest is ``info.trailer``, as IPv4, IPv6 (#1209) and IPX (#1433) do, and :meth:`make` writes a ``trailer`` outside the length it declares. diff --git a/tests/vendor/test_crawler_reachability_unit.py b/tests/vendor/test_crawler_reachability_unit.py index f3e6581792..efd3ca8e4f 100644 --- a/tests/vendor/test_crawler_reachability_unit.py +++ b/tests/vendor/test_crawler_reachability_unit.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- """Every vendor crawler is reachable from the target list that runs them. -Until #531, :mod:`pcapkit.vendor` defined 117 crawlers and -:file:`pcapkit/vendor/__main__.py` could only ever run 116 of them, because it -does not discover crawlers -- it reads :attr:`__all__`: +:file:`pcapkit/vendor/__main__.py` does not discover crawlers -- it reads +:attr:`__all__`, both on the ``--target `` path and on the default path, +i.e. a bare ``pcapkit-vendor``: .. code-block:: python @@ -12,23 +12,14 @@ # pcapkit.vendor.__main__.main, the default path, i.e. a bare `pcapkit-vendor` target_list.extend(getattr(vendor_module, name) for name in vendor_module.__all__) -:attr:`pcapkit.vendor.__all__` listed ``'MH_HandoverACKStatus'`` *twice* and had -no entry for ``'MH_HandoverInitiateStatus'`` at all, in -:file:`pcapkit/vendor/__init__.py` and independently in -:file:`pcapkit/vendor/mh/__init__.py`. So both the default target list and -``--target mh`` were missing ``handover_initiate_status``, and that crawler had -never run: :file:`.github/workflows/cron-vendor.yml` invokes bare -``pcapkit-vendor``, which is exactly the default path. - -The corroborating evidence was in the generated file the crawler owns. -:meth:`pcapkit.vendor.default.Vendor.context` emitted a ``:meta private:`` -directive into every enumeration it wrote until #858 replaced the template. -When #531 landed, 116 of the 117 files under :file:`pcapkit/const/*/` carried -it. The one that did not was -:file:`pcapkit/const/mh/handover_initiate_status.py` -- the output of the one -crawler that could not be reached. The duplicate dates to commit ``24edd30d2`` -(2023-04-17, "added MH enumerations & vendor cralwers"), so the file has been -stale for the whole life of the crawler. +So a crawler missing from :attr:`__all__` never runs, and +:file:`.github/workflows/cron-vendor.yml` invokes bare ``pcapkit-vendor``, which +is exactly the default path. Guards against :attr:`pcapkit.vendor.__all__` +listing ``'MH_HandoverACKStatus'`` *twice* and ``'MH_HandoverInitiateStatus'`` +not at all, in :file:`pcapkit/vendor/__init__.py` and independently in +:file:`pcapkit/vendor/mh/__init__.py` (#531), which leaves +``handover_initiate_status`` out of both the default target list and +``--target mh``, and the constant file it owns stale. Why the contract is stated over *all* crawlers rather than as a pin on this one. The defect is a displaced duplicate, and that shape is invisible to the obvious @@ -42,15 +33,11 @@ Relationship to :file:`tests/project/test_public_api.py` (GitHub pull request #527). That module sweeps :attr:`__all__` across the tree for a related family of -defects. This module is a separate file for two reasons: #527 had not merged -when #531 added this one, so there was no file to extend; and the assertion here -is not the one it makes. The property checked here -- defined versus reachable -- -is about the crawler *registry* rather than about :attr:`__all__` hygiene. #527 -also skipped :mod:`pcapkit.vendor` through ``EXCLUDED_ROOTS``, saying the unit -tier lacked the ``vendor`` extra, though ``requests`` and ``bs4`` were already in -the ``test`` extra (#507). #1772 dropped that exclusion, so its -``test_no_duplicate_all_entries`` now gives the duplicate a second, cheaper -detector. +defects, and its ``test_no_duplicate_all_entries`` covers :mod:`pcapkit.vendor` +too (#1772), giving the duplicate a second, cheaper detector. This module stays +separate because its assertion is not the one that module makes: the property +checked here -- defined versus reachable -- is about the crawler *registry* +rather than about :attr:`__all__` hygiene. The suite is unit-tier (see :mod:`tests._tiers`): it reads no capture, and it instantiates nothing. That last part is load-bearing rather than incidental. @@ -98,10 +85,10 @@ HAS_CRAWLER_DEPS = all(importlib.util.find_spec(name) is not None for name in ('requests', 'bs4')) -#: The crawler this change makes reachable, as ``(module, class, exported -#: name)``. Named explicitly as well as covered by the general contract below, -#: so the failure says which crawler went missing instead of only how many did. -#: A count alone would be satisfied by any 117 crawlers, including the wrong 117. +#: The crawler #531 makes reachable, as ``(module, class, exported name)``. +#: Named explicitly as well as covered by the general contract below, so the +#: failure says which crawler went missing instead of only how many did. A count +#: alone would be satisfied by the right number of the wrong crawlers. REGRESSION_CRAWLER = ( 'pcapkit.vendor.mh.handover_initiate_status', 'HandoverInitiateStatus', @@ -210,13 +197,13 @@ def test_every_crawler_is_reachable_from_the_default_target_list(self) -> None: f'absent from pcapkit.vendor.__all__, so `pcapkit-vendor` with no ' f'arguments never runs them and the constant files they own go stale') - # And nothing is exported that no longer exists on disk, which is the + # And nothing is exported that does not exist on disk, which is the # same list drifting in the other direction. self.assertEqual(sorted(set(reachable) - set(defined)), []) def test_every_crawler_is_reachable_from_its_own_protocol_target(self) -> None: # The `--target ` path, which reads the subpackage's __all__ rather - # than the root's. It is a separate list and was separately wrong, so a + # than the root's. It is a separate list and can be separately wrong, so a # crawler can be reachable one way and not the other. defined = self._defined_crawlers() diff --git a/tests/vendor/test_ftp_return_code_unit.py b/tests/vendor/test_ftp_return_code_unit.py index 1669b95686..657d7e9745 100644 --- a/tests/vendor/test_ftp_return_code_unit.py +++ b/tests/vendor/test_ftp_return_code_unit.py @@ -58,8 +58,8 @@ #: :mod:`tests.vendor.test_user_agent_unit` for why ``requests`` alone is not #: enough. ``html5lib`` ships in the ``vendor`` extra (:file:`pyproject.toml`), #: not ``test`` -- so these skip on the ``test`` and ``gate`` jobs, which do not -#: install it. ``engine-tests`` ran them for real (#738) until #849 narrowed -#: each of its cells to that engine's own test files. +#: install it. ``engine-tests`` does not run them either: each of its cells +#: runs only that engine's own test files (#849). VENDOR_DEPS = ('requests', 'bs4', 'html5lib') #: Whether the crawlers are importable at all. @@ -157,8 +157,8 @@ def test_fixture_really_does_carry_a_cell_less_row(self) -> None: self.assertEqual(rows[-1].get('class'), ['mw-empty-elt']) def test_process_skips_the_cell_less_row_instead_of_raising(self) -> None: - # The regression itself. Before the guard this raised - # `IndexError: list index out of range` at `line[0]`. + # The regression itself: without the guard, `line[0]` on the cell-less + # row raises `IndexError: list index out of range`. vendor = self._vendor() enum = vendor.process(vendor.request(REGISTRY_HTML)) @@ -196,7 +196,7 @@ def test_context_survives_the_cell_less_row(self) -> None: self.assertIn("CODE_110: 'ReturnCode' = 110", context) self.assertIn("CODE_200: 'ReturnCode' = 200", context) - # GitHub issue #860: ReturnCode now mixes in EnumRegistry. + # GitHub issue #860: ReturnCode mixes in EnumRegistry. self.assertIn('class ReturnCode(EnumRegistry, IntEnum):', context) diff --git a/tests/vendor/test_ipx_packet_unit.py b/tests/vendor/test_ipx_packet_unit.py index 6ccd7492ef..a9f1c19766 100644 --- a/tests/vendor/test_ipx_packet_unit.py +++ b/tests/vendor/test_ipx_packet_unit.py @@ -1,16 +1,16 @@ # -*- coding: utf-8 -*- -"""Regression tests for the retired :mod:`pcapkit.vendor.ipx.packet` scrape. +"""Regression tests for the hand-maintained :mod:`pcapkit.vendor.ipx.packet` registry. GitHub issue #518, and the same failure on the same article that #507 found for -the sibling socket crawler. The packet-type crawler used to scrape +the sibling socket crawler. The packet-type crawler does not scrape ``find_all('table', class_='wikitable')[1]`` out of the Wikipedia *Internetwork -Packet Exchange* article, and both halves of that stopped working. Wikipedia -answers |requests|_' default User-Agent with HTTP 403, so -:meth:`pcapkit.vendor.default.Vendor._request` could not fetch the page; and the +Packet Exchange* article, because neither half of that works. Wikipedia answers +|requests|_' default User-Agent with HTTP 403, so +:meth:`pcapkit.vendor.default.Vendor._request` cannot fetch the page; and the table was deleted from the article on 2026-08-25 in revision 1371327031, leaving the live page with a single ``wikitable`` -- the IPX header format one -- so that -index raises :exc:`IndexError` even with the fetch fixed. The scrape is retired -in favour of the hand-maintained :data:`pcapkit.vendor.ipx.packet.DATA`. +index raises :exc:`IndexError` even with the fetch fixed. The crawler reads the +hand-maintained :data:`pcapkit.vendor.ipx.packet.DATA` instead. .. |requests| replace:: ``requests`` .. _requests: https://requests.readthedocs.io @@ -24,8 +24,8 @@ renaming it would break a public member, so the name is pinned rather than tidied. -The suite is unit-tier (see :mod:`tests._tiers`): it reads no capture and, by the -whole point of the change, makes no network call. +The suite is unit-tier (see :mod:`tests._tiers`): it reads no capture and, by +design, makes no network call. """ from __future__ import annotations @@ -58,8 +58,8 @@ #: Whether the crawlers are importable at all. ``html5lib`` ships in the #: ``vendor`` extra (:file:`pyproject.toml`), not ``test`` -- so these tests skip #: on the ``test`` and ``gate`` jobs of :file:`.github/workflows/unit-tests.yml`, -#: which never install ``vendor``. ``engine-tests`` ran them for real (#738) -#: until #849 narrowed each of its cells to that engine's own test files. +#: which never install ``vendor``. ``engine-tests`` does not run them either: +#: each of its cells runs only that engine's own test files (#849). #: Guarded the same way #: :file:`tests/protocols/test_dispatch_registry_unit.py` guards its own optional #: runtime dependencies, rather than making the whole unit tier depend on the @@ -112,7 +112,7 @@ def _normalize(context: 'str') -> 'str': @unittest.skipUnless(HAS_VENDOR_DEPS, f'vendor extra not installed ({", ".join(VENDOR_DEPS)})') class IPXPacketVendorTests(unittest.TestCase): - """The hand-maintained registry, and the crawler that no longer crawls.""" + """The hand-maintained registry, and the crawler that does not crawl.""" if TYPE_CHECKING: vendor_module: 'Any' @@ -155,9 +155,9 @@ def _vendor(self) -> 'Any': return vendor def test_link_is_none_so_nothing_is_fetched(self) -> None: - # The retirement, stated as an assertion: with no LINK, - # Vendor._request() short-circuits to Packet.request() and never reaches - # requests.get() -- which is what used to take the 403. + # No LINK, stated as an assertion: Vendor._request() short-circuits to + # Packet.request() and never reaches requests.get(), which is what takes + # the 403. self.assertIsNone(self.vendor_module.Packet.LINK) def test_request_makes_no_network_call(self) -> None: diff --git a/tests/vendor/test_ipx_socket_unit.py b/tests/vendor/test_ipx_socket_unit.py index 7128ab9abf..e6dbf04387 100644 --- a/tests/vendor/test_ipx_socket_unit.py +++ b/tests/vendor/test_ipx_socket_unit.py @@ -1,25 +1,25 @@ # -*- coding: utf-8 -*- -"""Regression tests for the retired :mod:`pcapkit.vendor.ipx.socket` scrape. +"""Regression tests for the hand-maintained :mod:`pcapkit.vendor.ipx.socket` registry. -GitHub issue #507: the crawler used to scrape the well-known-socket table out of -the Wikipedia *Internetwork Packet Exchange* article, and both halves of that -stopped working. Wikipedia answers ``requests``' default User-Agent with HTTP -403, so :meth:`pcapkit.vendor.default.Vendor._request` could not fetch the page; -and the table was deleted from the article on 2026-08-25, so there was nothing -left at ``find_all('table', class_='wikitable')[3]`` to parse even with the fetch -fixed. The scrape is retired in favour of the hand-maintained -:data:`pcapkit.vendor.ipx.socket.DATA`. +GitHub issue #507: the crawler does not scrape the well-known-socket table out of +the Wikipedia *Internetwork Packet Exchange* article, because neither half of +that works. Wikipedia answers ``requests``' default User-Agent with HTTP 403, so +:meth:`pcapkit.vendor.default.Vendor._request` cannot fetch the page; and the +table was deleted from the article on 2026-08-25, so there is nothing at +``find_all('table', class_='wikitable')[3]`` to parse even with the fetch fixed. +The crawler reads the hand-maintained :data:`pcapkit.vendor.ipx.socket.DATA` +instead. What that leaves worth pinning is the failure mode the issue is actually about: a regeneration that quietly drops sockets. :data:`EXPECTED_MEMBERS` spells out the enumeration in full, so losing one fails here rather than shipping. The value ``0x0000`` in it is the member GitHub issue #492 called for -- IPX's own -default for the ``dst``/``src`` socket field -- which the old crawler had to -prepend unconditionally *because* the scrape yielded nothing, and which a -hand-maintained table has no excuse to lose. +default for the ``dst``/``src`` socket field -- which a scrape yielding nothing +would have to prepend unconditionally, and which a hand-maintained table has no +excuse to lose. The suite is unit-tier (see :mod:`tests._tiers`): it reads no capture and, by -the whole point of the change, makes no network call. +design, makes no network call. """ from __future__ import annotations @@ -86,12 +86,12 @@ #: placed before a narrower one it fully contains masks that narrower one, so #: reordering the list changes behaviour without changing any member. #: -#: GitHub issue #841: three ranges used to be listed after a wider range that -#: fully contained them -- ``(0x0020, 0x003F)`` after ``(0x0001, 0x0BB8)``, and -#: both ``(0x4000, 0x4FFF)`` and ``(0x8000, 0xFFFF)`` after ``(0x0BB9, 0xFFFF)`` -#: -- which made those three branches of the generated ``_missing_`` -#: unreachable. Each subset range is now listed ahead of the wider range that -#: contains it, so every branch fires for at least the values only it covers. +#: GitHub issue #841: each subset range is listed ahead of the wider range that +#: contains it -- ``(0x0020, 0x003F)`` before ``(0x0001, 0x0BB8)``, and both +#: ``(0x4000, 0x4FFF)`` and ``(0x8000, 0xFFFF)`` before ``(0x0BB9, 0xFFFF)`` -- +#: so every branch of the generated ``_missing_`` fires for at least the values +#: only it covers. Listed after the wider range, a subset range's branch would +#: be unreachable. EXPECTED_RANGES = ( (0x0020, 0x003F, 'Experimental'), (0x0001, 0x0BB8, 'Registered by Xerox'), @@ -105,31 +105,25 @@ #: the value because the value alone cannot tell a live range branch from a #: masked one: every branch of the generated ``_missing_`` returns ``value``, #: so only the name says which branch ran -- for ``Registered by Xerox``, -#: that name still carries the value as a numeric suffix even though it no -#: longer mints (see below); asserting the value alone would pass just as -#: happily with the range branches deleted or reordered. +#: that name carries the value as a numeric suffix even though it does not +#: mint (see below); asserting the value alone would pass just as happily +#: with the range branches deleted or reordered. #: -#: Before GitHub issue #841 was fixed, three of these were unreachable -- -#: ``(0x0001, 0x0BB8)`` masked ``(0x0020, 0x003F)``, and ``(0x0BB9, 0xFFFF)`` -#: masked both ``(0x4000, 0x4FFF)`` and ``(0x8000, 0xFFFF)`` -- so ``0x0020``, -#: ``0x003F``, ``0x4000``, ``0x4FFF``, ``0x8000``, ``0x8061``, ``0x9094`` and -#: ``0xFFFF`` all resolved under the wrong wide range's name. See -#: :data:`pcapkit.vendor.ipx.socket.RANGES` for the reordering that fixed it. +#: Guards against the masking of GitHub issue #841: with the wide ranges +#: first, ``(0x0001, 0x0BB8)`` masks ``(0x0020, 0x003F)``, and ``(0x0BB9, +#: 0xFFFF)`` masks both ``(0x4000, 0x4FFF)`` and ``(0x8000, 0xFFFF)``, so +#: ``0x0020``, ``0x003F``, ``0x4000``, ``0x4FFF``, ``0x8000``, ``0x8061``, +#: ``0x9094`` and ``0xFFFF`` would all resolve under the wrong wide range's +#: name. See :data:`pcapkit.vendor.ipx.socket.RANGES` for the order. #: -#: GitHub issue #775's original mint-criterion ruling then converted four -#: of the five ranges -- ``Experimental``, ``Dynamically Assigned Socket -#: Numbers``, ``Statically Assigned Socket Numbers`` and ``Dynamically -#: Assigned`` each name an allocation *policy* for the pool rather than a -#: specific assignment, so they no longer mint and their bare name carries no -#: numeric suffix. ``Registered by Xerox`` was the one that stayed at the -#: time: the ruling on #775 kept it minting on that criterion, because a -#: proprietary protocol may expose no name of its own, so the company name -#: serves as its name. #775's *final* round converts it too, keeping its -#: hex-suffixed name unchanged -- PR #878 scoped the change this way: -#: preserve the existing name argument exactly, since this is about not -#: registering rather than about renaming -- so all five ranges resolve -#: through :meth:`~pcapkit.corekit.enum.EnumRegistry._unregistered_member` -#: now and none of them mint. +#: No range mints (GitHub issue #775). ``Experimental``, ``Dynamically +#: Assigned Socket Numbers``, ``Statically Assigned Socket Numbers`` and +#: ``Dynamically Assigned`` each name an allocation *policy* for the pool +#: rather than a specific assignment, so their bare name carries no numeric +#: suffix. ``Registered by Xerox`` keeps its hex-suffixed name: a proprietary +#: protocol may expose no name of its own, so the company name serves as its +#: name, and not registering is not renaming (PR #878). Every range resolves +#: through :meth:`~pcapkit.corekit.enum.EnumRegistry._unregistered_member`. EXPECTED_MISSING_NAMES = { 0x0000: 'Unspecified', # a defined member 0x0001: 'Routing_Information_Packet', # a defined member @@ -177,7 +171,7 @@ def _normalize(context: 'str') -> 'str': @unittest.skipUnless(HAS_CRAWLER_DEPS, 'requests and/or beautifulsoup4 not installed') class IPXSocketVendorTests(unittest.TestCase): - """The hand-maintained registry, and the crawler that no longer crawls.""" + """The hand-maintained registry, and the crawler that does not crawl.""" if TYPE_CHECKING: vendor_module: 'Any' @@ -221,9 +215,9 @@ def _vendor(self) -> 'Any': return vendor def test_link_is_none_so_nothing_is_fetched(self) -> None: - # The retirement, stated as an assertion: with no LINK, - # Vendor._request() short-circuits to Socket.request() and never - # reaches requests.get() -- which is what used to take the 403. + # No LINK, stated as an assertion: Vendor._request() short-circuits to + # Socket.request() and never reaches requests.get(), which is what + # takes the 403. self.assertIsNone(self.vendor_module.Socket.LINK) def test_request_makes_no_network_call(self) -> None: @@ -281,14 +275,13 @@ def test_range_order_is_preserved(self) -> None: self.assertEqual(tuple(self.vendor_module.RANGES), EXPECTED_RANGES) def test_previously_shadowed_ranges_are_reachable(self) -> None: - # GitHub issue #841: under the old ``RANGES`` order each of these three - # values resolved as the wider range that masked its own -- 0x0030 as + # GitHub issue #841: with each wider range listed first, these values + # would resolve as the range that masks their own -- 0x0030 as # 'Registered by Xerox_0x0030', 0x4080 and 0x8100 both as - # 'Dynamically Assigned_0x...'. Pinned separately from + # 'Dynamically Assigned'. Pinned separately from # test_unlisted_sockets_still_resolve so the regression this issue - # describes has a test that names it. Bare names, not the numeric- - # suffixed ones #841 pinned: all three of these ranges were - # converted under GitHub issue #775's ruling after #841 landed. + # describes has a test that names it. The names are bare because + # these ranges do not mint (GitHub issue #775). for value, name in ( (0x0030, 'Experimental'), (0x4080, 'Dynamically Assigned Socket Numbers'), diff --git a/tests/vendor/test_re_sub_positional_flag_unit.py b/tests/vendor/test_re_sub_positional_flag_unit.py index aef4cef6b3..6935ca3c89 100644 --- a/tests/vendor/test_re_sub_positional_flag_unit.py +++ b/tests/vendor/test_re_sub_positional_flag_unit.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """GitHub issue #796: a regex flag passed as ``re.sub``/``re.split``'s positional ``count``. -Thirteen call sites under :file:`pcapkit/vendor/` passed a regex flag -- +Guards against call sites under :file:`pcapkit/vendor/` passing a regex flag -- :data:`re.MULTILINE`, whose integer value is ``8`` -- as the *positional* fourth argument to :func:`re.sub` (third, for :func:`re.split`), which is the ``count``/``maxsplit`` slot, not ``flags``: @@ -9,7 +9,7 @@ .. code-block:: python re.sub(r'\\r*\\n', ' ', text, re.MULTILINE) # count=8, flags=0 (!) - re.sub(r'\\r*\\n', ' ', text, flags=re.MULTILINE) # the fix + re.sub(r'\\r*\\n', ' ', text, flags=re.MULTILINE) # correct Two independent defects follow from that one misplacement: @@ -17,32 +17,28 @@ occurrences of the pattern keeps its ninth match onward unsubstituted, because ``count=8`` caps replacement rather than being ignored. 2. **The flag never applies.** :data:`re.MULTILINE` changes the matching - behaviour of ``^``/``$`` only; every one of these thirteen patterns is - ``r'\\r*\\n'``, which carries no anchor, so the *intended* flag was already - inert -- moving it to ``flags=`` does not change what it matches, only - removes the accidental ``count`` cap and the interpreter warning. + behaviour of ``^``/``$`` only; every one of the affected patterns is + ``r'\\r*\\n'``, which carries no anchor, so the *intended* flag is inert + -- passing it as ``flags=`` does not change what it matches, only removes + the accidental ``count`` cap and the interpreter warning. It is also a live :exc:`DeprecationWarning` on Python 3.13+ (`'count' is passed as positional argument`), scheduled to become a :exc:`TypeError`. -A single-line grep over this tree only finds five of the thirteen: eight of -them put the flag argument on a *continuation* line -- +A single-line grep misses a flag argument put on a *continuation* line -- .. code-block:: python re.sub(r'\\r*\\n', ' ', text, re.MULTILINE) --- which no single-line pattern sees. :class:`PositionalFlagArgumentTests` -below therefore walks :mod:`ast` rather than grepping, exactly as the issue's -own enumeration did, and is self-tested against an inline known-positive -(plain and continuation-line shaped) and known-negative fixture before it is -ever pointed at the real tree -- see +-- so :class:`PositionalFlagArgumentTests` below walks :mod:`ast` rather than +grepping, exactly as the issue's own enumeration did, and is self-tested +against an inline known-positive (plain and continuation-line shaped) and +known-negative fixture before it is ever pointed at the real tree -- see :meth:`PositionalFlagArgumentTests.test_self_check_detects_known_positive_and_negative_fixtures`. -Run against stock ``4530424df`` (this issue's unblocking commit, before the -fix in this change), :meth:`PositionalFlagArgumentTests.test_no_positional_flag_argument_in_vendor_tree` -fails, reporting all thirteen sites now living at: +The sites #796 enumerated live in: * :file:`pcapkit/vendor/default.py` * :file:`pcapkit/vendor/hip/eddsa_curve.py` @@ -58,16 +54,17 @@ * :file:`pcapkit/vendor/tcp/mp_tcp_option.py` * :file:`pcapkit/vendor/tcp/option.py` -After the fix (``flags=`` on all thirteen), it passes. +and :meth:`PositionalFlagArgumentTests.test_no_positional_flag_argument_in_vendor_tree` +fails on any positional form in the tree. :class:`RuntimeDeprecationWarningTests` additionally pins the runtime symptom directly by re-creating the exact ``r'\\r*\\n'`` call shape used at -every one of these sites and asserting no :exc:`DeprecationWarning` fires -under ``error`` filtering -- run against the pre-fix positional form, the -same assertion raises :exc:`DeprecationWarning` (captured explicitly in +those sites and asserting no :exc:`DeprecationWarning` fires under ``error`` +filtering -- in the positional form, the same assertion raises +:exc:`DeprecationWarning` (captured explicitly in :meth:`RuntimeDeprecationWarningTests.test_positional_flag_shape_warns_only_from_the_version_that_added_it`, -which pins the *old* shape's own defect -- gated on the CPython version that -introduced it (3.13+; below that, the same shape must NOT warn) so a future -refactor cannot silently drop the regression coverage for it, and no +which pins the positional shape's own defect -- gated on the CPython version +that introduced it (3.13+; below that, the same shape must NOT warn) so a +future refactor cannot silently drop the regression coverage for it, and no matrix leg below 3.13 goes dark). The suite is unit-tier (see :mod:`tests._tiers`): it reads no fixture capture @@ -237,11 +234,11 @@ def _assert_positional_flag_shape(self, boundary: 'tuple[int, int]') -> None: with self.assertWarns(DeprecationWarning): with warnings.catch_warnings(): warnings.simplefilter('always') - re.sub(self.PATTERN, ' ', 'a\nb', re.MULTILINE) # noqa: intentional pre-fix shape + re.sub(self.PATTERN, ' ', 'a\nb', re.MULTILINE) # noqa: intentional positional shape else: with warnings.catch_warnings(record=True) as caught: warnings.simplefilter('always') - re.sub(self.PATTERN, ' ', 'a\nb', re.MULTILINE) # noqa: intentional pre-fix shape + re.sub(self.PATTERN, ' ', 'a\nb', re.MULTILINE) # noqa: intentional positional shape self.assertFalse( any(issubclass(w.category, DeprecationWarning) for w in caught), f'expected no DeprecationWarning on Python {tuple(sys.version_info[:2])} for the ' @@ -250,13 +247,12 @@ def _assert_positional_flag_shape(self, boundary: 'tuple[int, int]') -> None: ) def test_positional_flag_shape_warns_only_from_the_version_that_added_it(self) -> None: - # GitHub issue #819: the OLD version of this test asserted only the - # 3.13+ branch, with no version guard at all, so it went red on - # every leg below 3.13 (measured: Python 3.11, 3.12 -- "warns" and - # "asserted" are not the same interpreter). Asserting the ABSENCE - # below the boundary, instead of skipping it, keeps coverage on - # every leg and makes the boundary itself a tested fact rather than - # a comment only two of five legs ever exercised. + # GitHub issue #819: asserting only the 3.13+ branch, with no + # version guard at all, goes red on every leg below 3.13 (measured: + # Python 3.11, 3.12 -- "warns" and "asserted" are not the same + # interpreter). Asserting the ABSENCE below the boundary, instead of + # skipping it, keeps coverage on every leg and makes the boundary + # itself a tested fact rather than a comment only some legs exercise. self._assert_positional_flag_shape(self.POSITIONAL_COUNT_DEPRECATED_SINCE) def test_the_boundary_constant_is_load_bearing(self) -> None: @@ -295,10 +291,8 @@ def test_the_boundary_constant_is_load_bearing(self) -> None: self._assert_positional_flag_shape(self.POSITIONAL_COUNT_DEPRECATED_SINCE) def test_flags_keyword_shape_does_not_warn(self) -> None: - # The fix, exercised the same way: no DeprecationWarning under - # 'error' filtering. This is the assertion that fails on stock - # 4530424df if any of the thirteen sites still use the positional - # form, and passes once every site uses flags=. + # The keyword form, exercised the same way: no DeprecationWarning + # under 'error' filtering. with warnings.catch_warnings(): warnings.simplefilter('error', DeprecationWarning) result = re.sub(self.PATTERN, ' ', 'a\nb', flags=re.MULTILINE) @@ -307,17 +301,17 @@ def test_flags_keyword_shape_does_not_warn(self) -> None: def test_pattern_has_no_multiline_anchor(self) -> None: # Defect #2 from the issue body, checked directly rather than # asserted in prose: re.MULTILINE only changes '^'/'$' matching, and - # this pattern contains neither (nor \A/\Z), so the flag was already - # inert for every one of the thirteen sites -- the fix only removes - # the accidental count=8 cap, it does not change any match. + # this pattern contains neither (nor \A/\Z), so the flag is inert for + # every affected site -- passing it by keyword only removes the + # accidental count=8 cap, it does not change any match. for anchor in ('^', '$', r'\A', r'\Z'): self.assertNotIn(anchor, self.PATTERN, f'pattern {self.PATTERN!r} contains {anchor!r} -- re.MULTILINE would ' f'not be a no-op here, and the "byte-identical" argument does not hold') def test_uncapping_count_only_matters_beyond_eight_matches(self) -> None: - # Defect #1, empirically bounded: below the old count=8 cap, the - # positional (buggy) and keyword (fixed) forms already agree, and + # Defect #1, empirically bounded: up to the count=8 cap, the + # positional (buggy) and keyword (correct) forms agree, and # they diverge only once a string carries more than eight # occurrences of the pattern -- which is what "byte-identical for # today's data, latent for tomorrow's" means concretely. @@ -340,11 +334,12 @@ def new_call(s: 'str') -> 'str': class GeneratorRuntimeCoverageTests(unittest.TestCase): - """Exercise every fixed call site's owning method directly. + """Exercise every affected call site's owning method directly. - :class:`PositionalFlagArgumentTests` proves the defect is gone from the + :class:`PositionalFlagArgumentTests` proves the defect is absent from the *source text*; this class proves it by *running* the exact line coverage - tooling measures, so the fix is exercised rather than only inspected. + tooling measures, so the keyword form is exercised rather than only + inspected. Every instance is built with ``cls.__new__(cls)``, the same bypass :file:`tests/vendor/test_vendor_dest_path_unit.py` and @@ -359,11 +354,9 @@ class GeneratorRuntimeCoverageTests(unittest.TestCase): run first. Each case feeds ``process()`` a synthetic CSV row -- never live IANA - data -- reaching exactly the line this change touches, under - ``DeprecationWarning`` raised as an error. Run against stock - ``4530424df`` (the positional ``re.MULTILINE`` form), every one of these - fails with that :exc:`DeprecationWarning`; after the fix (``flags=``), - all pass. + data -- reaching exactly the affected line, under ``DeprecationWarning`` + raised as an error. In the positional ``re.MULTILINE`` form, every one of + these fails with that :exc:`DeprecationWarning`. """ diff --git a/tests/vendor/test_request_prompt_unit.py b/tests/vendor/test_request_prompt_unit.py index 98f4a54185..a4709390da 100644 --- a/tests/vendor/test_request_prompt_unit.py +++ b/tests/vendor/test_request_prompt_unit.py @@ -1,18 +1,18 @@ # -*- coding: utf-8 -*- """Regression tests for the manual-intervention prompt in ``Vendor._request``. -GitHub issue #522: the last resort of +GitHub issue #522: guards against the last resort of :meth:`pcapkit.vendor.default.Vendor._request` -- ask an operator to save the -page by hand -- was a ``while True`` whose only pause was an ``input()`` call +page by hand -- being a ``while True`` whose only pause is an ``input()`` call wrapped in ``contextlib.suppress(Exception)``. With nobody at a keyboard that suppression is what does the damage rather than the loop: ``input()`` raises instead of blocking, the suppression discards the exception, the file the loop waits for never appears, and the iteration repeats immediately. Three lines of output per pass, measured in the issue at **26.7 million lines / 2.6 GB** before -the process was killed. It was never a hang; it was a printer. +the process was killed. It is not a hang; it is a printer. ``input()`` fails three different ways depending on how ``stdin`` went away, and -the old code suppressed all three identically -- measured on CPython 3.14.7: +a blanket suppression treats all three identically -- measured on CPython 3.14.7: * a ``stdin`` at end of file, i.e. redirected from :file:`/dev/null` or a closed pipe, raises :exc:`EOFError`; @@ -23,21 +23,21 @@ loudest real-world shape raises **nothing at all**: under ``yes | make vendor`` ``input()`` returns ``'y'`` forever, the file still never appears, and the loop still spins. No amount of exception handling inside the loop catches that one, -which is why the fix has two halves and this module tests both: +which is why the guard has two halves and this module tests both: 1. **A precondition.** :func:`~pcapkit.vendor.default.stdin_is_interactive` decides whether there is a terminal at all, and a run without one is failed with the fetch error -- exactly as :envvar:`PCAPKIT_CI_MODE` fails it -- before a browser is opened, a temporary directory is made, or a word is printed. This is the half that covers the piped-stdin case above. -2. **A loop that cannot spin.** Any failure of ``input()`` now ends the wait with +2. **A loop that cannot spin.** Any failure of ``input()`` ends the wait with the original :exc:`requests.RequestException` instead of being discarded. The precondition does not make this redundant: a terminal can go away *during* the wait -- an SSH session dropping, a parent closing the descriptor -- and that lands in exactly the loop the precondition already let the process into. -Two properties are pinned alongside the fix, because a fix for a runaway loop is -easy to overshoot: +Two properties are pinned alongside, because a fix for a runaway loop is easy to +overshoot: * :meth:`ManualIntervenePromptTests.test_saving_the_page_still_completes_the_fetch` and :meth:`~ManualIntervenePromptTests.test_the_prompt_still_repeats_until_the_page_appears` @@ -46,17 +46,17 @@ * :meth:`~ManualIntervenePromptTests.test_keyboard_interrupt_still_aborts_as_itself` pins that :exc:`KeyboardInterrupt` is *not* swallowed into a fetch error. Ctrl-C is how an operator declines, and it is a :exc:`BaseException`, so it - escaped the old ``suppress(Exception)`` and must keep escaping the new - ``except Exception``. + escapes ``except Exception`` as it would ``suppress(Exception)``, and must + keep doing so. **No case here can wedge the suite, and none of them relies on a timeout to say so.** :func:`_fake_input` answers a bounded number of prompts and then raises :class:`_Runaway`, which derives from :exc:`BaseException` precisely so that -neither the old ``suppress(Exception)`` nor the new ``except Exception`` can -catch it: against the unfixed code a case fails in milliseconds with a message -naming the defect, rather than printing until something kills it. -:func:`~tests._support.time_limit` is layered on top as a backstop for a fix that -found some *other* way not to terminate. Standard output is captured throughout, +neither ``suppress(Exception)`` nor ``except Exception`` can catch it: with the +defect present a case fails in milliseconds with a message naming the defect, +rather than printing until something kills it. +:func:`~tests._support.time_limit` is layered on top as a backstop for a loop +that finds some *other* way not to terminate. Standard output is captured throughout, so the bounded flood is counted rather than spilled into the test report. Nothing here makes a network call: ``requests.get`` is replaced for the whole of @@ -110,15 +110,15 @@ #: Whether the crawlers are importable at all. ``html5lib`` ships in the #: ``vendor`` extra (:file:`pyproject.toml`), not ``test`` -- so these cases skip #: on the ``test`` and ``gate`` jobs of :file:`.github/workflows/unit-tests.yml`. -#: ``engine-tests`` ran them for real (#738) until #849 narrowed each of its -#: cells to that engine's own test files. +#: ``engine-tests`` does not run them either: each of its cells runs only that +#: engine's own test files (#849). #: Guarded the way :file:`tests/protocols/test_dispatch_registry_unit.py` guards #: its own optional dependencies rather than making the whole unit tier depend on #: the crawlers'. HAS_VENDOR_DEPS = all(importlib.util.find_spec(name) is not None for name in VENDOR_DEPS) #: How many prompts :func:`_fake_input` will answer before it decides the loop is -#: never going to end. Small enough that the unfixed code's output stays in the +#: never going to end. Small enough that a runaway loop's output stays in the #: tens of lines, large enough that no legitimate case comes near it -- the most #: any test here needs is two. PROMPT_BUDGET = 16 @@ -138,10 +138,10 @@ class _Runaway(BaseException): """Raised by :func:`_fake_input` once the prompt budget is exhausted. Derives from :exc:`BaseException` rather than :exc:`Exception` on purpose. - The code under test used to wrap its ``input()`` call in - ``contextlib.suppress(Exception)`` and now wraps it in ``except Exception``; - a sentinel either of those could catch would be swallowed and the loop would - carry on spinning, which is the failure this class exists to report. Only a + The code under test wraps its ``input()`` call in ``except Exception``, and + the defect's shape is ``contextlib.suppress(Exception)``; a sentinel either + of those could catch would be swallowed and the loop would carry on + spinning, which is the failure this class exists to report. Only a :exc:`BaseException` escapes both and reaches the test. """ @@ -185,8 +185,8 @@ def _fake_input(*replies: 'Any', limit: 'int' = PROMPT_BUDGET) -> 'tuple[Any, li guessing how many times it will be asked. Past ``limit`` calls the stand-in raises :class:`_Runaway` instead. That is - what makes a case against the unfixed code fail in milliseconds with a - diagnosis rather than printing until it is killed, and it is why no case here + what makes a case fail in milliseconds with a diagnosis when the loop runs + away, rather than printing until it is killed, and it is why no case here depends on a timeout to detect the runaway loop. Args: @@ -452,7 +452,7 @@ def save() -> 'str': def test_a_stdin_at_eof_ends_the_wait_instead_of_spinning(self) -> None: # The heart of #522. ``stdin_is_interactive`` is forced True so that the # precondition cannot be what saves this -- the loop itself has to end. - # Against the unfixed code the EOFError is suppressed, the file never + # With the defect present the EOFError is suppressed, the file never # appears, and the prompt is re-asked until _fake_input gives up. crawler = self._crawler() prompt, calls = _fake_input(EOFError('EOF when reading a line')) @@ -474,7 +474,7 @@ def test_a_stdin_at_eof_ends_the_wait_instead_of_spinning(self) -> None: def test_every_way_input_can_fail_ends_the_wait(self) -> None: # ``input()`` reports an unusable stdin three different ways, measured on - # CPython 3.14.7, and the old ``suppress(Exception)`` discarded all three + # CPython 3.14.7, and a blanket ``suppress(Exception)`` discards all three # alike. Pinning only EOFError would leave two thirds of the defect live, # so each is driven separately -- a subTest rather than one loop body, so # the exit code names which shape regressed. @@ -517,7 +517,7 @@ def test_the_prompt_failure_is_chained_as_the_cause(self) -> None: self.assertIs(caught.exception.__cause__, failure) def test_a_non_interactive_run_never_reaches_the_prompt(self) -> None: - # The half of the fix no in-loop exception handling can cover: under + # The half of the guard no in-loop exception handling can cover: under # ``yes | make vendor`` the prompt returns 'y' forever and raises nothing, # so the loop has to be *not entered*. Everything the branch would have # done as a side effect is asserted absent, since each is wasted or @@ -574,11 +574,11 @@ def test_keyboard_interrupt_still_aborts_as_itself(self) -> None: # KeyboardInterrupt rather than being folded into a network error -- # "connection failed" would be an outright lie about what happened. # - # This one passes on the unfixed code too, and is here deliberately: - # KeyboardInterrupt is a BaseException and so escaped the old - # ``suppress(Exception)`` exactly as it escapes the new - # ``except Exception``. It guards the fix against overshooting into - # ``except BaseException``, which is the natural over-correction. + # This one passes with the defect present too, and is here + # deliberately: KeyboardInterrupt is a BaseException and so escapes + # ``suppress(Exception)`` exactly as it escapes ``except Exception``. + # It guards against overshooting into ``except BaseException``, which + # is the natural over-correction. crawler = self._crawler() prompt, calls = _fake_input(KeyboardInterrupt()) diff --git a/tests/vendor/test_user_agent_unit.py b/tests/vendor/test_user_agent_unit.py index 2cf613b881..d461456746 100644 --- a/tests/vendor/test_user_agent_unit.py +++ b/tests/vendor/test_user_agent_unit.py @@ -1,14 +1,14 @@ # -*- coding: utf-8 -*- """Regression tests for the ``User-Agent`` the vendor crawlers send. -GitHub issue #518: :meth:`pcapkit.vendor.default.Vendor._request` used to call -``requests.get(self.LINK)`` with no headers at all, in both its direct and its -proxy branch. Wikimedia rejects |requests|_' default ``python-requests/`` -agent outright -- measured 2026-09-19 as HTTP 403 and 126 bytes of robot-policy -text, against 200 for a descriptive agent, reproduced on four separate articles -with nothing but the header changing -- so four crawlers could not fetch their -registry at all, and burned ``MAX_RETRY`` attempts against a refusal that no -amount of retrying lifts. +GitHub issue #518: guards against :meth:`pcapkit.vendor.default.Vendor._request` +calling ``requests.get(self.LINK)`` with no headers at all, in either its direct +or its proxy branch. Wikimedia rejects |requests|_' default +``python-requests/`` agent outright -- measured 2026-09-19 as HTTP 403 +and 126 bytes of robot-policy text, against 200 for a descriptive agent, +reproduced on four separate articles with nothing but the header changing -- so +a crawler sending it cannot fetch its registry at all, and burns ``MAX_RETRY`` +attempts against a refusal that no amount of retrying lifts. .. |requests| replace:: ``requests`` .. _requests: https://requests.readthedocs.io @@ -18,8 +18,8 @@ * **Both** call sites send it. The proxy branch is the one that gets forgotten, because it only runs when the direct fetch has already raised, so a test that - exercises only the happy path would have passed on the unfixed code's sibling - bug. :meth:`test_the_proxy_branch_sends_it_too` drives that branch deliberately. + exercises only the happy path would pass with that branch still sending no + header. :meth:`test_the_proxy_branch_sends_it_too` drives that branch deliberately. * The agent is **descriptive, not a browser spoof**. Wikimedia's policy asks for an agent that identifies the tool and offers a contact address; sending ``Mozilla/5.0 …`` would satisfy the 403 and misrepresent the client, so @@ -73,17 +73,17 @@ #: Whether the crawlers are importable at all. ``html5lib`` ships in the #: ``vendor`` extra (:file:`pyproject.toml`), not ``test`` -- so these tests skip #: on the ``test`` and ``gate`` jobs of :file:`.github/workflows/unit-tests.yml`, -#: which never install ``vendor``. ``engine-tests`` ran them for real (#738) -#: until #849 narrowed each of its cells to that engine's own test files. +#: which never install ``vendor``. ``engine-tests`` does not run them either: +#: each of its cells runs only that engine's own test files (#849). #: Guarded the same way #: :file:`tests/protocols/test_dispatch_registry_unit.py` guards its own optional #: runtime dependencies, rather than making the whole unit tier depend on the #: crawlers' requirements. See #518. HAS_VENDOR_DEPS = all(importlib.util.find_spec(name) is not None for name in VENDOR_DEPS) -#: Tokens that only appear in a browser's ``User-Agent``. The point of the fix is -#: an agent that says what the client actually is, so any of these appearing in -#: it means the fix has been replaced by a spoof. ``Gecko`` covers both the real +#: Tokens that only appear in a browser's ``User-Agent``. The point is an agent +#: that says what the client actually is, so any of these appearing in it means +#: the descriptive agent has been replaced by a spoof. ``Gecko`` covers both the real #: token and the ``like Gecko`` every Chromium agent carries. BROWSER_TOKENS = ('Mozilla', 'AppleWebKit', 'Chrome', 'Chromium', 'Safari', 'Gecko', 'Edg/', 'OPR/', 'Opera', 'Trident', 'Firefox') diff --git a/tests/vendor/test_vendor_dest_path_unit.py b/tests/vendor/test_vendor_dest_path_unit.py index cf9d196469..198a8e652c 100644 --- a/tests/vendor/test_vendor_dest_path_unit.py +++ b/tests/vendor/test_vendor_dest_path_unit.py @@ -1,16 +1,15 @@ # -*- coding: utf-8 -*- """Regression tests for :meth:`pcapkit.vendor.default.Vendor._dest_path`. -Prerequisite for GitHub issue #732, which turns -:file:`pcapkit/const/reg/apptype.py` into a package with per-transport modules -(``tcp.py``, ``udp.py``, ``dccp.py``, ``sctp.py``) mirrored under -:file:`pcapkit/vendor/reg/apptype/`. Before that split can be generated, the -path derivation :meth:`~pcapkit.vendor.default.Vendor.__init__` uses to decide -where to write a crawler's constant file has to handle a module nested *two* -levels under :mod:`pcapkit.vendor`, not just one. +GitHub issue #732 turns :file:`pcapkit/const/reg/apptype.py` into a package with +per-transport modules (``tcp.py``, ``udp.py``, ``dccp.py``, ``sctp.py``) mirrored +under :file:`pcapkit/vendor/reg/apptype/`, so the path derivation +:meth:`~pcapkit.vendor.default.Vendor.__init__` uses to decide where to write a +crawler's constant file has to handle a module nested *two* levels under +:mod:`pcapkit.vendor`, not just one. -The pre-fix code derived that path by splitting the module's absolute -filesystem path into exactly two levels: +Guards against deriving that path by splitting the module's absolute filesystem +path into exactly two levels: .. code-block:: python @@ -19,30 +18,29 @@ os.makedirs(os.path.join(ROOT, '..', 'const', STEM), exist_ok=True) with open(os.path.join(ROOT, '..', 'const', STEM, FILE), 'w') as file: -That was correct only because every crawler then sat exactly one level under -``vendor/``, e.g. :mod:`pcapkit.vendor.reg.apptype` wrote -:file:`pcapkit/const/reg/apptype.py`. Traced by hand for a module one level -deeper -- :file:`pcapkit/vendor/reg/apptype/tcp.py`, the shape #732 needs -- -``temp`` is :file:`.../vendor/reg/apptype`, so ``ROOT`` becomes -:file:`.../vendor/reg` and ``STEM`` becomes ``apptype``. The write then lands at +That is correct only for a crawler exactly one level under ``vendor/``, e.g. +:mod:`pcapkit.vendor.reg.apptype` writing :file:`pcapkit/const/reg/apptype.py`. +Traced by hand for a module one level deeper -- +:file:`pcapkit/vendor/reg/apptype/tcp.py`, the shape #732 needs -- ``temp`` is +:file:`.../vendor/reg/apptype`, so ``ROOT`` becomes :file:`.../vendor/reg` and +``STEM`` becomes ``apptype``. The write then lands at :file:`.../vendor/reg/../const/apptype/tcp.py`, i.e. :file:`pcapkit/vendor/const/apptype/tcp.py` -- *inside* ``vendor/`` itself, never reaching :mod:`pcapkit.const` at all. -The fix, :meth:`~pcapkit.vendor.default.Vendor._dest_path`, anchors on the -``vendor`` package's own ``__file__`` instead of counting a fixed number of -path components, and mirrors whatever path a module sits at *relative to* +:meth:`~pcapkit.vendor.default.Vendor._dest_path` anchors on the ``vendor`` +package's own ``__file__`` instead of counting a fixed number of path +components, and mirrors whatever path a module sits at *relative to* ``vendor/`` under ``const/``. -That relative-path mirroring has its own failure mode, caught in review -rather than by any test written for the first version of this change: -``const/`` and ``vendor/`` are siblings at equal depth, so for a module that -is *not* under the resolved ``vendor_root`` -- a second checkout, or any -module whose file simply is not under ``vendor/`` -- the leading ``..`` -segments of ``os.path.relpath(module, vendor_root)`` cancel out exactly -against ``const_root``, reproducing the module's own path. Unguarded, +That relative-path mirroring has its own failure mode: ``const/`` and +``vendor/`` are siblings at equal depth, so for a module that is *not* under +the resolved ``vendor_root`` -- a second checkout, or any module whose file +simply is not under ``vendor/`` -- the leading ``..`` segments of +``os.path.relpath(module, vendor_root)`` cancel out exactly against +``const_root``, reproducing the module's own path. Unguarded, :meth:`Vendor.__init__` then opens that with ``'w'``, silently truncating the -crawler's own source. The fix rejects that case outright, raising +crawler's own source. ``_dest_path`` rejects that case outright, raising :exc:`~pcapkit.utilities.exceptions.VendorPathNotFound` instead of returning a path that happens to equal its input. @@ -50,22 +48,18 @@ * :class:`ExistingCrawlerDestPathTests` -- the regression guard. Every crawler discovered under :mod:`pcapkit.vendor` must resolve to exactly the - :file:`const/` file it writes -- the depth-one ones by the pre-fix formula, - the five #732 nests under :file:`vendor/reg/apptype/` by the mirrored path; - this is the "did not move" half of the contract, checked by enumerating the - real crawlers on disk rather than sampling a couple by name. + :file:`const/` file it writes -- the depth-one ones by the two-level + formula, the ones #732 nests under :file:`vendor/reg/apptype/` by the + mirrored path; this is the "did not move" half of the contract, checked by + enumerating the real crawlers on disk rather than sampling a couple by name. * :class:`NestedCrawlerDestPathTests`\\ 's :meth:`~NestedCrawlerDestPathTests.test_nested_module_resolves_under_const` - -- the case that motivated the fix. A stub crawler is built two levels + -- the case that motivates ``_dest_path``. A stub crawler is built two levels under a *synthetic* ``vendor/`` tree in a temporary directory (never inside the checkout, since :meth:`Vendor.__init__` -- not exercised here -- would otherwise write into it), and :meth:`_dest_path` is asserted to mirror it - under a sibling ``const/`` at the same relative depth. Run against the - pre-fix code (with the ``_dest_path`` hunk reverted by hand), this raises - ``AttributeError: 'TCP' object has no attribute '_dest_path'`` -- the method - the fix introduces does not exist yet, which is itself evidence the old - inline code could not have passed this case either: :func:`_old_algorithm` - below reproduces the reverted logic directly against the same synthetic + under a sibling ``const/`` at the same relative depth. :func:`_old_algorithm` + below reproduces the two-level split directly against the same synthetic paths and lands it at ``vendor/const/apptype/tcp.py``, confirming the trace above without needing the checkout's real crawlers. * :class:`NestedCrawlerDestPathTests`\\ 's @@ -150,21 +144,21 @@ def _old_algorithm(module_file: 'str') -> 'str': - """Reproduce the reverted, pre-fix path derivation directly. + """Reproduce the two-level split path derivation directly. - Verbatim translation of the hunk this change replaces -- two - :func:`os.path.split` calls and a pair of ``os.path.join(ROOT, '..', ...)`` - -- kept here rather than imported, since the whole point is a copy that - survives the fix being applied. Used only by + A verbatim translation of the two-level split -- two :func:`os.path.split` + calls and a pair of ``os.path.join(ROOT, '..', ...)`` -- kept here rather + than imported, since the whole point is a copy independent of + :class:`~pcapkit.vendor.default.Vendor`. Used only by :meth:`NestedCrawlerDestPathTests.test_old_algorithm_would_have_written_inside_vendor` - to show *what* the old code got wrong, independent of whether - ``Vendor._dest_path`` currently exists to be reverted and re-run by hand. + to show *what* the two-level split gets wrong, independent of + ``Vendor._dest_path``. Args: module_file: Absolute path of the crawler's own module file. Returns: - The path the pre-fix code would have opened for writing. + The path the two-level split would open for writing. """ temp, file_name = os.path.split(os.path.abspath(module_file)) @@ -181,11 +175,11 @@ class ExistingCrawlerDestPathTests(unittest.TestCase): sampled by name so that adding a crawler without adding it here cannot silently go unchecked. - A depth-one crawler is held to the pre-fix formula, which is what "must not - move" means for the 116 that predate the fix. The five nested ones GitHub - issue #732 added -- :mod:`pcapkit.vendor.reg.apptype` and its four - per-transport modules -- are held to the mirrored path instead, since the - pre-fix formula is precisely what cannot express them: it would put + A depth-one crawler is held to the two-level formula, which is what "must + not move" means for it. The nested ones of GitHub issue #732 -- + :mod:`pcapkit.vendor.reg.apptype` and its per-transport modules -- are held + to the mirrored path instead, since the two-level formula is precisely what + cannot express them: it would put ``vendor/reg/apptype/tcp.py`` at ``vendor/const/apptype/tcp.py``, inside ``vendor/`` itself. Both arms end at the same assertion, that the file is really there, which is the half of this test the formula cannot fake. @@ -255,11 +249,11 @@ def test_every_crawler_resolves_to_its_existing_const_file(self) -> None: rel_path = os.path.relpath(module_file, vendor_root) if len(rel_path.split(os.sep)) == 2: - # The pre-fix formula. Every crawler that predates #732 sits - # at this depth and must still land exactly where it did. + # The two-level formula. Every crawler at this depth must land + # exactly where that formula puts it. expected = os.path.normpath(_old_algorithm(module_file)) else: - # #732's nested crawlers, which the pre-fix formula cannot + # #732's nested crawlers, which the two-level formula cannot # express -- mirrored under const/ at whatever depth they sit. expected = os.path.normpath(os.path.join( os.path.dirname(vendor_root), 'const', rel_path)) @@ -384,10 +378,9 @@ def test_module_outside_vendor_root_raises_instead_of_self_truncating(self) -> N self.assertIn(module_file, str(ctx.exception)) def test_old_algorithm_would_have_written_inside_vendor(self) -> None: - # Independent evidence that the reverted hunk is genuinely wrong here, - # not only that Vendor._dest_path does not exist yet on the pre-fix - # code (see the AttributeError captured in the PR description). This - # runs the pre-fix formula itself, copied verbatim into + # Independent evidence that the two-level split is genuinely wrong + # here, not only that Vendor._dest_path exists. This runs the + # two-level formula itself, copied verbatim into # _old_algorithm, against the very same synthetic paths this class # uses -- and it lands inside vendor/, exactly as the hand trace in # the issue predicts. diff --git a/tests/vendor/test_vendor_exit_code_unit.py b/tests/vendor/test_vendor_exit_code_unit.py index 0bc7f56483..1f2002af77 100644 --- a/tests/vendor/test_vendor_exit_code_unit.py +++ b/tests/vendor/test_vendor_exit_code_unit.py @@ -1,25 +1,25 @@ # -*- coding: utf-8 -*- """``python -m pcapkit.vendor`` must fail its exit code when a crawler raises. -GitHub issue #872. Before the fix, :func:`pcapkit.vendor.__main__.run` -swallowed every exception a crawler raised -- filed as a merely-filterable -:class:`~pcapkit.utilities.warnings.VendorRuntimeWarning` -- and -:func:`pcapkit.vendor.__main__.main` always ended on ``return 0``, with -no accounting of whether any target had failed. Because +GitHub issue #872. Guards against :func:`pcapkit.vendor.__main__.run` +swallowing every exception a crawler raises -- reported only as a +merely-filterable :class:`~pcapkit.utilities.warnings.VendorRuntimeWarning` -- +while :func:`pcapkit.vendor.__main__.main` always returns ``0``, with no +accounting of whether any target failed. Because :meth:`pcapkit.vendor.default.Vendor.__init__` runs its network fetch and render *before* the ``open(const_file, 'w')`` on its last line, a failed crawler writes nothing at all: the previous constant file is left untouched, one warning is emitted (silently, since :data:`~pcapkit.utilities.logging.VERBOSE` defaults to :data:`False`), and the -process reports success regardless. A no-op regeneration was therefore +process would report success regardless. A no-op regeneration would then be indistinguishable from a real one at the only boundary a CI job or a script actually checks -- the exit code. This was hit for real during GitHub issue #870's work, where a docstring edit silently failed to propagate. -The fix makes :func:`~pcapkit.vendor.__main__.run` return whether its target -succeeded, and :func:`~pcapkit.vendor.__main__.main` return ``1`` if *any* -target failed while still attempting every target regardless of an earlier -failure -- a crawler that is down should not stop the rest from regenerating. +:func:`~pcapkit.vendor.__main__.run` returns whether its target succeeded, and +:func:`~pcapkit.vendor.__main__.main` returns ``1`` if *any* target failed while +still attempting every target regardless of an earlier failure -- a crawler that +is down should not stop the rest from regenerating. This suite exercises the real :func:`~pcapkit.vendor.__main__.main` entrypoint end to end, with the default (no ``--target``) argument path, rather than @@ -36,12 +36,12 @@ ``__all__`` from -- so this suite never imports, runs, or writes anything under the real crawlers, and touches no file under :file:`pcapkit/const/`. -Run against the pre-fix code (``run()`` ending on a bare ``return None`` and -``main()`` ending on a bare ``return 0``), every method below fails: +With ``run()`` ending on a bare ``return None`` and ``main()`` ending on a bare +``return 0``, every method below fails: :meth:`~ExitCodeTests.test_run_returns_false_for_a_raising_crawler` (``run()`` returns :data:`None`, not the exact value :data:`False` -- :meth:`~unittest.TestCase.assertIs` is what makes this one discriminate: a -plain :meth:`~unittest.TestCase.assertFalse` would pass on both trees, since +plain :meth:`~unittest.TestCase.assertFalse` would pass either way, since :data:`None` is already falsy), :meth:`~ExitCodeTests.test_run_returns_true_for_a_clean_crawler` (``run()`` returns :data:`None`, not :data:`True`), @@ -49,7 +49,7 @@ :meth:`~ExitCodeTests.test_main_still_attempts_every_target_after_an_earlier_one_fails` (``main()`` returns ``0``, not ``1``), and :meth:`~ExitCodeTests.test_failing_target_and_error_reach_stderr_unconditionally` -(with warnings filtered out, pre-fix ``run()`` writes nothing to stderr at +(with warnings filtered out, such a ``run()`` writes nothing to stderr at all). """ @@ -138,10 +138,10 @@ def __init__(self) -> None: # pylint: disable=super-init-not-called def test_run_returns_false_for_a_raising_crawler(self) -> None: failing = self._make_failing_crawler() - # assertIs, not assertFalse: pre-fix run() returns None, which is - # merely falsy and would pass an assertFalse on both trees, pinning - # nothing. The exact value False is what main() actually branches - # on (`if not run(vendor): success = False`), so this has to check + # assertIs, not assertFalse: a run() returning None is merely falsy + # and would pass an assertFalse, pinning nothing. The exact value + # False is what main() actually branches on + # (`if not run(vendor): success = False`), so this has to check # for that literal, not for anything that happens to be falsy. self.assertIs(self.vendor_main.run(failing), False, 'run() must return the exact value False, not merely something ' @@ -153,10 +153,9 @@ def test_run_returns_true_for_a_clean_crawler(self) -> None: 'run() must report success when the crawler does not raise') def test_main_returns_non_zero_when_a_target_raises(self) -> None: - # The regression this issue is about: pre-fix, main() ends on a bare - # `return 0` with no accounting at all, so this assertion fails - # (0 != 1) against origin/main; see the module docstring for the - # full pre-fix failure list. + # The regression this issue is about: a main() ending on a bare + # `return 0` with no accounting at all fails this assertion (0 != 1); + # see the module docstring for the full failure list. failing = self._make_failing_crawler() fake_vendor_module = types.SimpleNamespace(__all__=['FailingCrawler'], FailingCrawler=failing) @@ -193,12 +192,12 @@ def test_main_still_attempts_every_target_after_an_earlier_one_fails(self) -> No def test_failing_target_and_error_reach_stderr_unconditionally(self) -> None: # Point 2 of the issue: VendorRuntimeWarning is filterable and VERBOSE - # defaults False, so the plain print() run() now does is the only + # defaults False, so the plain print() run() does is the only # thing guaranteed to reach a CI log regardless of warning filters. # # Warnings are suppressed here *on purpose*, and that is load-bearing - # rather than incidental: under the default filter, the pre-existing - # warn() call already writes both the qualified name and the + # rather than incidental: under the default filter, the warn() call + # already writes both the qualified name and the # exception repr to stderr on its own (Python's default "once per # location" display of an unfiltered warning), so an unsuppressed run # would pass this assertion whether or not the print() line exists -- diff --git a/tests/vendor/test_vendor_ipv6_extension_header_925_unit.py b/tests/vendor/test_vendor_ipv6_extension_header_925_unit.py index c721f2907e..ae160a8f8b 100644 --- a/tests/vendor/test_vendor_ipv6_extension_header_925_unit.py +++ b/tests/vendor/test_vendor_ipv6_extension_header_925_unit.py @@ -1,60 +1,54 @@ # -*- coding: utf-8 -*- """Regression tests for GitHub issue #925. -:mod:`pcapkit.vendor.ipv6.extension_header` used to crawl the *Protocol -Numbers* registry (``protocol-numbers/protocol-numbers-1.csv``), filtered on -its ``IPv6 Extension Header`` column -- a derived signal, not the registry -:rfc:`8200#section-4` names as authoritative for this enumeration. That -registry disagrees with the authoritative one on header 147 (``BIT-EMU``): -the Protocol Numbers registry flags it as an IPv6 extension header (citing -:rfc:`9801`), while IANA's *IPv6 Extension Header Types* registry -(``ipv6-parameters/extension-header.csv``) omits 147 entirely. The fix moves -:attr:`~pcapkit.vendor.ipv6.extension_header.ExtensionHeader.LINK` to the -authoritative registry and rewrites the parser for its 3-column shape -(``Protocol Number,Description,Reference`` -- no ``IPv6 Extension Header`` -flag to filter on). +:mod:`pcapkit.vendor.ipv6.extension_header` crawls IANA's *IPv6 Extension +Header Types* registry (``ipv6-parameters/extension-header.csv``), the registry +:rfc:`8200#section-4` names as authoritative for this enumeration. Guards +against crawling the *Protocol Numbers* registry +(``protocol-numbers/protocol-numbers-1.csv``) filtered on its ``IPv6 Extension +Header`` column instead -- a derived signal that disagrees with the +authoritative registry on header 147 (``BIT-EMU``): the Protocol Numbers +registry flags it as an IPv6 extension header (citing :rfc:`9801`), while the +authoritative registry omits 147 entirely. +:attr:`~pcapkit.vendor.ipv6.extension_header.ExtensionHeader.LINK` names the +authoritative registry, and the parser reads its 3-column shape (``Protocol +Number,Description,Reference`` -- no ``IPv6 Extension Header`` flag to filter +on). That registry's ``Description`` column is verbose (``Routing Header for -IPv6``), unlike the old registry's short ``Keyword`` column (``IPv6-Route``) -that :meth:`~pcapkit.vendor.ipv6.extension_header.ExtensionHeader.process` -used to derive most of the current member names from. Naively deriving names -from the new column would silently rename eight of the eleven surviving -members -- a breaking change far worse than the defect being fixed -- so the -crawler now carries an explicit -:attr:`~pcapkit.vendor.ipv6.extension_header.ExtensionHeader.NAMES` override -for exactly those eight, and :data:`EXPECTED_MEMBERS` below pins that no name -moves. +IPv6``), unlike the Protocol Numbers registry's short ``Keyword`` column +(``IPv6-Route``) that most of the member names come from. Naively deriving names +from the ``Description`` column would silently rename most of the members -- a +breaking change far worse than the defect -- so the crawler carries an explicit +:attr:`~pcapkit.vendor.ipv6.extension_header.ExtensionHeader.NAMES` override for +exactly those, and :data:`EXPECTED_MEMBERS` below pins that no name moves. This suite feeds the fixture CSV text directly to the crawler's own :meth:`~pcapkit.vendor.default.Vendor.request`, :meth:`count`, :meth:`process` and :meth:`context` -- the same pipeline :meth:`~pcapkit.vendor.default. -Vendor.__init__` would drive from a live fetch -- so the fix is exercised +Vendor.__init__` would drive from a live fetch -- so the crawler is exercised without ever calling :mod:`requests`. Per the standing rule on this package, no test here (or anywhere) invokes the crawler against the network. -**The const half of the fix is now applied too.** Removing ``BIT_EMU`` looked -blocked at first: :meth:`pcapkit.protocols.internet.ipv6.IPv6 +**The const side drops ``BIT_EMU`` as well.** :meth:`pcapkit.protocols.internet.ipv6.IPv6 ._decode_next_layer`'s walk resolves ``Enum_ExtensionHeader(proto)`` at the -top of its loop and used to rely on that succeeding for 147. But that test's -own docstring says 147 was only ever the *example* -- ``"253``/``254`` are -the same code path (also unregistered, also resolve to ``Raw``)"`` -- and 253 -*is* in the authoritative registry where 147 never was, so +top of its loop, and 147 was only ever the *example* of an unregistered, +``Raw``-resolving code -- ``"253``/``254`` are the same code path (also +unregistered, also resolve to ``Raw``)"`` -- so ``tests.protocols.internet.test_ipv6_ext_unit .IPv6ExtUnitTests.test_unimplemented_terminal_code_stops_the_walk_not_the_packet`` -now exercises the identical code path on 253 instead, which frees -:mod:`pcapkit.const.ipv6.extension_header` to drop ``BIT_EMU`` for real. +exercises that code path on 253, which *is* in the authoritative registry. :mod:`tests.const.test_const_ipv6_extension_header_925_unit` pins that side; :func:`test_context_matches_the_committed_const_file_byte_for_byte` below is the seam between the two -- it proves the *committed* const file is exactly -what this fixture, run through the fixed crawler, produces. +what this fixture, run through the crawler, produces. -A real crawl against the live registry was still never run here -- the -standing rule on this repository disallows it. What this suite proves is -narrower and machine-checkable without one: feeding it the fixture text this -issue's own investigation fetched reproduces the committed const file -byte-for-byte. The owner should still run the crawler for real once, to -confirm the *live* registry matches the fixture -- IANA could have edited -the registry between the fetch and #926 landing. +No real crawl against the live registry is run here -- the standing rule on +this repository disallows it. What this suite proves is narrower and +machine-checkable without one: feeding it the fixture text this issue's own +investigation fetched reproduces the committed const file byte-for-byte. +Whether the *live* registry still matches the fixture is a question only a real +crawl answers -- IANA can edit the registry at any time. """ from __future__ import annotations @@ -106,10 +100,10 @@ '254,Use for experimentation and testing,[RFC3692][RFC4727]' ) -#: Every member the fixed crawler is expected to produce from -#: :data:`FIXTURE_CSV`, as ``(name, value)`` in registry order -- exactly the -#: 11 non-``BIT_EMU`` members the committed -#: :class:`pcapkit.const.ipv6.extension_header.ExtensionHeader` now carries, +#: Every member the crawler is expected to produce from :data:`FIXTURE_CSV`, +#: as ``(name, value)`` in registry order -- exactly the non-``BIT_EMU`` +#: members the committed +#: :class:`pcapkit.const.ipv6.extension_header.ExtensionHeader` carries, #: spelled out so a future change to :meth:`process` that renames one #: silently fails here instead of shipping. EXPECTED_MEMBERS = ( @@ -126,17 +120,17 @@ ('Use_for_experimentation_and_testing_254', 254), ) -#: Comments :data:`EXPECTED_MEMBERS` carried in -#: :mod:`pcapkit.const.ipv6.extension_header` *before* this fix (i.e. sourced -#: from the Protocol Numbers registry), for the six members whose text this -#: registry change does not disturb -- the other five (``IPv6_Route``, -#: ``IPv6_Frag``, ``ESP``, and both ``Use_for_experimentation_and_testing`` -#: members) draw genuinely different reference text from the new registry -#: (real RFC citations where the old one cited an author's name, or an extra -#: RFC 4727 citation), which is a known, reported divergence rather than a -#: parsing defect -- see the module docstring and this suite's +#: Comments :data:`EXPECTED_MEMBERS` carry when sourced from the Protocol +#: Numbers registry, for the members whose text the two registries agree on -- +#: the others (``IPv6_Route``, ``IPv6_Frag``, ``ESP``, and both +#: ``Use_for_experimentation_and_testing`` members) draw genuinely different +#: reference text from the authoritative registry (real RFC citations where the +#: Protocol Numbers registry cites an author's name, or an extra RFC 4727 +#: citation), which is a known, reported divergence rather than a parsing +#: defect -- see this suite's #: ``test_five_comments_genuinely_diverge_from_the_historical_registry``. The -#: committed const file now carries the *new* text for all 11, which is what +#: committed const file carries the authoritative registry's text for every +#: member, which is what #: ``test_context_matches_the_committed_const_file_byte_for_byte`` pins. UNCHANGED_COMMENTS = { 'HOPOPT': 'HOPOPT, IPv6 Hop-by-Hop Option [:rfc:`8200`]', @@ -226,8 +220,8 @@ def _vendor(self) -> 'Any': return vendor, lines def test_link_points_at_the_authoritative_registry(self) -> None: - # The root-cause fix, stated as an assertion: no longer the Protocol - # Numbers registry filtered on a derived flag. + # The root cause, stated as an assertion: the authoritative registry, + # not the Protocol Numbers registry filtered on a derived flag. link = self.vendor_module.ExtensionHeader.LINK self.assertEqual(link, 'https://www.iana.org/assignments/ipv6-parameters/extension-header.csv') self.assertNotIn('protocol-numbers', link) @@ -240,8 +234,8 @@ def test_fixture_splits_into_twelve_lines(self) -> None: self.assertEqual(len(lines), 12) def test_no_extension_header_type_is_lost_or_renamed(self) -> None: - # The count, and every surviving name, in one assertion: losing 147 - # (BIT_EMU) is the fix; losing or renaming any of the other 11 is not. + # The count, and every name, in one assertion: 147 (BIT_EMU) is absent + # by design; losing or renaming any other member is a defect. vendor, lines = self._vendor() enum, miss = vendor.process(lines) self.assertEqual(len(enum), 11) @@ -316,7 +310,7 @@ def test_context_round_trips_through_the_full_pipeline(self) -> None: self.assertNotIn('147', generated) def test_context_matches_the_committed_const_file_byte_for_byte(self) -> None: - # The seam this fix rests on: pcapkit/const/ipv6/extension_header.py + # The seam the const side rests on: pcapkit/const/ipv6/extension_header.py # was hand-applied rather than produced by a live crawl (disallowed # in this environment), so this is what stands in for "regenerating # is a no-op" -- see tests.vendor.test_ipx_packet_unit for the same diff --git a/tests/vendor/test_vendor_missing_body_unit.py b/tests/vendor/test_vendor_missing_body_unit.py index f402767698..1c41b974c6 100644 --- a/tests/vendor/test_vendor_missing_body_unit.py +++ b/tests/vendor/test_vendor_missing_body_unit.py @@ -4,20 +4,19 @@ GitHub issue #866. A vendor crawler renders the body of the generated registry's :meth:`~enum.Enum._missing_` as a list of source lines, held in the -local ``miss``. Two crawlers -- -:mod:`pcapkit.vendor.pcapng.record_type` and -:mod:`pcapkit.vendor.pcapng.secrets_type` -- emitted a **two**-line body whose -first line called :meth:`~pcapkit.corekit.enum.EnumRegistry._unregistered_member` -without returning it and whose second line was ``return cls(value)``:: +local ``miss``. Guards against a crawler emitting a **two**-line body whose first +line calls :meth:`~pcapkit.corekit.enum.EnumRegistry._unregistered_member` +without returning it and whose second line is ``return cls(value)``:: cls._unregistered_member(value, 'Unassigned') return cls(value) -That shape was harmless while the first line was ``extend_enum(...)``, which -*registers* the member, so the following ``cls(value)`` found it and returned. -#861 replaced the ``extend_enum`` call with ``_unregistered_member``, which -deliberately does **not** register -- so ``cls(value)`` missed again, re-entered -``_missing_``, and recursed until ``RecursionError``. +That shape is harmless only while the first line is ``extend_enum(...)``, which +*registers* the member, so the following ``cls(value)`` finds it and returns. +``_unregistered_member`` deliberately does **not** register (#861) -- so +``cls(value)`` misses again, re-enters ``_missing_``, and recurses until +``RecursionError``. #866 found :mod:`pcapkit.vendor.pcapng.record_type` and +:mod:`pcapkit.vendor.pcapng.secrets_type` emitting it. The invariant is deliberately not "every emitted line returns": the base crawler itself emits legitimate non-returning lines for a range-guarded body. @@ -37,36 +36,22 @@ *ends* in a ``return`` and that no emitted line re-enters ``_missing_`` for the same value -- not that every line returns. -#861 only *half*-corrected the two generated files, and never touched the two -crawlers. It replaced the non-returning ``extend_enum(...)`` call with a -returning ``_unregistered_member(...)`` call, but the ``return cls(value)`` -line that followed was left in place, now unreachable dead code:: - - - extend_enum(cls, 'Unassigned_0x%08x' % value, value) - + return cls._unregistered_member(value, 'Unassigned') - return cls(value) # <- still there, now unreachable - -That is why #861's own CI was green: the first ``return`` short-circuits, so -the dead second line never ran. (A statement following a ``return`` in -generated output is itself worth noticing on its own -- it means the -generator that will overwrite the file disagrees with what the file -currently does.) Because the crawlers were never touched, a later -regeneration -- ``e58618bdf`` ("Bumped version to 1.5.0b5") -- reproduced the -original two-line body from the stale crawlers and reintroduced the -recursion, turning **12** tests red across **three** files: -:file:`tests/const/test_const_enum_lookup.py`, -:file:`tests/const/test_const_enum_no_mint.py` -(``RulingConversionDoesNotMintTests.test_converted_value_does_not_mint`` and -``test_repeated_lookup_does_not_grow_members``, two registries each), and -:file:`tests/protocols/misc/test_pcapng_unit.py` (7 of the 12) -- all twelve +A statement following a ``return`` in generated output is worth noticing on +its own: it means the generator that will overwrite the file disagrees with +what the file currently does. A generated file whose first line returns hides a +dead ``return cls(value)`` behind it, so its own tests stay green, while a +regeneration from the crawler reproduces the recursing two-line body -- which +is how #866 turned :file:`tests/const/test_const_enum_lookup.py`, +:file:`tests/const/test_const_enum_no_mint.py` and +:file:`tests/protocols/misc/test_pcapng_unit.py` red, all with ``RecursionError``. The guard here is deliberately at the **crawler** layer rather than the -generated one. ``tests/const/test_const_enum_lookup.py`` already exercises every -committed registry and is what caught the regression; what nothing covered was -the source a crawler *emits*, so a fix to a generated file could silently -disagree with the generator that will overwrite it. This module needs no network -and no regeneration: it reads the crawlers' own ASTs. +generated one. ``tests/const/test_const_enum_lookup.py`` exercises every +committed registry and catches the regression once a file is regenerated; the +source a crawler *emits* needs its own check, so a fix to a generated file +cannot silently disagree with the generator that will overwrite it. This module +needs no network and no regeneration: it reads the crawlers' own ASTs. """ from __future__ import annotations @@ -163,7 +148,7 @@ def test_the_hard_coded_body_ends_in_a_return(self) -> None: This test does **not** catch #866 on its own: #866's body ended on ``return cls(value)``, which does return, so it passes this check - both before and after the fix. It is + either way. It is :meth:`test_no_crawler_emits_a_self_recursive_lookup` that catches the cycle -- this test only guards against a body that does not return at all. @@ -197,8 +182,8 @@ def test_no_crawler_emits_a_self_recursive_lookup(self) -> None: def test_the_two_regressed_crawlers_emit_a_single_returning_line(self) -> None: """The specific pair from #866, pinned by name. - Kept alongside the general sweeps because a regression here is the one - that reached ``main``, and a named test says which file to look at + Kept alongside the general sweeps because these are the crawlers #866 + was filed against, and a named test says which file to look at without having to read a subTest label. """ diff --git a/tests/vendor/test_vendor_missing_extra_1850_unit.py b/tests/vendor/test_vendor_missing_extra_1850_unit.py index 1af475a412..38dd03adf0 100644 --- a/tests/vendor/test_vendor_missing_extra_1850_unit.py +++ b/tests/vendor/test_vendor_missing_extra_1850_unit.py @@ -1,18 +1,18 @@ # -*- coding: utf-8 -*- """``pcapkit-vendor`` without the ``vendor`` extra exits on one line, not with a traceback. -GitHub issue #1850. :mod:`pcapkit.vendor` imported every crawler unconditionally, -and :mod:`pcapkit.vendor.default` imports ``requests`` at module scope, so without -the extra importing the package raised :exc:`ModuleNotFoundError`. The console -script and ``python -m pcapkit.vendor`` import that package before any line of -:mod:`pcapkit.vendor.__main__` runs, so the user got a traceback; the +GitHub issue #1850. :mod:`pcapkit.vendor.default` imports ``requests`` at module +scope, so without the extra a :mod:`pcapkit.vendor` that imports every crawler +unconditionally raises :exc:`ModuleNotFoundError` on import. The console script +and ``python -m pcapkit.vendor`` import that package before any line of +:mod:`pcapkit.vendor.__main__` runs, so the user would get a traceback; the :class:`~pcapkit.utilities.warnings.VendorWarning` emitted first is a :exc:`ResourceWarning`, which Python hides by default. The owner ruled that the command should instead exit with one line naming the missing packages and the extra to install, for a pip install without ``[vendor]`` as much as for the conda package, which does not vendor the crawlers' dependencies (#1814). -The package now records what is missing in ``_MISSING`` and imports no crawler +The package records what is missing in ``_MISSING`` and imports no crawler while it is not empty. Its :pep:`562` ``__getattr__`` raises a :exc:`ModuleNotFoundError` carrying the message for a crawler's name, and :func:`~pcapkit.vendor.__main__.main` prints the message and returns ``1``. @@ -26,10 +26,10 @@ :meth:`MissingExtraTests.test_import_succeeds_and_a_crawler_name_raises` checks that the child imported this checkout. -Against the unfixed tree every test of :class:`MissingExtraTests` fails on the +Without that handling every test of :class:`MissingExtraTests` fails on the traceback of that import, and :class:`MainTests` fails with :exc:`AttributeError`, -as there is no ``_MISSING`` to patch. :class:`PresentExtraTests` passes before and -after the fix, as it should: it pins that nothing changes with the extra installed. +as there is no ``_MISSING`` to patch. :class:`PresentExtraTests` passes either +way, as it should: it pins that nothing changes with the extra installed. """ from __future__ import annotations diff --git a/tests/vendor/test_vendor_ngap_unit.py b/tests/vendor/test_vendor_ngap_unit.py index 4e9b6ef14d..01f1083b9e 100644 --- a/tests/vendor/test_vendor_ngap_unit.py +++ b/tests/vendor/test_vendor_ngap_unit.py @@ -5,9 +5,9 @@ the four closed :mod:`pcapkit.protocols.internet.mh` helper enums #877 ruled on, :class:`~pcapkit.const.ngap.procedure_code.ProcedureCode` and :class:`~pcapkit.const.ngap.protocol_ie.ProtocolIE` are genuinely open per -3GPP TS 38.413, and the fix is to source their real assignment from |pycrate|_ -through a vendor crawler rather than hand-maintaining the list or minting a -value at lookup time. This module tests the crawler half of that: that +3GPP TS 38.413, so their real assignment is sourced from |pycrate|_ through a +vendor crawler rather than by hand-maintaining the list or minting a value at +lookup time. This module tests the crawler half of that: that :meth:`~pcapkit.vendor.ngap.procedure_code.ProcedureCode._request` reads |pycrate|_'s own compiled specification correctly, that its output matches the generated :mod:`pcapkit.const.ngap` modules exactly, that two independent diff --git a/tests/vendor/test_vendor_reference_formats_1664_unit.py b/tests/vendor/test_vendor_reference_formats_1664_unit.py index a71736eec7..387b5d6f28 100644 --- a/tests/vendor/test_vendor_reference_formats_1664_unit.py +++ b/tests/vendor/test_vendor_reference_formats_1664_unit.py @@ -153,8 +153,9 @@ 'ADAT,secu,Authentication/Security Data,a,o,[RFC 2228]; [RFC 2773]; [RFC 4217]\r\n' ) -#: ``ALGS`` live on 2026-10-13. It read ``[RFC6384] Section 11`` before, so it -#: is the one row whose rendering moves: the section is now in the citation. +#: ``ALGS`` live on 2026-10-13, with the section inside the citation. The older +#: registry format reads ``[RFC6384] Section 11``, so this is the one row whose +#: rendering differs between the two formats. FTP_ALGS_NEW = ( 'cmd,FEAT code,description,type,conf,References and Notes\r\n' 'ALGS,,FTP64 ALG status,,o,"[RFC 6384, Section 11]"\r\n' diff --git a/tests/vendor/test_vendor_reg_apptype_generator_unit.py b/tests/vendor/test_vendor_reg_apptype_generator_unit.py index 5b85b105d0..2d7033e399 100644 --- a/tests/vendor/test_vendor_reg_apptype_generator_unit.py +++ b/tests/vendor/test_vendor_reg_apptype_generator_unit.py @@ -1,120 +1,95 @@ # -*- coding: utf-8 -*- """The mechanical shapes :mod:`pcapkit.vendor.reg.apptype.apptype` must keep. -GitHub issue #744: every one of :class:`~pcapkit.const.reg.apptype.apptype.AppType`'s -members used to be an *annotated* assignment -- +GitHub issue #744: :class:`~pcapkit.const.reg.apptype.apptype.AppType`'s +members are plain, not *annotated*, assignments. An annotated member -- ``tcpmux: 'TCP' = 1, 'tcpmux', TransportProtocol.get('tcp') | TransportProtocol.get('udp')`` --- which put one forward-ref string per member into the class's +-- puts one forward-ref string per member into the class's ``__annotations__``. Sphinx's default autodoc calls ``sphinx.util.typing.get_type_hints(parent, ...)`` once per member (from ``_make_props_from_imported_object`` in ``sphinx/ext/autodoc/_dynamic/_loader.py`` -- the wrapper around :func:`typing.get_type_hints` that catches the ``NameError`` a ``TYPE_CHECKING``-only forward ref would otherwise raise), each call -re-resolving the *whole* class, which is what made documenting this one -registry cost 378.8s on CI -- the single largest stall in the entire docs -build. The annotation carried no information a lookup needs: member -construction reads the positional tuple, not ``__annotations__``. Measured -when #769 fixed it: dropping it removed 86,838 bytes (7/8 bytes per member, -depending on whether its class name is 3 or 4 letters long). +re-resolving the *whole* class, which made documenting this one registry cost +378.8s on CI -- the single largest stall in the entire docs build. The +annotation carries no information a lookup needs: member construction reads the +positional tuple, not ``__annotations__``. Measured by #769, dropping it removes +86,838 bytes (7/8 bytes per member, depending on whether its class name is 3 or +4 letters long). GitHub issue #768: the ``proto`` argument on every one of those members, and every ``cls.__transport__ is ...`` test in a range-row ``_missing_`` branch, -called :meth:`~pcapkit.const.reg.apptype.apptype.TransportProtocol.get` -(524 ns, an ``isinstance`` check, two ``.lower()`` calls, a membership test -and a ``__getitem__``) to reach a member plain attribute access returns in -24 ns -- every such call site rewritten to the attribute form, saving 7 bytes -each (``TransportProtocol.get('tcp')`` is 28 characters, +uses attribute access rather than +:meth:`~pcapkit.const.reg.apptype.apptype.TransportProtocol.get` (524 ns, an +``isinstance`` check, two ``.lower()`` calls, a membership test and a +``__getitem__``) to reach a member plain attribute access returns in 24 ns, +saving 7 bytes each (``TransportProtocol.get('tcp')`` is 28 characters, ``TransportProtocol.tcp`` is 21), for 167,587 bytes (163.7 KiB) of generated -source as measured when #769 fixed it. The generator only ever emits the five -declared names (``TRANSPORTS + ('undefined',)``), so the attribute form cannot -miss. +source as measured by #769. The generator only ever emits the five declared +names (``TRANSPORTS + ('undefined',)``), so the attribute form cannot miss. -GitHub issue #770: ``undefined = 0`` in the ``BASE`` template is a bare int +GitHub issue #770: a bare ``undefined = 0`` in the ``BASE`` template is an int literal, so mypy -- which has no :mod:`aenum` plugin and so treats this class as a plain one -- infers the class attribute's type as ``int`` while the ``auto()``-valued siblings (``tcp``, ``udp``, ``sctp``, ``dccp``) infer as -``Any``. That is what made ``TransportProtocol.undefined`` disagree with its -own ``'TransportProtocol'`` annotation at the sites that default to it -- -the class attribute ``__transport__``, and the ``proto`` parameter on -``__new__``, ``get`` and ``get_all``. The fix wraps the literal in -:func:`~typing.cast` so mypy infers ``TransportProtocol`` there too, without -changing what runs: :func:`~typing.cast` is the identity function at run time, -so ``TransportProtocol.undefined`` stays the same ``0`` -- ``TransportProtocol(0) -is undefined`` and ``bool(undefined)`` is ``False`` -- and the same genuine -``TransportProtocol`` member it always was: the ``proto`` sentinel default at -each of those sites, and what the base registry's ``_missing_`` extends -unassigned/reserved rows from. Two rejected alternatives, measured rather than -argued: an explicit member annotation (``undefined: 'TransportProtocol' = 0``) -does not fix this at all, it only *relocates* the error -- mypy still reports -one ``[assignment]`` at the member's own declaration line, for 113 errors in -39 files rather than 116, so ``cast`` is preferred 0/38 against 1/39, not on -parity. ``auto() & 0`` is run-time identical to the bare literal -- value -``0``, no burned counter slot, ``tcp``/``udp``/``sctp``/``dccp`` unaffected -- -and is rejected only because it leans on :mod:`aenum`'s undocumented internal -order of operations for resolving a composed ``auto()``, not because it -misbehaves. - -(That description is #770's own, and it describes the tree as it ships -- -but not without a detour along the way. An intermediate revision of GitHub -issue #860 step 2 PR 2 briefly moved every one of the five members, -``undefined`` included, onto ``auto()`` with an explicit ``_start_ = 0``, -which made #770's own literal-vs-``auto()`` asymmetry disappear entirely: -with no bare literal left anywhere, no member's wrapper was load-bearing -against a mypy error any more, since an ``auto()``-valued member infers as -``Any``, which is assignable to ``TransportProtocol`` with no cast at all. -The owner's final ruling, given on GitHub pull request #874, reinstated the -original shape instead: ``undefined`` takes ``0`` directly, and the other real -transports use ``auto()``, so that no ``_start_`` has to be defined and the -declaration of ``undefined`` is explicit. So ``undefined`` is a direct, -explicit ``0`` again, and ``tcp``/``udp``/``sctp``/``dccp`` continue from it -via plain ``auto()`` with no ``_start_`` needed at all -- ``auto()`` picks up -the next value after whatever came immediately before it, explicit literal or -not. #770's own account above is therefore accurate again as shipped, and -``undefined`` is once more the one member among the five whose wrapper is -load-bearing against a mypy error, exactly as it always was -- see +``Any``. That makes ``TransportProtocol.undefined`` disagree with its own +``'TransportProtocol'`` annotation at the sites that default to it -- the class +attribute ``__transport__``, and the ``proto`` parameter on ``__new__``, +``get`` and ``get_all``. The template wraps the literal in :func:`~typing.cast` +so mypy infers ``TransportProtocol`` there too, without changing what runs: +:func:`~typing.cast` is the identity function at run time, so +``TransportProtocol.undefined`` is the same ``0`` -- ``TransportProtocol(0) is +undefined`` and ``bool(undefined)`` is ``False`` -- and a genuine +``TransportProtocol`` member: the ``proto`` sentinel default at each of those +sites, and what the base registry's ``_missing_`` extends unassigned/reserved +rows from. Two rejected alternatives, measured rather than argued: an explicit +member annotation (``undefined: 'TransportProtocol' = 0``) does not fix this at +all, it only *relocates* the error -- mypy still reports one ``[assignment]`` at +the member's own declaration line, for 113 errors in 39 files rather than 116, +so ``cast`` is preferred 0/38 against 1/39, not on parity. ``auto() & 0`` is +run-time identical to the bare literal -- value ``0``, no burned counter slot, +``tcp``/``udp``/``sctp``/``dccp`` unaffected -- and is rejected only because it +leans on :mod:`aenum`'s undocumented internal order of operations for resolving +a composed ``auto()``, not because it misbehaves. + +``undefined`` takes ``0`` directly and the other real transports use +``auto()``, so that no ``_start_`` has to be defined and the declaration of +``undefined`` is explicit (the owner's ruling on GitHub pull request #874); +``auto()`` picks up the next value after whatever came immediately before it, +explicit literal or not. That makes ``undefined`` the one member among the five +whose wrapper is load-bearing against a mypy error -- see :meth:`AppTypeGeneratorShapeTests. test_undefined_member_is_cast_rather_than_a_bare_literal`'s own docstring -for the measurements.) +for the measurements. #744 and #768 are pinned two ways: directly against the generator's own :meth:`~pcapkit.vendor.reg.apptype.apptype.AppType.flag`, which needs no network access since it is a ``@staticmethod``, and against the files it actually produced, committed under :mod:`pcapkit.const.reg.apptype`. #770 is pinned against that same base module -- a plain source-text assertion that the -member stays wrapped in ``cast`` rather than reverting to a bare literal, plus -a direct run through mypy's own API when :mod:`mypy` is importable, since the +member stays wrapped in ``cast`` rather than being a bare literal, plus a +direct run through mypy's own API when :mod:`mypy` is importable, since the source-text shape alone cannot tell a correct fix from a differently-worded one that stops mypy agreeing. That second pin is gated the way :mod:`tests._dependency_gates` can actually see: a module-level ``HAS_MYPY`` flag and an ``@unittest.skipUnless`` -decorator. It was written as an inline -``try``/``except ImportError: self.skipTest(...)`` instead when #770 landed, -for an obstacle that was real at the time -- ``mypy`` had no -:data:`~tests._dependency_gates.MODULE_PROVIDERS` entry, so adding the -decorator without one made -:class:`~tests.test_tier_guard.DependencyGateCoverageTests` report 2 failures -and 5 errors of its 10 tests, every one of those errors a bare -``KeyError: 'mypy'`` out of -:func:`~tests._dependency_gates.extras_providing` -- re-measured on this -change's own base; #779 reports 4 errors of 9 tests because it was written -before #774 added the tenth. #779 closed that end: -``mypy`` is mapped now and its gap is recorded as a deliberate exclusion, so -the gate can take the visible shape. The inline form worked, but was invisible -to the guard *by construction* -- -:func:`~tests._dependency_gates._gates_of` walks a definition's decorator list -and never its body -- which is the "dark test" hazard #745 exists to stop, and -which #766 carries for the sibling isort check. - -What the gate is worth has not changed. mypy is a :file:`Pipfile` -``[dev-packages]`` entry and is in no :file:`pyproject.toml` extra, -so no ``pytest`` job in :file:`.github/workflows/unit-tests.yml` -- every one -installs ``.[test,...]`` -- ever has it importable, and this test skips on all -of them; it runs where mypy is installed, which is a developer checkout and -``make mypy``'s own environment. The difference is that the skip is now -counted: it is a :class:`~tests._dependency_gates.Gap` on each job that -reaches it, and +decorator, with ``mypy`` mapped in +:data:`~tests._dependency_gates.MODULE_PROVIDERS` and its gap recorded as a +deliberate exclusion (#779). An inline ``try``/``except ImportError: +self.skipTest(...)`` would work, but is invisible to the guard *by +construction* -- :func:`~tests._dependency_gates._gates_of` walks a +definition's decorator list and never its body -- which is the "dark test" +hazard #745 exists to stop, and which #766 carries for the sibling isort check. + +What the gate is worth: mypy is a :file:`Pipfile` ``[dev-packages]`` entry and +is in no :file:`pyproject.toml` extra, so no ``pytest`` job in +:file:`.github/workflows/unit-tests.yml` -- every one installs +``.[test,...]`` -- ever has it importable, and this test skips on all of them; +it runs where mypy is installed, which is a developer checkout and ``make +mypy``'s own environment. The skip is counted: it is a +:class:`~tests._dependency_gates.Gap` on each job that reaches it, and :data:`~tests._dependency_gates.DEPENDENCY_GATE_EXCLUSIONS`\\ ``['HAS_MYPY']`` carries the reason for declining to close it -- a type checker belongs to the lint tier, not to a pytest install line. @@ -133,7 +108,7 @@ #: ``skipUnless`` flag rather than an inline ``skipTest``, so #: :func:`~tests._dependency_gates.gated_scopes` counts the gate and #: :mod:`tests.test_tier_guard` can audit which CI jobs it darkens (#779); see -#: this module's own docstring for what that replaced and why. +#: this module's own docstring for why. HAS_MYPY = importlib.util.find_spec('mypy') is not None @@ -168,7 +143,7 @@ def test_flag_emits_attribute_access_not_get_calls(self) -> None: def test_generated_registries_carry_no_per_member_annotation(self) -> None: """GitHub issue #744, against what the committed files produced. - Before the fix, ``TCP.__annotations__`` alone held an entry for every + Guards against ``TCP.__annotations__`` holding an entry for every member on top of its class-level attributes. Only the class-level attributes each registry declares exactly once should remain: ``__transport__``, ``__registry__``, ``__canonical__``, and @@ -217,38 +192,19 @@ def test_undefined_member_is_cast_rather_than_a_bare_literal(self) -> None: ``undefined`` is the one bare int literal among five members whose remaining four (``tcp``, ``udp``, ``sctp``, ``dccp``) are - ``auto()``-valued, which is exactly the asymmetry #770 fixed -- + ``auto()``-valued, which is exactly the asymmetry #770 is about -- mypy infers the literal as ``int`` and the ``auto()`` siblings as ``Any``, so wrapping the literal in :func:`~typing.cast` is what makes all five agree on ``TransportProtocol``. - That shape briefly changed and changed back during GitHub issue - #860 step 2 PR 2's own development: an intermediate revision moved - ``undefined`` onto ``auto()`` too, with an explicit ``_start_ = 0`` - to keep every member's run-time value the same, which made #770's - literal-vs-``auto()`` asymmetry disappear entirely -- with no bare - literal left anywhere, no member's wrapper was load-bearing against - a mypy error any more (measured at the time: unwrapping ``undefined`` - alone stayed mypy-clean, identically to unwrapping ``tcp`` instead). - The owner's final ruling, given on GitHub pull request #874, - reinstated the original shape: ``undefined`` takes ``0`` directly, and - the other real transports use ``auto()``, so that no ``_start_`` has to - be defined and the declaration of ``undefined`` is explicit. So - ``undefined`` is a direct, explicit ``cast('TransportProtocol', 0)`` - again, exactly as #770 first shaped it, and this test's own check (and - #770's asymmetry) are both back to describing the tree as it actually - ships. - Measured directly, to prove the load-bearing claim rather than assert it: with ``undefined`` unwrapped to a bare ``0`` (everything else unchanged), the mypy run :meth:`test_undefined_member_infers_as_transport_protocol_under_mypy` - below performs now reports real errors again -- see that method's - own docstring for the exact count -- where it did not while - ``undefined`` was still ``auto()``-valued. Unwrapping ``tcp`` - instead, leaving ``undefined`` wrapped, still leaves that run - clean, since ``tcp`` stays ``auto()``-valued and so still infers as - ``Any`` either way. + below performs reports real errors -- see that method's own + docstring for the exact count. Unwrapping ``tcp`` instead, leaving + ``undefined`` wrapped, leaves that run clean, since ``tcp`` stays + ``auto()``-valued and so infers as ``Any`` either way. """ import inspect import re @@ -277,11 +233,10 @@ def test_undefined_member_is_cast_rather_than_a_bare_literal(self) -> None: # unwrapped ``undefined = 0`` sits somewhere else in the source -- # a leftover from a bad edit, e.g. -- which a plain substring test # has no way to notice since the string it looks for is still - # there. ``undefined = auto()`` bare is checked too, against the - # one intermediate shape this issue's own development already - # tried and the owner's ruling retired -- a regression back to it - # would otherwise pass unnoticed, since it changes no run-time - # value at all. + # there. ``undefined = auto()`` bare is checked too: the owner's + # ruling on GitHub pull request #874 rules that shape out, and a + # regression to it would otherwise pass unnoticed, since it changes + # no run-time value at all. for pattern in (r'\n[ \t]*undefined = 0[ \t]*\n', r'\n[ \t]*undefined = auto\(\)[ \t]*\n'): match = re.search(pattern, source) @@ -301,36 +256,23 @@ def test_undefined_member_infers_as_transport_protocol_under_mypy(self) -> None: directory, exactly as ``make mypy`` does, so the effective config is stricter than these four flags alone would suggest. - Measured directly on this tree, rather than read off #770's own - issue text (this module's own account above states 4 errors as - #770's original repro; neither of us has independently confirmed - that against the issue itself, so it is stated here as this - module's claim, not a re-verified one): stripping just ``undefined``'s - ``cast`` wrapper -- ``undefined = 0``, every other member and the - rest of the source unchanged -- gives 3 ``[assignment]`` errors on - *this* tree -- ``__transport__``, ``__new__``'s ``proto`` default, - and :meth:`~pcapkit.const.reg.apptype.apptype. - AppType._unregistered_member`'s own ``proto`` default, new since - this issue (#860) and still plainly ``TransportProtocol``-typed -- - plus 762 ``[arg-type]`` errors, one per ``_missing_`` branch that - passes ``TransportProtocol.undefined`` where it is now typed as a - bare ``int``, for 765 total. Stripping ``tcp``'s wrapper instead, - leaving ``undefined`` wrapped, stays clean -- ``tcp`` is still - ``auto()``-valued, which infers as ``Any`` regardless of the - wrapper (see :meth:`test_undefined_member_is_cast_rather_than_a_ - bare_literal`'s own docstring for that measurement). If the module - docstring's 4 is right about #770's own time, the count of 3 moved - there in two separate steps since: GitHub PR #836 (the same one - that dropped the ``IntFlag`` base) had already annotated ``get``'s - and ``get_all``'s own ``proto`` parameters ``'TransportProtocol | - str | int'`` -- a union a bare ``int`` default satisfies without - error -- which would have taken two sites out of play before this - issue touched anything; this issue (#860) then added a third, new - site with the plain ``TransportProtocol`` annotation the two - retired ones used to carry, ``_unregistered_member``'s own - default, landing the count measured here at 3. None of that is - exercised by this test, which only ever runs mypy against the - *fixed* source and requires zero errors of any code. + Measured directly: stripping just ``undefined``'s ``cast`` wrapper -- + ``undefined = 0``, every other member and the rest of the source + unchanged -- gives three ``[assignment]`` errors -- ``__transport__``, + ``__new__``'s ``proto`` default, and + :meth:`~pcapkit.const.reg.apptype.apptype.AppType._unregistered_member`'s + own ``proto`` default (#860), plainly ``TransportProtocol``-typed -- + plus one ``[arg-type]`` error per ``_missing_`` branch that passes + ``TransportProtocol.undefined`` where it is then typed as a bare + ``int``. ``get``'s and ``get_all``'s own ``proto`` parameters are + annotated ``'TransportProtocol | str | int'`` (GitHub PR #836) -- a + union a bare ``int`` default satisfies without error -- so they are + not among the three. Stripping ``tcp``'s wrapper instead, leaving + ``undefined`` wrapped, stays clean -- ``tcp`` is ``auto()``-valued, + which infers as ``Any`` regardless of the wrapper (see + :meth:`test_undefined_member_is_cast_rather_than_a_bare_literal`'s + own docstring for that measurement). This test itself only ever runs + mypy against the shipped source and requires zero errors of any code. This is not in :file:`.github/workflows/unit-tests.yml`'s reach -- mypy is a :file:`Pipfile` ``[dev-packages]`` entry, not a @@ -370,16 +312,14 @@ def test_transport_protocol_undefined_still_zero(self) -> None: is undefined`` and ``bool(undefined)`` is ``False`` -- rather than merely being *typed* as one. - Before GitHub issue #808 dropped the ``IntFlag`` base, this also - asserted ``TransportProtocol.tcp | TransportProtocol.undefined is - TransportProtocol.tcp`` -- ``|``'s neutral element composing back to the - same singleton. That assertion is gone rather than adapted: ``|`` on a - plain :class:`~aenum.IntEnum` falls through to ``int.__or__`` and - returns a bare :class:`int`, never a ``TransportProtocol`` -- not even - one identical to an existing member -- so there is no longer a - singleton for ``is`` to find on *either* side of ``|``, and pinning - ``int(tcp | undefined) == int(tcp)`` would only be pinning integer - arithmetic. + ``TransportProtocol.tcp | TransportProtocol.undefined is + TransportProtocol.tcp`` is not asserted: with no ``IntFlag`` base + (GitHub issue #808), ``|`` on a plain :class:`~aenum.IntEnum` falls + through to ``int.__or__`` and returns a bare :class:`int`, never a + ``TransportProtocol`` -- not even one identical to an existing member + -- so there is no singleton for ``is`` to find on *either* side of + ``|``, and pinning ``int(tcp | undefined) == int(tcp)`` would only be + pinning integer arithmetic. """ from pcapkit.const.reg.apptype.apptype import TransportProtocol diff --git a/tests/vendor/test_vendor_snapshot_restore_unit.py b/tests/vendor/test_vendor_snapshot_restore_unit.py index f8d6ed467e..140a2c9fb4 100644 --- a/tests/vendor/test_vendor_snapshot_restore_unit.py +++ b/tests/vendor/test_vendor_snapshot_restore_unit.py @@ -1,44 +1,35 @@ # -*- coding: utf-8 -*- """``run()`` must restore a target's const file when the target's run fails. -GitHub issue #872. Rounds 4-8 made -:meth:`~pcapkit.vendor.default.Vendor.__init__`'s own write atomic -- a -temp-file-then-:func:`os.replace` at the point of the write, with permission -matching for the replacement. The owner's ruling on GitHub pull request #873, -answering two inline questions: he preferred :mod:`contextlib` to managing the -temp file's deletion by hand on the strength of best intent alone (a -try-finally), and for atomic writing he saw an easier path in simply keeping a -copy of the file before running the sub-vendor and reverting if anything -failed. - -That supersedes the atomic write entirely rather than adjusting it. -``Vendor._write_atomic`` no longer exists; -:meth:`Vendor.__init__`'s last line is once again a plain:: +GitHub issue #872. The protection lives in +:func:`pcapkit.vendor.__main__._snapshot_and_restore`, a +:func:`contextlib.contextmanager` that :func:`~pcapkit.vendor.__main__.run` +wraps every ``vendor()`` call in, rather than in an atomic write inside +:meth:`~pcapkit.vendor.default.Vendor.__init__`. That follows the owner's +ruling on GitHub pull request #873: keeping a copy of the file before running +the sub-vendor and reverting if anything fails is the easier path, with +:mod:`contextlib` managing the cleanup rather than a hand-written try-finally. +:meth:`Vendor.__init__`'s last line is a plain:: with open(const_file, 'w') as file: print(context, file=file) -The protection moved up a level, to -:func:`pcapkit.vendor.__main__._snapshot_and_restore`, a -:func:`contextlib.contextmanager` that :func:`~pcapkit.vendor.__main__.run` -wraps every ``vendor()`` call in. It copies the target's existing const file -aside *before* the crawler is instantiated at all -- resolving the -destination via :meth:`~pcapkit.vendor.default.Vendor._dest_path`, a -classmethod for exactly this reason, called on the crawler *class* rather -than an instance -- and restores that copy if the crawler raises for any -reason. This is a **wider** guarantee than an atomic write at one call site: -it covers any way a crawler's own code could end up touching its const -file, not only the single ``open``/``print`` pair the base class happens to -use, and it puts the discarding exactly at the per-target boundary the -owner's earlier ruling (b) asked for. - -Because the protection now lives in ``run()`` rather than inside -:meth:`Vendor.__init__`, this suite calls -:func:`pcapkit.vendor.__main__.run` -- not the crawler class directly, the -way rounds 4-8's version of this file did -- exactly as -:file:`tests/vendor/test_vendor_exit_code_unit.py` already does. Calling the -crawler class directly would exercise :meth:`Vendor.__init__` in isolation, -which is no longer where the guarantee lives. +``_snapshot_and_restore`` copies the target's existing const file aside +*before* the crawler is instantiated at all -- resolving the destination via +:meth:`~pcapkit.vendor.default.Vendor._dest_path`, a classmethod for exactly +this reason, called on the crawler *class* rather than an instance -- and +restores that copy if the crawler raises for any reason. This is a **wider** +guarantee than an atomic write at one call site: it covers any way a crawler's +own code could end up touching its const file, not only the single +``open``/``print`` pair the base class happens to use, and it puts the +discarding exactly at the per-target boundary. + +Because the protection lives in ``run()`` rather than inside +:meth:`Vendor.__init__`, this suite calls :func:`pcapkit.vendor.__main__.run` +-- not the crawler class directly -- exactly as +:file:`tests/vendor/test_vendor_exit_code_unit.py` does. Calling the crawler +class directly would exercise :meth:`Vendor.__init__` in isolation, which is +not where the guarantee lives. This suite makes no network call: :class:`~pcapkit.vendor.default.Vendor`'s ``_request``, ``count``, ``context`` and ``_dest_path`` are all overridden by @@ -52,37 +43,23 @@ "skip the snapshot", silently exercising the unprotected fallback instead of what these tests are actually about. -The three permission-matching methods rounds 6-8 added -(``test_existing_permissions_survive_a_successful_write``, -``test_a_read_only_destination_is_regenerated_and_stays_read_only``, -``test_a_new_destination_gets_umask_masked_permissions``) are gone with the -code they pinned: ``_write_atomic``'s mode-matching had three genuinely -different branches (existing mode preserved via :func:`os.stat`, the -``0o444``-succeeds-anyway case, and a fresh-file umask fallback), each -worth its own test. Snapshot-and-restore has no equivalent branching to -pin -- a restored file is simply the exact bytes and mode -:func:`shutil.copy2` copied out, once, for every case alike -- so keeping -three separate permutations would pin nothing that one does not already -cover, and I chose to delete them rather than repoint them at a property -that no longer varies by case. +Snapshot-and-restore has no per-mode branching to pin -- a restored file is +simply the exact bytes and mode :func:`shutil.copy2` copied out, once, for every +case alike -- so one mode assertion covers what separate permutations would. :meth:`SnapshotRestoreTests.test_successful_run_replaces_the_previous_file_content` -is the happy-path round trip. Run against ``origin/main`` (pre-#872, no -exit-code accounting at all), it fails too -- -``self.assertTrue(self.vendor_main.run(...))`` sees :data:`None`, since -pre-fix ``run()`` never returns anything -- so this is a second method the -fix changes, not only the one below. - -What survives, in the new shape, is the invariant the original pin actually -encoded: a failure during or around a target's regeneration leaves the -previous const file byte-for-byte intact. That is +is the happy-path round trip. It also pins that ``run()`` returns :data:`True` +on success, which a ``run()`` with no exit-code accounting fails. + +The invariant pinned is that a failure during or around a target's +regeneration leaves the previous const file byte-for-byte intact. That is :meth:`SnapshotRestoreTests.test_a_failure_leaves_the_previous_file_byte_for_byte_intact` -below, still simulated by making :func:`print` raise once the destination is -open for writing -- :meth:`Vendor.__init__` still opens ``const_file`` -directly (there is no temp file to redirect the write onto any more), so -this again truncates the previous content before the injected failure -fires; :func:`~pcapkit.vendor.__main__._snapshot_and_restore` is what -restores it afterwards. It asserts on the *reported* error +below, simulated by making :func:`print` raise once the destination is open +for writing -- :meth:`Vendor.__init__` opens ``const_file`` directly (there is +no temp file to redirect the write onto), so this truncates the previous +content before the injected failure fires; +:func:`~pcapkit.vendor.__main__._snapshot_and_restore` is what restores it +afterwards. It asserts on the *reported* error (:meth:`~unittest.TestCase.assertWarnsRegex` against the crawler's own ``RuntimeError`` text) rather than merely on ``run()`` returning falsy, deliberately: a mutant that deletes the ``raise`` from @@ -96,16 +73,12 @@ ``assertFalse(run(...))`` cannot catch a return value that flips to *true*; only asserting that the crawler's own error was actually reported can. It also asserts the destination's mode (``0o640`` in, ``0o640`` out) alongside -its content, which is the measurement that actually justifies collapsing -rounds 6-8's three separate mode-permutation tests into one: restore -preserves mode as a side effect of :func:`shutil.copy2` plus a rename, for -every case alike, rather than needing a case-by-case check. Run against -both of :mod:`pcapkit.vendor.__main__` and :mod:`pcapkit.vendor.default` -taken from ``origin/main`` (swapping only one of the two leaves the other's -fix in place and proves nothing -- see GitHub issue #872's own round 8), -this fails on the content assertion with ``'' != 'GOOD CONTENT\\n'``: the -previous content is destroyed before ``run()`` (pre-#872) ever gets a -chance to report anything. +its content, which is the measurement that justifies a single mode +assertion: restore preserves mode as a side effect of :func:`shutil.copy2` +plus a rename, for every case alike, rather than needing a case-by-case +check. Without the protection this fails on the content assertion with +``'' != 'GOOD CONTENT\\n'``: the previous content is destroyed before +``run()`` ever gets a chance to report anything. :meth:`SnapshotRestoreTests.test_a_keyboard_interrupt_still_restores_and_propagates` pins ``except BaseException:`` itself, rather than the narrower @@ -125,38 +98,31 @@ exactly the defect this pins. :meth:`SnapshotRestoreTests.test_a_read_only_destination_is_left_exactly_as_it_was` -re-measures the ``0o444`` case under the new design rather than assuming it -still behaves as rounds 6-8 documented. It does not: ``os.replace()`` -(round 4-8's last step) needed write permission on the *directory*, not the -target file, so a ``0o444`` destination was still successfully regenerated. -A plain ``open(const_file, 'w')`` (this round's design) needs write -permission on the file itself, so a ``0o444`` destination now fails to -regenerate exactly as it did on `main`, before #872 -- a capability the -atomic-write approach had and this one has deliberately traded away for -simplicity, per the owner's ruling. What the new design still guarantees is -that the failure leaves the file exactly as it was, mode included, which is -what this method actually pins. - -Run against ``origin/main``, this method passes -- it is a measurement, not -a regression pin. `main`'s plain ``open(const_file, 'w')`` already raised -:exc:`PermissionError` on a ``0o444`` destination *before* truncating -anything (the permission check happens before the write, not after), so the -file was already left untouched on the pre-#872 tree for this one specific -scenario; there is nothing here for the fix to have changed. +measures the ``0o444`` case. A plain ``open(const_file, 'w')`` needs write +permission on the file itself, so a ``0o444`` destination fails to +regenerate -- a capability an :func:`os.replace`-based atomic write, which +needs write permission on the *directory* instead, would have, and which +this design deliberately trades away for simplicity, per the owner's +ruling. What the design guarantees is that the failure leaves the file +exactly as it was, mode included, which is what this method pins. It is a +measurement, not a regression pin: ``open()`` raises :exc:`PermissionError` +on a ``0o444`` destination *before* truncating anything (the permission +check happens before the write, not after), so the file is left untouched +with or without the snapshot. The ``0o444`` file is one traded capability; there is a second, in the opposite direction, not covered by any method here. A writable file inside -a *non-writable directory* also regenerated on `main` -- opening an -*existing* file for writing needs write permission on the file, not on its -parent directory -- but fails under this design, because -:func:`~pcapkit.vendor.__main__._snapshot_and_restore`'s +a *non-writable directory* regenerates under a plain ``open`` with no +snapshot -- opening an *existing* file for writing needs write permission on +the file, not on its parent directory -- but fails under this design, +because :func:`~pcapkit.vendor.__main__._snapshot_and_restore`'s :func:`tempfile.mkstemp` call needs write permission on the directory to create the backup, and :meth:`Vendor.__init__`'s own ``open(const_file, -'w')`` never needed that. Measured: ``run()`` returns :data:`False` with a +'w')`` does not. Measured: ``run()`` returns :data:`False` with a :exc:`PermissionError`, and the previous content survives untouched because -``vendor()`` is never even called. This is not new relative to rounds 4-8 -(``os.replace()`` needed directory write too), only relative to `main`, so -#873 discloses two traded capabilities rather than one. See +``vendor()`` is never even called. An :func:`os.replace`-based write needs +directory write too, so #873 discloses two traded capabilities rather than +one. See :func:`~pcapkit.vendor.__main__._snapshot_and_restore`'s own docstring for the asymmetry between this and the ``0o444`` case: a ``_dest_path`` resolution failure *skips* the snapshot and lets the target proceed @@ -335,10 +301,10 @@ def _raise_on_render(*args: object, **kwargs: object) -> None: 'a failure during a target\'s run must leave the previous const ' 'file byte-for-byte untouched, not truncated or partially ' 'overwritten') - # The mode check, not just content: this is what actually justifies - # collapsing rounds 6-8's three separate mode-permutation tests into - # one -- restore preserves mode as a side effect of shutil.copy2 - # plus a rename, uniformly, rather than needing a case-by-case check. + # The mode check, not just content: this is what justifies a single + # mode assertion rather than separate mode-permutation tests -- + # restore preserves mode as a side effect of shutil.copy2 plus a + # rename, uniformly, rather than needing a case-by-case check. # Windows chmod sets only the read-only bit, so 0o640 reads back as 0o666 (#2102). if os.name != 'nt': mode = stat.S_IMODE(os.stat(const_file).st_mode) @@ -392,13 +358,13 @@ def test_a_read_only_destination_is_left_exactly_as_it_was(self) -> None: stub_crawler = self._make_crawler(const_file, 'NEW CONTENT') - # Measured, not assumed (see the module docstring): unlike round - # 4-8's os.replace()-based design, a plain open(const_file, 'w') - # needs write permission on the file itself, so this still fails -- - # run() must still report that failure. + # Measured, not assumed (see the module docstring): unlike an + # os.replace()-based design, a plain open(const_file, 'w') needs + # write permission on the file itself, so this fails -- run() must + # report that failure. self.assertFalse(self.vendor_main.run(stub_crawler)) - # What the new design does guarantee: the failure leaves the file + # What the design does guarantee: the failure leaves the file # exactly as it was, content and mode both, even though nothing was # ever actually rewritten (open() raised before truncating anything). with open(const_file, encoding='utf-8') as file: diff --git a/tests/vendor/test_vendor_variables_1726_unit.py b/tests/vendor/test_vendor_variables_1726_unit.py index 4bcb1f0774..bf3cc7455c 100644 --- a/tests/vendor/test_vendor_variables_1726_unit.py +++ b/tests/vendor/test_vendor_variables_1726_unit.py @@ -103,9 +103,9 @@ def test_max_retry_defaults_to_five(self) -> None: def test_max_retry_takes_one_and_refuses_anything_below(self) -> None: self.assertEqual(self.load('MAX_RETRY', '1'), (1, [])) - # 0 was read as 1 before #1726, and -1 is no limit only where a limit - # has a risk to name; here both are refused, as is a non-integer, which - # crashed the import. + # 0 must not be read as 1 (#1726), and -1 is no limit only where a + # limit has a risk to name; here both are refused, as is a + # non-integer, which must not crash the import. for text in ('0', '-1', 'lots'): with self.subTest(text=text): value, messages = self.load('MAX_RETRY', text) diff --git a/tests/vendor/test_vendor_wrap_comment_unit.py b/tests/vendor/test_vendor_wrap_comment_unit.py index 1a1c0c7d8f..95d294b987 100644 --- a/tests/vendor/test_vendor_wrap_comment_unit.py +++ b/tests/vendor/test_vendor_wrap_comment_unit.py @@ -5,17 +5,17 @@ with its continuation lines joined by a space, and an AppType ``list-table`` cell the same way. -* GitHub issue #1846: ``[http://ele2.io][Damir Franusic]`` autolinked - ``http://ele2.io][Damir``, since docutils reads ``]`` and ``[`` as URL - characters, and ``(S) flag`` rendered as an enumerated list. - :meth:`~pcapkit.vendor.default.Vendor.wrap_comment` now spaces a URL reference +* GitHub issue #1846: guards against ``[http://ele2.io][Damir Franusic]`` + autolinking ``http://ele2.io][Damir``, since docutils reads ``]`` and ``[`` as + URL characters, and against ``(S) flag`` rendering as an enumerated list. + :meth:`~pcapkit.vendor.default.Vendor.wrap_comment` spaces a URL reference off the bracket after it and backslash-escapes a leading enumerator. -* GitHub issue #1847: :mod:`textwrap` broke lines at hyphens, so the docs read - ``run- time``, and a URL split that way in a cell lost the rest of its - autolink. Neither wrapper breaks at a hyphen now. +* GitHub issue #1847: guards against :mod:`textwrap` breaking lines at + hyphens, under which the docs read ``run- time`` and a URL split that way in + a cell loses the rest of its autolink. Neither wrapper breaks at a hyphen. -The committed :mod:`pcapkit.const` files were re-rendered offline with the -patched wrappers, so the last cases check the tree as well as the generators. +The committed :mod:`pcapkit.const` files are rendered by the same wrappers, so +the last cases check the tree as well as the generators. Nothing here makes a network call. """ @@ -115,7 +115,7 @@ def test_leading_enumerator_is_escaped(self) -> None: def test_no_line_is_broken_at_a_hyphen(self) -> None: from pcapkit.vendor.default import Vendor - # The width is unchanged, and the old wrap did split this one there. + # At the same width, a plain textwrap.wrap splits this one there. self.assertIn('[draft-ietf-httpbis-resumable-', textwrap.wrap(RESUMABLE, 76)[1]) lines = _lines(Vendor.wrap_comment(RESUMABLE))