firegex/tests/unit/test_llhttp_binding.py
2026-09-23 11:21:51 +02:00

136 lines
5.1 KiB
Python

"""The HTTP parser binding stands on its own.
`firegex._llhttp` is a fork of `pyllhttp`, and for a while it was still *calling* it: the
error path imported the module `pyllhttp` to fetch the exception class to raise. That
package is archived, is not a dependency, and is installed on no machine the wheel is
installed on — so the import failed, its traceback went to stderr (which on a filter is
the service log), and the function returned without setting an exception. CPython reports
that to the caller as `SystemError: ... returned NULL without setting an exception`, and
a malformed request — the traffic this parser exists to look at — said exactly that
instead of naming the problem.
It went unnoticed because a machine that once installed `pyllhttp` has it, and both the
laptop and the VM these tests were written on did. So the cases below take it away first.
"""
import sys
import pytest
class _Blocked:
"""A meta-path finder that makes one module unimportable, as if it were not there."""
def __init__(self, name: str):
self.name = name
def find_spec(self, name, path=None, target=None):
if name == self.name:
raise ImportError(f"No module named {self.name}")
return None
@pytest.fixture
def without_pyllhttp():
"""The state every machine that installs the wheel is in."""
blocker = _Blocked("pyllhttp")
sys.meta_path.insert(0, blocker)
saved = sys.modules.pop("pyllhttp", None)
try:
yield
finally:
sys.meta_path.remove(blocker)
if saved is not None:
sys.modules["pyllhttp"] = saved
def test_the_parser_names_itself():
"""The types and the exceptions belong to this module, under this module's name."""
from firegex import _llhttp
assert _llhttp.Request.__module__ == "firegex._llhttp"
assert _llhttp.Response.__module__ == "firegex._llhttp"
assert _llhttp.Error.__module__ == "firegex._llhttp"
def test_a_parse_error_raises_this_modules_exception(without_pyllhttp):
"""…and raises it with nothing else installed to borrow it from."""
from firegex import _llhttp
parser = _llhttp.Request()
with pytest.raises(_llhttp.InvalidMethodError) as raised:
parser.execute(b"NOTAMETHOD / HTTP/1.1\r\n\r\n")
assert isinstance(raised.value, _llhttp.Error)
assert type(raised.value).__module__ == "firegex._llhttp"
# The message is llhttp's own reason, not the shape of the failure to find a class.
assert "method" in str(raised.value).lower()
def test_every_error_code_has_a_class_to_raise(without_pyllhttp):
"""A code with no class would be the silent half of the old bug coming back.
`RuntimeError` is the floor here, deliberately: whatever goes wrong looking the class
up, something is raised, because returning without an exception set is what produced
a `SystemError` naming a package the operator has never heard of.
"""
from firegex import _llhttp
# One representative per kind of thing that can go wrong mid-message.
cases = [
(b"NOTAMETHOD / HTTP/1.1\r\n\r\n", "request"),
(b"GET / HTTP/1.1\r\nHost: a\r\nTransfer-Encoding: \r\n\r\n", "request"),
(b"HTTP/1.1 200 OK\nContent-Length: 0\r\n\r\n", "response"),
]
for payload, kind in cases:
parser = _llhttp.Request() if kind == "request" else _llhttp.Response()
with pytest.raises(_llhttp.Error):
parser.execute(payload)
def test_a_valid_exchange_is_unaffected(without_pyllhttp):
from firegex import _llhttp
response = _llhttp.Response()
response.execute(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n")
assert response.status_code == 404
# --- naming the method --------------------------------------------------------
# The name used to be read out of an array built from `HTTP_METHOD_MAP` and indexed with
# the method's number. That map is not the numbers in order — it runs 0 to 33 and jumps to
# QUERY at 46, with PRI and the RTSP methods not in it at all — so a request parser handed
# any of those read past the end of the array: QUERY and PLAY came back named after
# whatever lay beyond it, and FLUSH crashed the process. A client decides which method it
# sends, so each case runs in a process of its own: a crash has to fail one test, not end
# the run.
_METHOD_OF = """
import sys
from firegex import _llhttp
class Parser(_llhttp.Request):
def on_headers_complete(self):
print(self.method, flush=True)
Parser().execute(sys.argv[1].encode())
"""
@pytest.mark.parametrize("method, line", [
("GET", "GET / HTTP/1.1"),
("QUERY", "QUERY / HTTP/1.1"),
("PLAY", "PLAY rtsp://example/ RTSP/1.0"),
("FLUSH", "FLUSH rtsp://example/ RTSP/1.0"),
("DESCRIBE", "DESCRIBE rtsp://example/ RTSP/1.0"),
])
def test_every_method_the_parser_accepts_is_named(method, line):
import subprocess
ran = subprocess.run(
[sys.executable, "-c", _METHOD_OF, f"{line}\r\nHost: a\r\nCSeq: 1\r\n\r\n"],
capture_output=True, text=True, timeout=30,
)
assert ran.returncode == 0, f"parsing {line!r} ended the process: {ran.stderr[-300:]}"
assert ran.stdout.strip() == method