mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-08-06 17:03:32 +02:00
fix(room_server): enforce firmware post text budget in bytes
The room post limit was 160 Python characters; firmware caps post text at MAX_POST_TEXT_LEN = 160-9 = 151 bytes (the 160-byte encrypted text budget minus the timestamp/flags/author-prefix header). Store posts truncated to 151 UTF-8 bytes at a codepoint boundary, and clamp again when pushing so previously stored oversized posts cannot produce oversized frames.
This commit is contained in:
@@ -27,7 +27,10 @@ PUSH_TIMEOUT_BASE_MS = 4000
|
||||
PUSH_ACK_TIMEOUT_FACTOR_MS = 2000
|
||||
|
||||
# Safety limits and protections
|
||||
MAX_MESSAGE_LENGTH = 160 # Match C++ MAX_POST_TEXT_LEN (151 bytes for text)
|
||||
# Match C++ MAX_POST_TEXT_LEN from examples/simple_room_server/MyMesh.h:
|
||||
# #define MAX_POST_TEXT_LEN (160-9) -- 160-byte encrypted text budget minus the
|
||||
# 9-byte prefix (4-byte timestamp + 1-byte flags/attempt + 4-byte author pubkey prefix).
|
||||
MAX_POST_TEXT_LEN = 151
|
||||
MAX_POSTS_PER_CLIENT_PER_MINUTE = 10 # Prevent spam
|
||||
MAX_CLIENTS_PER_ROOM = 50 # From ACL default
|
||||
MAX_PUSH_FAILURES = 3 # Evict after this many consecutive failures
|
||||
@@ -47,6 +50,19 @@ _global_push_lock = asyncio.Lock()
|
||||
GLOBAL_MIN_GAP_BETWEEN_MESSAGES = 1.1 # 1.1s minimum gap between transmissions
|
||||
|
||||
|
||||
def _truncate_utf8(text: str, max_bytes: int) -> str:
|
||||
"""Truncate ``text`` to at most ``max_bytes`` of its UTF-8 encoding.
|
||||
|
||||
Cuts at a codepoint boundary so a partial multi-byte sequence is never
|
||||
emitted (matches firmware's byte-length text budget, but stays
|
||||
UTF-8-safe rather than the firmware's raw ``strncpy``).
|
||||
"""
|
||||
encoded = text.encode("utf-8")
|
||||
if len(encoded) <= max_bytes:
|
||||
return text
|
||||
return encoded[:max_bytes].decode("utf-8", errors="ignore")
|
||||
|
||||
|
||||
class GlobalRateLimiter:
|
||||
def __init__(self, min_gap_seconds: float = 0.1):
|
||||
self.min_gap = min_gap_seconds # Minimum gap between consecutive messages
|
||||
@@ -234,13 +250,15 @@ class RoomServer:
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
# SAFETY: Validate message length
|
||||
if len(message_text) > MAX_MESSAGE_LENGTH:
|
||||
# SAFETY: Validate message length (byte length, matching firmware's
|
||||
# MAX_POST_TEXT_LEN text budget), cutting at a UTF-8 codepoint boundary.
|
||||
encoded_len = len(message_text.encode("utf-8"))
|
||||
if encoded_len > MAX_POST_TEXT_LEN:
|
||||
logger.warning(
|
||||
f"Room '{self.room_name}': Message from {client_pubkey[:4].hex()} "
|
||||
f"exceeds max length ({len(message_text)} > {MAX_MESSAGE_LENGTH}), truncating"
|
||||
f"exceeds max length ({encoded_len} > {MAX_POST_TEXT_LEN} bytes), truncating"
|
||||
)
|
||||
message_text = message_text[:MAX_MESSAGE_LENGTH]
|
||||
message_text = _truncate_utf8(message_text, MAX_POST_TEXT_LEN)
|
||||
|
||||
# SAFETY: Rate limit per client
|
||||
client_key = client_pubkey.hex()
|
||||
@@ -362,7 +380,13 @@ class RoomServer:
|
||||
author_prefix = author_pubkey[:4]
|
||||
|
||||
# Plaintext: timestamp(4) + flags(1) + author_prefix(4) + text
|
||||
message_bytes = post["message_text"].encode("utf-8")
|
||||
# SAFETY: Clamp at push time too, in case a legacy stored post
|
||||
# (from before this limit was enforced on write) exceeds
|
||||
# MAX_POST_TEXT_LEN bytes -- truncate at a UTF-8 codepoint boundary.
|
||||
message_text = post["message_text"]
|
||||
if len(message_text.encode("utf-8")) > MAX_POST_TEXT_LEN:
|
||||
message_text = _truncate_utf8(message_text, MAX_POST_TEXT_LEN)
|
||||
message_bytes = message_text.encode("utf-8")
|
||||
plaintext = (
|
||||
timestamp.to_bytes(4, "little") + bytes([flags]) + author_prefix + message_bytes
|
||||
)
|
||||
|
||||
@@ -5,10 +5,12 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from repeater.handler_helpers.room_server import (
|
||||
MAX_POST_TEXT_LEN,
|
||||
MAX_UNSYNCED_POSTS,
|
||||
TXT_TYPE_PLAIN,
|
||||
TXT_TYPE_SIGNED_PLAIN,
|
||||
RoomServer,
|
||||
_truncate_utf8,
|
||||
)
|
||||
|
||||
|
||||
@@ -91,7 +93,7 @@ async def test_room_server_add_post_truncates_and_rate_limits_client():
|
||||
first_ok = await rs.add_post(client_key, long_msg, sender_timestamp=5)
|
||||
assert first_ok is True
|
||||
args = db.insert_room_message.call_args.kwargs
|
||||
assert len(args["message_text"]) == 160
|
||||
assert len(args["message_text"].encode("utf-8")) == MAX_POST_TEXT_LEN
|
||||
|
||||
# Force client to appear at post-per-minute limit.
|
||||
rs.client_post_times[client_key.hex()] = [time.time() - 1] * 10
|
||||
@@ -99,6 +101,71 @@ async def test_room_server_add_post_truncates_and_rate_limits_client():
|
||||
assert second_ok is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_room_server_add_post_ascii_over_limit_stores_exactly_max_bytes():
|
||||
db = _FakeDB()
|
||||
rs = _make_room_server(db=db)
|
||||
|
||||
long_msg = "a" * 200 # ASCII: 1 byte per char, well over MAX_POST_TEXT_LEN
|
||||
ok = await rs.add_post(b"G" * 32, long_msg, sender_timestamp=1)
|
||||
assert ok is True
|
||||
|
||||
stored = db.insert_room_message.call_args.kwargs["message_text"]
|
||||
assert len(stored.encode("utf-8")) == MAX_POST_TEXT_LEN
|
||||
assert stored == "a" * MAX_POST_TEXT_LEN
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_room_server_add_post_multibyte_utf8_truncates_on_codepoint_boundary():
|
||||
db = _FakeDB()
|
||||
rs = _make_room_server(db=db)
|
||||
|
||||
# Each emoji is 4 bytes in UTF-8; padding forces the cut to land mid-emoji
|
||||
# if truncation were byte-naive instead of codepoint-aware.
|
||||
padding = "a" * (MAX_POST_TEXT_LEN - 2)
|
||||
msg = padding + "\U0001f600\U0001f600\U0001f600" # grinning face emoji x3
|
||||
assert len(msg.encode("utf-8")) > MAX_POST_TEXT_LEN
|
||||
|
||||
ok = await rs.add_post(b"H" * 32, msg, sender_timestamp=2)
|
||||
assert ok is True
|
||||
|
||||
stored = db.insert_room_message.call_args.kwargs["message_text"]
|
||||
encoded = stored.encode("utf-8")
|
||||
assert len(encoded) <= MAX_POST_TEXT_LEN
|
||||
# Must decode cleanly (no partial multi-byte sequence) and round-trip.
|
||||
assert encoded.decode("utf-8") == stored
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_room_server_add_post_exact_limit_text_untouched():
|
||||
db = _FakeDB()
|
||||
rs = _make_room_server(db=db)
|
||||
|
||||
msg = "y" * MAX_POST_TEXT_LEN
|
||||
ok = await rs.add_post(b"I" * 32, msg, sender_timestamp=3)
|
||||
assert ok is True
|
||||
|
||||
stored = db.insert_room_message.call_args.kwargs["message_text"]
|
||||
assert stored == msg
|
||||
assert len(stored.encode("utf-8")) == MAX_POST_TEXT_LEN
|
||||
|
||||
|
||||
def test_truncate_utf8_helper_boundary_cases():
|
||||
# Under the limit: untouched.
|
||||
assert _truncate_utf8("short", 151) == "short"
|
||||
|
||||
# Exactly at the limit: untouched.
|
||||
exact = "z" * 151
|
||||
assert _truncate_utf8(exact, 151) == exact
|
||||
|
||||
# Multi-byte straddling the boundary: cuts cleanly, decodes, stays <= limit.
|
||||
text = ("b" * 149) + "\U0001f600\U0001f600" # 149 + 4 + 4 = 157 bytes
|
||||
result = _truncate_utf8(text, 151)
|
||||
encoded = result.encode("utf-8")
|
||||
assert len(encoded) <= 151
|
||||
assert encoded.decode("utf-8") == result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_room_server_add_post_returns_false_on_db_insert_failure():
|
||||
db = _FakeDB()
|
||||
@@ -192,6 +259,47 @@ async def test_room_server_push_post_to_client_success_direct_route_sets_path_an
|
||||
rs.global_limiter.release.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_room_server_push_post_to_client_clamps_oversized_legacy_stored_text():
|
||||
"""A post stored before the write-time limit was enforced (or otherwise
|
||||
over MAX_POST_TEXT_LEN bytes) must still be clamped at push time so the
|
||||
outgoing frame's text portion never exceeds the firmware's budget."""
|
||||
db = _FakeDB()
|
||||
db.get_client_sync.return_value = {"push_failures": 0}
|
||||
injector = AsyncMock(return_value=True)
|
||||
rs = _make_room_server(db=db, injector=injector)
|
||||
rs.global_limiter = SimpleNamespace(acquire=AsyncMock(), release=MagicMock())
|
||||
rs._handle_ack_received = AsyncMock()
|
||||
|
||||
client = _FakeClient(pubkey=b"E" * 32, out_path=b"\xaa\xbb", out_path_len=2)
|
||||
oversized_text = "q" * (MAX_POST_TEXT_LEN + 50)
|
||||
post = {
|
||||
"author_pubkey": (b"F" * 32).hex(),
|
||||
"message_text": oversized_text,
|
||||
"post_timestamp": 1234.5,
|
||||
}
|
||||
|
||||
packet = SimpleNamespace(path=bytearray(), path_len=0)
|
||||
with (
|
||||
patch(
|
||||
"repeater.handler_helpers.room_server.CryptoUtils.sha256",
|
||||
return_value=b"\x01\x02\x03\x04abcd",
|
||||
),
|
||||
patch(
|
||||
"repeater.handler_helpers.room_server.PacketBuilder.create_datagram",
|
||||
return_value=packet,
|
||||
) as create_datagram,
|
||||
):
|
||||
ok = await rs.push_post_to_client(client, post)
|
||||
|
||||
assert ok is True
|
||||
plaintext = create_datagram.call_args.kwargs["plaintext"]
|
||||
# Prefix is timestamp(4) + flags(1) + author_prefix(4) = 9 bytes.
|
||||
text_portion = plaintext[9:]
|
||||
assert len(text_portion) == MAX_POST_TEXT_LEN
|
||||
assert text_portion.decode("utf-8") == "q" * MAX_POST_TEXT_LEN
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_room_server_push_expected_ack_matches_firmware_signed_ack():
|
||||
"""The pending ACK CRC must match what a signed-plain receiver sends back.
|
||||
|
||||
Reference in New Issue
Block a user