"""Remote client-policy parsing and non-sensitive local caching.""" from __future__ import annotations import json import os import tempfile from dataclasses import dataclass, replace from datetime import datetime, timezone from . import appconfig POLICY_VERSION = 1 CACHE_VERSION = 1 CACHE_FILENAME = "client_policy.json" class ClientPolicyError(ValueError): """Raised when a remote or cached client policy is malformed.""" @dataclass(frozen=True) class ClientPolicy: policy_version: int subscription_check_enabled: bool subscription_enforcement_enabled: bool updated_at: str source: str = "remote" warning: str = "" @property def mode(self) -> str: if not self.subscription_check_enabled: return "off" if self.subscription_enforcement_enabled: return "enforce" return "observe" def to_contract_dict(self) -> dict: return { "policy_version": self.policy_version, "subscription_check_enabled": self.subscription_check_enabled, "subscription_enforcement_enabled": ( self.subscription_enforcement_enabled ), "updated_at": self.updated_at, } def observation_policy(*, source="fallback", warning="") -> ClientPolicy: """Return the fail-open client fallback that still observes entitlement.""" return ClientPolicy( policy_version=POLICY_VERSION, subscription_check_enabled=True, subscription_enforcement_enabled=False, updated_at="", source=source, warning=warning, ) def parse_client_policy(value, *, source="remote") -> ClientPolicy: if not isinstance(value, dict): raise ClientPolicyError("客户端策略不是 JSON 对象") policy_version = value.get("policy_version") if type(policy_version) is not int or policy_version != POLICY_VERSION: raise ClientPolicyError("客户端策略版本不受支持") check_enabled = value.get("subscription_check_enabled") enforcement_enabled = value.get("subscription_enforcement_enabled") if type(check_enabled) is not bool or type(enforcement_enabled) is not bool: raise ClientPolicyError("客户端订阅策略开关必须是布尔值") updated_at = _parse_timezone_timestamp( value.get("updated_at"), field_name="客户端策略更新时间", ) warning = "" if not check_enabled and enforcement_enabled: enforcement_enabled = False warning = "客户端订阅策略组合非法,已按关闭模式处理" return ClientPolicy( policy_version=policy_version, subscription_check_enabled=check_enabled, subscription_enforcement_enabled=enforcement_enabled, updated_at=updated_at, source=source, warning=warning, ) def policy_cache_path(config=None) -> str: return appconfig.data_path("config", CACHE_FILENAME, config=config) def save_cached_policy( policy: ClientPolicy, *, config=None, path=None, cached_at=None, ) -> str: destination = os.path.abspath(path or policy_cache_path(config)) os.makedirs(os.path.dirname(destination), exist_ok=True) acquired_at = _parse_timezone_timestamp( cached_at or _now_iso(), field_name="客户端策略缓存时间", ) payload = { "cache_version": CACHE_VERSION, "cached_at": acquired_at, "client_policy": policy.to_contract_dict(), } temporary_path = "" try: with tempfile.NamedTemporaryFile( mode="w", encoding="utf-8", dir=os.path.dirname(destination), prefix="client-policy-", suffix=".tmp", delete=False, ) as handle: temporary_path = handle.name json.dump(payload, handle, ensure_ascii=False, indent=2) handle.write("\n") handle.flush() os.fsync(handle.fileno()) os.replace(temporary_path, destination) finally: if temporary_path and os.path.exists(temporary_path): try: os.remove(temporary_path) except OSError: pass return destination def load_cached_policy(*, config=None, path=None) -> ClientPolicy: source_path = os.path.abspath(path or policy_cache_path(config)) try: with open(source_path, "r", encoding="utf-8") as handle: payload = json.load(handle) except (OSError, ValueError) as exc: raise ClientPolicyError("客户端策略缓存不可用") from exc cache_version = payload.get("cache_version") if isinstance(payload, dict) else None if type(cache_version) is not int or cache_version != CACHE_VERSION: raise ClientPolicyError("客户端策略缓存版本不受支持") _parse_timezone_timestamp( payload.get("cached_at"), field_name="客户端策略缓存时间", ) return parse_client_policy(payload.get("client_policy"), source="cache") def resolve_client_policy( remote_policy, *, config=None, path=None, ) -> ClientPolicy: """Prefer a valid live policy, then cache, then observation mode.""" if isinstance(remote_policy, ClientPolicy): try: save_cached_policy(remote_policy, config=config, path=path) except (OSError, ClientPolicyError) as exc: warning = _join_warning( remote_policy.warning, "客户端策略缓存写入失败:%s" % exc, ) return replace(remote_policy, source="remote", warning=warning) return replace(remote_policy, source="remote") try: return load_cached_policy(config=config, path=path) except ClientPolicyError as exc: return observation_policy( warning="未取得有效远程策略或本地缓存,已使用观察模式:%s" % exc ) def _parse_timezone_timestamp(value, *, field_name) -> str: text = str(value or "").strip() if not text: raise ClientPolicyError("%s不能为空" % field_name) try: parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) except ValueError as exc: raise ClientPolicyError("%s格式不正确" % field_name) from exc if parsed.tzinfo is None or parsed.utcoffset() is None: raise ClientPolicyError("%s必须包含时区" % field_name) return text def _now_iso() -> str: return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds") def _join_warning(*messages) -> str: return ";".join(str(message).strip() for message in messages if str(message).strip())