"""Remove credentials and listener identity from persisted cassettes.""" import base64 import copy import json import re from dataclasses import dataclass from pathlib import Path from typing import cast from urllib.parse import urlparse SYNTHETIC_ACCOUNT_ID = "synthetic-account" SYNTHETIC_DISPLAY_NAME = "Synthetic Listener" SYNTHETIC_TASTE_PATH = Path(__file__).with_name("synthetic_taste.json") BEARER_PATTERN = re.compile(r"Bearer\s+[A-Za-z0-9._~+/=-]+", re.IGNORECASE) SENSITIVE_PATH_PARTS = {"authorize", "oauth", "token"} @dataclass(frozen=True) class _ListenerIdentities: account_ids: frozenset[str] display_names: frozenset[str] def is_sensitive_url(url: str) -> bool: """Return whether an authentication exchange must never be recorded.""" parsed = urlparse(url) path_parts = {part.casefold() for part in parsed.path.split("/") if part} return parsed.hostname == "accounts.spotify.com" or bool(path_parts & SENSITIVE_PATH_PARTS) def redact_cassette(interactions: list[dict[str, object]]) -> list[dict[str, object]]: """Return safe interactions without mutating or persisting the source.""" safe = [ _drop_headers(copy.deepcopy(interaction)) for interaction in interactions if not is_sensitive_url(str(interaction.get("url", ""))) ] identities = _collect_identities(safe) synthetic_taste = _load_synthetic_taste() return [_redact_interaction(interaction, identities, synthetic_taste) for interaction in safe] def _drop_headers(value: object) -> object: if isinstance(value, dict): return { key: _drop_headers(item) for key, item in value.items() if not str(key).casefold().endswith("headers") } if isinstance(value, list): return [_drop_headers(item) for item in value] return value def _collect_identities(interactions: list[object]) -> _ListenerIdentities: account_ids: set[str] = set() display_names: set[str] = set() for interaction in interactions: if not isinstance(interaction, dict) or not _is_current_user_url( str(interaction.get("url", "")) ): continue payload = _decode_json(str(interaction.get("response_body_base64", ""))) if not isinstance(payload, dict): continue for key in ("id", "account_id"): value = payload.get(key) if isinstance(value, str) and value: account_ids.add(value) display_name = payload.get("display_name") if isinstance(display_name, str) and display_name: display_names.add(display_name) return _ListenerIdentities(frozenset(account_ids), frozenset(display_names)) def _redact_interaction( interaction: object, identities: _ListenerIdentities, synthetic_taste: dict[str, object], ) -> dict[str, object]: if not isinstance(interaction, dict): raise ValueError("Cassette interaction must be a mapping") redacted = {str(key): value for key, value in interaction.items()} url = _replace_text(str(redacted.get("url", "")), identities) redacted["url"] = url redacted["request_body_base64"] = _redact_body( str(redacted.get("request_body_base64", "")), identities, None, synthetic_taste ) replacement = _taste_replacement(url, synthetic_taste) original_body = str(redacted.get("response_body_base64", "")) response_body = _redact_body(original_body, identities, replacement, synthetic_taste) redacted["response_body_base64"] = response_body chunks = redacted.get("response_chunks_base64", []) if response_body != original_body: redacted["response_chunks_base64"] = [response_body] if response_body else [] elif isinstance(chunks, list): redacted["response_chunks_base64"] = [ _redact_body(str(chunk), identities, None, synthetic_taste) for chunk in chunks ] return cast(dict[str, object], _redact_value(redacted, identities, synthetic_taste)) def _redact_body( encoded: str, identities: _ListenerIdentities, replacement: object | None, synthetic_taste: dict[str, object], ) -> str: try: raw = base64.b64decode(encoded, validate=True) except ValueError: raw = b"" if replacement is not None: value = replacement else: try: value = json.loads(raw) except (json.JSONDecodeError, UnicodeDecodeError): text = raw.decode("utf-8", errors="replace") return _encode(_replace_text(text, identities).encode()) redacted = _redact_value(value, identities, synthetic_taste) return _encode(json.dumps(redacted, ensure_ascii=True, separators=(",", ":")).encode("ascii")) def _redact_value( value: object, identities: _ListenerIdentities, synthetic_taste: dict[str, object], ) -> object: if isinstance(value, dict): return { str(key): _taste_summary(synthetic_taste) if str(key) == "taste_profile" else _synthetic_identity(item, identities, synthetic_taste) for key, item in value.items() if not str(key).casefold().endswith("headers") } if isinstance(value, list): return [_redact_value(item, identities, synthetic_taste) for item in value] if isinstance(value, str): text = _replace_text(value, identities) try: nested = json.loads(text) except json.JSONDecodeError: return text if isinstance(nested, (dict, list)): return json.dumps( _redact_value(nested, identities, synthetic_taste), ensure_ascii=True, separators=(",", ":"), ) return text return value def _synthetic_identity( value: object, identities: _ListenerIdentities, synthetic_taste: dict[str, object], ) -> object: if isinstance(value, str) and value in identities.display_names: return SYNTHETIC_DISPLAY_NAME if isinstance(value, str) and value in identities.account_ids: return SYNTHETIC_ACCOUNT_ID return _redact_value(value, identities, synthetic_taste) def _replace_text(value: str, identities: _ListenerIdentities) -> str: redacted = BEARER_PATTERN.sub("[REDACTED]", value) replacements = { **{identity: SYNTHETIC_ACCOUNT_ID for identity in identities.account_ids}, **{identity: SYNTHETIC_DISPLAY_NAME for identity in identities.display_names}, } for identity, replacement in sorted( replacements.items(), key=lambda item: len(item[0]), reverse=True ): redacted = redacted.replace(identity, replacement) return redacted def _taste_replacement(url: str, synthetic_taste: dict[str, object]) -> object | None: path = urlparse(url).path.rstrip("/") if path.endswith("/me/top/artists"): return synthetic_taste.get("top_artists") if path.endswith("/me/top/tracks"): return synthetic_taste.get("top_tracks") if path.endswith("/me/tracks"): return synthetic_taste.get("saved_tracks") return None def _is_current_user_url(url: str) -> bool: return urlparse(url).path.rstrip("/").endswith("/v1/me") def _taste_summary(synthetic_taste: dict[str, object]) -> str: summary = synthetic_taste.get("summary") if not isinstance(summary, str): raise ValueError("Synthetic taste fixture must contain a summary") return summary def _load_synthetic_taste() -> dict[str, object]: payload: object = json.loads(SYNTHETIC_TASTE_PATH.read_text(encoding="ascii")) if not isinstance(payload, dict): raise ValueError("Synthetic taste fixture must be a mapping") return cast(dict[str, object], payload) def _decode_json(encoded: str) -> object: try: return json.loads(base64.b64decode(encoded, validate=True)) except (ValueError, json.JSONDecodeError, UnicodeDecodeError): return None def _encode(value: bytes) -> str: return base64.b64encode(value).decode("ascii")