diff --git a/docs/source/pcapkit/protocols/application/http.rst b/docs/source/pcapkit/protocols/application/http.rst index 4096b65386..f1769e34a5 100644 --- a/docs/source/pcapkit/protocols/application/http.rst +++ b/docs/source/pcapkit/protocols/application/http.rst @@ -4,11 +4,13 @@ HTTP - Hypertext Transfer Protocol .. module:: pcapkit.protocols.application.http :mod:`pcapkit.protocols.application.http` contains -:class:`~pcapkit.protocols.application.http.HTTP` -only, which is a base class for Hypertext Transfer +:class:`~pcapkit.protocols.application.http.HTTP`, +which is a base class for Hypertext Transfer Protocol (HTTP) [*]_ family, eg. :class:`HTTP/1.* ` -and :class:`HTTP/2 `. +and :class:`HTTP/2 `, +and :func:`~pcapkit.protocols.application.http.test_start_line`, +which tells whether a payload opens with an HTTP/1.* start line. .. autoclass:: pcapkit.protocols.application.http.HTTP :no-members: @@ -30,6 +32,8 @@ and :class:`HTTP/2 `. .. autoattribute:: _preface_length +.. autofunction:: pcapkit.protocols.application.http.test_start_line + .. autodata:: pcapkit.protocols.application.http._HTTP2_PREFACE .. rubric:: Footnotes diff --git a/docs/source/pcapkit/protocols/application/httpv1.rst b/docs/source/pcapkit/protocols/application/httpv1.rst index 899ad947da..b97f1c3193 100644 --- a/docs/source/pcapkit/protocols/application/httpv1.rst +++ b/docs/source/pcapkit/protocols/application/httpv1.rst @@ -40,8 +40,6 @@ as below: .. automethod:: _read_http_header .. automethod:: _read_http_body -.. autofunction:: pcapkit.protocols.application.httpv1._test_start_line - Auxiliary Data -------------- diff --git a/pcapkit/protocols/application/http.py b/pcapkit/protocols/application/http.py index 10f4c5685f..f0adf15d40 100644 --- a/pcapkit/protocols/application/http.py +++ b/pcapkit/protocols/application/http.py @@ -5,16 +5,19 @@ .. module:: pcapkit.protocols.application.http :mod:`pcapkit.protocols.application.http` contains -:class:`~pcapkit.protocols.application.http.HTTP` -only, which is a base class for Hypertext Transfer +:class:`~pcapkit.protocols.application.http.HTTP`, +which is a base class for Hypertext Transfer Protocol (HTTP) [*]_ family, eg. :class:`HTTP/1.* ` -and :class:`HTTP/2 `. +and :class:`HTTP/2 `, +and :func:`~pcapkit.protocols.application.http.test_start_line`, +which tells whether a payload opens with an HTTP/1.* start line. .. [*] https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol """ import contextlib +import re import struct from typing import TYPE_CHECKING, Generic, cast @@ -30,7 +33,7 @@ from typing_extensions import Literal, Self -__all__ = ['HTTP'] +__all__ = ['HTTP', 'test_start_line'] #: The HTTP/2 connection preface (:rfc:`9113#section-3.4`). A client opens every #: HTTP/2 connection -- with prior knowledge, over TLS, or after an upgrade -- @@ -102,6 +105,99 @@ def _test_http2_frame(data: 'bytes') -> 'bool': return any(data[5:9]) is stream +# Regular expression to match HTTP methods. Anchored at both ends: :func:`re.match` +# anchors only at the start, so an unanchored pattern prefix-matches and accepts the +# leading ``G`` of ``Get`` as a whole method token. Method tokens are case-sensitive +# per :rfc:`9110#section-9.1`, so ``Get`` is not ``GET`` and must not parse as one. +_RE_METHOD = re.compile(rb"(?P[A-Z][A-Z-]*)\Z") # RFC 9110, section 16.1.1, 9.1, 5.6.2 +# Regular expression to match HTTP version string. +_RE_VERSION = re.compile(rb"HTTP/(?P\d\.\d)") +# Regular expression to match HTTP status code. Anchored for the same reason as +# ``_RE_METHOD``, and it matters more here: this pattern is only a guard, and +# ``httpv1.HTTP`` takes the value from ``int(para2)`` on the raw token, so an +# unanchored prefix match would let ``200x`` and ``2000`` 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 -- the +# grammar is in HTTP/1.1 because ``status-code`` is part of its ``status-line`` +# production; :rfc:`9110#section-15` covers the code semantics and registry, not +# the syntax. +_RE_STATUS = re.compile(rb'\d{3}\Z') + +# The HTTP/2 connection preface's header block, as ``httpv1.HTTP.read`` splits it +# off at the first CRLFCRLF: ``PRI * HTTP/2.0``. A start line with zero field +# lines, so the patterns above accept it; it is refused by name in +# ``httpv1.HTTP._read_http_header`` and :func:`test_start_line` so that the +# preface never parses as HTTP/1. +_HTTP2_PREFACE_HEADER = _HTTP2_PREFACE.split(b'\r\n\r\n', maxsplit=1)[0] + + +def test_start_line(data: 'bytes') -> 'bool': + """Whether ``data`` opens with an HTTP/1.* start line. + + This is a *classification* predicate and parses nothing: it answers "is this + HTTP/1?" for :meth:`HTTP._guess_version + `. Classifying by + trial-parsing every version in the family would decide a payload that is + not HTTP at all by which parser happened to fail less loudly. + + Args: + data: Payload to classify. + + Returns: + Whether the payload's first line is a ``request-line`` or a + ``status-line`` (:rfc:`9112#section-2.1`). + + Note: + The acceptance rule is deliberately the *same* one + :meth:`HTTP._read_http_header + ` applies + -- ``_RE_METHOD`` with ``_RE_VERSION`` for a request, ``_RE_VERSION`` + with ``_RE_STATUS`` for a response -- which is why those three patterns + are defined beside this predicate and + :mod:`~pcapkit.protocols.application.httpv1` imports them from here. The + two must accept the same start lines: a predicate looser than the parser + classifies payloads the parser then refuses, and one tighter than the + parser hands real HTTP/1 to a later arm. + ``test_start_line_predicate_agrees_with_the_httpv1_parser`` pins that + agreement. + + The checks the parser performs *before* those patterns are mirrored too, + and this is not pedantry -- they are the whole reason the HTTP/2 + connection preface is not claimed here. ``PRI * + HTTP/2.0\\r\\n\\r\\nSM\\r\\n\\r\\n`` is deliberately a well-formed + HTTP/1.1 *request line* (:rfc:`9113#section-3.4`), so a predicate that + tested only the first line would answer :data:`True` for it. Split at the + header/body separator first, as :meth:`HTTP.read + ` does, and the preface's + header is exactly ``PRI * HTTP/2.0`` (``_HTTP2_PREFACE_HEADER``), which + the parser refuses by name and so does this. Any *other* header with no + CRLF in it is a start line with zero field lines, which + :rfc:`9112#section-2.1` allows and both accept (:issue:`1041`). + + An HTTP/0.9 request line carries only two tokens and so is not recognised + here either, matching the parser, which raises on fewer than three. + + """ + header = data.split(b'\r\n\r\n', maxsplit=1)[0] + if header == data: # no header/body separator -- ``read`` raises + return False + + if header == _HTTP2_PREFACE_HEADER: # the preface -- ``_read_http_header`` raises + return False + + startline = header.split(b'\r\n', maxsplit=1)[0] + try: + para1, para2, para3 = re.split(rb'\s+', startline, maxsplit=2) + except ValueError: + return False + + return bool( + (re.match(_RE_METHOD, para1) and re.match(_RE_VERSION, para3)) # request-line + or (re.match(_RE_VERSION, para1) and re.match(_RE_STATUS, para2)) # status-line + ) + + class HTTP(Application[_PT, _ST], Generic[_PT, _ST]): """This class implements all protocols in HTTP family. @@ -473,14 +569,12 @@ def _guess_version(self, length: 'int', **kwargs: 'Any') -> 'HTTP': return http from pcapkit.protocols.application.httpv1 import HTTP as HTTPv1 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel - from pcapkit.protocols.application.httpv1 import \ - _test_start_line # isort: skip # pylint: disable=import-outside-toplevel # NOTE: The second identification: an HTTP/1 ``request-line`` or # ``status-line``, tested with the very patterns ``httpv1.HTTP`` parses - # with (``httpv1.py``'s ``_RE_METHOD``, ``_RE_VERSION`` and - # ``_RE_STATUS``, all anchored), so the predicate and the parser cannot - # disagree about what HTTP/1 looks like. + # with (``_RE_METHOD``, ``_RE_VERSION`` and ``_RE_STATUS`` above, all + # anchored, which ``httpv1.py`` imports from this module), so the + # predicate and the parser cannot disagree about what HTTP/1 looks like. # # Identified means *committed*: a malformed HTTP/1 message is reported as # such instead of being handed to the HTTP/2 arm, which accepts any @@ -494,7 +588,7 @@ def _guess_version(self, length: 'int', **kwargs: 'Any') -> 'HTTP': # pins that a :exc:`struct.error` -- or pcapkit's own ``StructError``, # whose ``eof`` flag ``NoPayload`` handling reads -- reaches the caller # from this route rather than being converted or swallowed. - if _test_start_line(self._data[:length]): + if test_start_line(self._data[:length]): return HTTPv1(self._data, length, **kwargs) # NOTE: Neither identification matched, so this is the fall-through: a diff --git a/pcapkit/protocols/application/httpv1.py b/pcapkit/protocols/application/httpv1.py index 45df8bf0c3..1eaf70c148 100644 --- a/pcapkit/protocols/application/httpv1.py +++ b/pcapkit/protocols/application/httpv1.py @@ -34,7 +34,8 @@ from pcapkit.const.http.status_code import StatusCode as Enum_StatusCode from pcapkit.corekit.enum import EnumLookup from pcapkit.corekit.multidict import OrderedMultiDict -from pcapkit.protocols.application.http import _HTTP2_PREFACE +from pcapkit.protocols.application.http import (_HTTP2_PREFACE_HEADER, _RE_METHOD, _RE_STATUS, + _RE_VERSION) from pcapkit.protocols.application.http import HTTP as HTTPBase from pcapkit.protocols.data.application.httpv1 import HTTP as Data_HTTP from pcapkit.protocols.data.application.httpv1 import RequestHeader as Data_RequestHeader @@ -56,95 +57,10 @@ __all__ = ['HTTP'] -# Regular expression to match HTTP methods. Anchored at both ends: :func:`re.match` -# anchors only at the start, so an unanchored pattern prefix-matches and accepts the -# leading ``G`` of ``Get`` as a whole method token. Method tokens are case-sensitive -# per :rfc:`9110#section-9.1`, so ``Get`` is not ``GET`` and must not parse as one. -_RE_METHOD = re.compile(rb"(?P[A-Z][A-Z-]*)\Z") # RFC 9110, section 16.1.1, 9.1, 5.6.2 -# Regular expression to match HTTP version string. -_RE_VERSION = re.compile(rb"HTTP/(?P\d\.\d)") -# Regular expression to match HTTP status code. Anchored for the same reason as -# ``_RE_METHOD``, and it matters more here: this pattern is only a guard, and the -# value is taken from ``int(para2)`` on the raw token, so an unanchored prefix -# match would let ``200x`` and ``2000`` 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 -- the -# grammar is in HTTP/1.1 because ``status-code`` is part of its ``status-line`` -# production; :rfc:`9110#section-15` covers the code semantics and registry, not -# the syntax. -_RE_STATUS = re.compile(rb'\d{3}\Z') - -# The HTTP/2 connection preface's header block, as ``HTTP.read`` splits it off at -# the first CRLFCRLF: ``PRI * HTTP/2.0``. A start line with zero field lines, so -# the patterns above accept it; it is refused by name in ``_read_http_header`` -# and ``_test_start_line`` so that the preface never parses as HTTP/1. -_HTTP2_PREFACE_HEADER = _HTTP2_PREFACE.split(b'\r\n\r\n', maxsplit=1)[0] - - -def _test_start_line(data: 'bytes') -> 'bool': - """Whether ``data`` opens with an HTTP/1.* start line. - - This is a *classification* predicate and parses nothing: it answers "is this - HTTP/1?" for :meth:`HTTP._guess_version - `. Classifying by - trial-parsing every version in the family would decide a payload that is - not HTTP at all by which parser happened to fail less loudly. - - Args: - data: Payload to classify. - - Returns: - Whether the payload's first line is a ``request-line`` or a - ``status-line`` (:rfc:`9112#section-2.1`). - - Note: - The acceptance rule is deliberately the *same* one - :meth:`HTTP._read_http_header - ` applies - further down this module -- ``_RE_METHOD`` with ``_RE_VERSION`` for a - request, ``_RE_VERSION`` with ``_RE_STATUS`` for a response -- which is - why this lives beside those three patterns rather than in the dispatcher - that calls it. The two must accept the same start lines: a predicate - looser than the parser classifies payloads the parser then refuses, and - one tighter than the parser hands real HTTP/1 to a later arm. - ``test_start_line_predicate_agrees_with_the_httpv1_parser`` pins that - agreement. - - The checks the parser performs *before* those patterns are mirrored too, - and this is not pedantry -- they are the whole reason the HTTP/2 - connection preface is not claimed here. ``PRI * - HTTP/2.0\\r\\n\\r\\nSM\\r\\n\\r\\n`` is deliberately a well-formed - HTTP/1.1 *request line* (:rfc:`9113#section-3.4`), so a predicate that - tested only the first line would answer :data:`True` for it. Split at the - header/body separator first, as :meth:`HTTP.read - ` does, and the preface's - header is exactly ``PRI * HTTP/2.0`` (``_HTTP2_PREFACE_HEADER``), which - the parser refuses by name and so does this. Any *other* header with no - CRLF in it is a start line with zero field lines, which - :rfc:`9112#section-2.1` allows and both accept (:issue:`1041`). - - An HTTP/0.9 request line carries only two tokens and so is not recognised - here either, matching the parser, which raises on fewer than three. - - """ - header = data.split(b'\r\n\r\n', maxsplit=1)[0] - if header == data: # no header/body separator -- ``read`` raises - return False - - if header == _HTTP2_PREFACE_HEADER: # the preface -- ``_read_http_header`` raises - return False - - startline = header.split(b'\r\n', maxsplit=1)[0] - try: - para1, para2, para3 = re.split(rb'\s+', startline, maxsplit=2) - except ValueError: - return False - - return bool( - (re.match(_RE_METHOD, para1) and re.match(_RE_VERSION, para3)) # request-line - or (re.match(_RE_VERSION, para1) and re.match(_RE_STATUS, para2)) # status-line - ) +# NOTE: The start-line patterns ``_RE_METHOD``, ``_RE_VERSION`` and ``_RE_STATUS``, +# and ``_HTTP2_PREFACE_HEADER``, are imported from ``http.py``, where +# :func:`~pcapkit.protocols.application.http.test_start_line` classifies with +# them; one copy keeps that predicate and this parser in agreement. class Type(EnumLookup, StrEnum): @@ -536,7 +452,8 @@ def _read_http_header(self, header: 'bytes') -> 'tuple[Data_Header, OrderedMulti # one such header refused by name is the HTTP/2 connection preface's: # ``PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n`` splits in ``read`` to exactly # ``PRI * HTTP/2.0``, which ``_RE_METHOD`` and ``_RE_VERSION`` would - # otherwise accept as an HTTP/1 request (see ``_HTTP2_PREFACE_HEADER``). + # otherwise accept as an HTTP/1 request (see ``_HTTP2_PREFACE_HEADER`` in + # ``http.py``). if header == _HTTP2_PREFACE_HEADER: raise ProtocolError('HTTP: invalid format') startline, _, headerfield = header.partition(b'\r\n') diff --git a/tests/const/test_const_method_value_lookup_908_unit.py b/tests/const/test_const_method_value_lookup_908_unit.py index 1b0d4dfd33..ab1a26a163 100644 --- a/tests/const/test_const_method_value_lookup_908_unit.py +++ b/tests/const/test_const_method_value_lookup_908_unit.py @@ -14,10 +14,11 @@ the constructor (:meth:`~pcapkit.const.http.method.Method._missing_`, which this override never touched). -**Why a capture reaches it.** ``pcapkit/protocols/application/httpv1.py``'s -``_RE_METHOD = re.compile(rb"(?P[A-Z][A-Z-]*)\\Z")`` admits a hyphen, -so a request line carrying ``BASELINE-CONTROL`` matches, reaches -``Enum_Method.get(...)`` at ``httpv1.py:434``, parses successfully, and is +**Why a capture reaches it.** ``pcapkit/protocols/application/http.py``'s +``_RE_METHOD = re.compile(rb"(?P[A-Z][A-Z-]*)\\Z")``, which +``httpv1.py`` imports, admits a hyphen, so a request line carrying +``BASELINE-CONTROL`` matches, reaches ``Enum_Method.get(...)`` in +``httpv1.HTTP._read_start_line``, parses successfully, and is reported with the wrong ``idempotent`` -- the harder kind of defect to notice, since the parse itself never fails. :class:`HTTPv1EndToEndTests` below pins that whole path, not just the direct @@ -191,7 +192,7 @@ class HTTPv1EndToEndTests(unittest.TestCase): def test_a_baseline_control_request_line_reports_the_registrys_idempotent_flag( self) -> None: """``_RE_METHOD`` admits the hyphen, so this request line reaches - ``Enum_Method.get(...)`` at ``httpv1.py:434`` and used to come back + ``Enum_Method.get(...)`` in ``httpv1.HTTP._read_start_line`` and used to come back with ``idempotent=False`` -- the wrong answer for a parse that otherwise succeeds silently. Pins the whole path, not only the direct ``Method.get`` call above.""" @@ -269,9 +270,9 @@ def test_a_bytes_key_raises_instead_of_returning_a_bytes_valued_member(self) -> Before #908 this handed back a member whose ``name`` and ``value`` were both ``b'GET'``. ``bytes`` is the plausible mistake here, since - :attr:`~pcapkit.protocols.application.httpv1.HTTP._RE_METHOD` is a - bytes pattern and ``httpv1`` is bytes throughout -- the live call at - ``httpv1.py:434`` is safe only because it wraps the match in + ``_RE_METHOD`` (in ``pcapkit/protocols/application/http.py``) is a + bytes pattern and ``httpv1`` is bytes throughout -- the live call in + ``httpv1.HTTP._read_start_line`` is safe only because it wraps the match in ``self.decode(...)``. """ from pcapkit.const.http.method import Method 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 new file mode 100644 index 0000000000..1c0ef887ed --- /dev/null +++ b/tests/protocols/application/test_http_test_start_line_1519_unit.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +"""``test_start_line`` is public and lives in the HTTP base module. C.f. #1519. + +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 +shares with the HTTP/1.* parser -- ``_RE_METHOD``, ``_RE_VERSION``, +``_RE_STATUS`` and ``_HTTP2_PREFACE_HEADER`` -- moved with 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 +collects any module-level callable named ``test_*``, and would run the predicate +itself as a test. + +""" + +from __future__ import annotations + +import importlib.util +import unittest +import unittest.mock + +from tests._support import reimport_once_per_class + +RUNTIME_DEPS = ('tbtrim', 'aenum', 'chardet', 'dictdumper') +HAS_RUNTIME = all(importlib.util.find_spec(name) is not None for name in RUNTIME_DEPS) + +#: The HTTP/2 connection preface (:rfc:`9113#section-3.4`). +PREFACE = b'PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n' + +#: An empty ``SETTINGS`` frame, what a client sends right after the preface. +SETTINGS = bytes.fromhex('000000040000000000') + + +@unittest.skipUnless(HAS_RUNTIME, 'runtime dependencies not installed') +class StartLinePredicateTests(unittest.TestCase): + """``http.test_start_line`` classifies a payload as HTTP/1.* or not.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_accepts_a_request_line(self) -> None: + """A ``request-line``: method, request target, HTTP version.""" + from pcapkit.protocols.application.http import test_start_line + + for raw in (b'GET /index.html HTTP/1.1\r\nHost: example.test\r\n\r\n', + b'M-SEARCH * HTTP/1.1\r\nHost: example.test\r\n\r\n', + b'POST /form HTTP/1.0\r\nContent-Length: 4\r\n\r\nbody', + b'GET / HTTP/1.0\r\n\r\n'): + with self.subTest(raw=raw): + self.assertIs(test_start_line(raw), True) + + def test_accepts_a_status_line(self) -> None: + """A ``status-line``: HTTP version, three-digit status, reason phrase.""" + from pcapkit.protocols.application.http import test_start_line + + for raw in (b'HTTP/1.1 200 OK\r\nServer: example\r\n\r\nhello', + b'HTTP/1.0 404 Not Found\r\nServer: example\r\n\r\n', + b'HTTP/1.0 200 OK\r\n\r\nbody'): + with self.subTest(raw=raw): + self.assertIs(test_start_line(raw), True) + + def test_refuses_the_http2_connection_preface(self) -> None: + """The preface is a well-formed request line, refused by name.""" + from pcapkit.protocols.application import http + from pcapkit.protocols.application.http import test_start_line + + self.assertEqual(http._HTTP2_PREFACE_HEADER, b'PRI * HTTP/2.0') + for raw in (PREFACE, PREFACE + SETTINGS, b'PRI * HTTP/2.0\r\n\r\n'): + with self.subTest(raw=raw): + self.assertIs(test_start_line(raw), False) + + def test_refuses_payloads_that_are_not_http(self) -> None: + """Text and binary that carry no HTTP/1.* start line.""" + from pcapkit.protocols.application.http import test_start_line + + for raw in (b'', + b'not http at all', + b'the quick brown fox jumps over the lazy dog\r\n\r\n', + b'Get / HTTP/1.1\r\nHost: example.test\r\n\r\n', + b'HTTP/1.1 2000 OK\r\n\r\n', + b'USER anonymous\r\n\r\n', + b'GET / HTTP/1.1', + SETTINGS): + with self.subTest(raw=raw): + self.assertIs(test_start_line(raw), False) + + def test_httpv1_parses_with_the_patterns_defined_in_http(self) -> None: + """One copy of each pattern, so the predicate and the parser cannot drift.""" + from pcapkit.protocols.application import http, httpv1 + + self.assertIn('test_start_line', http.__all__) + self.assertFalse(hasattr(httpv1, '_test_start_line')) + for name in ('_RE_METHOD', '_RE_VERSION', '_RE_STATUS', '_HTTP2_PREFACE_HEADER'): + with self.subTest(name=name): + self.assertIs(getattr(httpv1, name), getattr(http, name)) + + def test_guess_version_calls_the_module_level_predicate(self) -> None: + """The dispatcher calls ``http.test_start_line``, not a lazy import of it.""" + from pcapkit.protocols.application import http + + raw = b'GET / HTTP/1.1\r\nHost: example.test\r\n\r\n' + with unittest.mock.patch.object(http, 'test_start_line', + wraps=http.test_start_line) as predicate: + self.assertEqual(http.HTTP(raw, len(raw)).version, '1.1') + predicate.assert_called_once_with(raw) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/protocols/application/test_http_unit.py b/tests/protocols/application/test_http_unit.py index 5228d0b6dd..40eda49c12 100644 --- a/tests/protocols/application/test_http_unit.py +++ b/tests/protocols/application/test_http_unit.py @@ -699,8 +699,7 @@ def test_guess_version_does_not_classify_text_as_http2(self) -> None: """ import io - from pcapkit.protocols.application.http import HTTP - from pcapkit.protocols.application.httpv1 import _test_start_line + from pcapkit.protocols.application.http import HTTP, test_start_line from pcapkit.utilities.exceptions import ProtocolError preface = b'PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n' @@ -715,7 +714,7 @@ def test_guess_version_does_not_classify_text_as_http2(self) -> None: with self.subTest(case=label): # Neither identification claims it ... self.assertFalse(raw.startswith(preface)) - self.assertFalse(_test_start_line(raw)) + self.assertFalse(test_start_line(raw)) # ... and the fall-through refuses it rather than guessing. with self.assertRaises(ProtocolError): HTTP(io.BytesIO(raw), len(raw)) @@ -740,8 +739,7 @@ def test_guess_version_commits_to_http1_once_the_start_line_says_so(self) -> Non """ import io - from pcapkit.protocols.application.http import HTTP - from pcapkit.protocols.application.httpv1 import _test_start_line + from pcapkit.protocols.application.http import HTTP, test_start_line from pcapkit.utilities.exceptions import ProtocolError cases = ( @@ -752,18 +750,18 @@ def test_guess_version_commits_to_http1_once_the_start_line_says_so(self) -> Non for label, raw in cases: with self.subTest(case=label): - self.assertTrue(_test_start_line(raw)) + self.assertTrue(test_start_line(raw)) with self.assertRaises(ProtocolError) as ctx: HTTP(io.BytesIO(raw), len(raw)) self.assertEqual(str(ctx.exception), 'HTTP: invalid format') def test_start_line_predicate_agrees_with_the_httpv1_parser(self) -> None: - """``_test_start_line`` must accept exactly what ``httpv1.HTTP`` accepts. + """``test_start_line`` must accept exactly what ``httpv1.HTTP`` accepts. The predicate classifies and the parser parses, and they are two - statements of the same rule -- which is why the predicate lives beside - ``_RE_METHOD``/``_RE_VERSION``/``_RE_STATUS`` in ``httpv1.py`` rather - than in the dispatcher. Drift either way is a defect: a predicate looser + statements of the same rule -- which is why the two share one copy of + ``_RE_METHOD``/``_RE_VERSION``/``_RE_STATUS``, defined in ``http.py`` + beside the predicate and imported by ``httpv1.py`` (#1519). Drift either way is a defect: a predicate looser than the parser classifies payloads the parser then refuses, and a tighter one hands real HTTP/1 to a later arm, which is the mislabel #787 and #800 are both about. @@ -775,8 +773,8 @@ def test_start_line_predicate_agrees_with_the_httpv1_parser(self) -> None: """ import io + from pcapkit.protocols.application.http import test_start_line from pcapkit.protocols.application.httpv1 import HTTP as HTTPv1 - from pcapkit.protocols.application.httpv1 import _test_start_line from pcapkit.utilities.exceptions import ProtocolError accepted = ( @@ -803,13 +801,13 @@ def test_start_line_predicate_agrees_with_the_httpv1_parser(self) -> None: for label, raw in accepted: with self.subTest(accepted=label): - self.assertTrue(_test_start_line(raw)) + self.assertTrue(test_start_line(raw)) # The parser agrees: it reads this without raising. self.assertIn(HTTPv1(io.BytesIO(raw), len(raw)).version, ('1.0', '1.1')) for label, raw in refused: with self.subTest(refused=label): - self.assertFalse(_test_start_line(raw)) + self.assertFalse(test_start_line(raw)) # The parser agrees: it refuses this at the start line. with self.assertRaises(ProtocolError): HTTPv1(io.BytesIO(raw), len(raw)) @@ -830,8 +828,7 @@ def test_guess_version_leaves_a_mid_stream_frame_to_the_fall_through(self) -> No """ import io - from pcapkit.protocols.application.http import HTTP - from pcapkit.protocols.application.httpv1 import _test_start_line + from pcapkit.protocols.application.http import HTTP, test_start_line from pcapkit.utilities.exceptions import ProtocolError preface = b'PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n' @@ -839,7 +836,7 @@ def test_guess_version_leaves_a_mid_stream_frame_to_the_fall_through(self) -> No # Neither identification fires, so this reaches the fall-through. self.assertFalse(settings.startswith(preface)) - self.assertFalse(_test_start_line(settings)) + self.assertFalse(test_start_line(settings)) guessed = HTTP(io.BytesIO(settings), len(settings)) self.assertEqual(guessed.version, '2') @@ -1059,15 +1056,15 @@ 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, + through the dispatcher too: ``test_start_line`` mirrored that refusal, so ``HTTP`` answered ``unknown HTTP version`` for the same bytes. """ import io - from pcapkit.protocols.application.http import HTTP + from pcapkit.protocols.application.http import HTTP, test_start_line from pcapkit.protocols.application.httpv1 import HTTP as HTTPv1 - from pcapkit.protocols.application.httpv1 import Type, _test_start_line + from pcapkit.protocols.application.httpv1 import Type raw = b'GET / HTTP/1.0\r\n\r\n' proto = HTTPv1(io.BytesIO(raw), len(raw)) @@ -1080,16 +1077,16 @@ def test_httpv1_reads_a_request_with_no_header_fields(self) -> None: self.assertIsNone(proto.info.body) self.assertEqual(proto.length, len(b'GET / HTTP/1.0')) - self.assertTrue(_test_start_line(raw)) + self.assertTrue(test_start_line(raw)) self.assertEqual(HTTP(io.BytesIO(raw), len(raw)).version, '1.0') def test_httpv1_reads_a_response_with_no_header_fields_and_a_body(self) -> None: """A status line, the empty line, then a body parses (#1041).""" import io - from pcapkit.protocols.application.http import HTTP + from pcapkit.protocols.application.http import HTTP, test_start_line from pcapkit.protocols.application.httpv1 import HTTP as HTTPv1 - from pcapkit.protocols.application.httpv1 import Type, _test_start_line + from pcapkit.protocols.application.httpv1 import Type raw = b'HTTP/1.0 200 OK\r\n\r\nbody' proto = HTTPv1(io.BytesIO(raw), len(raw)) @@ -1101,7 +1098,7 @@ def test_httpv1_reads_a_response_with_no_header_fields_and_a_body(self) -> None: self.assertEqual(len(proto.info.header), 0) self.assertEqual(proto.info.body, b'body') - self.assertTrue(_test_start_line(raw)) + self.assertTrue(test_start_line(raw)) self.assertEqual(HTTP(io.BytesIO(raw), len(raw)).version, '1.0') def test_httpv1_make_read_round_trips_a_message_with_no_header_fields(self) -> None: @@ -1179,9 +1176,8 @@ def test_httpv1_still_refuses_the_http2_connection_preface(self) -> None: """ import io - from pcapkit.protocols.application.http import HTTP + from pcapkit.protocols.application.http import HTTP, test_start_line from pcapkit.protocols.application.httpv1 import HTTP as HTTPv1 - from pcapkit.protocols.application.httpv1 import _test_start_line from pcapkit.utilities.exceptions import ProtocolError cases = ( @@ -1195,7 +1191,7 @@ def test_httpv1_still_refuses_the_http2_connection_preface(self) -> None: with self.assertRaises(ProtocolError) as ctx: HTTPv1(io.BytesIO(raw), len(raw)) self.assertEqual(str(ctx.exception), 'HTTP: invalid format') - self.assertFalse(_test_start_line(raw)) + self.assertFalse(test_start_line(raw)) with self.assertRaises(ProtocolError) as ctx: HTTP(io.BytesIO(raw), len(raw)) @@ -1725,7 +1721,7 @@ def test_httpv1_method_regex_is_anchored(self) -> None: """ import re - from pcapkit.protocols.application.httpv1 import _RE_METHOD + from pcapkit.protocols.application.http import _RE_METHOD for probe, expected in ((b'GET', b'GET'), (b'POST', b'POST'),