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
59 changes: 52 additions & 7 deletions src/acp/_cookies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]


Expand All @@ -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."""
Expand All @@ -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
30 changes: 30 additions & 0 deletions tests/http/test_cookies.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

import pytest

from acp._cookies import MemoryAcpCookieStore


Expand All @@ -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
Expand Down