initial commit

This commit is contained in:
Filip
2026-03-11 16:06:00 +01:00
parent b3c69053f6
commit 5fd80e6dd6
127 changed files with 39684 additions and 0 deletions

125
zaloha/protocol.py Normal file
View File

@@ -0,0 +1,125 @@
"""Newline-delimited JSON protocol with base64 encoding for binary data."""
import asyncio
import base64
import binascii
import json
import os
def encode_binary(data: bytes) -> str:
"""Encode bytes to base64 string."""
return base64.b64encode(data).decode("ascii")
def decode_binary(data: str) -> bytes:
"""Decode base64 string to bytes."""
try:
return base64.b64decode(data)
except (TypeError, binascii.Error) as e:
raise ValueError(f"Invalid base64: {e}")
VERSION = "0.8.2"
MIN_CLIENT_VERSION = "0.8" # server rejects clients older than this
def version_gte(version: str, minimum: str) -> bool:
"""Return True if version >= minimum (compares numeric tuples, e.g. '0.8.1' >= '0.8')."""
def _parse(v: str) -> tuple[int, ...]:
try:
return tuple(int(x) for x in v.split("."))
except (ValueError, AttributeError):
return (0,)
return _parse(version) >= _parse(minimum)
MAX_MESSAGE_BYTES = int(os.getenv("MAX_MESSAGE_BYTES", "65536")) # 64 KiB default
MAX_IMAGE_BYTES = int(os.getenv("MAX_IMAGE_BYTES", str(5 * 1024 * 1024))) # 5 MiB default, 0 = no limit
MAX_FILE_BYTES = int(os.getenv("MAX_FILE_BYTES", str(50 * 1024 * 1024))) # 50 MiB default
IMAGE_CHUNK_SIZE = 32768 # 32 KiB raw chunk size for image upload/download
def build_request(msg_type: str, request_id: str | None = None, **kwargs) -> bytes:
"""Build a protocol message (newline-terminated JSON)."""
msg = {"type": msg_type, **kwargs}
if request_id:
msg["request_id"] = request_id
return json.dumps(msg, ensure_ascii=False).encode("utf-8") + b"\n"
def build_response(
msg_type: str,
status: str,
data: dict | None = None,
request_id: str | None = None,
) -> bytes:
"""Build a server response."""
msg = {"type": msg_type, "status": status}
if data is not None:
msg["data"] = data
if request_id:
msg["request_id"] = request_id
return json.dumps(msg, ensure_ascii=False).encode("utf-8") + b"\n"
def parse_message(line: bytes) -> dict:
"""Parse a single protocol message from bytes."""
try:
return json.loads(line.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError) as e:
raise ValueError(f"Invalid message: {e}")
class ProtocolReader:
"""Read newline-delimited JSON messages from an asyncio StreamReader."""
def __init__(self, reader: asyncio.StreamReader):
self._reader = reader
async def read_message(self) -> dict | None:
"""Read and parse one message. Returns None on EOF."""
try:
line = await self._reader.readuntil(b"\n")
except (asyncio.IncompleteReadError, ConnectionError):
return None
except asyncio.LimitOverrunError:
# Message exceeded limit — drain the internal buffer and signal error
self._reader._buffer.clear()
self._reader._maybe_resume_transport()
raise ValueError("Message exceeds maximum size")
if not line:
return None
return parse_message(line.strip())
class ProtocolWriter:
"""Write newline-delimited JSON messages to an asyncio StreamWriter."""
def __init__(self, writer: asyncio.StreamWriter):
self._writer = writer
async def send_request(self, msg_type: str, request_id: str | None = None, **kwargs):
"""Send a request message."""
payload = build_request(msg_type, request_id=request_id, **kwargs)
if len(payload) > MAX_MESSAGE_BYTES:
raise ValueError(f"Message exceeds limit ({len(payload)} > {MAX_MESSAGE_BYTES})")
self._writer.write(payload)
await self._writer.drain()
async def send_response(
self,
msg_type: str,
status: str,
data: dict | None = None,
request_id: str | None = None,
):
"""Send a response message."""
payload = build_response(msg_type, status, data, request_id=request_id)
if len(payload) > MAX_MESSAGE_BYTES:
raise ValueError(f"Message exceeds limit ({len(payload)} > {MAX_MESSAGE_BYTES})")
self._writer.write(payload)
await self._writer.drain()
def close(self):
self._writer.close()