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