Files
wehub-resource-sync 09e9f3545f
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

718 lines
32 KiB
Python

"""Cookie storage snapshot/delta persistence helpers for authentication."""
from __future__ import annotations
import contextlib
import errno
import json
import logging
import os
import sys
import warnings
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path
from typing import Any, NamedTuple, TypeAlias
import httpx
from .._atomic_io import atomic_write_json
from . import cookie_policy as _cookie_policy
from . import cookies as _auth_cookies
from .paths import _storage_state_lock_path
logger = logging.getLogger("notebooklm.auth")
CookieKey: TypeAlias = _auth_cookies.CookieKey
_cookie_is_http_only = _auth_cookies._cookie_is_http_only
_cookie_key_variants = _auth_cookies._cookie_key_variants
_cookie_to_storage_state = _auth_cookies._cookie_to_storage_state
_find_cookie_for_storage = _auth_cookies._find_cookie_for_storage
_is_allowed_cookie_domain = _cookie_policy._is_allowed_cookie_domain
class CookieSnapshotKey(NamedTuple):
"""Path-aware cookie identity used by the snapshot/delta save machinery.
RFC 6265 treats ``path`` as part of cookie identity: two cookies with the
same ``(name, domain)`` but different paths are distinct entries. The
snapshot/delta path widens the legacy ``(name, domain)`` key (still used
elsewhere for back-compat — see ``CookieKey``) to ``(name, domain, path)``
so that path-scoped cookies (e.g. ``OSID`` on a per-product path) survive
a load → save round trip and so that a sibling-process write to a
different-path variant of the same name is not silently overwritten.
"""
name: str
domain: str
path: str
class CookieSnapshotValue(NamedTuple):
"""Snapshot value tuple: ``(value, expires, secure, http_only)``.
Widened from a bare ``str`` so that a ``Set-Cookie`` which keeps the same
value but renews ``expires`` (or flips ``secure`` / ``httpOnly``) still
registers as a delta. The legacy save path compared ``expires`` directly
and would write the new expiry through; the snapshot path previously
keyed on value alone and silently dropped attribute-only refreshes.
"""
value: str
expires: int | None
secure: bool
http_only: bool
CookieSnapshot: TypeAlias = dict[CookieSnapshotKey, CookieSnapshotValue]
@dataclass(frozen=True)
class CookieSaveResult:
"""Detailed result for callers that need to maintain a save baseline."""
ok: bool
cas_rejected_keys: frozenset[CookieSnapshotKey] = frozenset()
_LOCK_CONTENTION_ERRNOS = {errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES}
@contextlib.contextmanager
def _file_lock(lock_path: Path, *, blocking: bool, log_prefix: str) -> Iterator[str]:
"""Cross-process exclusive lock on ``lock_path``.
Yields one of:
- ``"held"`` — the lock is held; release it on exit.
- ``"contended"`` — non-blocking acquire saw the lock held elsewhere.
Only ever yielded when ``blocking=False``.
- ``"unavailable"`` — lock infrastructure failed (cannot mkdir, cannot
open the sentinel, NFS without flock support). Caller should
**fail open** (proceed without coordination) rather than retry forever.
Wrappers translate this tristate into bool. Distinguishing contention from
infrastructure failure matters: a non-blocking caller should **skip** on
contention (someone else is rotating) but **proceed** on infrastructure
failure (otherwise a read-only auth dir would permanently suppress
rotation).
"""
try:
lock_path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600)
except OSError as exc:
# Read-only directory, permission denied, ENOSPC, etc. Yield
# "unavailable" so the wrapper can fail open.
logger.debug("%s: lock file unavailable %s (%s)", log_prefix, lock_path, exc)
yield "unavailable"
return
locked = False
state = "unavailable"
try:
try:
if sys.platform == "win32":
import msvcrt
msvcrt.locking(fd, msvcrt.LK_LOCK if blocking else msvcrt.LK_NBLCK, 1)
else:
import fcntl
op = fcntl.LOCK_EX if blocking else fcntl.LOCK_EX | fcntl.LOCK_NB
fcntl.flock(fd, op)
locked = True
state = "held"
except OSError as exc:
if not blocking and exc.errno in _LOCK_CONTENTION_ERRNOS:
# Non-blocking acquire bounced because another process holds
# the lock — this is the "skip" signal.
state = "contended"
logger.debug("%s: lock contended (%s)", log_prefix, exc)
else:
# NFS without flock, kernel quirk, etc. Caller should fail open.
state = "unavailable"
logger.debug("%s: lock op unavailable (%s)", log_prefix, exc)
yield state
finally:
if locked:
try:
if sys.platform == "win32":
import msvcrt
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(fd, fcntl.LOCK_UN)
except OSError as exc:
logger.debug("%s: failed to release file lock (%s)", log_prefix, exc)
os.close(fd)
# Dedupe contract: best-effort under threads, exactly-once on a single
# event loop. ``_file_lock_exclusive`` below reads ``_FLOCK_UNAVAILABLE_WARNED``
# and sets it to ``True`` in one synchronous block with no intervening
# ``await``, so concurrent coroutines on one loop cannot interleave between
# the check and the set — the warning fires exactly once per process. Under
# genuine OS threads (out of scope per the documented concurrency contract),
# duplicate warnings are possible. We accept that rather than serialize a
# logging side-effect behind a lock for an unsupported configuration.
#
# Note: ``functools.lru_cache`` and ``logging.LoggerAdapter`` do NOT solve
# this — ``lru_cache`` memoizes return values, not the ``logger.warning``
# side-effect; ``LoggerAdapter`` only rewrites records, it does not filter
# duplicates.
_FLOCK_UNAVAILABLE_WARNED = False
@contextlib.contextmanager
def _file_lock_exclusive(lock_path: Path) -> Iterator[None]:
"""Blocking cross-process exclusive lock on ``lock_path``.
Multiple Python processes that all save to the same ``storage_state.json``
(e.g. a long-running ``NotebookLMClient(keepalive=...)`` worker plus a
cron-driven ``notebooklm auth refresh``) would otherwise race on the read-
merge-write cycle and lose updates. The lock is held on a sentinel file
sibling to the storage file (``.storage_state.json.lock``, derived by
:func:`notebooklm._auth.paths._storage_state_lock_path`), since locking the
storage file itself would interfere with the atomic temp-rename below.
``_auth/account.py`` holds this *same* sentinel via ``filelock.FileLock``
when it writes account metadata into ``storage_state.json``. The two
mechanisms interoperate because ``filelock.FileLock`` also uses
``fcntl.flock`` on POSIX, so an exclusive hold from either side blocks the
other — that cross-mechanism compatibility is what lets cookie saves and
account-metadata writes serialize on one file.
The lock is per-process: threads within one process aren't serialized —
that's the intra-process ``threading.Lock`` held by the client. If the
lock can't be acquired (e.g. NFS where flock semantics vary, read-only
parent dir, fd exhaustion), the save proceeds anyway; correctness in
that mode is best-effort and relies on the snapshot/delta CAS guards in
:func:`_merge_cookies_with_snapshot` alone. The first time this
fallback fires per process emits a WARNING so operators learn their
deployment is running without cross-process coordination.
"""
global _FLOCK_UNAVAILABLE_WARNED
with _file_lock(lock_path, blocking=True, log_prefix="save_cookies_to_storage") as state:
if state == "unavailable" and not _FLOCK_UNAVAILABLE_WARNED:
_FLOCK_UNAVAILABLE_WARNED = True
logger.warning(
"Cross-process file lock unavailable at %s; cookie saves will "
"proceed without cross-process coordination and rely solely on "
"snapshot/delta CAS guards. Common causes: NFS without flock "
"support, read-only parent directory, fd exhaustion. (Logged "
"once per process.)",
lock_path,
)
yield
def snapshot_cookie_jar(cookie_jar: httpx.Cookies) -> CookieSnapshot:
"""Capture an open-time snapshot of an httpx cookie jar.
Snapshots are the input to the dirty-flag/delta merge in
:func:`save_cookies_to_storage`: at save time, only cookies whose
in-memory value differs from the snapshot — plus cookies absent from
the jar but present in the snapshot (deletions) — are propagated to
disk. Cookies the in-process code never touched are left to whatever
a sibling process may have written (closes the §3.4.1
stale-overwrite-fresh hazard).
The key shape is path-aware ``(name, domain, path)`` (also closes
§3.4.2). Cookies with no name or no domain are skipped — the storage
format requires both.
Args:
cookie_jar: The httpx.Cookies object to snapshot.
Returns:
Mapping of ``CookieSnapshotKey -> CookieSnapshotValue`` capturing
each cookie's value and the attributes the storage_state schema
persists (``expires``, ``secure``, ``httpOnly``).
"""
return {
CookieSnapshotKey(cookie.name, cookie.domain, cookie.path or "/"): CookieSnapshotValue(
value=cookie.value,
expires=cookie.expires,
secure=bool(cookie.secure),
http_only=_cookie_is_http_only(cookie),
)
for cookie in cookie_jar.jar
if cookie.name and cookie.domain and cookie.value is not None
}
def _cookie_snapshot_key_variants(key: CookieSnapshotKey) -> set[CookieSnapshotKey]:
"""Return equivalent host/domain snapshot keys for leading-dot domains.
Mirrors :func:`_cookie_key_variants` but preserves the path component so
storage entries on the same path match snapshot entries regardless of
whether ``http.cookiejar`` normalized the domain to a leading dot.
"""
variants = {key}
if key.domain.startswith("."):
variants.add(CookieSnapshotKey(key.name, key.domain[1:], key.path))
else:
variants.add(CookieSnapshotKey(key.name, f".{key.domain}", key.path))
return variants
def _stored_cookie_snapshot_key(stored_cookie: dict[str, Any]) -> CookieSnapshotKey | None:
"""Build a path-aware snapshot key from a Playwright storage_state cookie."""
name = stored_cookie.get("name")
domain = stored_cookie.get("domain", "")
if not name or not domain:
return None
path = stored_cookie.get("path") or "/"
return CookieSnapshotKey(name, domain, path)
def advance_cookie_snapshot_after_save(
original_snapshot: CookieSnapshot | None,
post_save_snapshot: CookieSnapshot,
cas_rejected_keys: frozenset[CookieSnapshotKey],
) -> CookieSnapshot | None:
"""Advance save baseline for successful keys while preserving rejected ones.
A save can partially succeed: one cookie delta may write through while a
sibling-process CAS conflict rejects another. Advancing the whole baseline
would lose the rejected delta; keeping the whole old baseline would replay
already-written deltas and wedge future saves. This helper advances every
key to ``post_save_snapshot`` except the CAS-rejected keys, which retain
their old baseline value or absence. Rejected keys are matched through
leading-dot variants because the merge path can reject a normalized variant
of the key captured in ``original_snapshot``.
"""
if original_snapshot is None:
return None
advanced = dict(post_save_snapshot)
for key in cas_rejected_keys:
original_key = next(
(
variant
for variant in _cookie_snapshot_key_variants(key)
if variant in original_snapshot
),
None,
)
for variant in _cookie_snapshot_key_variants(key):
advanced.pop(variant, None)
if original_key is not None:
advanced[original_key] = original_snapshot[original_key]
return advanced
def _cookie_save_return(
result: CookieSaveResult, *, return_result: bool
) -> bool | CookieSaveResult:
"""Return either the detailed save result or its public bool projection."""
return result if return_result else result.ok
def save_cookies_to_storage(
cookie_jar: httpx.Cookies,
path: Path | None = None,
*,
original_snapshot: CookieSnapshot | None = None,
return_result: bool = False,
) -> bool | CookieSaveResult:
"""Save an updated httpx.Cookies jar back to Playwright storage_state.json.
This ensures that when Google issues short-lived token refreshes (e.g.
during 302 redirects to accounts.google.com), those updated cookies are
serialized back to disk so the session remains valid across CLI invocations.
If auth was loaded from an environment variable (no file), this is a no-op.
Cross-process safety: the read-merge-write cycle is wrapped in an OS-level
file lock (``.storage_state.json.lock``) so concurrent writers from
different Python processes (e.g. an in-process ``NotebookLMClient`` keepalive
plus a cron-driven ``notebooklm auth refresh``) serialize cleanly rather
than tearing or losing updates.
Two merge modes:
- **Legacy (``original_snapshot=None``)**: every in-memory cookie whose
value differs from disk wins. Vulnerable to the stale-overwrite-fresh
race documented in ``docs/auth-cookie-lifecycle.md`` §3.4.1 and emits a
``RuntimeWarning`` safety advisory about that race (this is a permanent
back-compat shim, not a scheduled deprecation, so the advisory is a
``RuntimeWarning`` and is not silenced by ``NOTEBOOKLM_QUIET_DEPRECATIONS``).
Kept only as a public-API back-compat shim for callers outside this repo;
every first-party caller passes ``original_snapshot``.
- **Snapshot/delta (``original_snapshot`` provided)**: only cookies
whose in-memory persisted tuple differs from the snapshot are written, and
cookies present in the snapshot but no longer in the jar are
deleted from disk. Cookies the in-process code never touched are
left untouched on disk so a sibling-process write survives.
Path-aware ``(name, domain, path)`` keys are used here (also closes
§3.4.2).
Args:
cookie_jar: The httpx.Cookies object containing the latest cookies.
path: Path to storage_state.json. If None, cookie sync is skipped.
original_snapshot: Open-time snapshot from
:func:`snapshot_cookie_jar`. When provided, only deltas and
deletions relative to the snapshot are persisted.
return_result: Internal escape hatch for callers that need CAS-rejected
keys to maintain a per-cookie baseline. Public callers should use
the default bool return.
Returns:
``True`` if the disk state now reflects the caller's intent (write
succeeded, was a successful no-op, or the call was a deliberate skip
because auth was loaded from an env var). ``False`` if an I/O error
prevented the save or a CAS guard preserved a sibling-process write.
With ``return_result=True``, callers can inspect CAS-rejected keys and
advance their baseline for the keys that did write through.
"""
if (
not path
and "NOTEBOOKLM_AUTH_JSON" in os.environ
and os.environ["NOTEBOOKLM_AUTH_JSON"].strip()
):
logger.debug("Skipping cookie sync: Auth loaded from NOTEBOOKLM_AUTH_JSON env var")
return _cookie_save_return(CookieSaveResult(True), return_result=return_result)
if not path:
logger.debug("Skipping cookie sync: No storage file path available")
return _cookie_save_return(CookieSaveResult(True), return_result=return_result)
if original_snapshot is None:
# NOT a deprecation: the original_snapshot=None form is a *permanent*
# public-API back-compat shim (docs/auth-cookie-lifecycle.md §3.4.1),
# not a scheduled removal — every in-tree caller already passes a
# snapshot. The warning is a runtime safety advisory about the
# stale-overwrite-fresh race that path is vulnerable to, so it is a
# RuntimeWarning, not a DeprecationWarning. It is therefore outside
# ADR-0018's scope: no NOTEBOOKLM_QUIET_DEPRECATIONS gate, no removal
# version, and emitted directly here rather than via warn_deprecated.
warnings.warn(
"save_cookies_to_storage called without original_snapshot; the "
"legacy full-merge path is vulnerable to the stale-overwrite-fresh "
"race (docs/auth-cookie-lifecycle.md §3.4.1). Pass an original_snapshot "
"captured via snapshot_cookie_jar() at jar-open time.",
RuntimeWarning,
stacklevel=2,
)
lock_path = _storage_state_lock_path(path)
with _file_lock_exclusive(lock_path):
if not path.exists():
logger.debug("Skipping cookie sync: Storage file not found at %s", path)
return _cookie_save_return(CookieSaveResult(False), return_result=return_result)
try:
storage_data = json.loads(path.read_text(encoding="utf-8"))
except Exception as e:
logger.warning("Failed to read storage state for cookie sync: %s", e)
return _cookie_save_return(CookieSaveResult(False), return_result=return_result)
cookies = storage_data.get("cookies") if isinstance(storage_data, dict) else None
if not isinstance(cookies, list) or any(not isinstance(cookie, dict) for cookie in cookies):
logger.warning(
"storage_state at %s has an invalid 'cookies' key/payload; "
"rotated cookies will not be persisted",
path,
)
return _cookie_save_return(CookieSaveResult(False), return_result=return_result)
if original_snapshot is None:
updated_count = _merge_cookies_legacy(cookie_jar, storage_data)
cas_rejected_keys: frozenset[CookieSnapshotKey] = frozenset()
else:
updated_count, cas_rejected_keys = _merge_cookies_with_snapshot(
cookie_jar, storage_data, original_snapshot
)
if updated_count == 0:
# A CAS rejection with no other successful work means disk does
# not reflect our intent; the caller must not advance baseline.
return _cookie_save_return(
CookieSaveResult(not cas_rejected_keys, cas_rejected_keys),
return_result=return_result,
)
try:
atomic_write_json(path, storage_data)
logger.debug("Successfully synced %d refreshed cookies to %s", updated_count, path)
# Even on a successful disk write, if any CAS arm rejected work,
# disk diverges from ``post`` for at least one key — caller must
# not advance baseline.
return _cookie_save_return(
CookieSaveResult(not cas_rejected_keys, cas_rejected_keys),
return_result=return_result,
)
except Exception as e:
logger.warning("Failed to write updated cookies to %s: %s", path, e)
return _cookie_save_return(CookieSaveResult(False), return_result=return_result)
def _merge_cookies_legacy(cookie_jar: httpx.Cookies, storage_data: dict[str, Any]) -> int:
"""Legacy merge: trust in-memory whenever it differs from disk.
Vulnerable to the stale-overwrite-fresh race (§3.4.1). Kept only for
callers that have not yet opted into snapshot semantics. New callers
must pass ``original_snapshot`` to :func:`save_cookies_to_storage`.
Returns:
Number of cookie entries added or modified in ``storage_data``.
"""
cookies_by_key: dict[CookieKey, Any] = {
(cookie.name, cookie.domain, cookie.path or "/"): cookie
for cookie in cookie_jar.jar
if cookie.name and cookie.domain and _is_allowed_cookie_domain(cookie.domain)
}
updated_count = 0
stored_keys: set[CookieKey] = set()
for stored_cookie in storage_data["cookies"]:
name = stored_cookie.get("name")
domain = stored_cookie.get("domain", "")
if not name or not domain:
continue
key: CookieKey = (name, domain, stored_cookie.get("path") or "/")
stored_keys.update(_cookie_key_variants(key))
refreshed_cookie = _find_cookie_for_storage(cookies_by_key, key, stored_cookie.get("value"))
if refreshed_cookie is None:
continue
new_expires = refreshed_cookie.expires if refreshed_cookie.expires is not None else -1
changed = (
stored_cookie.get("value") != refreshed_cookie.value
or stored_cookie.get("expires") != new_expires
)
if changed:
stored_cookie["value"] = refreshed_cookie.value
stored_cookie["expires"] = new_expires
# Normalize present-but-empty ``"path": ""`` to ``"/"`` so the row
# we write matches the path normalization used to build the
# identity key one block up (and used by every loader). Without
# the trailing ``or "/"`` an on-disk row with ``"path": ""`` would
# survive across save cycles while every other code path treats
# it as ``"/"``.
stored_cookie["path"] = refreshed_cookie.path or stored_cookie.get("path") or "/"
stored_cookie["secure"] = refreshed_cookie.secure
stored_cookie["httpOnly"] = _cookie_is_http_only(refreshed_cookie)
updated_count += 1
for key, cookie in cookies_by_key.items():
if key in stored_keys:
continue
storage_data["cookies"].append(_cookie_to_storage_state(cookie))
updated_count += 1
return updated_count
def _merge_cookies_with_snapshot(
cookie_jar: httpx.Cookies,
storage_data: dict[str, Any],
original_snapshot: CookieSnapshot,
) -> tuple[int, frozenset[CookieSnapshotKey]]:
"""Snapshot/delta merge: write only what this process actually changed.
Closes §3.4.1 (stale-overwrite-fresh) and §3.4.2 (path collapse):
- **Deltas (CAS-guarded for keys in the snapshot)**: cookies in the
jar whose snapshot tuple (``value, expires, secure, http_only``)
differs from ``original_snapshot`` are written to disk **only if**
the on-disk value still matches the snapshot value. If disk has
rotated since open time, a sibling process has written it; we
preserve their write rather than clobber it with our local
rotation. New cookies acquired during the session are written only
when no same-key storage row exists yet; an existing row means a
sibling acquired the same cookie first. Comparing the full snapshot
tuple keeps attribute-only refreshes (same value, new ``expires``)
flowing to disk, but CAS remains value-only because attribute-only
sibling drift is routine session metadata and should not wedge later
value rotations.
- **Deletions (CAS-guarded)**: a key present in the snapshot but
absent from the jar is dropped from disk **only if** the on-disk
value still matches the snapshot value — symmetric with the
value-update CAS above. An ``Max-Age=0`` that evicted our
locally-expired copy must not erase the sibling's freshly-issued
replacement.
- **Untouched**: cookies in the jar whose tuple matches the snapshot
are not written, so a sibling-process write to the same key
survives. Cookies on disk that are not in the snapshot are also
left alone (they belong to a sibling process or another path).
Args:
cookie_jar: Current in-memory cookie jar.
storage_data: Mutable storage_state.json dict (modified in place).
original_snapshot: Open-time snapshot of the same jar.
Returns:
Tuple of ``(updated_count, cas_rejected_keys)``:
- ``updated_count``: cookie entries added, modified, or removed
(drives whether the temp-write step runs).
- ``cas_rejected_keys``: keys whose CAS check rejected a delta or
deletion. Caller uses this to advance the baseline only for keys
that were actually written or already matched.
"""
current_snapshot = snapshot_cookie_jar(cookie_jar)
# Path-aware index of jar cookies for delta application. Restricting to
# _is_allowed_cookie_domain matches the legacy save's allowlist gate so
# this PR doesn't inadvertently widen the persisted-domain set.
# Filter ``cookie.value is not None`` to mirror ``snapshot_cookie_jar``: a
# value-less cookie is treated as a deletion (absent from this index, absent
# from ``current_snapshot``) rather than a delta that would write ``null``
# to disk.
cookies_by_snapshot_key = {
CookieSnapshotKey(cookie.name, cookie.domain, cookie.path or "/"): cookie
for cookie in cookie_jar.jar
if (
cookie.name
and cookie.domain
and cookie.value is not None
and _is_allowed_cookie_domain(cookie.domain)
)
}
deltas: dict[CookieSnapshotKey, Any] = {}
for snapshot_key, cookie in cookies_by_snapshot_key.items():
if original_snapshot.get(snapshot_key) != current_snapshot.get(snapshot_key):
deltas[snapshot_key] = cookie
deletion_candidates: set[CookieSnapshotKey] = {
snapshot_key
for snapshot_key in original_snapshot
if snapshot_key not in current_snapshot
# Only delete cookies the merge would otherwise be allowed to write.
# Snapshot may include sibling-product domains the allowlist filters
# out at write time; treating those as deletions would silently drop
# disk entries we never persisted to begin with.
and _is_allowed_cookie_domain(snapshot_key.domain)
}
updated_count = 0
cas_rejected_keys: set[CookieSnapshotKey] = set()
# Apply deltas + deletions to the existing storage entries in place.
new_cookies: list[dict[str, Any]] = []
matched_delta_keys: set[CookieSnapshotKey] = set()
for stored_cookie in storage_data["cookies"]:
stored_key = _stored_cookie_snapshot_key(stored_cookie)
if stored_key is None:
new_cookies.append(stored_cookie)
continue
# Find the delta (or deletion) that maps to this stored entry.
# Match leading-dot domain variants so e.g. snapshot
# ``.accounts.google.com`` lines up with stored ``accounts.google.com``.
# A delta wins over a deletion: if the same stored entry matches
# both (which can happen when httpx normalized one variant), we
# prefer to update rather than drop, because dropping would lose
# the rotation we just applied.
matched_delta_cookie = None
matched_delta_key: CookieSnapshotKey | None = None
for variant in _cookie_snapshot_key_variants(stored_key):
if variant in deltas:
matched_delta_cookie = deltas[variant]
matched_delta_key = variant
break
if matched_delta_cookie is not None:
if matched_delta_key is None: # pragma: no cover - loop invariant
raise RuntimeError("matched_delta_cookie set without matched_delta_key")
# CAS-guard for value updates: if our snapshot had this key in any
# leading-dot variant and disk's current value differs from the
# snapshot value, a sibling process has rewritten the row between
# our open and our save. Preserve their write rather than clobber,
# unless disk has already converged to our current value; in that
# case the save intent is satisfied and the caller may advance its
# baseline.
# Variant-aware lookup mirrors the delta match above: if the snapshot
# was keyed on ``accounts.google.com`` but the matched delta key is
# the leading-dot variant, a plain ``.get(matched_delta_key)`` would
# miss the entry and silently bypass the CAS.
snapshot_entry = next(
(
original_snapshot[variant]
for variant in _cookie_snapshot_key_variants(matched_delta_key)
if variant in original_snapshot
),
None,
)
stored_value = stored_cookie.get("value")
if (
snapshot_entry is not None
and stored_value != snapshot_entry.value
and stored_value != matched_delta_cookie.value
):
logger.debug(
"Skipped CAS-guarded value update of %s on %s: disk value "
"differs from snapshot (sibling write preserved)",
matched_delta_key.name,
matched_delta_key.domain,
)
cas_rejected_keys.add(matched_delta_key)
matched_delta_keys.add(matched_delta_key)
new_cookies.append(stored_cookie)
continue
if snapshot_entry is None and stored_value != matched_delta_cookie.value:
logger.debug(
"Skipped CAS-guarded value update of new cookie %s on %s: "
"disk row already exists (sibling write preserved)",
matched_delta_key.name,
matched_delta_key.domain,
)
cas_rejected_keys.add(matched_delta_key)
matched_delta_keys.add(matched_delta_key)
new_cookies.append(stored_cookie)
continue
new_expires = (
matched_delta_cookie.expires if matched_delta_cookie.expires is not None else -1
)
stored_cookie["value"] = matched_delta_cookie.value
stored_cookie["expires"] = new_expires
# Mirror :func:`_merge_cookies_legacy`: ``or "/"`` normalizes a
# present-but-empty ``"path": ""`` so the written row matches the
# path normalization used by the identity key and every loader.
stored_cookie["path"] = matched_delta_cookie.path or stored_cookie.get("path") or "/"
stored_cookie["secure"] = matched_delta_cookie.secure
stored_cookie["httpOnly"] = _cookie_is_http_only(matched_delta_cookie)
matched_delta_keys.add(matched_delta_key)
updated_count += 1
new_cookies.append(stored_cookie)
continue
deletion_match = next(
(
variant
for variant in _cookie_snapshot_key_variants(stored_key)
if variant in deletion_candidates
),
None,
)
if deletion_match is not None:
# CAS-guard: only drop the disk row if its value still matches
# what we observed at snapshot time. A sibling process may have
# rewritten this key between our open and our save; clobbering
# their fresh value with our local eviction would resurrect the
# exact stale-overwrite-fresh hazard the snapshot path exists
# to close (just inverted — deletion-of-fresh instead of
# value-write-of-stale).
snapshot_value = original_snapshot[deletion_match].value
if stored_cookie.get("value") == snapshot_value:
updated_count += 1
continue # drop the entry from disk
cas_rejected_keys.add(deletion_match)
new_cookies.append(stored_cookie)
# Append delta cookies that didn't match any existing storage entry
# (genuinely new cookies acquired during the session).
for snapshot_key, cookie in deltas.items():
if snapshot_key in matched_delta_keys:
continue
new_cookies.append(_cookie_to_storage_state(cookie))
updated_count += 1
storage_data["cookies"] = new_cookies
return updated_count, frozenset(cas_rejected_keys)