Source code for aioafero.v1.conclave.frames
r"""Conclave wire-frame decoder.
Server frames arrive as a zlib ``hello`` blob, then newline-delimited or
concatenated JSON objects, with bare ``\n`` heartbeats mixed in.
Pure helpers (:func:`try_parse_zlib_prefix`, :func:`index_json_object_start`)
are module-level for unit tests; :class:`ConclaveFrameDecoder` is the
incremental buffer used by the TLS client.
"""
from __future__ import annotations
from collections.abc import Iterator
from dataclasses import dataclass
import json
import logging
import zlib
logger = logging.getLogger(__name__)
HEARTBEAT_FRAME: dict = {"__heartbeat__": True}
_NEWLINE = ord("\n")
_WS_ORDS = frozenset({ord(c) for c in "\n\r \t"})
_NON_OBJECT_JSON = object()
[docs]
@dataclass(frozen=True)
class ZlibPrefixResult:
"""Result of :func:`try_parse_zlib_prefix`."""
frame: dict | None
consumed: int
decided: bool
[docs]
def index_json_object_start(data: bytes) -> int | None:
"""Return the byte offset of the first ``{`` in ``data``, if any."""
index = data.find(b"{")
return index if index >= 0 else None
[docs]
def try_parse_zlib_prefix(data: bytes) -> ZlibPrefixResult:
"""Attempt to decompress a leading zlib envelope from ``data``."""
if not data:
return ZlibPrefixResult(None, 0, False)
if data[0] != 0x78:
return ZlibPrefixResult(None, 0, True)
if len(data) < 2:
return ZlibPrefixResult(None, 0, False)
decompressor = zlib.decompressobj()
try:
decompressed = decompressor.decompress(data)
except zlib.error:
return ZlibPrefixResult(None, 0, True)
if not decompressor.eof:
return ZlibPrefixResult(None, 0, False)
consumed = len(data) - len(decompressor.unused_data)
try:
frame = json.loads(decompressed.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
logger.warning("Discarding malformed zlib-framed Conclave hello frame")
return ZlibPrefixResult(None, consumed, True)
if not isinstance(frame, dict):
logger.debug("Discarding non-object zlib-framed Conclave frame: %r", frame)
return ZlibPrefixResult(None, consumed, True)
return ZlibPrefixResult(frame, consumed, True)
def _heartbeats_from_bytes(data: bytes) -> Iterator[dict]:
"""Yield :data:`HEARTBEAT_FRAME` for each newline byte in ``data``."""
for byte in data:
if byte == _NEWLINE:
yield HEARTBEAT_FRAME
[docs]
class ConclaveFrameDecoder:
"""Incremental decoder for the Conclave server-to-client stream."""
[docs]
def __init__(self) -> None:
"""Start decoding a fresh Conclave connection."""
self._buffer = bytearray()
self._expecting_zlib_prefix = True
[docs]
def feed(self, chunk: bytes) -> Iterator[dict]:
"""Feed bytes and yield each complete frame."""
if chunk:
self._buffer.extend(chunk)
yield from self._drain()
def _drain(self) -> Iterator[dict]:
if self._expecting_zlib_prefix:
result = try_parse_zlib_prefix(bytes(self._buffer))
if not result.decided:
return
self._expecting_zlib_prefix = False
if result.consumed:
del self._buffer[: result.consumed]
if result.frame is not None:
yield result.frame
yield from self._drain_json()
def _drain_json(self) -> Iterator[dict]:
decoder = json.JSONDecoder()
while self._buffer:
yield from self._yield_ws_heartbeats()
if not self._buffer:
break
if self._buffer[0] != ord("{"):
json_start = index_json_object_start(self._buffer)
if json_start is None:
if self._consume_json_value(decoder) is _NON_OBJECT_JSON:
continue
return
yield from self._heartbeats_before(json_start)
result = self._consume_json_value(decoder)
if result is None:
return
if result is not _NON_OBJECT_JSON:
yield result
def _yield_ws_heartbeats(self) -> Iterator[dict]:
while self._buffer and self._buffer[0] in _WS_ORDS:
heartbeat = self._buffer[0] == _NEWLINE
del self._buffer[:1]
if heartbeat:
yield HEARTBEAT_FRAME
def _heartbeats_before(self, json_start: int) -> Iterator[dict]:
prefix = bytes(self._buffer[:json_start])
del self._buffer[:json_start]
yield from _heartbeats_from_bytes(prefix)
def _consume_json_value(self, decoder: json.JSONDecoder) -> dict | object | None:
"""Parse one JSON value from the front of the buffer.
:returns: A frame dict, ``_NON_OBJECT_JSON`` when a scalar/array was
dropped, or ``None`` when more bytes are needed.
"""
try:
text = self._buffer.decode("utf-8")
except UnicodeDecodeError:
return None
try:
obj, end_index = decoder.raw_decode(text)
except json.JSONDecodeError:
return None
byte_end = len(text[:end_index].encode("utf-8"))
del self._buffer[:byte_end]
if isinstance(obj, dict):
return obj
logger.debug("Discarding non-object Conclave frame: %r", obj)
return _NON_OBJECT_JSON
[docs]
def encode_frame(payload: dict) -> bytes:
"""Encode a client-to-server JSON frame."""
return json.dumps(payload, separators=(",", ":")).encode("utf-8") + b"\n"
[docs]
def encode_heartbeat() -> bytes:
"""Return the client heartbeat byte sequence."""
return b"\n"