mirror of
https://github.com/Av0idD4rk/firegex.git
synced 2026-10-23 15:10:59 +00:00
136 lines
5.1 KiB
Python
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
|