Files
MeshTerm/tests/test_regions.py
T
Jean-Pierre MartineauandClaude Opus 5.5 01a8f711d6 The session flood scope is framed by MeshTerm, so an older meshcore 2.3 can send scoped
reset_flood_scope and force_unscoped arrived late in meshcore 2.3.x. The uConsole's 2.3.7 satisfies meshcore>=2.3 without them, so every scoped channel send would have crashed on restoring the scope. set_flood_scope now sends the firmware's command 54 itself ([54][0][key], [54][0], [54][1]), as the default scope's command 63 already was. Every other library call MeshTerm makes exists in 2.3.7.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 02:06:44 -04:00

325 lines
13 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Region scopes: the key and code maths, the known-region store, and where scopes are kept.
The maths is pinned against the firmware's own definitions (``TransportKeyStore``,
``TransportKey::calcTransportCode``) spelled out independently here, so a slip in
:mod:`meshterm.core.regions` cannot pass by agreeing with itself.
"""
from __future__ import annotations
import asyncio
import hashlib
import hmac
from datetime import timedelta
from pathlib import Path
from types import SimpleNamespace
import pytest
from meshterm.core import regions
from meshterm.core.models import Observation, utcnow
from meshterm.core.region_store import RegionStore
from meshterm.core.regions import (
UNSCOPED,
RegionNameError,
Scope,
frame_scope,
parse_codes,
parse_region_list,
region_key,
resolve,
scope_body,
transport_code,
validate,
)
from meshterm.persistence.repository import Repository
#: A channel-text payload: channel hash, 2-byte MAC, ciphertext (contents are arbitrary).
_PAYLOAD = bytes.fromhex("a71c2d00112233445566778899aabbccddeeff")
_GRP_TXT = 5
def _firmware_code(name: str, payload_type: int, payload: bytes) -> int:
"""The transport code as the firmware computes it, written out longhand."""
key = hashlib.sha256(("#" + name).encode("utf-8")).digest()[:16]
mac = hmac.new(key, bytes([payload_type]) + payload, hashlib.sha256).digest()
code = mac[0] | (mac[1] << 8)
return {0: 1, 0xFFFF: 0xFFFE}.get(code, code)
def _scoped_raw(name: str, payload: bytes = _PAYLOAD) -> dict:
"""An RX-log frame's raw payload for a flood scoped to ``name``, as the library parses it."""
code = _firmware_code(name, _GRP_TXT, payload)
return {
"route_typename": "TC_FLOOD",
"payload_type": _GRP_TXT,
"payload_typename": "GRP_TXT",
"pkt_payload": payload,
"transport_code": code.to_bytes(2, "little").hex() + "0000",
}
# -- the maths ---------------------------------------------------------------------------
def test_key_is_the_hashed_hashtag_name_either_way_it_is_written() -> None:
"""``yul`` and ``#yul`` are one region; the key hashes ``#yul`` as UTF-8."""
expected = hashlib.sha256(b"#yul").digest()[:16]
assert region_key("yul") == region_key("#yul") == region_key(" #yul ") == expected
assert region_key("montréal") == hashlib.sha256("#montréal".encode()).digest()[:16]
def test_code_matches_the_firmware_definition() -> None:
"""HMAC over type byte + payload, first two bytes little-endian."""
code = transport_code(region_key("yul"), scope_body(_GRP_TXT, _PAYLOAD))
assert code == _firmware_code("yul", _GRP_TXT, _PAYLOAD)
def test_reserved_codes_step_off(monkeypatch: pytest.MonkeyPatch) -> None:
"""0x0000 and 0xFFFF are reserved; the firmware sends 0x0001 and 0xFFFE instead."""
for digest, expected in ((b"\x00\x00", 0x0001), (b"\xff\xff", 0xFFFE)):
fake = SimpleNamespace(digest=lambda d=digest: d + bytes(30))
monkeypatch.setattr(regions.hmac, "new", lambda *a, f=fake, **k: f)
assert transport_code(bytes(16), b"x") == expected
def test_codes_field_splits_little_endian() -> None:
"""The 4-byte field is two little-endian uint16s; junk parses to nothing."""
assert parse_codes("341200ff") == (0x1234, 0xFF00)
assert parse_codes(None) is None
assert parse_codes("12") is None
assert parse_codes("zzzzzzzz") is None
@pytest.mark.parametrize(
("name", "message"),
[
("", "needs a name"),
("#", "needs a name"),
("*", "every region"),
("$secret", "private"),
("é" * 16, "30 bytes"),
("north shore", "no spaces"),
("a,b", "no spaces or commas"),
],
)
def test_names_the_firmware_would_refuse_are_refused(name: str, message: str) -> None:
"""A name that could be stored but never matched or listed back is refused up front."""
with pytest.raises(RegionNameError, match=message):
validate(name)
def test_valid_names_come_back_bare() -> None:
"""Validation is also normalization: the ``#`` is the key's, not the name's."""
assert validate("#yul-nord") == "yul-nord"
assert validate("x" * 30) == "x" * 30
def test_resolution_finds_the_region_whose_key_reproduces_the_code() -> None:
"""A code names no region; trying the known names does."""
raw = _scoped_raw("harbour")
code = parse_codes(raw["transport_code"])[0]
body = scope_body(_GRP_TXT, _PAYLOAD)
assert resolve(["lakeside", "#harbour"], body, code) == "harbour"
assert resolve(["lakeside", "*"], body, code) is None
def test_frame_scope_reads_the_three_states_and_ignores_direct_frames() -> None:
"""Plain flood → unscoped; scoped → a region or unknown; direct → no scope at all."""
assert frame_scope({"route_typename": "FLOOD"}, ["harbour"]) is UNSCOPED
assert frame_scope({"route_typename": "DIRECT"}, ["harbour"]) is None
assert frame_scope({"route_typename": "TC_DIRECT"}, ["harbour"]) is None
assert frame_scope({}, ["harbour"]) is None
raw = _scoped_raw("harbour")
code = raw["transport_code"][:4]
code = f"{int.from_bytes(bytes.fromhex(code), 'little'):04x}"
assert frame_scope(raw, ["harbour"]) == Scope("scoped", "harbour", code)
assert frame_scope(raw, ["lakeside"]) == Scope("unknown", None, code)
def test_a_repeaters_region_list_parses_bare_with_the_wildcard_kept() -> None:
"""``*`` first when it relays unscoped floods; names bare, duplicates and blanks dropped."""
assert parse_region_list("*,#lakeside,harbour,,harbour\x00") == ["*", "lakeside", "harbour"]
assert parse_region_list(None) == []
# -- the store ---------------------------------------------------------------------------
def test_store_learns_persists_and_orders_chosen_names_first(tmp_path: Path) -> None:
"""Names the reader chose resolve before names only a repeater mentioned."""
path = tmp_path / "regions.json"
store = RegionStore(path)
store.learn_carried("aabbccddeeff0011", ["*", "harbour", "lakeside"])
store.learn("lakeside", "typed")
assert store.names() == ["lakeside", "harbour"]
assert store.carriers("harbour") == ("aabbccddeeff",)
assert store.carried_by("AABBCCDDEEFF") == ["lakeside", "harbour"]
reopened = RegionStore(path)
assert reopened.names() == ["lakeside", "harbour"]
assert reopened.get("#lakeside").sources == ("repeater", "typed")
def test_a_repeaters_new_list_replaces_what_it_said_before(tmp_path: Path) -> None:
"""A region only that repeater taught is forgotten when it stops naming it."""
store = RegionStore(tmp_path / "regions.json")
store.learn_carried("aabbccddeeff", ["harbour", "lakeside"])
store.learn("lakeside", "typed")
store.learn_carried("aabbccddeeff", ["harbour"])
assert store.names() == ["lakeside", "harbour"]
assert store.carriers("lakeside") == ()
store.learn_carried("aabbccddeeff", [])
assert store.names() == ["lakeside"]
def test_channel_scope_is_kept_by_identity_and_teaches_the_name(tmp_path: Path) -> None:
"""Setting a channel's scope records the region; clearing it leaves the name known."""
store = RegionStore(tmp_path / "regions.json")
store.set_channel_scope("chan-1", "#harbour")
assert store.channel_scope("chan-1") == "harbour"
assert store.get("harbour").sources == ("channel",)
with pytest.raises(RegionNameError):
store.set_channel_scope("chan-1", "two words")
store.set_channel_scope("chan-1", None)
assert store.channel_scope("chan-1") is None
assert RegionStore(tmp_path / "regions.json").names() == ["harbour"]
def test_forgetting_a_region_clears_the_channels_scoped_to_it(tmp_path: Path) -> None:
"""A channel is never left scoped to a region the store no longer knows."""
store = RegionStore(tmp_path / "regions.json")
store.set_channel_scope("chan-1", "harbour")
store.forget("harbour")
assert store.names() == []
assert store.channel_scope("chan-1") is None
def test_scope_of_resolves_against_names_learned_after_the_frame(tmp_path: Path) -> None:
"""The memo is dropped when a name arrives, so a frame heard earlier resolves now."""
store = RegionStore(tmp_path / "regions.json")
raw = _scoped_raw("harbour")
assert store.scope_of(raw).state == "unknown"
store.learn("harbour", "typed")
assert store.scope_of(raw).region == "harbour"
assert store.scope_of({"route_typename": "FLOOD"}) is UNSCOPED
assert store.scope_of({"route_typename": "DIRECT"}) is None
assert store.scope_of(None) is None
# -- persistence -------------------------------------------------------------------------
def test_a_scoped_frame_keeps_what_it_needs_to_resolve_from_history(tmp_path: Path) -> None:
"""The code and its HMAC input survive the database, and nothing is kept for others."""
repo = Repository(tmp_path / "meshterm.db")
run = repo.start_run("monitor", {})
when = utcnow()
scoped = Observation(
node=None, name=None, kind="packet", path="", observed_at=when, raw=_scoped_raw("harbour")
)
plain = Observation(
node=None,
name=None,
kind="packet",
path="",
observed_at=when + timedelta(seconds=1),
raw={"route_typename": "FLOOD", "payload_typename": "GRP_TXT", "pkt_payload": _PAYLOAD},
)
repo.record_observation(run, scoped)
repo.record_observation(run, plain)
replayed = repo.recent_observations(since=when - timedelta(minutes=1))
assert [o.raw.get("route_typename") for o in replayed] == ["TC_FLOOD", "FLOOD"]
assert replayed[0].raw["scope_body"] == scope_body(_GRP_TXT, _PAYLOAD).hex()
assert "scope_body" not in replayed[1].raw and "transport_code" not in replayed[1].raw
store = RegionStore(tmp_path / "regions.json")
store.learn("harbour", "repeater", repeater="aabbccddeeff")
assert store.scope_of(replayed[0].raw).region == "harbour"
# -- the device ----------------------------------------------------------------------------
class _Commands:
"""A meshcore ``commands`` stand-in that records what was sent."""
def __init__(self) -> None:
self.calls: list[tuple] = []
async def send(self, frame, expected): # noqa: ANN001, ANN201
self.calls.append(("send", bytes(frame)))
async def set_flood_scope(self, key): # noqa: ANN001, ANN201
self.calls.append(("scope", key))
async def reset_flood_scope(self): # noqa: ANN201
self.calls.append(("reset",))
async def force_unscoped(self): # noqa: ANN201
self.calls.append(("unscoped",))
def _device(commands: _Commands): # noqa: ANN202
from meshterm.core.connection import MeshCoreDevice
device = object.__new__(MeshCoreDevice)
device._mc = SimpleNamespace(commands=commands)
return device
def test_default_scope_frame_is_bare_and_byte_padded() -> None:
"""``[63][name, 31 bytes NUL-padded][key16]`` — bare name, UTF-8 bytes, 48 in all."""
commands = _Commands()
device = _device(commands)
asyncio.run(device.set_default_flood_scope("#montréal"))
frame = commands.calls[-1][1]
name = "montréal".encode()
assert frame == bytes([63]) + name + bytes(31 - len(name)) + region_key("montréal")
assert len(frame) == 48
asyncio.run(device.set_default_flood_scope(""))
assert commands.calls[-1] == ("send", bytes([63]))
with pytest.raises(RegionNameError):
asyncio.run(device.set_default_flood_scope("x" * 31))
def test_session_scope_sends_the_key_the_override_or_the_reset() -> None:
"""A name sends its key; ``*`` forces unscoped; ``None`` falls back to the default.
Framed by MeshTerm as the firmware's command 54, never through the library's
``reset_flood_scope``/``force_unscoped``, which an older meshcore 2.3 lacks.
"""
commands = _Commands()
device = _device(commands)
asyncio.run(device.set_flood_scope("#harbour"))
asyncio.run(device.set_flood_scope("*"))
asyncio.run(device.set_flood_scope(None))
assert commands.calls == [
("send", bytes([54, 0]) + region_key("harbour")),
("send", bytes([54, 1])),
("send", bytes([54, 0])),
]
def test_mock_device_mirrors_scope_and_answers_regions_from_repeaters() -> None:
"""The mock stores bare names and only a repeater answers the regions request."""
from meshterm.core.connection import DeviceCommandError, MockDevice
async def run() -> None:
device = MockDevice()
await device.connect()
await device.set_default_flood_scope("#harbour")
assert await device.get_default_flood_scope() == "harbour"
await device.set_flood_scope("*")
assert device._send_scope == "*"
contacts = await device.get_contacts()
repeaters = [c for c in contacts if device._neighbour_tables.get(device._mock_key(c)[:8])]
others = [c for c in contacts if c not in repeaters]
assert repeaters and others
assert (await device.request_regions(repeaters[0]))[0] == "*"
with pytest.raises(DeviceCommandError):
await device.request_regions(others[0])
asyncio.run(run())