rope-protocol 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rope_protocol/__init__.py +17 -0
- rope_protocol/channel.py +146 -0
- rope_protocol/consumer.py +97 -0
- rope_protocol/frames.py +340 -0
- rope_protocol/hub.py +524 -0
- rope_protocol/linksim.py +289 -0
- rope_protocol/media.py +431 -0
- rope_protocol/messages.py +313 -0
- rope_protocol/participant.py +325 -0
- rope_protocol/receiver.py +70 -0
- rope_protocol/recipient.py +23 -0
- rope_protocol/roster.py +88 -0
- rope_protocol/sender.py +228 -0
- rope_protocol/source.py +220 -0
- rope_protocol/status.py +87 -0
- rope_protocol/store.py +273 -0
- rope_protocol-0.1.1.dist-info/METADATA +171 -0
- rope_protocol-0.1.1.dist-info/RECORD +22 -0
- rope_protocol-0.1.1.dist-info/WHEEL +5 -0
- rope_protocol-0.1.1.dist-info/licenses/LICENSE +201 -0
- rope_protocol-0.1.1.dist-info/licenses/NOTICE +4 -0
- rope_protocol-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""ROPE - Resumable Offline Protocol for Emergencies.
|
|
2
|
+
|
|
3
|
+
Specification: docs/spec.md. Implemented so far: the frame codec of the wire format (``rope_protocol.frames``), the
|
|
4
|
+
link simulator for tests (``rope_protocol.linksim``) and streams from sources via a hub to consumers
|
|
5
|
+
(``rope_protocol.source``, ``rope_protocol.hub``, ``rope_protocol.consumer``; shared parts in ``sender``,
|
|
6
|
+
``receiver``, ``participant``, ``store``), messages with custody (``rope_protocol.messages``,
|
|
7
|
+
``rope_protocol.recipient``), offline media as a second path (``rope_protocol.media``) and the participant list
|
|
8
|
+
with names and a stream selection per consumer (``rope_protocol.roster``).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.1"
|
|
12
|
+
|
|
13
|
+
#: Protocol version announced in the greeting (spec 14.5). Increase on incompatible changes.
|
|
14
|
+
PROTOCOL_VERSION = 1
|
|
15
|
+
|
|
16
|
+
#: Default TCP port of the hub (spec 14.1). Applications must allow overriding it.
|
|
17
|
+
DEFAULT_PORT = 47310
|
rope_protocol/channel.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Frames over an asyncio stream connection, with timeouts on every network call (spec section 10)."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from collections import deque
|
|
6
|
+
|
|
7
|
+
from . import frames as F
|
|
8
|
+
|
|
9
|
+
READ_SIZE = 64 * 1024
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def now_ms():
|
|
13
|
+
return int(time.time() * 1000)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def reap(tasks, timeout_s=2.0):
|
|
17
|
+
"""Cancel helper tasks and collect their results, waiting at most ``timeout_s``.
|
|
18
|
+
|
|
19
|
+
Bounded on purpose: on Python 3.11 ``asyncio.wait_for`` can lose a cancellation, so a task might not end at once;
|
|
20
|
+
the caller must never hang on it. Collecting the results keeps late failures out of the "never retrieved" log.
|
|
21
|
+
"""
|
|
22
|
+
tasks = [t for t in tasks if t is not None]
|
|
23
|
+
for t in tasks:
|
|
24
|
+
t.add_done_callback(_retrieve) # also if this cleanup itself is cancelled while waiting
|
|
25
|
+
t.cancel()
|
|
26
|
+
if tasks:
|
|
27
|
+
await asyncio.wait(tasks, timeout=timeout_s)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _retrieve(task):
|
|
31
|
+
if not task.cancelled():
|
|
32
|
+
task.exception()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def serve_frames(ch, timeout_s, handle, peer):
|
|
36
|
+
"""Receive frames until the connection ends. ``handle(frame)`` (async) returns False for frames it does not
|
|
37
|
+
take; heartbeats and unknown extensions are skipped, anything else ends the session."""
|
|
38
|
+
while True:
|
|
39
|
+
f = await ch.recv(timeout_s)
|
|
40
|
+
if await handle(f):
|
|
41
|
+
continue
|
|
42
|
+
if f.type == F.FrameType.HEARTBEAT or f.is_extension:
|
|
43
|
+
continue
|
|
44
|
+
if f.type == F.FrameType.BYE:
|
|
45
|
+
raise ConnectionError("the %s closed the session: %s" % (peer, f.meta["reason"]))
|
|
46
|
+
if f.type == F.FrameType.ERROR:
|
|
47
|
+
raise ConnectionError("the %s reported %s: %s" % (peer, f.meta["code"], f.meta["text"]))
|
|
48
|
+
raise F.FrameError("bad_frame", "unexpected %s from the %s" % (F.FrameType(f.type).name, peer))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Wakeup:
|
|
52
|
+
"""Like ``asyncio.Event`` with a timeout on ``wait``, for a single waiter, built without ``asyncio.wait_for``
|
|
53
|
+
(see ``reap``): a cancellation always ends the wait."""
|
|
54
|
+
|
|
55
|
+
def __init__(self):
|
|
56
|
+
self._flag = False
|
|
57
|
+
self._fut = None
|
|
58
|
+
|
|
59
|
+
def set(self):
|
|
60
|
+
self._flag = True
|
|
61
|
+
if self._fut is not None and not self._fut.done():
|
|
62
|
+
self._fut.set_result(None)
|
|
63
|
+
|
|
64
|
+
def clear(self):
|
|
65
|
+
self._flag = False
|
|
66
|
+
|
|
67
|
+
async def wait(self, timeout_s):
|
|
68
|
+
if self._flag:
|
|
69
|
+
return
|
|
70
|
+
loop = asyncio.get_running_loop()
|
|
71
|
+
fut = self._fut = loop.create_future()
|
|
72
|
+
timer = loop.call_later(timeout_s, lambda: fut.done() or fut.set_result(None))
|
|
73
|
+
try:
|
|
74
|
+
await fut
|
|
75
|
+
finally:
|
|
76
|
+
timer.cancel()
|
|
77
|
+
self._fut = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Channel:
|
|
81
|
+
"""Send and receive validated frames. Any failure raises ConnectionError (or FrameError for bad frames)."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, reader, writer, send_timeout_s=10.0):
|
|
84
|
+
self.reader = reader
|
|
85
|
+
self.writer = writer
|
|
86
|
+
self.send_timeout_s = send_timeout_s
|
|
87
|
+
self.version = F.VERSION
|
|
88
|
+
self._decoder = F.FrameDecoder()
|
|
89
|
+
self._pending = deque()
|
|
90
|
+
self._send_lock = asyncio.Lock()
|
|
91
|
+
self.last_sent = time.monotonic()
|
|
92
|
+
self.last_received = time.monotonic()
|
|
93
|
+
|
|
94
|
+
async def send(self, frame):
|
|
95
|
+
frame.version = self.version
|
|
96
|
+
raw = F.encode(frame)
|
|
97
|
+
async with self._send_lock:
|
|
98
|
+
if self.writer.is_closing():
|
|
99
|
+
raise ConnectionError("connection closed")
|
|
100
|
+
self.writer.write(raw)
|
|
101
|
+
try:
|
|
102
|
+
await asyncio.wait_for(self.writer.drain(), self.send_timeout_s)
|
|
103
|
+
except asyncio.TimeoutError:
|
|
104
|
+
raise ConnectionError("send timed out after %.1f s" % self.send_timeout_s) from None
|
|
105
|
+
self.last_sent = time.monotonic()
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def closed(self):
|
|
109
|
+
return self.writer.is_closing()
|
|
110
|
+
|
|
111
|
+
async def recv(self, timeout_s):
|
|
112
|
+
"""Next frame; ConnectionError if nothing at all arrives within ``timeout_s``."""
|
|
113
|
+
while not self._pending:
|
|
114
|
+
try:
|
|
115
|
+
data = await asyncio.wait_for(self.reader.read(READ_SIZE), timeout_s)
|
|
116
|
+
except asyncio.TimeoutError:
|
|
117
|
+
raise ConnectionError("no sign of life for %.1f s" % timeout_s) from None
|
|
118
|
+
if not data:
|
|
119
|
+
raise ConnectionError("connection closed by peer")
|
|
120
|
+
self.last_received = time.monotonic()
|
|
121
|
+
self._pending.extend(self._decoder.feed(data))
|
|
122
|
+
frame = self._pending.popleft()
|
|
123
|
+
F.validate(frame)
|
|
124
|
+
return frame
|
|
125
|
+
|
|
126
|
+
async def heartbeat(self, interval_s):
|
|
127
|
+
"""Send HEARTBEAT whenever nothing else was sent for ``interval_s`` (runs until cancelled or closed)."""
|
|
128
|
+
while True:
|
|
129
|
+
wait = self.last_sent + interval_s - time.monotonic()
|
|
130
|
+
if wait > 0:
|
|
131
|
+
await asyncio.sleep(wait)
|
|
132
|
+
continue
|
|
133
|
+
await self.send(F.Frame(F.FrameType.HEARTBEAT, {"t": now_ms()}))
|
|
134
|
+
|
|
135
|
+
async def error_and_close(self, code, text):
|
|
136
|
+
try:
|
|
137
|
+
await asyncio.wait_for(self.send(F.Frame(F.FrameType.ERROR, {"code": code, "text": text})), 1.0)
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
self.abort()
|
|
141
|
+
|
|
142
|
+
def abort(self):
|
|
143
|
+
try:
|
|
144
|
+
self.writer.transport.abort()
|
|
145
|
+
except Exception:
|
|
146
|
+
pass
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Consumer participant: receives the streams of all sources from the hub, stores them durably and checks them
|
|
2
|
+
(spec 7, 8). Streams are named ``<source>/<stream>``.
|
|
3
|
+
|
|
4
|
+
The application reads the received bytes from ``path`` up to ``committed`` while a stream is still growing;
|
|
5
|
+
``on_written`` and ``on_checked`` tell it when there is more and when a stream is complete and verified.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
from . import DEFAULT_PORT
|
|
12
|
+
from . import frames as F
|
|
13
|
+
from . import media
|
|
14
|
+
from .participant import Participant
|
|
15
|
+
from .receiver import BLOCK_SIZE, StreamReceiver
|
|
16
|
+
from .store import StreamStore
|
|
17
|
+
|
|
18
|
+
#: Key under which a consumer stores what it receives (it has exactly one sender: the hub).
|
|
19
|
+
FROM_HUB = "hub"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Consumer(Participant):
|
|
23
|
+
"""``root``: folder for the received streams and the mailbox. ``on_written(stream, gen, committed)`` after every
|
|
24
|
+
durable write, ``on_checked(stream, gen, result, path)`` after every final check, ``on_message(meta, body)`` once
|
|
25
|
+
per new message - all called in the event loop."""
|
|
26
|
+
|
|
27
|
+
role = "consumer"
|
|
28
|
+
|
|
29
|
+
def __init__(self, participant, operation, hub_host, root, hub_port=DEFAULT_PORT, timeout_s=10.0,
|
|
30
|
+
backoff=(1.0, 10.0), block_size=BLOCK_SIZE, on_written=None, on_checked=None, on_message=None,
|
|
31
|
+
on_delivery=None):
|
|
32
|
+
super().__init__(participant, operation, hub_host, hub_port, timeout_s, backoff,
|
|
33
|
+
mailbox_root=os.path.join(root, "messages"), on_message=on_message,
|
|
34
|
+
state_dir=root, on_delivery=on_delivery)
|
|
35
|
+
self.store = StreamStore(root)
|
|
36
|
+
self.on_written = on_written
|
|
37
|
+
self.on_checked = on_checked
|
|
38
|
+
self.receiver = StreamReceiver(self.store, FROM_HUB, block_size, on_written=self._written,
|
|
39
|
+
on_checked=self._checked, me="consumer")
|
|
40
|
+
|
|
41
|
+
# --- application interface -------------------------------------------------------------------------------
|
|
42
|
+
def streams(self):
|
|
43
|
+
"""Everything received so far: [{stream, gen, committed, complete, verified, path}]."""
|
|
44
|
+
return [dict(stream=e["stream"], gen=e["gen"], committed=e["committed"], complete=e["complete"],
|
|
45
|
+
verified=e["verified"], path=e["path"])
|
|
46
|
+
for e in sorted(self.store.entries(), key=lambda e: (e["stream"], e["gen"]))
|
|
47
|
+
if e["participant"] == FROM_HUB]
|
|
48
|
+
|
|
49
|
+
def received(self, stream, gen=1):
|
|
50
|
+
"""The stored stream (``path``, ``committed``, ``verified``)."""
|
|
51
|
+
return self.store.get(FROM_HUB, stream, gen)
|
|
52
|
+
|
|
53
|
+
def _activities(self):
|
|
54
|
+
return super()._activities() + [self.store.activity]
|
|
55
|
+
|
|
56
|
+
# --- session ---------------------------------------------------------------------------------------------------
|
|
57
|
+
async def _have(self):
|
|
58
|
+
return await asyncio.to_thread(self.store.have, FROM_HUB)
|
|
59
|
+
|
|
60
|
+
async def _active(self, ch, welcome):
|
|
61
|
+
async def handle(f):
|
|
62
|
+
return await self.receiver.handle(ch, f)
|
|
63
|
+
return [self._serve(ch, handle)]
|
|
64
|
+
|
|
65
|
+
# --- offline media (spec 8.6) ------------------------------------------------------------------------------------
|
|
66
|
+
async def _media_acks(self):
|
|
67
|
+
"""What this consumer holds: the hub's next bundle for it starts there."""
|
|
68
|
+
return [(h["stream"], h["gen"], h["committed"]) for h in await self._have()]
|
|
69
|
+
|
|
70
|
+
async def _media_checks(self):
|
|
71
|
+
def failed():
|
|
72
|
+
return [(e["stream"], e["gen"], self.store.get(FROM_HUB, e["stream"], e["gen"]), self.receiver.block_size)
|
|
73
|
+
for e in self.store.entries() if e["participant"] == FROM_HUB and e["verified"] == "mismatch"]
|
|
74
|
+
return await asyncio.to_thread(failed)
|
|
75
|
+
|
|
76
|
+
def _media_frame(self, f, report, skipped):
|
|
77
|
+
if f.type == F.FrameType.SEGMENT:
|
|
78
|
+
media.import_segment(self.store, FROM_HUB, f, report, skipped)
|
|
79
|
+
elif f.type == F.FrameType.STREAM_END:
|
|
80
|
+
media.import_stream_end(self.store, FROM_HUB, f, report, skipped)
|
|
81
|
+
else:
|
|
82
|
+
return False
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
async def _media_imported(self, report):
|
|
86
|
+
for owner, stream, gen, committed in report.written:
|
|
87
|
+
self._written(owner, stream, gen, committed)
|
|
88
|
+
for owner, stream, gen, result, path in report.checked:
|
|
89
|
+
self._checked(owner, stream, gen, result, path)
|
|
90
|
+
|
|
91
|
+
def _written(self, owner, stream, gen, committed):
|
|
92
|
+
if self.on_written:
|
|
93
|
+
self.on_written(stream, gen, committed)
|
|
94
|
+
|
|
95
|
+
def _checked(self, owner, stream, gen, result, path):
|
|
96
|
+
if self.on_checked:
|
|
97
|
+
self.on_checked(stream, gen, result, path)
|
rope_protocol/frames.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Frame codec of the ROPE wire format (spec section 14, draft 1).
|
|
2
|
+
|
|
3
|
+
A frame is a fixed 12-byte header, a JSON object ("meta"), raw bytes ("blob") and a CRC-32 over everything before it.
|
|
4
|
+
This module only encodes and decodes frames; it does no I/O. ``FrameDecoder`` is incremental: feed it whatever the
|
|
5
|
+
socket returned and it yields complete frames. It checks the length limits before buffering a frame, so a damaged or
|
|
6
|
+
hostile length field cannot make it allocate more than the limits allow.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import enum
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import struct
|
|
13
|
+
import zlib
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
from . import roster
|
|
17
|
+
|
|
18
|
+
MAGIC = b"RP"
|
|
19
|
+
VERSION = 1
|
|
20
|
+
HEADER = struct.Struct(">2sBBII")
|
|
21
|
+
TRAILER = struct.Struct(">I")
|
|
22
|
+
MAX_META = 64 * 1024
|
|
23
|
+
MAX_BLOB = 1024 * 1024
|
|
24
|
+
EXTENSION_FIRST = 0x80
|
|
25
|
+
|
|
26
|
+
_IDENTIFIER = re.compile(r"[A-Za-z0-9._-]{1,64}\Z")
|
|
27
|
+
_HEX32 = re.compile(r"[0-9a-f]{32}\Z")
|
|
28
|
+
_HEX64 = re.compile(r"[0-9a-f]{64}\Z")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FrameType(enum.IntEnum):
|
|
32
|
+
HELLO = 0x01
|
|
33
|
+
WELCOME = 0x02
|
|
34
|
+
REJECT = 0x03
|
|
35
|
+
HEARTBEAT = 0x04
|
|
36
|
+
BYE = 0x05
|
|
37
|
+
ERROR = 0x06
|
|
38
|
+
SEGMENT = 0x10
|
|
39
|
+
ACK = 0x11
|
|
40
|
+
RESEND = 0x12
|
|
41
|
+
STREAM_END = 0x13
|
|
42
|
+
STREAM_CHECK = 0x14
|
|
43
|
+
BLOCK_SUMS = 0x15
|
|
44
|
+
MESSAGE = 0x20
|
|
45
|
+
MESSAGE_ACK = 0x21
|
|
46
|
+
DELIVERY = 0x22
|
|
47
|
+
DELIVERY_ACK = 0x23
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
#: Required meta keys per core frame type (spec 14.3). Unknown keys are allowed and ignored.
|
|
51
|
+
REQUIRED = {
|
|
52
|
+
FrameType.HELLO: ("participant", "operation", "role", "versions", "t", "have"),
|
|
53
|
+
FrameType.WELCOME: ("version", "hub", "t", "t_hello", "heartbeat_s", "timeout_s", "have"),
|
|
54
|
+
FrameType.REJECT: ("code", "text"),
|
|
55
|
+
FrameType.HEARTBEAT: ("t",),
|
|
56
|
+
FrameType.BYE: ("reason",),
|
|
57
|
+
FrameType.ERROR: ("code", "text"),
|
|
58
|
+
FrameType.SEGMENT: ("stream", "gen", "off", "len", "crc", "t", "enc"),
|
|
59
|
+
FrameType.ACK: ("stream", "gen", "committed"),
|
|
60
|
+
FrameType.RESEND: ("stream", "gen", "off", "len"),
|
|
61
|
+
FrameType.STREAM_END: ("stream", "gen", "size", "sha256"),
|
|
62
|
+
FrameType.STREAM_CHECK: ("stream", "gen", "result", "size"),
|
|
63
|
+
FrameType.BLOCK_SUMS: ("stream", "gen", "block_size", "first", "sums"),
|
|
64
|
+
FrameType.MESSAGE: ("id", "from", "to", "t", "kind", "len", "crc", "enc"),
|
|
65
|
+
FrameType.MESSAGE_ACK: ("id",),
|
|
66
|
+
FrameType.DELIVERY: ("id", "delivered", "pending", "unknown"),
|
|
67
|
+
FrameType.DELIVERY_ACK: ("id", "delivered"),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
ROLES = ("source", "consumer", "recipient")
|
|
71
|
+
ENCODINGS = ("none", "zlib")
|
|
72
|
+
CODES = ("unknown_participant", "wrong_operation", "version", "role", "busy", "bad_frame", "too_large", "unknown_type",
|
|
73
|
+
"storage", "internal")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class FrameError(Exception):
|
|
77
|
+
"""A frame violates the wire format. ``code`` is the error code to send (spec 14.4) before closing."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, code, text):
|
|
80
|
+
super().__init__(text)
|
|
81
|
+
self.code = code
|
|
82
|
+
self.text = text
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class Frame:
|
|
87
|
+
type: int
|
|
88
|
+
meta: dict = field(default_factory=dict)
|
|
89
|
+
blob: bytes = b""
|
|
90
|
+
version: int = VERSION
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def is_extension(self):
|
|
94
|
+
return self.type >= EXTENSION_FIRST
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def encode_meta(meta):
|
|
98
|
+
"""Compact, key-sorted UTF-8 JSON (spec 14.2) - reproducible, so test vectors are exact."""
|
|
99
|
+
return json.dumps(meta, separators=(",", ":"), sort_keys=True, ensure_ascii=False).encode("utf-8")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def encode(frame):
|
|
103
|
+
"""Frame -> bytes. Raises FrameError if a limit is exceeded or the type is out of range."""
|
|
104
|
+
if not 0 <= frame.type <= 0xFF or not 0 <= frame.version <= 0xFF:
|
|
105
|
+
raise FrameError("bad_frame", "frame type or version out of range")
|
|
106
|
+
meta = encode_meta(frame.meta)
|
|
107
|
+
blob = bytes(frame.blob)
|
|
108
|
+
if len(meta) > MAX_META:
|
|
109
|
+
raise FrameError("too_large", "meta is %d bytes, limit %d" % (len(meta), MAX_META))
|
|
110
|
+
if len(blob) > MAX_BLOB:
|
|
111
|
+
raise FrameError("too_large", "blob is %d bytes, limit %d" % (len(blob), MAX_BLOB))
|
|
112
|
+
body = HEADER.pack(MAGIC, frame.version, frame.type, len(meta), len(blob)) + meta + blob
|
|
113
|
+
return body + TRAILER.pack(zlib.crc32(body))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class FrameDecoder:
|
|
117
|
+
"""Incremental decoder. ``feed(data)`` returns the frames completed by ``data``.
|
|
118
|
+
|
|
119
|
+
After a FrameError the stream position is lost; the caller must send ERROR and close the connection (spec 14.2).
|
|
120
|
+
Further calls then raise the same error again.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(self, max_meta=MAX_META, max_blob=MAX_BLOB):
|
|
124
|
+
self.max_meta = max_meta
|
|
125
|
+
self.max_blob = max_blob
|
|
126
|
+
self._buf = bytearray()
|
|
127
|
+
self._need = None # total frame length once the header is known
|
|
128
|
+
self._error = None
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def buffered(self):
|
|
132
|
+
return len(self._buf)
|
|
133
|
+
|
|
134
|
+
def feed(self, data):
|
|
135
|
+
if self._error:
|
|
136
|
+
raise self._error
|
|
137
|
+
self._buf += data
|
|
138
|
+
out = []
|
|
139
|
+
try:
|
|
140
|
+
while True:
|
|
141
|
+
frame = self._next()
|
|
142
|
+
if frame is None:
|
|
143
|
+
return out
|
|
144
|
+
out.append(frame)
|
|
145
|
+
except FrameError as e:
|
|
146
|
+
self._error = e
|
|
147
|
+
self._buf.clear()
|
|
148
|
+
raise
|
|
149
|
+
|
|
150
|
+
def _next(self):
|
|
151
|
+
if self._need is None:
|
|
152
|
+
if len(self._buf) < HEADER.size:
|
|
153
|
+
# reject a wrong magic as early as possible
|
|
154
|
+
if self._buf[:2] and not MAGIC.startswith(bytes(self._buf[:2])):
|
|
155
|
+
raise FrameError("bad_frame", "wrong magic")
|
|
156
|
+
return None
|
|
157
|
+
magic, _version, _type, m, b = HEADER.unpack_from(self._buf)
|
|
158
|
+
if magic != MAGIC:
|
|
159
|
+
raise FrameError("bad_frame", "wrong magic")
|
|
160
|
+
if m > self.max_meta or b > self.max_blob:
|
|
161
|
+
raise FrameError("too_large", "frame announces meta %d / blob %d bytes" % (m, b))
|
|
162
|
+
self._need = HEADER.size + m + b + TRAILER.size
|
|
163
|
+
if len(self._buf) < self._need:
|
|
164
|
+
return None
|
|
165
|
+
raw = bytes(self._buf[:self._need])
|
|
166
|
+
del self._buf[:self._need]
|
|
167
|
+
self._need = None
|
|
168
|
+
return decode(raw)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def decode(raw):
|
|
172
|
+
"""Exactly one complete frame (bytes) -> Frame. Checks magic, lengths, CRC and that meta is a JSON object."""
|
|
173
|
+
if len(raw) < HEADER.size + TRAILER.size:
|
|
174
|
+
raise FrameError("bad_frame", "frame shorter than header and CRC")
|
|
175
|
+
magic, version, ftype, m, b = HEADER.unpack_from(raw)
|
|
176
|
+
if magic != MAGIC:
|
|
177
|
+
raise FrameError("bad_frame", "wrong magic")
|
|
178
|
+
if len(raw) != HEADER.size + m + b + TRAILER.size:
|
|
179
|
+
raise FrameError("bad_frame", "frame length does not match header")
|
|
180
|
+
(crc,) = TRAILER.unpack_from(raw, len(raw) - TRAILER.size)
|
|
181
|
+
if zlib.crc32(raw[:-TRAILER.size]) != crc:
|
|
182
|
+
raise FrameError("bad_frame", "CRC mismatch")
|
|
183
|
+
try:
|
|
184
|
+
meta = json.loads(raw[HEADER.size:HEADER.size + m].decode("utf-8"))
|
|
185
|
+
except (UnicodeDecodeError, ValueError):
|
|
186
|
+
raise FrameError("bad_frame", "meta is not valid UTF-8 JSON") from None
|
|
187
|
+
if not isinstance(meta, dict):
|
|
188
|
+
raise FrameError("bad_frame", "meta is not a JSON object")
|
|
189
|
+
return Frame(ftype, meta, raw[HEADER.size + m:HEADER.size + m + b], version)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _is_int(v, minimum=0):
|
|
193
|
+
return isinstance(v, int) and not isinstance(v, bool) and v >= minimum
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _check_stream(meta):
|
|
197
|
+
s = meta["stream"]
|
|
198
|
+
if not isinstance(s, str) or not 1 <= len(s.encode("utf-8")) <= 255:
|
|
199
|
+
raise FrameError("bad_frame", "stream name must be 1-255 bytes UTF-8")
|
|
200
|
+
if not _is_int(meta["gen"], 1):
|
|
201
|
+
raise FrameError("bad_frame", "generation must be an integer >= 1")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def validate(frame):
|
|
205
|
+
"""Check a decoded core frame against spec 14.3/14.4. Extensions pass unchecked; an unknown core type raises
|
|
206
|
+
FrameError('unknown_type'). Returns the FrameType."""
|
|
207
|
+
if frame.is_extension:
|
|
208
|
+
return None
|
|
209
|
+
try:
|
|
210
|
+
ftype = FrameType(frame.type)
|
|
211
|
+
except ValueError:
|
|
212
|
+
raise FrameError("unknown_type", "unknown core frame type 0x%02x" % frame.type) from None
|
|
213
|
+
meta = frame.meta
|
|
214
|
+
missing = [k for k in REQUIRED[ftype] if k not in meta]
|
|
215
|
+
if missing:
|
|
216
|
+
raise FrameError("bad_frame", "%s without %s" % (ftype.name, ", ".join(missing)))
|
|
217
|
+
for k in ("t", "t_hello"):
|
|
218
|
+
if k in REQUIRED[ftype] and not _is_int(meta[k]):
|
|
219
|
+
raise FrameError("bad_frame", "%s must be milliseconds since 1970 UTC" % k)
|
|
220
|
+
for k in ("participant", "hub", "operation", "from"):
|
|
221
|
+
if k in REQUIRED[ftype] and not (isinstance(meta[k], str) and _IDENTIFIER.match(meta[k])):
|
|
222
|
+
raise FrameError("bad_frame", "%s is not a valid identifier" % k)
|
|
223
|
+
if "stream" in REQUIRED[ftype]:
|
|
224
|
+
_check_stream(meta)
|
|
225
|
+
for k in ("off", "len", "size", "committed", "first", "crc"):
|
|
226
|
+
if k in REQUIRED[ftype] and not _is_int(meta[k]):
|
|
227
|
+
raise FrameError("bad_frame", "%s must be a non-negative integer" % k)
|
|
228
|
+
if ftype in (FrameType.REJECT, FrameType.ERROR) and not isinstance(meta["text"], str):
|
|
229
|
+
raise FrameError("bad_frame", "text must be a string")
|
|
230
|
+
if ftype is FrameType.HELLO:
|
|
231
|
+
if meta["role"] not in ROLES:
|
|
232
|
+
raise FrameError("bad_frame", "unknown role %r" % (meta["role"],))
|
|
233
|
+
if not (isinstance(meta["versions"], list) and meta["versions"] and all(_is_int(v, 1) for v in meta["versions"])):
|
|
234
|
+
raise FrameError("bad_frame", "versions must be a non-empty list of integers >= 1")
|
|
235
|
+
if ftype in (FrameType.HELLO, FrameType.WELCOME):
|
|
236
|
+
if not isinstance(meta["have"], list):
|
|
237
|
+
raise FrameError("bad_frame", "have must be a list")
|
|
238
|
+
for h in meta["have"]:
|
|
239
|
+
if not (isinstance(h, dict) and "stream" in h and "gen" in h and _is_int(h.get("committed"))):
|
|
240
|
+
raise FrameError("bad_frame", "have entries need stream, gen, committed")
|
|
241
|
+
_check_stream(h)
|
|
242
|
+
if ftype is FrameType.WELCOME:
|
|
243
|
+
if not _is_int(meta["version"], 1):
|
|
244
|
+
raise FrameError("bad_frame", "version must be an integer >= 1")
|
|
245
|
+
for k in ("heartbeat_s", "timeout_s"):
|
|
246
|
+
if isinstance(meta[k], bool) or not isinstance(meta[k], (int, float)) or meta[k] <= 0:
|
|
247
|
+
raise FrameError("bad_frame", "%s must be a positive number" % k)
|
|
248
|
+
if "participants" in meta:
|
|
249
|
+
reason = roster.check_public(meta["participants"])
|
|
250
|
+
if reason:
|
|
251
|
+
raise FrameError("bad_frame", reason)
|
|
252
|
+
if ftype in (FrameType.SEGMENT, FrameType.MESSAGE) and meta["enc"] not in ENCODINGS:
|
|
253
|
+
raise FrameError("bad_frame", "unknown encoding %r" % (meta["enc"],))
|
|
254
|
+
if ftype is FrameType.SEGMENT and not isinstance(meta.get("repair", False), bool):
|
|
255
|
+
raise FrameError("bad_frame", "repair must be true or false")
|
|
256
|
+
if ftype is FrameType.STREAM_END and not (isinstance(meta["sha256"], str) and _HEX64.match(meta["sha256"])):
|
|
257
|
+
raise FrameError("bad_frame", "sha256 must be 64 lower-case hex characters")
|
|
258
|
+
if ftype is FrameType.STREAM_CHECK and meta["result"] not in ("ok", "mismatch"):
|
|
259
|
+
raise FrameError("bad_frame", "result must be ok or mismatch")
|
|
260
|
+
if ftype is FrameType.BLOCK_SUMS:
|
|
261
|
+
if not _is_int(meta["block_size"], 1):
|
|
262
|
+
raise FrameError("bad_frame", "block_size must be an integer >= 1")
|
|
263
|
+
if not (isinstance(meta["sums"], list) and all(isinstance(s, str) and _HEX64.match(s) for s in meta["sums"])):
|
|
264
|
+
raise FrameError("bad_frame", "sums must be SHA-256 hex digests")
|
|
265
|
+
if ftype in (FrameType.MESSAGE, FrameType.MESSAGE_ACK, FrameType.DELIVERY, FrameType.DELIVERY_ACK) and not (isinstance(meta["id"], str) and _HEX32.match(meta["id"])):
|
|
266
|
+
raise FrameError("bad_frame", "message id must be 32 lower-case hex characters")
|
|
267
|
+
if ftype is FrameType.MESSAGE:
|
|
268
|
+
if not (isinstance(meta["to"], list) and meta["to"] and all(isinstance(t, str) and _IDENTIFIER.match(t) for t in meta["to"])):
|
|
269
|
+
raise FrameError("bad_frame", "to must be a non-empty list of identifiers")
|
|
270
|
+
if not (isinstance(meta["kind"], str) and _IDENTIFIER.match(meta["kind"])):
|
|
271
|
+
raise FrameError("bad_frame", "kind is not a valid identifier")
|
|
272
|
+
if ftype is FrameType.DELIVERY:
|
|
273
|
+
for k in ("delivered", "pending", "unknown"):
|
|
274
|
+
if not (isinstance(meta[k], list) and all(isinstance(t, str) and _IDENTIFIER.match(t) for t in meta[k])):
|
|
275
|
+
raise FrameError("bad_frame", "%s must be a list of identifiers" % k)
|
|
276
|
+
if ftype is FrameType.DELIVERY_ACK and not _is_int(meta["delivered"]):
|
|
277
|
+
raise FrameError("bad_frame", "delivered must be a non-negative integer")
|
|
278
|
+
if ftype in (FrameType.REJECT, FrameType.ERROR) and meta["code"] not in CODES:
|
|
279
|
+
# unknown codes from newer versions are allowed but must be strings
|
|
280
|
+
if not isinstance(meta["code"], str):
|
|
281
|
+
raise FrameError("bad_frame", "code must be a string")
|
|
282
|
+
return ftype
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def encode_payload(data, level=1):
|
|
286
|
+
"""Raw bytes -> (enc, blob): zlib only when it makes the blob smaller (spec 14.4)."""
|
|
287
|
+
packed = zlib.compress(data, level)
|
|
288
|
+
return ("zlib", packed) if len(packed) < len(data) else ("none", bytes(data))
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def decode_payload(enc, blob, length):
|
|
292
|
+
"""(enc, blob) -> raw bytes of exactly ``length``; FrameError on anything else (never more than ``length``)."""
|
|
293
|
+
if enc == "none":
|
|
294
|
+
data = bytes(blob)
|
|
295
|
+
elif enc == "zlib":
|
|
296
|
+
d = zlib.decompressobj()
|
|
297
|
+
try:
|
|
298
|
+
data = d.decompress(blob, length + 1)
|
|
299
|
+
except zlib.error:
|
|
300
|
+
raise FrameError("bad_frame", "zlib data damaged") from None
|
|
301
|
+
if not d.eof or d.unused_data:
|
|
302
|
+
raise FrameError("bad_frame", "zlib data incomplete or followed by extra bytes")
|
|
303
|
+
else:
|
|
304
|
+
raise FrameError("bad_frame", "unknown encoding %r" % (enc,))
|
|
305
|
+
if len(data) != length:
|
|
306
|
+
raise FrameError("bad_frame", "decoded length %d, expected %d" % (len(data), length))
|
|
307
|
+
return data
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def segment(stream, gen, off, data, t, level=1, **extra):
|
|
311
|
+
"""Build a SEGMENT frame for raw ``data`` at offset ``off``."""
|
|
312
|
+
enc, blob = encode_payload(data, level)
|
|
313
|
+
meta = dict(extra, stream=stream, gen=gen, off=off, len=len(data), crc=zlib.crc32(data), t=t, enc=enc)
|
|
314
|
+
return Frame(FrameType.SEGMENT, meta, blob)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def message(msg_id, sender, to, t, kind, body, level=1):
|
|
318
|
+
"""Build a MESSAGE frame with the raw ``body`` (spec 8.5, 14.3)."""
|
|
319
|
+
enc, blob = encode_payload(body, level)
|
|
320
|
+
meta = dict(id=msg_id, to=list(to), t=t, kind=kind, len=len(body), crc=zlib.crc32(body), enc=enc)
|
|
321
|
+
meta["from"] = sender
|
|
322
|
+
return Frame(FrameType.MESSAGE, meta, blob)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def message_body(frame):
|
|
326
|
+
"""Decoded, CRC-checked body of a MESSAGE frame."""
|
|
327
|
+
m = frame.meta
|
|
328
|
+
body = decode_payload(m["enc"], frame.blob, m["len"])
|
|
329
|
+
if zlib.crc32(body) != m["crc"]:
|
|
330
|
+
raise FrameError("bad_frame", "message %s damaged (CRC mismatch)" % m["id"])
|
|
331
|
+
return body
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def segment_data(frame):
|
|
335
|
+
"""Decoded, CRC-checked raw data of a SEGMENT frame."""
|
|
336
|
+
m = frame.meta
|
|
337
|
+
data = decode_payload(m["enc"], frame.blob, m["len"])
|
|
338
|
+
if zlib.crc32(data) != m["crc"]:
|
|
339
|
+
raise FrameError("bad_frame", "segment CRC mismatch")
|
|
340
|
+
return data
|