diff --git a/src/acp/_cookies.py b/src/acp/_cookies.py index a4b05d6..5cd757e 100644 --- a/src/acp/_cookies.py +++ b/src/acp/_cookies.py @@ -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 @@ -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.""" @@ -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 diff --git a/tests/http/test_cookies.py b/tests/http/test_cookies.py index 5bc0ae4..1788590 100644 --- a/tests/http/test_cookies.py +++ b/tests/http/test_cookies.py @@ -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