Skip to content
Open
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
36 changes: 29 additions & 7 deletions src/acp/_cookies.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
``Set-Cookie`` headers from the upgrade response and echo them back as a
``Cookie`` request header for the socket lifetime.

This is intentionally minimal: it stores name→value pairs without attribute
parsing (domain/path/expiry), matching the affinity-only use case in the RFD.
This is intentionally minimal: it stores name→value pairs and only honors
expiration attributes that remove a cookie, matching the affinity-only use case
in the RFD.
"""

from __future__ import annotations
Expand All @@ -23,16 +24,23 @@ def __init__(self) -> None:
def store_set_cookie(self, header_value: str) -> None:
"""Ingest a single ``Set-Cookie`` header value.

Only the leading ``name=value`` pair is retained; cookie attributes
(``; Path=/``, ``; HttpOnly`` etc.) are ignored.
Only the leading ``name=value`` pair is retained; most cookie attributes
(``; Path=/``, ``; HttpOnly`` etc.) are ignored. Expiration attributes
that explicitly clear a cookie (``Max-Age=0`` or an epoch ``Expires``
value) remove any stored cookie with the same name.
"""
first = header_value.split(";", 1)[0].strip()
parts = [part.strip() for part in header_value.split(";")]
first = parts[0]
if not first or "=" not in first:
return
name, _, value = first.partition("=")
name = name.strip()
if name:
self._cookies[name] = value.strip()
if not name:
return
if _is_deletion_cookie(parts[1:]):
self._cookies.pop(name, None)
return
self._cookies[name] = value.strip()

def store_set_cookies(self, header_values: list[str]) -> None:
"""Ingest multiple ``Set-Cookie`` header values."""
Expand All @@ -51,3 +59,17 @@ def clear(self) -> None:

def __len__(self) -> int:
return len(self._cookies)


def _is_deletion_cookie(attributes: list[str]) -> bool:
for attribute in attributes:
key, separator, value = attribute.partition("=")
if not separator:
continue
key = key.strip().lower()
value = value.strip().lower()
if key == "max-age" and value == "0":
return True
if key == "expires" and value in {"thu, 01 jan 1970 00:00:00 gmt", "0"}:
return True
return False
15 changes: 15 additions & 0 deletions tests/http/test_cookies.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,21 @@ def test_later_value_overwrites_same_name() -> None:
assert len(store) == 1


def test_expiring_cookie_removes_stored_value() -> None:
store = MemoryAcpCookieStore()
store.store_set_cookie("affinity=abc123; Path=/")
store.store_set_cookie("affinity=; Max-Age=0; Path=/")
assert store.cookie_header() is None
assert len(store) == 0


def test_epoch_expires_cookie_removes_stored_value() -> None:
store = MemoryAcpCookieStore()
store.store_set_cookie("affinity=abc123; Path=/")
store.store_set_cookie("affinity=deleted; Expires=Thu, 01 Jan 1970 00:00:00 GMT")
assert store.cookie_header() is None


def test_empty_store_returns_none() -> None:
store = MemoryAcpCookieStore()
assert store.cookie_header() is None
Expand Down