diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index bb4c76fb366..6d4bf162d59 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -29,17 +29,17 @@ jobs: uses: actions/checkout@v7 - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.0 + uses: github/codeql-action/init@v4.37.1 with: languages: ${{ matrix.language }} config-file: ./.github/codeql.yml queries: +security-and-quality - name: Autobuild - uses: github/codeql-action/autobuild@v4.37.0 + uses: github/codeql-action/autobuild@v4.37.1 if: ${{ matrix.language == 'python' || matrix.language == 'javascript' }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.37.0 + uses: github/codeql-action/analyze@v4.37.1 with: category: "/language:${{ matrix.language }}" diff --git a/CHANGES/13054.bugfix.rst b/CHANGES/13054.bugfix.rst new file mode 100644 index 00000000000..75196b9bfe6 --- /dev/null +++ b/CHANGES/13054.bugfix.rst @@ -0,0 +1,4 @@ +Fixed ``escape_quotes`` in the Digest authentication middleware not escaping +backslashes, so a ``WWW-Authenticate`` challenge value containing a backslash +could break out of its quoted-string in the generated ``Authorization`` header +-- by :user:`dxbjavid`. diff --git a/CHANGES/13171.bugfix.rst b/CHANGES/13171.bugfix.rst new file mode 100644 index 00000000000..fead0685a8e --- /dev/null +++ b/CHANGES/13171.bugfix.rst @@ -0,0 +1,3 @@ +Fixed the C HTTP parser folding the fragment into the query string for an +origin-form request target with an empty query (e.g. ``/path?#frag``), +which diverged from the pure-Python parser -- by :user:`GiulioDER`. diff --git a/CHANGES/13174.bugfix.rst b/CHANGES/13174.bugfix.rst new file mode 100644 index 00000000000..56cf18eea32 --- /dev/null +++ b/CHANGES/13174.bugfix.rst @@ -0,0 +1 @@ +Fixed the C parser reporting newer HTTP methods such as ``QUERY`` as ````; the method table is now derived from the vendored llhttp instead of a hand-maintained count -- by :user:`Dreamsorcerer`. diff --git a/CHANGES/13176.feature.rst b/CHANGES/13176.feature.rst new file mode 100644 index 00000000000..db505da1a88 --- /dev/null +++ b/CHANGES/13176.feature.rst @@ -0,0 +1,3 @@ +Exposed the request's timeout configuration to client middlewares as the +read-only :attr:`ClientRequest.timeout ` +property -- by :user:`rodrigobnogueira`. diff --git a/CHANGES/5303.bugfix.rst b/CHANGES/5303.bugfix.rst new file mode 100644 index 00000000000..fcb1e7c8ae9 --- /dev/null +++ b/CHANGES/5303.bugfix.rst @@ -0,0 +1,7 @@ +Fixed :py:attr:`~aiohttp.web.StreamResponse.last_modified` rounding a +:class:`datetime.datetime` with a fractional second down while rounding an +equivalent :class:`float` timestamp up, so the two produced different +``Last-Modified`` headers for the same instant. Both now round up to the +next whole second, consistent with how :py:class:`~aiohttp.web.FileResponse` +relies on this rounding to avoid falsely reporting a resource as +unmodified. diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index 724ee94965c..624b9400fbd 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -198,6 +198,7 @@ Jan Buchar Jan Gosmann Jarno Elonen Jashandeep Sohi +Javid Khan Javier Torres Jean-Baptiste Estival Jens Steinhauser diff --git a/aiohttp/_cparser.pxd b/aiohttp/_cparser.pxd index cc7ef58d664..903f35c4590 100644 --- a/aiohttp/_cparser.pxd +++ b/aiohttp/_cparser.pxd @@ -1,29 +1,20 @@ -from libc.stdint cimport int32_t, uint8_t, uint16_t, uint64_t +# This file contains just the definitions needed in our Cython code. + +from libc.stdint cimport uint8_t, uint16_t, uint64_t cdef extern from "llhttp.h": struct llhttp__internal_s: - int32_t _index - void* _span_pos0 - void* _span_cb0 - int32_t error - const char* reason - const char* error_pos void* data - void* _current uint64_t content_length uint8_t type uint8_t method uint8_t http_major uint8_t http_minor - uint8_t header_state - uint8_t lenient_flags uint8_t upgrade - uint8_t finish uint16_t flags uint16_t status_code - void* settings ctypedef llhttp__internal_s llhttp__internal_t ctypedef llhttp__internal_t llhttp_t @@ -43,20 +34,10 @@ cdef extern from "llhttp.h": llhttp_cb on_chunk_header llhttp_cb on_chunk_complete - llhttp_cb on_url_complete - llhttp_cb on_status_complete - llhttp_cb on_header_field_complete - llhttp_cb on_header_value_complete - ctypedef llhttp_settings_s llhttp_settings_t enum llhttp_errno: HPE_OK, - HPE_INTERNAL, - HPE_STRICT, - HPE_LF_EXPECTED, - HPE_UNEXPECTED_CONTENT_LENGTH, - HPE_CLOSED_CONNECTION, HPE_INVALID_METHOD, HPE_INVALID_URL, HPE_INVALID_CONSTANT, @@ -73,8 +54,7 @@ cdef extern from "llhttp.h": HPE_CB_CHUNK_HEADER, HPE_CB_CHUNK_COMPLETE, HPE_PAUSED, - HPE_PAUSED_UPGRADE, - HPE_USER + HPE_PAUSED_UPGRADE ctypedef llhttp_errno llhttp_errno_t @@ -84,58 +64,10 @@ cdef extern from "llhttp.h": enum llhttp_type: HTTP_REQUEST, - HTTP_RESPONSE, - HTTP_BOTH + HTTP_RESPONSE enum llhttp_method: - HTTP_DELETE, - HTTP_GET, - HTTP_HEAD, - HTTP_POST, - HTTP_PUT, - HTTP_CONNECT, - HTTP_OPTIONS, - HTTP_TRACE, - HTTP_COPY, - HTTP_LOCK, - HTTP_MKCOL, - HTTP_MOVE, - HTTP_PROPFIND, - HTTP_PROPPATCH, - HTTP_SEARCH, - HTTP_UNLOCK, - HTTP_BIND, - HTTP_REBIND, - HTTP_UNBIND, - HTTP_ACL, - HTTP_REPORT, - HTTP_MKACTIVITY, - HTTP_CHECKOUT, - HTTP_MERGE, - HTTP_MSEARCH, - HTTP_NOTIFY, - HTTP_SUBSCRIBE, - HTTP_UNSUBSCRIBE, - HTTP_PATCH, - HTTP_PURGE, - HTTP_MKCALENDAR, - HTTP_LINK, - HTTP_UNLINK, - HTTP_SOURCE, - HTTP_PRI, - HTTP_DESCRIBE, - HTTP_ANNOUNCE, - HTTP_SETUP, - HTTP_PLAY, - HTTP_PAUSE, - HTTP_TEARDOWN, - HTTP_GET_PARAMETER, - HTTP_SET_PARAMETER, - HTTP_REDIRECT, - HTTP_RECORD, - HTTP_FLUSH - - ctypedef llhttp_method llhttp_method_t; + HTTP_CONNECT void llhttp_settings_init(llhttp_settings_t* settings) void llhttp_init(llhttp_t* parser, llhttp_type type, @@ -152,8 +84,6 @@ cdef extern from "llhttp.h": const char* llhttp_get_error_reason(const llhttp_t* parser) const char* llhttp_get_error_pos(const llhttp_t* parser) - const char* llhttp_method_name(llhttp_method_t method) - void llhttp_set_lenient_headers(llhttp_t* parser, int enabled) void llhttp_set_lenient_optional_cr_before_lf(llhttp_t* parser, int enabled) void llhttp_set_lenient_spaces_after_chunk_size(llhttp_t* parser, int enabled) diff --git a/aiohttp/_http_parser.pyx b/aiohttp/_http_parser.pyx index 475b3263764..19273ac31f7 100644 --- a/aiohttp/_http_parser.pyx +++ b/aiohttp/_http_parser.pyx @@ -100,20 +100,29 @@ cdef inline object extend(object buf, const char* at, size_t length): memcpy(ptr + s, at, length) -DEF METHODS_COUNT = 46; +# The method-name table and its length come straight from llhttp's canonical +# HTTP_ALL_METHOD_MAP, so they track the vendored llhttp version automatically +# instead of relying on a hand-maintained method count. +cdef extern from *: + """ + #include "llhttp.h" + + #define _AIOHTTP_METHOD_NAME(NUM, NAME, STRING) [NUM] = #STRING, + static const char* const _aiohttp_method_names[] = { + HTTP_ALL_METHOD_MAP(_AIOHTTP_METHOD_NAME) + }; + #undef _AIOHTTP_METHOD_NAME + """ + const char* _aiohttp_method_names[] + const int METHODS_COUNT "((int)(sizeof(_aiohttp_method_names) / sizeof(_aiohttp_method_names[0])))" + cdef list _http_method = [] for i in range(METHODS_COUNT): - _http_method.append( - cparser.llhttp_method_name( i).decode('ascii')) - + assert _aiohttp_method_names[i] is not NULL + _http_method.append(_aiohttp_method_names[i].decode('ascii')) -cdef inline str http_method_str(int i): - if i < METHODS_COUNT: - return _http_method[i] - else: - return "" cdef inline object find_header(bytes raw_header): cdef Py_ssize_t size @@ -506,7 +515,7 @@ cdef class HttpParser: encoding = enc if self._cparser.type == cparser.HTTP_REQUEST: - method = http_method_str(self._cparser.method) + method = _http_method[self._cparser.method] msg = _new_request_message( method, self._path, http_version, headers, raw_headers, @@ -736,7 +745,7 @@ cdef class HttpRequestParser(HttpParser): else: path = self._path[0:idx1] idx1 += 1 - idx2 = self._path.find("#", idx1+1) + idx2 = self._path.find("#", idx1) if idx2 == -1: query = self._path[idx1:] fragment = "" diff --git a/aiohttp/client_middleware_digest_auth.py b/aiohttp/client_middleware_digest_auth.py index f4904e363d8..fbcdf549b4a 100644 --- a/aiohttp/client_middleware_digest_auth.py +++ b/aiohttp/client_middleware_digest_auth.py @@ -102,13 +102,13 @@ class DigestAuthChallenge(TypedDict, total=False): def escape_quotes(value: str) -> str: - """Escape double quotes for HTTP header values.""" - return value.replace('"', '\\"') + """Escape backslashes and double quotes for HTTP quoted-strings.""" + return value.replace("\\", "\\\\").replace('"', '\\"') def unescape_quotes(value: str) -> str: - """Unescape double quotes in HTTP header values.""" - return value.replace('\\"', '"') + """Unescape backslashes and double quotes in HTTP quoted-strings.""" + return value.replace('\\"', '"').replace("\\\\", "\\") def parse_header_pairs(header: str) -> dict[str, str]: diff --git a/aiohttp/client_reqrep.py b/aiohttp/client_reqrep.py index 51da8711c58..88934197978 100644 --- a/aiohttp/client_reqrep.py +++ b/aiohttp/client_reqrep.py @@ -1126,6 +1126,11 @@ def body(self) -> payload.Payload: def skip_auto_headers(self) -> CIMultiDict[None]: return self._skip_auto_headers or CIMultiDict() + @property + def timeout(self) -> ClientTimeout: + """The timeout configuration this request runs under (read-only).""" + return self._timeout + @property def connection_key(self) -> ConnectionKey: if proxy_headers := self.proxy_headers: diff --git a/aiohttp/web_response.py b/aiohttp/web_response.py index c9fa02f2524..8b0e6ed1c52 100644 --- a/aiohttp/web_response.py +++ b/aiohttp/web_response.py @@ -267,6 +267,8 @@ def last_modified( "%a, %d %b %Y %H:%M:%S GMT", time.gmtime(math.ceil(value)) ) elif isinstance(value, datetime.datetime): + if value.microsecond: + value = value.replace(microsecond=0) + datetime.timedelta(seconds=1) self._headers[hdrs.LAST_MODIFIED] = time.strftime( "%a, %d %b %Y %H:%M:%S GMT", value.utctimetuple() ) diff --git a/docs/client_reference.rst b/docs/client_reference.rst index 5fd965acc12..72a68670a5c 100644 --- a/docs/client_reference.rst +++ b/docs/client_reference.rst @@ -2106,6 +2106,16 @@ ClientRequest - :class:`ssl.SSLContext`: Custom SSL context - :class:`Fingerprint`: Verify specific certificate fingerprint + .. attribute:: timeout + :type: ClientTimeout + + The timeout configuration this request runs under (read-only): the + per-request timeout when one was passed to the request method, the + session's default otherwise. Useful in middleware to bound waits or + retries by the caller's time budget. + + .. versionadded:: 3.15 + .. attribute:: url :type: yarl.URL diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 5ba2fa07929..e6bd40845de 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -65,7 +65,7 @@ click==8.4.2 # via # pip-tools # towncrier -coverage==7.15.1 +coverage==7.15.2 # via # -r requirements/test-common.in # pytest-cov @@ -85,7 +85,7 @@ exceptiongroup==1.3.1 # pytest execnet==2.1.2 # via pytest-xdist -filelock==3.29.7 +filelock==3.30.2 # via # python-discovery # virtualenv diff --git a/requirements/dev.txt b/requirements/dev.txt index f25ae78fc3c..be38ff875a9 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -65,7 +65,7 @@ click==8.4.2 # via # pip-tools # towncrier -coverage==7.15.1 +coverage==7.15.2 # via # -r requirements/test-common.in # pytest-cov @@ -83,7 +83,7 @@ exceptiongroup==1.3.1 # pytest execnet==2.1.2 # via pytest-xdist -filelock==3.29.7 +filelock==3.30.2 # via # python-discovery # virtualenv diff --git a/requirements/lint.txt b/requirements/lint.txt index 6abeb16e52d..7281cc528ef 100644 --- a/requirements/lint.txt +++ b/requirements/lint.txt @@ -42,7 +42,7 @@ exceptiongroup==1.3.1 # via # aiofastnet # pytest -filelock==3.29.7 +filelock==3.30.2 # via # python-discovery # virtualenv diff --git a/requirements/test-common-base.txt b/requirements/test-common-base.txt index e0c459cc5fe..3b4d891f50c 100644 --- a/requirements/test-common-base.txt +++ b/requirements/test-common-base.txt @@ -14,7 +14,7 @@ attrs==26.1.0 # via aiohttp backports-asyncio-runner==1.2.0 # via pytest-asyncio -coverage==7.15.1 +coverage==7.15.2 # via pytest-cov exceptiongroup==1.3.1 # via pytest diff --git a/requirements/test-common.txt b/requirements/test-common.txt index 1fe9c9f37a0..8a9ec2f2900 100644 --- a/requirements/test-common.txt +++ b/requirements/test-common.txt @@ -22,7 +22,7 @@ blockbuster==1.5.26 # via -r requirements/test-common.in cffi==2.1.0 # via cryptography -coverage==7.15.1 +coverage==7.15.2 # via # -r requirements/test-common.in # pytest-cov diff --git a/requirements/test-ft.txt b/requirements/test-ft.txt index 1c94a68019c..ee8bdad86f9 100644 --- a/requirements/test-ft.txt +++ b/requirements/test-ft.txt @@ -38,7 +38,7 @@ cffi==2.1.0 # via # cryptography # pycares -coverage==7.15.1 +coverage==7.15.2 # via # -r requirements/test-common.in # pytest-cov diff --git a/requirements/test-mobile.txt b/requirements/test-mobile.txt index 870c0227097..ad8817265fe 100644 --- a/requirements/test-mobile.txt +++ b/requirements/test-mobile.txt @@ -34,7 +34,7 @@ cffi==2.1.0 ; sys_platform != "android" and sys_platform != "ios" # via # -r requirements/test-mobile.in # pycares -coverage==7.15.1 +coverage==7.15.2 # via pytest-cov exceptiongroup==1.3.1 # via diff --git a/requirements/test.txt b/requirements/test.txt index 2d1125835ff..84b05d7bba9 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -38,7 +38,7 @@ cffi==2.1.0 # via # cryptography # pycares -coverage==7.15.1 +coverage==7.15.2 # via # -r requirements/test-common.in # pytest-cov diff --git a/tests/test_client_middleware_digest_auth.py b/tests/test_client_middleware_digest_auth.py index fb0cd0ec876..793afed88aa 100644 --- a/tests/test_client_middleware_digest_auth.py +++ b/tests/test_client_middleware_digest_auth.py @@ -551,6 +551,32 @@ async def test_escaping_quotes_in_auth_header() -> None: assert 'opaque="opaque\\"with\\"quotes"' in header +async def test_backslash_in_challenge_cannot_break_quoting() -> None: + """A backslash in a server-supplied value must not escape the closing quote.""" + auth = DigestAuthMiddleware("bob", "secret") + # A malicious/compromised server could return directives ending in a + # backslash; without escaping it the trailing quote of the field would be + # turned into an escaped quote, letting the value run into later directives. + auth._challenge = DigestAuthChallenge( + realm="realm\\", + nonce="n0", + qop="auth", + algorithm="MD5", + opaque="op\\", + ) + + header = await auth._encode("GET", URL("http://example.com/path"), b"") + + assert 'realm="realm\\\\"' in header + assert 'opaque="op\\\\"' in header + # Re-parsing the header must recover the exact realm value, i.e. the + # backslash did not consume the closing quote and swallow ``nonce``. + params = parse_header_pairs(header[len("Digest ") :]) + assert params["realm"] == "realm\\" + assert params["nonce"] == "n0" + assert params["opaque"] == "op\\" + + async def test_template_based_header_construction( auth_mw_with_challenge: DigestAuthMiddleware, mock_sha1_digest: mock.MagicMock, @@ -624,7 +650,9 @@ async def test_template_based_header_construction( ), ('""', '\\"\\"', "Just double quotes"), ('"', '\\"', "Single double quote"), - ('already\\"escaped', 'already\\\\"escaped', "Already escaped quotes"), + ('already\\"escaped', 'already\\\\\\"escaped', "Already escaped quotes"), + ("back\\slash", "back\\\\slash", "Embedded backslash"), + ("trailing\\", "trailing\\\\", "Trailing backslash"), ], ) def test_quote_escaping_functions( diff --git a/tests/test_client_request.py b/tests/test_client_request.py index 5124fadbe54..0c7fca410f4 100644 --- a/tests/test_client_request.py +++ b/tests/test_client_request.py @@ -122,6 +122,12 @@ async def test_version_default(make_client_request: _RequestMaker) -> None: assert req.version == (1, 1) +async def test_timeout_property(make_client_request: _RequestMaker) -> None: + timeout = ClientTimeout(total=42) + req = make_client_request("get", URL("http://python.org/"), timeout=timeout) + assert req.timeout is timeout + + async def test_request_info(make_client_request: _RequestMaker) -> None: req = make_client_request("get", URL("http://python.org/")) url = URL("http://python.org/") diff --git a/tests/test_client_ws_functional.py b/tests/test_client_ws_functional.py index ef69bdaffb6..8eccc163150 100644 --- a/tests/test_client_ws_functional.py +++ b/tests/test_client_ws_functional.py @@ -3,7 +3,6 @@ import struct import sys import zlib -from contextlib import suppress from typing import Literal, NoReturn from unittest import mock @@ -898,24 +897,8 @@ async def handler(request: web.Request) -> web.WebSocketResponse: # surface as a timeout/closure on the client side. ws = web.WebSocketResponse(autoping=False) await ws.prepare(request) - - assert ws._writer is not None - transport = ws._writer.transport - - # Server-to-client frames are not masked. - length = len(payload) # payload is fixed length of 2048 bytes - header = bytes((0x82, 126)) + struct.pack("!H", length) - - frame = header + payload - for i in range(0, len(frame), chunk_size): - transport.write(frame[i : i + chunk_size]) - await asyncio.sleep(delay) - - # Ensure the server side is cleaned up. - with suppress(asyncio.TimeoutError): - await ws.receive(timeout=1.0) - with suppress(Exception): - await ws.close() + # Keep the connection open until the client tears it down. + await ws.receive() return ws app = web.Application() @@ -923,15 +906,43 @@ async def handler(request: web.Request) -> web.WebSocketResponse: client = await aiohttp_client(app) async with client.ws_connect("/", heartbeat=heartbeat) as resp: - # If heartbeat were not reset on incoming bytes, the client would send - # a PING while this frame is still being streamed. - with mock.patch.object( - resp._writer, "send_frame", wraps=resp._writer.send_frame - ) as sf: - msg = await resp.receive() + assert resp._conn is not None + protocol = resp._conn.protocol + assert protocol is not None + + # A server->client BINARY frame (unmasked, 16-bit length form). + header = bytes((0x82, 126)) + struct.pack("!H", len(payload)) + frame = header + payload + + loop = asyncio.get_running_loop() + now = loop.time() + # Drive the clock so the result cannot depend on wall-clock scheduling. + with ( + mock.patch.object(loop, "time") as loop_time, + mock.patch.object( + resp._writer, "send_frame", wraps=resp._writer.send_frame + ) as sf, + ): + loop_time.return_value = now + # If heartbeat were not reset on incoming bytes, the client would + # send a PING while this frame is still being streamed. + for i in range(0, len(frame), chunk_size): + # Deliver a chunk via the real receive path, then advance the + # clock by less than the heartbeat so the reset stays ahead. + protocol.data_received(frame[i : i + chunk_size]) + now += delay + loop_time.return_value = now + # Two ticks: run the coalesced reset, then let the heartbeat + # timer re-evaluate its deadline against the advanced clock. + await asyncio.sleep(0) + await asyncio.sleep(0) + assert ( sf.call_args_list.count(mock.call(b"", WSMsgType.PING)) == 0 ), "Heartbeat PING sent while data was still being received" + + # Clock restored: the fully-received frame arrives as one BINARY message. + msg = await resp.receive() assert msg.type is WSMsgType.BINARY assert msg.data == payload assert not resp.closed diff --git a/tests/test_helpers.py b/tests/test_helpers.py index deaea0f9fa0..9e2092fc36b 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -4,6 +4,7 @@ import ipaddress import itertools import sys +import time import weakref from collections.abc import Iterator from math import ceil, modf @@ -352,6 +353,10 @@ def test_timeout_handle(event_loop: asyncio.AbstractEventLoop) -> None: assert not handle._callbacks +@pytest.mark.skipif( + time.get_clock_info("monotonic").resolution > 0.001, + reason="loop.time() resolution is coarser than the test's 1ms tolerance", +) def test_when_timeout_smaller_second(event_loop: asyncio.AbstractEventLoop) -> None: timeout = 0.1 diff --git a/tests/test_http_parser.py b/tests/test_http_parser.py index 15d3338bbc6..b37c71f397f 100644 --- a/tests/test_http_parser.py +++ b/tests/test_http_parser.py @@ -706,6 +706,21 @@ def test_parse(parser: HttpRequestParser) -> None: assert msg.headers["Host"] == "a" +def test_parse_query_method(parser: HttpRequestParser) -> None: + """QUERY has the highest llhttp method id; both parsers must decode its name. + + Regression test for a stale hand-maintained method count in the C parser + that mapped the last method(s) to "". + """ + text = b"QUERY /test HTTP/1.1\r\nHost: a\r\n\r\n" + messages, upgrade, tail = parser.feed_data(text) + assert len(messages) == 1 + msg = messages[0][0] + assert msg.method == "QUERY" + assert msg.path == "/test" + assert msg.version == (1, 1) + + async def test_parse_body(parser: HttpRequestParser) -> None: text = b"GET /test HTTP/1.1\r\nHost: a\r\nContent-Length: 4\r\n\r\nbody" messages, upgrade, tail = parser.feed_data(text) @@ -2542,6 +2557,19 @@ def test_parse_uri_utf8_percent_encoded(parser: HttpRequestParser) -> None: assert msg.url.fragment == "фраг" +def test_parse_uri_empty_query_with_fragment(parser: HttpRequestParser) -> None: + # Origin-form target with an empty query but a fragment: the ``#`` sits + # immediately after ``?``. Regression for the C parser folding ``#frag`` + # into the query string instead of the fragment. + text = b"GET /path?#frag HTTP/1.1\r\nHost: a\r\n\r\n" + messages, upgrade, tail = parser.feed_data(text) + msg = messages[0][0] + + assert msg.url.path == "/path" + assert msg.url.query == {} + assert msg.url.fragment == "frag" + + @pytest.mark.skipif( "HttpRequestParserC" not in dir(aiohttp.http_parser), reason="C based HTTP parser not available", diff --git a/tests/test_web_response.py b/tests/test_web_response.py index cc564cf832f..d4fb92d3cf4 100644 --- a/tests/test_web_response.py +++ b/tests/test_web_response.py @@ -302,6 +302,23 @@ def test_last_modified_datetime() -> None: assert resp.last_modified == dt +def test_last_modified_datetime_and_timestamp_round_consistently() -> None: + dt = datetime.datetime(2020, 12, 2, 9, 51, 2, 500000, datetime.timezone.utc) + + resp_dt = web.StreamResponse() + resp_dt.last_modified = dt + + resp_ts = web.StreamResponse() + resp_ts.last_modified = dt.timestamp() + + assert resp_dt.headers["Last-Modified"] == resp_ts.headers["Last-Modified"] + # Both should round up to the next whole second, so that Last-Modified + # is never earlier than the file's true st_mtime, invalidating the caching. + assert resp_dt.last_modified == datetime.datetime( + 2020, 12, 2, 9, 51, 3, 0, datetime.timezone.utc + ) + + def test_last_modified_reset() -> None: resp = web.StreamResponse()