From 6e36197fcb26c3482837f70dea7737b4a5ed1e55 Mon Sep 17 00:00:00 2001 From: Kent Bull Date: Fri, 28 Aug 2026 17:39:46 -0600 Subject: [PATCH] Parse buffered HTTP framing before EOF Attempt to consume complete request and response framing already present in the parser buffer before treating EOF as premature closure. Reject partial status lines, header blocks, and fixed-length response bodies at EOF so a parser cannot remain suspended after the connection has closed. Cover the framing boundaries with real Requestant, Respondent, and transport objects. --- src/hio/core/http/clienting.py | 27 +++++++----- src/hio/core/http/serving.py | 18 ++++---- tests/core/http/test_clienting.py | 68 +++++++++++++++++++++++++++++++ tests/core/http/test_serving.py | 52 ++++++++++++++++++++++- 4 files changed, 145 insertions(+), 20 deletions(-) diff --git a/src/hio/core/http/clienting.py b/src/hio/core/http/clienting.py index a84945bd..572432b5 100644 --- a/src/hio/core/http/clienting.py +++ b/src/hio/core/http/clienting.py @@ -409,12 +409,13 @@ def parseHead(self): # create generator lineParser = httping.parseLine(raw=self.msg, eols=(CRLF, LF), kind="status line") while True: # parse until we get a non-100 status - if self.closed and not self.msg: # connection closed prematurely - raise httping.PrematureClosure("Connection closed unexpectedly" - " while parsing response start line") - line = next(lineParser) if line is None: + if self.closed: + lineParser.close() + raise httping.PrematureClosure( + "Connection closed unexpectedly while parsing response " + "start line") (yield None) continue lineParser.close() # close generator @@ -427,13 +428,15 @@ def parseHead(self): eols=(CRLF, LF), kind="continue header line") while True: - if self.closed and not self.msg: # connection closed prematurely - raise httping.PrematureClosure("Connection closed unexpectedly" - " while parsing response header") headers = next(leaderParser) if headers is not None: leaderParser.close() break + if self.closed: + leaderParser.close() + raise httping.PrematureClosure( + "Connection closed unexpectedly while parsing response " + "header") (yield None) self.code = self.status = status @@ -450,13 +453,15 @@ def parseHead(self): eols=(CRLF, LF), kind="leader header line") while True: - if self.closed and not self.msg: # connection closed prematurely - raise httping.PrematureClosure("Connection closed unexpectedly" - " while parsing response header") headers = next(leaderParser) if headers is not None: leaderParser.close() break + if self.closed: + leaderParser.close() + raise httping.PrematureClosure( + "Connection closed unexpectedly while parsing response " + "header") (yield None) self.headers.update(headers) @@ -575,7 +580,7 @@ def parseBody(self): elif self.length != None: # known content length while len(self.msg) < self.length: - if self.closed and not self.msg: # connection closed prematurely + if self.closed: raise httping.PrematureClosure("Connection closed unexpectedly" " while parsing response body") (yield None) diff --git a/src/hio/core/http/serving.py b/src/hio/core/http/serving.py index 4a62a5c0..3740fc67 100644 --- a/src/hio/core/http/serving.py +++ b/src/hio/core/http/serving.py @@ -97,12 +97,13 @@ def parseHead(self): # create generator lineParser = httping.parseLine(raw=self.msg, eols=(CRLF, LF), kind="status line") while True: # parse until we get full start line - if self.closed: # connection closed prematurely - raise httping.PrematureClosure("Connection closed unexpectedly " - "while parsing request start line") - line = next(lineParser) if line is None: + if self.closed: + lineParser.close() + raise httping.PrematureClosure( + "Connection closed unexpectedly while parsing request " + "start line") (yield None) continue lineParser.close() # close generator @@ -134,14 +135,15 @@ def parseHead(self): eols=(CRLF, LF), kind="leader header line") while True: - if self.closed: # connection closed prematurely - raise httping.PrematureClosure("Connection closed unexpectedly " - "while parsing request header") - headers = next(leaderParser) if headers is not None: leaderParser.close() break + if self.closed: + leaderParser.close() + raise httping.PrematureClosure( + "Connection closed unexpectedly while parsing request " + "header") (yield None) self.headers.update(headers) diff --git a/tests/core/http/test_clienting.py b/tests/core/http/test_clienting.py index 9f2fe242..f28c800e 100644 --- a/tests/core/http/test_clienting.py +++ b/tests/core/http/test_clienting.py @@ -28,6 +28,74 @@ certdirpath = os.path.join(tlsdirpath, 'tls', 'certs') +def _service_respondent(respondent, limit=8): + """Drive a finite response parser without hiding a terminal EOF stall.""" + for _ in range(limit): + respondent.parse() + if respondent.parser is None: + return + raise AssertionError("response parser did not settle") + + +@pytest.mark.parametrize( + "initial, buffered, expected_body", + [ + (b"", + b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n", + b""), + (b"HTTP/1.1 200 OK\r\n", + b"Content-Length: 0\r\n\r\n", + b""), + (b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\n", + b"test", + b"test"), + ], +) +def test_respondent_consumes_complete_buffered_framing_before_eof( + initial, buffered, expected_body): + """Complete start-line, headers, or fixed body buffered at EOF are valid.""" + respondent = clienting.Respondent(msg=bytearray(initial)) + respondent.parse() + assert respondent.parser is not None + + respondent.msg.extend(buffered) + respondent.close() + _service_respondent(respondent) + + assert respondent.closed + assert respondent.ended + assert not respondent.errored + assert respondent.headed + assert respondent.bodied + assert respondent.body == expected_body + assert not respondent.msg + + +@pytest.mark.parametrize( + "initial, partial", + [ + (b"", b"HTTP/1.1 200"), + (b"HTTP/1.1 200 OK\r\n", b"Content-Length: 0\r\n"), + (b"HTTP/1.1 100 Continue\r\n", b"Extension: partial"), + (b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\n", b"te"), + ], +) +def test_respondent_rejects_partial_buffered_framing_at_eof(initial, partial): + """Partial start-line, headers, or fixed body at EOF settle as failure.""" + respondent = clienting.Respondent(msg=bytearray(initial)) + respondent.parse() + assert respondent.parser is not None + + respondent.msg.extend(partial) + respondent.close() + _service_respondent(respondent) + + assert respondent.closed + assert respondent.ended + assert respondent.errored + assert "closed unexpectedly" in respondent.error.lower() + + def mockEchoService(server): """ mock echo server service for testing diff --git a/tests/core/http/test_serving.py b/tests/core/http/test_serving.py index c741ae55..9de8171d 100644 --- a/tests/core/http/test_serving.py +++ b/tests/core/http/test_serving.py @@ -11,7 +11,8 @@ from hio import help from hio.help import helping from hio.base import tyming, doing -from hio.core import http +from hio.core import http, tcp +from hio.core.http import serving logger = help.ogler.getLogger() @@ -22,6 +23,55 @@ sys.modules.get(__name__).__file__))) certdirpath = os.path.join(tlsdirpath, 'tls', 'certs') + +def _service_requestant(requestant, limit=8): + """Drive a finite request parser without hiding a terminal EOF stall.""" + for _ in range(limit): + requestant.parse() + if requestant.parser is None: + return + raise AssertionError("request parser did not settle") + + +@pytest.mark.parametrize( + "initial, buffered, expected_body", + [ + (b"", + b"GET /buffered HTTP/1.1\r\nHost: localhost\r\n\r\n", + b""), + (b"GET /buffered HTTP/1.1\r\n", + b"Host: localhost\r\n\r\n", + b""), + (b"POST /buffered HTTP/1.1\r\nHost: localhost\r\n" + b"Content-Length: 4\r\n\r\n", + b"test", + b"test"), + ], +) +def test_requestant_consumes_complete_buffered_framing_before_eof( + initial, buffered, expected_body): + """Complete start-line, headers, or fixed body buffered at EOF are valid.""" + remoter = tcp.Remoter(ha=("127.0.0.1", 6101), + ca=("127.0.0.1", 6102), + cs=None) + requestant = serving.Requestant(msg=bytearray(initial), remoter=remoter) + requestant.parse() + assert requestant.parser is not None + + requestant.msg.extend(buffered) + requestant.close() + _service_requestant(requestant) + + assert requestant.closed + assert requestant.ended + assert not requestant.errored + assert requestant.headed + assert requestant.bodied + assert requestant.path == "/buffered" + assert requestant.body == expected_body + assert not requestant.msg + + def test_bare_server_echo(): """ Test BaserServer service request response of echo non blocking