Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGES/11243.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Updated `Content-Disposition` header parsing to handle trailing semicolons and empty parts
-- by :user:`PLPeeters`.
4 changes: 4 additions & 0 deletions aiohttp/multipart.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,10 @@ def unescape(text: str, *, chars: str = "".join(map(re.escape, CHAR))) -> str:
while parts:
item = parts.pop(0)

if not item: # To handle trailing semicolons
warnings.warn(BadContentDispositionHeader(header))
continue

if "=" not in item:
warnings.warn(BadContentDispositionHeader(header))
return None, {}
Expand Down
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,11 @@ def assert_sock_fits(sock_path: str) -> None:
return


@pytest.fixture
async def event_loop(loop: asyncio.AbstractEventLoop) -> asyncio.AbstractEventLoop:
return asyncio.get_running_loop()


@pytest.fixture
def selector_loop() -> Iterator[asyncio.AbstractEventLoop]:
factory = asyncio.SelectorEventLoop
Expand Down
53 changes: 28 additions & 25 deletions tests/isolated/check_for_client_response_leak.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import asyncio
import contextlib
import gc
import socket
import sys

from aiohttp import ClientError, ClientSession, web
from aiohttp.test_utils import get_unused_port_socket
from aiohttp.test_utils import REUSE_ADDRESS

gc.set_debug(gc.DEBUG_LEAK)

Expand All @@ -18,30 +19,32 @@ async def stream_handler(request: web.Request) -> web.Response:
return web.Response()

app.router.add_get("/stream", stream_handler)
sock = get_unused_port_socket("127.0.0.1")
port = sock.getsockname()[1]

runner = web.AppRunner(app)
await runner.setup()
site = web.SockSite(runner, sock)
await site.start()

session = ClientSession()

async def fetch_stream(url: str) -> None:
"""Fetch a stream and read a few bytes from it."""
with contextlib.suppress(ClientError):
await session.get(url)

client_task = asyncio.create_task(fetch_stream(f"http://localhost:{port}/stream"))
await client_task
gc.collect()
client_response_present = any(
type(obj).__name__ == "ClientResponse" for obj in gc.garbage
)
await session.close()
await runner.cleanup()
sys.exit(1 if client_response_present else 0)
with socket.create_server(("127.0.0.1", 0), reuse_port=REUSE_ADDRESS) as sock:
port = sock.getsockname()[1]

runner = web.AppRunner(app)
await runner.setup()
site = web.SockSite(runner, sock)
await site.start()

session = ClientSession()

async def fetch_stream(url: str) -> None:
"""Fetch a stream and read a few bytes from it."""
with contextlib.suppress(ClientError):
await session.get(url)

client_task = asyncio.create_task(
fetch_stream(f"http://localhost:{port}/stream")
)
await client_task
gc.collect()
client_response_present = any(
type(obj).__name__ == "ClientResponse" for obj in gc.garbage
)
await session.close()
await runner.cleanup()
sys.exit(1 if client_response_present else 0)


asyncio.run(main())
39 changes: 20 additions & 19 deletions tests/isolated/check_for_request_leak.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import asyncio
import gc
import socket
import sys
from typing import NoReturn

from aiohttp import ClientSession, web
from aiohttp.test_utils import get_unused_port_socket
from aiohttp.test_utils import REUSE_ADDRESS

gc.set_debug(gc.DEBUG_LEAK)

Expand All @@ -17,24 +18,24 @@ async def handler(request: web.Request) -> NoReturn:
assert False

app.router.add_route("GET", "/json", handler)
sock = get_unused_port_socket("127.0.0.1")
port = sock.getsockname()[1]

runner = web.AppRunner(app)
await runner.setup()
site = web.SockSite(runner, sock)
await site.start()

async with ClientSession() as session:
async with session.get(f"http://127.0.0.1:{port}/json") as resp:
await resp.read()

# Give time for the cancelled task to be collected
await asyncio.sleep(0.5)
gc.collect()
request_present = any(type(obj).__name__ == "Request" for obj in gc.garbage)
await session.close()
await runner.cleanup()
with socket.create_server(("127.0.0.1", 0), reuse_port=REUSE_ADDRESS) as sock:
port = sock.getsockname()[1]

runner = web.AppRunner(app)
await runner.setup()
site = web.SockSite(runner, sock)
await site.start()

async with ClientSession() as session:
async with session.get(f"http://127.0.0.1:{port}/json") as resp:
await resp.read()

# Give time for the cancelled task to be collected
await asyncio.sleep(0.5)
gc.collect()
request_present = any(type(obj).__name__ == "Request" for obj in gc.garbage)
await session.close()
await runner.cleanup()
sys.exit(1 if request_present else 0)


Expand Down
29 changes: 29 additions & 0 deletions tests/test_client_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from aiohttp.client_reqrep import ClientResponse, RequestInfo
from aiohttp.connector import Connection
from aiohttp.helpers import TimerNoop
from aiohttp.multipart import BadContentDispositionHeader


class WriterMock(mock.AsyncMock):
Expand Down Expand Up @@ -996,6 +997,34 @@ def test_content_disposition_no_parameters() -> None:
assert {} == response.content_disposition.parameters


@pytest.mark.parametrize(
"content_disposition",
(
'attachment; filename="archive.tar.gz";',
'attachment;; filename="archive.tar.gz"',
),
)
def test_content_disposition_empty_parts(content_disposition: str) -> None:
response = ClientResponse(
"get",
URL("http://def-cl-resp.org"),
request_info=mock.Mock(),
writer=WriterMock(),
continue100=None,
timer=TimerNoop(),
traces=[],
loop=mock.Mock(),
session=mock.Mock(),
)
h = {"Content-Disposition": content_disposition}
response._headers = CIMultiDictProxy(CIMultiDict(h))

with pytest.warns(BadContentDispositionHeader):
assert response.content_disposition is not None
assert "attachment" == response.content_disposition.type
assert "archive.tar.gz" == response.content_disposition.filename


def test_content_disposition_no_header() -> None:
response = ClientResponse(
"get",
Expand Down
Loading
Loading