diff --git a/src/acp/_cookies.py b/src/acp/_cookies.py index a4b05d6..8bd7e6f 100644 --- a/src/acp/_cookies.py +++ b/src/acp/_cookies.py @@ -5,12 +5,16 @@ ``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 +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime + __all__ = ["MemoryAcpCookieStore"] @@ -23,16 +27,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 (non-positive ``Max-Age`` or a past + ``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 +62,37 @@ def clear(self) -> None: def __len__(self) -> int: return len(self._cookies) + + +def _is_deletion_cookie(attributes: list[str]) -> bool: + return _expiry_decision(attributes) is True + + +def _expiry_decision(attributes: list[str]) -> bool | None: + """Return whether attributes expire the cookie now. + + ``True`` means delete now, ``False`` means keep the cookie, and ``None`` + means no usable expiration attribute was present. Per RFC 6265 section 5.3, + a valid ``Max-Age`` attribute takes precedence over ``Expires``. + """ + expires_verdict: bool | None = None + for attribute in attributes: + key, separator, value = attribute.partition("=") + if not separator: + continue + key = key.strip().lower() + value = value.strip() + if key == "max-age": + try: + return int(value) <= 0 + except ValueError: + continue + if key == "expires" and expires_verdict is None: + try: + expires_at = parsedate_to_datetime(value) + except (TypeError, ValueError): + continue + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + expires_verdict = expires_at <= datetime.now(timezone.utc) + return expires_verdict diff --git a/tests/http/test_cookies.py b/tests/http/test_cookies.py index 5bc0ae4..d62c046 100644 --- a/tests/http/test_cookies.py +++ b/tests/http/test_cookies.py @@ -1,5 +1,7 @@ from __future__ import annotations +import pytest + from acp._cookies import MemoryAcpCookieStore @@ -23,6 +25,34 @@ def test_later_value_overwrites_same_name() -> None: assert len(store) == 1 +@pytest.mark.parametrize( + "expiration_attribute", + [ + "Max-Age=0", + "Max-Age=-1", + "Max-Age=00", + "Expires=Thu, 01 Jan 1970 00:00:00 GMT", + "Expires=Thu, 01 Jan 1970 00:00:01 GMT", + "Expires=Wed, 31 Dec 1969 23:59:59 GMT", + "Expires=Thu, 01-Jan-1970 00:00:00 GMT", + "Expires=Mon, 01 Jan 2024 00:00:00 GMT", + ], +) +def test_expiring_cookie_removes_stored_value(expiration_attribute: str) -> None: + store = MemoryAcpCookieStore() + store.store_set_cookie("affinity=abc123; Path=/") + store.store_set_cookie(f"affinity=; {expiration_attribute}; Path=/") + assert store.cookie_header() is None + assert len(store) == 0 + + +def test_positive_max_age_takes_precedence_over_past_expires() -> None: + store = MemoryAcpCookieStore() + store.store_set_cookie("affinity=abc123; Path=/") + store.store_set_cookie("affinity=keepme; Max-Age=3600; Expires=Thu, 01 Jan 1970 00:00:00 GMT") + assert store.cookie_header() == "affinity=keepme" + + def test_empty_store_returns_none() -> None: store = MemoryAcpCookieStore() assert store.cookie_header() is None