Files
potato-mesh/tests/test_reticulum_unit.py

2427 lines
95 KiB
Python

# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for :mod:`data.mesh_ingestor.protocols.reticulum`."""
from __future__ import annotations
import importlib
import inspect
import re
import sys
import time
import types
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from RNS.vendor import umsgpack # noqa: E402 - path setup
import data.mesh_ingestor.config as config # noqa: E402 - path setup
import data.mesh_ingestor.node_identity as node_identity # noqa: E402 - path setup
import data.mesh_ingestor.protocols.reticulum as _mod # noqa: E402 - path setup
from data.mesh_ingestor.protocols.reticulum import ( # noqa: E402 - path setup
ReticulumProvider,
_ANNOUNCE_ASPECTS,
_ReticulumAnnounceHandler,
_ReticulumInterface,
_announce_hops,
_announce_to_node_dict,
_decode_display_name,
_reticulum_hash_hex,
_reticulum_node_id,
_reticulum_short_name,
)
_DEST_HASH = bytes.fromhex("aabbccdd" + "00" * 12)
"""A full 16-byte Reticulum destination hash used across tests."""
_IDENTITY_HASH = bytes.fromhex("beef0001" + "11" * 12)
"""A full 16-byte Reticulum identity hash used across tests."""
_PUBLIC_KEY = bytes.fromhex("ab" * 64)
"""A 64-byte Reticulum identity public key used across tests."""
_SELF_IDENTITY_HASH = bytes.fromhex("5e1f0002" + "22" * 12)
"""The identity hash a freshly generated *ingestor* identity carries."""
class _FakeIdentity:
"""Stand-in for :class:`RNS.Identity` exposing the members the provider reads."""
def __init__(
self, hash_bytes: bytes = _IDENTITY_HASH, public_key: object = _PUBLIC_KEY
):
"""Bind the fake to an identity hash and public key."""
self.hash = hash_bytes
self._public_key = public_key
def get_public_key(self) -> object:
"""Return the identity's public key (bytes for a real identity)."""
return self._public_key
def _fake_rns(
*,
existing_instance=None,
hops=128,
interface="RNodeInterface[RNode LoRa]",
recalled=None,
self_identity_hash=_SELF_IDENTITY_HASH,
identity_write_error=None,
identity_read_error=None,
):
"""Build a fake ``RNS`` module namespace for provider tests.
``Identity`` is a *class* rather than a namespace because the provider both
constructs one (generating the ingestor's own identity) and calls the
``from_file``/``to_file`` classmethods on it. ``from_file`` returns
``None`` for an empty file, mirroring how the real
:meth:`RNS.Identity.from_file` reports an unusable key file.
Parameters:
existing_instance: Value :meth:`RNS.Reticulum.get_instance` returns.
hops: Hop count :func:`RNS.Transport.hops_to` reports.
interface: Interface :func:`RNS.Transport.next_hop_interface` reports.
recalled: Identity :meth:`RNS.Identity.recall` returns.
self_identity_hash: Hash a freshly constructed identity carries.
identity_write_error: Exception ``to_file`` raises, or ``None``.
identity_read_error: Exception ``from_file`` raises, or ``None``.
Returns:
``(fake_module, state)`` where ``state`` records constructed
Reticulum configdirs, (de)registered announce handlers, and the
identity files written and loaded.
"""
state = {
"created": [],
"registered": [],
"deregistered": [],
"identities_written": [],
"identities_loaded": [],
}
class FakeReticulum:
def __init__(self, configdir=None):
state["created"].append(configdir)
@staticmethod
def get_instance():
return existing_instance
class FakeIdentityClass:
"""Constructible stand-in for :class:`RNS.Identity`."""
def __init__(self, create_keys=True):
self.hash = self_identity_hash
self._public_key = _PUBLIC_KEY
def get_public_key(self):
return self._public_key
def to_file(self, path):
if identity_write_error is not None:
raise identity_write_error
with open(path, "wb") as handle:
handle.write(self.hash)
state["identities_written"].append(path)
@staticmethod
def from_file(path):
if identity_read_error is not None:
raise identity_read_error
with open(path, "rb") as handle:
raw = handle.read()
state["identities_loaded"].append(path)
if not raw:
return None
loaded = FakeIdentityClass()
loaded.hash = raw
return loaded
@staticmethod
def recall(_dest_hash):
return recalled
transport = types.SimpleNamespace(
PATHFINDER_M=128,
register_announce_handler=lambda h: state["registered"].append(h),
deregister_announce_handler=lambda h: state["deregistered"].append(h),
hops_to=lambda _dh: hops,
next_hop_interface=lambda _dh: interface,
)
fake = types.SimpleNamespace(
Reticulum=FakeReticulum, Transport=transport, Identity=FakeIdentityClass
)
return fake, state
def _no_ingestor_node_id(monkeypatch, tmp_path):
"""Point the provider at *tmp_path* with no operator-supplied node id.
A config dir the test owns, the default (RNode) interface scope, and
``INGESTOR_NODE_ID`` cleared so the derived id is what gets exercised.
"""
monkeypatch.setattr(_mod.config, "RETICULUM_CONFIG_DIR", str(tmp_path))
monkeypatch.setattr(_mod.config, "RETICULUM_INTERFACES", ())
monkeypatch.setattr(_mod.config, "INGESTOR_NODE_ID", None)
monkeypatch.setattr(_mod.config, "CONNECTION", None)
# ---------------------------------------------------------------------------
# _reticulum_node_id / _reticulum_hash_hex / _reticulum_short_name
# ---------------------------------------------------------------------------
def test_reticulum_node_id_from_bytes():
"""The node ID is the first four bytes of the identity hash."""
assert _reticulum_node_id(_IDENTITY_HASH) == "!beef0001"
def test_reticulum_node_id_from_hex_string():
"""A hex-string identity hash is accepted and lowercased."""
assert _reticulum_node_id("BEEF0001" + "11" * 12) == "!beef0001"
def test_reticulum_node_id_none_on_short_or_invalid():
"""Too-short or non-hash inputs yield None."""
assert _reticulum_node_id(b"\xaa\xbb") is None
assert _reticulum_node_id("abc") is None
assert _reticulum_node_id(None) is None
assert _reticulum_node_id(12345) is None
def test_reticulum_hash_hex_roundtrip():
"""_reticulum_hash_hex returns the full lowercase hex of the hash."""
assert _reticulum_hash_hex(_DEST_HASH) == _DEST_HASH.hex()
assert _reticulum_hash_hex("AABB") == "aabb"
assert _reticulum_hash_hex("") is None
assert _reticulum_hash_hex(None) is None
def test_reticulum_short_name_first_two_bytes():
"""_reticulum_short_name returns the first four hex chars of the node ID."""
assert _reticulum_short_name("!aabbccdd") == "aabb"
assert _reticulum_short_name("cafef00d") == "cafe"
def test_reticulum_short_name_empty_when_too_short():
"""Missing or too-short node IDs yield an empty short name."""
assert _reticulum_short_name("") == ""
assert _reticulum_short_name("!ab") == ""
assert _reticulum_short_name(None) == ""
# ---------------------------------------------------------------------------
# _decode_display_name
# ---------------------------------------------------------------------------
def test_decode_display_name_raw_utf8():
"""Pre-0.5 LXMF and nomadnet announces carry raw UTF-8 name bytes."""
assert _decode_display_name("Alice Node".encode("utf-8")) == "Alice Node"
def test_decode_display_name_msgpack_array():
"""LXMF >= 0.5 announces pack [display_name, stamp_cost] with msgpack."""
app_data = umsgpack.packb(["Bob's LXMF".encode("utf-8"), 8])
assert _decode_display_name(app_data) == "Bob's LXMF"
def test_decode_display_name_msgpack_str_element():
"""A msgpack array whose first element is already a str is accepted."""
app_data = umsgpack.packb(["StrName", None])
assert _decode_display_name(app_data) == "StrName"
def test_decode_display_name_msgpack_empty_array():
"""A msgpack array with no elements yields None."""
assert _decode_display_name(umsgpack.packb([])) is None
def test_decode_display_name_msgpack_non_array():
"""A msgpack payload that unpacks to a non-array yields None."""
# 0x9? is the fixarray lead range, so craft a lead byte in range whose
# unpacked value is not a list.
assert _decode_display_name(umsgpack.packb({"a": 1})) is None
def test_decode_display_name_msgpack_none_element():
"""A msgpack array with a None display name yields None."""
assert _decode_display_name(umsgpack.packb([None, 8])) is None
def test_decode_display_name_invalid_utf8_returns_none():
"""Undecodable raw bytes must be handled gracefully."""
assert _decode_display_name(b"\xff\xfe\xfd") is None
def test_decode_display_name_empty_inputs():
"""None, empty bytes, and whitespace-only names all yield None."""
assert _decode_display_name(None) is None
assert _decode_display_name(b"") is None
assert _decode_display_name(b" ") is None
assert _decode_display_name(" ") is None
def test_decode_display_name_non_bytes_returns_none():
"""Non-bytes, non-str app_data yields None."""
assert _decode_display_name(1234) is None
assert _decode_display_name(["not", "bytes"]) is None
def test_decode_display_name_truncated_msgpack_returns_none():
"""A byte string that looks like msgpack but fails to unpack yields None."""
assert _decode_display_name(b"\xdc\xff") is None
def test_decode_display_name_unicode():
"""Unicode display names survive decoding."""
assert _decode_display_name("pete 🍁".encode("utf-8")) == "pete 🍁"
# ---------------------------------------------------------------------------
# _announce_hops
# ---------------------------------------------------------------------------
def test_announce_hops_returns_known_count(monkeypatch):
"""A known path yields its hop count."""
fake, _state = _fake_rns(hops=3)
monkeypatch.setattr(_mod, "RNS", fake)
assert _announce_hops(_DEST_HASH) == 3
def test_announce_hops_none_on_pathfinder_sentinel(monkeypatch):
"""The PATHFINDER_M max-hops sentinel (unknown path) maps to None."""
fake, _state = _fake_rns(hops=128)
monkeypatch.setattr(_mod, "RNS", fake)
assert _announce_hops(_DEST_HASH) is None
def test_announce_hops_none_on_error(monkeypatch):
"""Transport errors are swallowed and yield None."""
fake, _state = _fake_rns()
fake.Transport.hops_to = lambda _dh: (_ for _ in ()).throw(RuntimeError("boom"))
monkeypatch.setattr(_mod, "RNS", fake)
assert _announce_hops(_DEST_HASH) is None
def test_announce_hops_none_for_non_bytes():
"""Non-bytes destination hashes never touch the transport."""
assert _announce_hops("aabbccdd") is None
assert _announce_hops(None) is None
# ---------------------------------------------------------------------------
# _identity_from_announce / _identity_public_key_hex
# ---------------------------------------------------------------------------
def test_identity_from_announce_prefers_the_callback_identity(monkeypatch):
"""The identity RNS hands the handler is used without a recall round trip."""
fake, _state = _fake_rns(recalled=_FakeIdentity(b"\x99" * 16))
monkeypatch.setattr(_mod, "RNS", fake)
identity = _FakeIdentity()
assert _mod._identity_from_announce(identity, _DEST_HASH) is identity
def test_identity_from_announce_recalls_when_absent_or_hashless(monkeypatch):
"""A missing or hash-less callback identity falls back to recall()."""
recalled = _FakeIdentity()
fake, _state = _fake_rns(recalled=recalled)
monkeypatch.setattr(_mod, "RNS", fake)
assert _mod._identity_from_announce(None, _DEST_HASH) is recalled
# An object with no usable `hash` is treated as absent.
assert _mod._identity_from_announce(object(), _DEST_HASH) is recalled
def test_identity_from_announce_none_when_recall_yields_nothing(monkeypatch):
"""recall() returning None or a hash-less object yields None."""
fake, _state = _fake_rns(recalled=None)
monkeypatch.setattr(_mod, "RNS", fake)
assert _mod._identity_from_announce(None, _DEST_HASH) is None
fake.Identity.recall = lambda _dh: object()
assert _mod._identity_from_announce(None, _DEST_HASH) is None
def test_identity_from_announce_none_for_non_bytes_hash():
"""A non-bytes destination hash never reaches the transport."""
assert _mod._identity_from_announce(None, "aabb") is None
assert _mod._identity_from_announce(None, None) is None
def test_identity_from_announce_swallows_recall_errors(monkeypatch):
"""A raising recall() yields None rather than killing the receive path."""
fake, _state = _fake_rns()
fake.Identity.recall = lambda _dh: (_ for _ in ()).throw(RuntimeError("boom"))
monkeypatch.setattr(_mod, "RNS", fake)
assert _mod._identity_from_announce(None, _DEST_HASH) is None
def test_identity_public_key_hex_variants():
"""Bytes keys hex-encode, str keys normalise, anything else yields None."""
assert _mod._identity_public_key_hex(None) is None
assert _mod._identity_public_key_hex(_FakeIdentity()) == _PUBLIC_KEY.hex()
assert _mod._identity_public_key_hex(_FakeIdentity(public_key="AABB")) == "aabb"
assert _mod._identity_public_key_hex(_FakeIdentity(public_key=" ")) is None
assert _mod._identity_public_key_hex(_FakeIdentity(public_key=1234)) is None
def test_identity_public_key_hex_swallows_errors():
"""An identity whose key accessor raises yields None."""
class _Broken:
hash = _IDENTITY_HASH
def get_public_key(self):
raise RuntimeError("locked")
assert _mod._identity_public_key_hex(_Broken()) is None
# ---------------------------------------------------------------------------
# _announce_interface_name / _interface_allowed
# ---------------------------------------------------------------------------
def test_announce_interface_name_returns_the_path_interface(monkeypatch):
"""The interface an announce's path arrived on is reported by name."""
fake, _state = _fake_rns(interface="RNodeInterface[RNode LoRa]")
monkeypatch.setattr(_mod, "RNS", fake)
assert _mod._announce_interface_name(_DEST_HASH) == "RNodeInterface[RNode LoRa]"
def test_announce_interface_name_none_for_non_bytes_or_unknown(monkeypatch):
"""A non-bytes hash, or an unknown path, yields None."""
fake, _state = _fake_rns(interface=None)
monkeypatch.setattr(_mod, "RNS", fake)
assert _mod._announce_interface_name("aabb") is None
assert _mod._announce_interface_name(_DEST_HASH) is None
def test_announce_interface_name_swallows_errors(monkeypatch):
"""Transport errors and unstringable interfaces both yield None."""
fake, _state = _fake_rns()
fake.Transport.next_hop_interface = lambda _dh: (_ for _ in ()).throw(
RuntimeError("boom")
)
monkeypatch.setattr(_mod, "RNS", fake)
assert _mod._announce_interface_name(_DEST_HASH) is None
class _Unstringable:
def __str__(self):
raise RuntimeError("no name")
fake.Transport.next_hop_interface = lambda _dh: _Unstringable()
assert _mod._announce_interface_name(_DEST_HASH) is None
def test_interface_allowed_matches_case_insensitive_substrings(monkeypatch):
"""Allowlist entries match as lowercased substrings of the interface name."""
monkeypatch.setattr(_mod.config, "RETICULUM_INTERFACES", ("rnode", "serial"))
assert _mod._announce_admitted(1, "RNodeInterface[RNode LoRa]") is True
assert _mod._announce_admitted(1, "SerialInterface[radio0]") is True
assert _mod._announce_admitted(1, "TCPClientInterface[hub]") is False
def test_interface_allowed_rejects_unknown_interface_when_allowlisted(monkeypatch):
"""An unverifiable interface is not among the named ones, so it is rejected."""
monkeypatch.setattr(_mod.config, "RETICULUM_INTERFACES", ("rnode",))
assert _mod._announce_admitted(1, None) is False
assert _mod._announce_admitted(1, "") is False
# ---------------------------------------------------------------------------
# Review regressions: shared-instance blackout and identity-derived fallback
# ---------------------------------------------------------------------------
def test_fallback_name_matches_the_web_placeholder_for_every_id_shape():
"""The placeholder must equal what the web upsert builds, byte for byte.
Ruby composes it from ``protocol_display_label`` plus the **upper-cased**
canonical short id and compares with ``==``. An id with a digits-only
head, like ``!0001beef``, matches either way, so a fixture built from one
hides a case mismatch that breaks ~85% of the id space — which is exactly
how the first attempt at this fix passed its own test while still
clobbering names.
"""
identity = _FakeIdentity(bytes.fromhex(_FIELD_PRIMARY))
for dest_ref, expected in (
("!0001beef", "Reticulum 0001"), # digits only - matches even lower-cased
("!c0ffee00", "Reticulum C0FF"),
("!beefcafe", "Reticulum BEEF"),
("!deadbeef", "Reticulum DEAD"),
("!0000000a", "Reticulum 0000"),
):
# The placeholder names the row it lands on, the destination (SPEC
# RA10(b)), so walk destination hashes under one identity. The expected
# strings below are the *web tier's* rule (placeholder_short_id in
# identity.rb, which upsert_destination applies to "!" plus the first
# eight hex of the destination): first four hex, upper-cased, for
# reticulum. If the two sides drift, the web upsert stops recognising
# these as placeholders and they start overwriting real display names.
dest = bytes.fromhex(dest_ref[1:] + "11" * 12)
node = _announce_to_node_dict(dest, None, identity=identity, last_heard=1)
assert node["user"]["longName"] == expected, dest_ref
# ---------------------------------------------------------------------------
# End-to-end findings (#893 field test): host id, interface scope, per-destination rows
# ---------------------------------------------------------------------------
def test_host_node_id_uses_the_reticulum_mapping_not_the_meshtastic_one():
"""T-D-2: one identity must not yield two different node ids.
``canonical_node_id`` parses a hex string as an integer and keeps the low
32 bits, which is right for a Meshtastic node num and wrong for a 16-byte
identity hash — it truncates from the opposite end to
:func:`_reticulum_node_id`. In the field this showed as the ingestor
registering ``!86c39940`` while its own peer row was ``!27716218``.
"""
identity_hash = "27716218762cfd2864141ef286c39940"
# The shared helper keeps the low 32 bits: the id the field run registered.
assert node_identity.canonical_node_id(identity_hash) == "!86c39940"
assert _mod._reticulum_node_id(bytes.fromhex(identity_hash)) == "!27716218"
# The provider must canonicalise a raw identity hash the same way.
assert ReticulumProvider()._canonical_host_node_id(identity_hash) == "!27716218"
def test_derived_host_node_id_is_none_when_no_identity_exists(monkeypatch):
"""An unreachable identity yields no host id rather than raising.
The ingestor should degrade to an unregistered heartbeat, not die on
startup, when the stack cannot produce an identity at all.
"""
fake, _state = _fake_rns()
def _boom():
raise RuntimeError("no identity")
fake.Transport.internal_identity = _boom
monkeypatch.setattr(_mod, "RNS", fake)
monkeypatch.setattr(_mod.config, "INGESTOR_NODE_ID", None)
assert ReticulumProvider().extract_host_node_id(None) is None
def test_ingestor_node_id_env_overrides_the_derived_id(monkeypatch):
"""The operator's value wins, and is canonicalised the Reticulum way.
Intent preserved from #896, whose own generated-identity mechanism was
dropped: an override must still take precedence over derivation.
"""
fake, _state = _fake_rns()
fake.Transport.internal_identity = lambda: _FakeIdentity(
bytes.fromhex("fbf8e3389bc79a4fe9ed22eae97fc268")
)
monkeypatch.setattr(_mod, "RNS", fake)
monkeypatch.setattr(
_mod.config, "INGESTOR_NODE_ID", "27716218762cfd2864141ef286c39940"
)
assert ReticulumProvider().extract_host_node_id(None) == "!27716218"
def test_the_derived_node_id_is_stable_across_calls(monkeypatch):
"""A restart on the same config dir yields the same id.
Intent preserved from #896: a churning id would file one orphan ingestor
row per restart. The transport identity is stable per config dir, so no
key generation or persistence is needed to get that property.
"""
_local_stack(
monkeypatch,
{_FIELD_LXMF: _FIELD_PRIMARY, _FIELD_NOMADNET: _FIELD_PRIMARY},
)
monkeypatch.setattr(_mod.config, "INGESTOR_NODE_ID", None)
first = ReticulumProvider().extract_host_node_id(None)
second = ReticulumProvider().extract_host_node_id(None)
assert first == second == "!27716218"
# ---------------------------------------------------------------------------
# Host identity discovery (SPEC RE7/RE8)
#
# Fixture values are REAL, captured from a live stack, and the destination
# hashes are verified to derive from the primary identity by RNS itself (see
# test_field_destinations_derive_from_the_primary_identity). Using real values
# keeps the fixture from drifting into a shape RNS would never produce.
# ---------------------------------------------------------------------------
_FIELD_PRIMARY = "27716218762cfd2864141ef286c39940"
_FIELD_TRANSPORT = "fbf8e3389bc79a4fe9ed22eae97fc268"
_FIELD_LXMF = "4cf985bf933c21b1aa8dabd407d4ef69"
_FIELD_PROPAGATION = "fee521eb6fcd937cc519a1ec8c8b0b2a"
_FIELD_NOMADNET = "9c59da5e1516745d74cc908243e0ba2b"
def _local_stack(
monkeypatch,
dest_to_identity,
*,
transport_enabled=False,
interfaces=None,
app_data=None,
):
"""Point the real RNS at a synthetic 0-hop path table.
Patches only the four stack accessors discovery uses, leaving
``RNS.Destination.hash`` real so aspect labelling is exercised against
RNS's own derivation rather than a reimplementation of it.
Parameters:
monkeypatch: pytest fixture.
dest_to_identity: ``{destination_hex: identity_hex}`` for 0-hop paths.
transport_enabled: What ``RNS.Reticulum.transport_enabled`` reports.
interfaces: ``{destination_hex: interface name}`` for path entries.
app_data: ``{destination_hex: announce app_data bytes}`` the stack
remembers, as ``RNS.Identity.recall_app_data`` would return.
"""
import RNS
interfaces = interfaces or {}
app_data = app_data or {}
table = [
{"hash": bytes.fromhex(d), "hops": 0, "interface": interfaces.get(d)}
for d in dest_to_identity
]
monkeypatch.setattr(
RNS.Identity,
"recall_app_data",
staticmethod(lambda dh, **_k: app_data.get(bytes(dh).hex())),
)
instance = types.SimpleNamespace(get_path_table=lambda max_hops=None: table)
monkeypatch.setattr(RNS.Reticulum, "get_instance", staticmethod(lambda: instance))
monkeypatch.setattr(
RNS.Reticulum, "transport_enabled", staticmethod(lambda: transport_enabled)
)
monkeypatch.setattr(
RNS.Transport,
"internal_identity",
staticmethod(lambda: _FakeIdentity(bytes.fromhex(_FIELD_TRANSPORT))),
)
monkeypatch.setattr(
RNS.Identity,
"recall",
staticmethod(
lambda dh, **_k: _FakeIdentity(
bytes.fromhex(dest_to_identity[bytes(dh).hex()])
)
),
)
def test_field_destinations_derive_from_the_primary_identity():
"""The captured aspects really are destinations of the primary identity.
Anchors the whole model: RNS recomputes each field destination hash from
the identity hash alone, which is why one identity is one node and the
aspects are its destinations (SPEC RE7).
"""
import RNS
primary = bytes.fromhex(_FIELD_PRIMARY)
assert RNS.Destination.hash(primary, "lxmf", "delivery").hex() == _FIELD_LXMF
assert (
RNS.Destination.hash(primary, "lxmf", "propagation").hex() == _FIELD_PROPAGATION
)
assert (
RNS.Destination.hash(primary, "nomadnetwork", "node").hex() == _FIELD_NOMADNET
)
# The transport identity is NOT one of them - it is its own identity.
assert _FIELD_TRANSPORT not in {_FIELD_LXMF, _FIELD_PROPAGATION, _FIELD_NOMADNET}
def test_host_node_id_is_derived_when_unset(monkeypatch):
"""T-B: the host id is the primary identity, never the transport identity.
The field case: a host whose primary identity is ``27716218…`` registered
as ``!fbf8e338`` because the id came from the transport identity, matching
none of the operator's announced destinations (SPEC RE8).
"""
_local_stack(
monkeypatch,
{
_FIELD_LXMF: _FIELD_PRIMARY,
_FIELD_NOMADNET: _FIELD_PRIMARY,
_FIELD_PROPAGATION: _FIELD_PRIMARY,
},
)
monkeypatch.setattr(_mod.config, "INGESTOR_NODE_ID", None)
assert ReticulumProvider().extract_host_node_id(None) == "!27716218"
def test_transport_identity_never_wins_the_primary_pick(monkeypatch):
"""A transport-only stack yields no host id rather than the wrong one."""
_local_stack(monkeypatch, {_FIELD_TRANSPORT: _FIELD_TRANSPORT})
monkeypatch.setattr(_mod.config, "INGESTOR_NODE_ID", None)
assert ReticulumProvider().extract_host_node_id(None) is None
def test_primary_identity_tie_is_not_guessed(monkeypatch):
"""Two identities with equal destination counts yield None, not a coin flip.
Path-table ordering is not stable, so guessing would let the ingestor's own
id change between restarts (SPEC RE8).
"""
other = "11223344" + "55" * 12
_local_stack(monkeypatch, {_FIELD_LXMF: _FIELD_PRIMARY, _FIELD_NOMADNET: other})
monkeypatch.setattr(_mod.config, "INGESTOR_NODE_ID", None)
assert ReticulumProvider().extract_host_node_id(None) is None
def test_host_destinations_label_every_announced_aspect(monkeypatch):
"""All of the host's aspects become destinations of ONE node record."""
_local_stack(
monkeypatch,
{
_FIELD_LXMF: _FIELD_PRIMARY,
_FIELD_NOMADNET: _FIELD_PRIMARY,
_FIELD_PROPAGATION: _FIELD_PRIMARY,
},
)
records = ReticulumProvider().host_destination_nodes()
assert {r["nodeId"] for r in records} == {"!27716218"}
by_aspect = {r["destination"]["aspect"]: r["destination"] for r in records}
assert by_aspect["lxmf.delivery"]["id"] == _FIELD_LXMF
assert by_aspect["lxmf.propagation"]["id"] == _FIELD_PROPAGATION
assert by_aspect["nomadnetwork.node"]["id"] == _FIELD_NOMADNET
assert by_aspect["lxmf.propagation"]["role"] == "PROPAGATION"
assert by_aspect["nomadnetwork.node"]["role"] == "NODE"
# Transport is off by default, so its aspect is absent.
assert _mod._TRANSPORT_ASPECT not in by_aspect
assert all(r["identityHash"] == _FIELD_PRIMARY for r in records)
def test_host_destinations_carry_their_interface_and_announced_name(monkeypatch):
"""Field regression: discovered aspects lost both name and interface.
A discovered destination comes from the path table, not from an announce,
so it has no ``app_data`` of its own -- but the stack kept the last one it
heard. Without recalling it every host destination stored the
``Reticulum <SHORT>`` placeholder and, through the original RE10 headline
rule, named the node with it. The interface came straight from the path
entry and was simply dropped (SPEC RE8).
"""
_local_stack(
monkeypatch,
{_FIELD_LXMF: _FIELD_PRIMARY, _FIELD_NOMADNET: _FIELD_PRIMARY},
interfaces={
_FIELD_LXMF: "LocalInterface[rns/default]",
_FIELD_NOMADNET: "RNodeInterface[RNode Reticulum Berlin]",
},
app_data={
_FIELD_LXMF: b"Afri Nomad Orion",
_FIELD_NOMADNET: b"Department of Decentralization",
},
)
by_aspect = {
r["destination"]["aspect"]: r
for r in ReticulumProvider().host_destination_nodes()
}
lxmf = by_aspect["lxmf.delivery"]
nomad = by_aspect["nomadnetwork.node"]
assert lxmf["user"]["longName"] == "Afri Nomad Orion"
assert nomad["user"]["longName"] == "Department of Decentralization"
assert lxmf["interface"] == "LocalInterface[rns/default]"
assert nomad["interface"] == "RNodeInterface[RNode Reticulum Berlin]"
def test_host_destination_falls_back_to_the_placeholder_without_a_name(monkeypatch):
"""A destination the stack has never heard a name for keeps the placeholder.
The generic name is wanted, but only where there is no real one to prefer.
The web upsert recognises both placeholder forms, the node id's head and the
destination's own head, so it stores this one on a first sighting, never
lets it replace a real name, and never lets it head the node, which keeps
its own ``Reticulum 2771`` (web/spec/reticulum_spec.rb pins the same string).
"""
_local_stack(monkeypatch, {_FIELD_LXMF: _FIELD_PRIMARY})
record = ReticulumProvider().host_destination_nodes()[0]
# Named from the *destination* hash, not the identity's: the field showed
# "Reticulum 6218" (the node) on destination !fee521eb.
assert record["user"]["longName"] == "Reticulum 4CF9"
# No interface in the path entry -> the key is omitted, not set to None.
assert "interface" not in record
def test_peer_destination_placeholder_names_its_own_hash():
"""A nameless peer announce names its destination from that destination.
SPEC RA10(b): each row is named from its own hash. The host path above
does this; the announce path built the fallback from the identity's node
id, so the field peer's ``lxmf.delivery`` destination ``4cf985bf...`` was
stored as ``Reticulum 2771``, the node's name, instead of its own
``Reticulum 4CF9``. The node keeps its own short name (RA10(a)).
"""
node = _announce_to_node_dict(
bytes.fromhex(_FIELD_LXMF),
None,
identity=_FakeIdentity(bytes.fromhex(_FIELD_PRIMARY)),
aspect="lxmf.delivery",
last_heard=1,
)
assert node["nodeId"] == "!27716218"
assert node["user"]["shortName"] == "2771"
assert node["destination"]["id"] == _FIELD_LXMF
assert node["user"]["longName"] == "Reticulum 4CF9"
def test_transport_aspect_is_gated_on_transport_enabled(monkeypatch):
"""TRANSPORT is reported only when the stack actually relays traffic.
The transport identity exists on every stack; claiming the role on a
non-transport one would assert something false (SPEC RE8).
"""
dests = {_FIELD_LXMF: _FIELD_PRIMARY}
_local_stack(monkeypatch, dests, transport_enabled=True)
on = {
r["destination"]["aspect"]: r["destination"]
for r in ReticulumProvider().host_destination_nodes()
}
assert on[_mod._TRANSPORT_ASPECT]["id"] == _FIELD_TRANSPORT
assert on[_mod._TRANSPORT_ASPECT]["role"] == "TRANSPORT"
_local_stack(monkeypatch, dests, transport_enabled=False)
off = {
r["destination"]["aspect"] for r in ReticulumProvider().host_destination_nodes()
}
assert _mod._TRANSPORT_ASPECT not in off
def test_local_announces_survive_an_interface_allowlist(monkeypatch):
"""T-C: a 0-hop announce is this machine's own and is always ingested.
``Transport.inbound`` adds a hop to every packet and takes it back for a
local-client or shared-instance interface, so **0 hops means "announced by
an app on this machine"**. Scoping to a radio must not hide the operator's
own nodes, which is what filtering purely on interface name did.
"""
monkeypatch.setattr(_mod.config, "RETICULUM_INTERFACES", ("rnode",))
assert (
_mod._announce_admitted(hops=0, interface_name="LocalInterface[rns/default]")
is True
)
# A remote peer is still scoped by interface.
assert (
_mod._announce_admitted(hops=1, interface_name="RNodeInterface[RNode Berlin]")
is True
)
assert _mod._announce_admitted(hops=1, interface_name="TCPInterface[hub]") is False
def test_every_aspect_of_one_identity_is_one_node_row():
"""SPEC RE7: one peer is one node record, whatever it announces on.
An identity announcing ``lxmf.delivery`` and ``nomadnetwork.node`` is a
single node with two destinations -- not two nodes. Verified against real
``RNS.Destination.hash`` output so the derivation cannot drift from RNS.
"""
import RNS
idn = RNS.Identity()
lxmf = RNS.Destination.hash(idn, "lxmf", "delivery")
nomad = RNS.Destination.hash(idn, "nomadnetwork", "node")
a = _announce_to_node_dict(
lxmf, b"Afri Nomad Orion", identity=idn, aspect="lxmf.delivery"
)
b = _announce_to_node_dict(
nomad, b"Dept of Decentralization", identity=idn, aspect="nomadnetwork.node"
)
# One identity, one node id -- keyed on the identity, never the destination.
assert a["nodeId"] == b["nodeId"] == "!" + idn.hash.hex()[:8]
assert a["nodeId"] != "!" + lxmf.hex()[:8]
assert a["identityHash"] == b["identityHash"] == idn.hash.hex()
# The aspects stay distinguishable through their destination rows (RE2).
assert a["destination"]["id"] == lxmf.hex()
assert b["destination"]["id"] == nomad.hex()
assert a["destination"]["aspect"] != b["destination"]["aspect"]
def test_node_record_carries_the_interface_it_was_heard_on():
"""T-C: 'heard on RNode Reticulum Berlin' is the honest LoRa-vs-IP answer."""
import RNS
idn = RNS.Identity()
dest = RNS.Destination.hash(idn, "lxmf", "delivery")
node = _announce_to_node_dict(
dest,
b"X",
identity=idn,
aspect="lxmf.delivery",
interface="RNodeInterface[RNode Reticulum Berlin]",
)
assert node["interface"] == "RNodeInterface[RNode Reticulum Berlin]"
# ---------------------------------------------------------------------------
# _announce_to_node_dict
# ---------------------------------------------------------------------------
def test_announce_to_node_dict_basic_fields():
"""The node dict carries lastHeard, protocol, and the derived user block."""
node = _announce_to_node_dict(
_DEST_HASH,
b"Alice",
identity=_FakeIdentity(),
aspect="lxmf.delivery",
hops=2,
last_heard=1700000000,
)
assert node["lastHeard"] == 1700000000
assert node["protocol"] == "reticulum"
assert node["hopsAway"] == 2
assert node["user"]["longName"] == "Alice"
# Keyed on the identity (SPEC RE7); the destination it arrived on is
# carried separately, in the destinations table.
assert node["nodeId"] == "!beef0001"
assert node["identityHash"] == _IDENTITY_HASH.hex()
assert node["destination"] == {
"id": _DEST_HASH.hex(),
"aspect": "lxmf.delivery",
"role": "PEER",
}
assert node["user"]["shortName"] == "beef"
# publicKey is the identity's real key, never a destination hash (#888).
assert node["user"]["publicKey"] == _PUBLIC_KEY.hex()
assert node["user"]["publicKey"] != _DEST_HASH.hex()
def test_announce_to_node_dict_public_key_none_when_unreadable():
"""An identity whose key cannot be read still yields a node row."""
class _Broken(_FakeIdentity):
def get_public_key(self):
raise RuntimeError("no key")
node = _announce_to_node_dict(_DEST_HASH, b"A", identity=_Broken(), last_heard=1)
assert node["user"]["publicKey"] is None
def test_announce_to_node_dict_long_name_falls_back_to_its_destination_placeholder():
"""Undecodable app_data falls back to a placeholder naming the destination.
The web stores this name on the destination row, so it is built from the
destination's own hash (SPEC RA10(b)); the node keeps its own headline
(RE10). Only an unusable destination hash, which writes no destination
row, falls back to the node id's placeholder.
"""
node = _announce_to_node_dict(
_DEST_HASH, b"\xff\xfe", identity=_FakeIdentity(), last_heard=1
)
assert node["user"]["longName"] == "Reticulum AABB"
salvaged = _announce_to_node_dict(
b"\xaa", b"\xff\xfe", identity=_FakeIdentity(), last_heard=1
)
assert "destination" not in salvaged
assert salvaged["user"]["longName"] == "Reticulum BEEF"
def test_announce_to_node_dict_omits_hops_when_unknown():
"""hopsAway is absent (not None) when the hop count is unknown."""
node = _announce_to_node_dict(
_DEST_HASH, b"Alice", identity=_FakeIdentity(), hops=None, last_heard=1
)
assert "hopsAway" not in node
def test_announce_to_node_dict_defaults_last_heard_to_now():
"""Without an explicit receipt time, lastHeard is the wall clock."""
before = int(time.time())
node = _announce_to_node_dict(_DEST_HASH, b"Alice", identity=_FakeIdentity())
after = int(time.time())
assert before <= node["lastHeard"] <= after
def test_announce_to_node_dict_needs_an_identity_or_a_usable_destination():
"""The identity keys the row; the destination is only its aspect (RE7).
A malformed destination hash no longer sinks the announce -- the identity
still keys the node -- but it must not produce a ``destination`` mapping,
which would write a destinations row under a truncated id.
"""
salvaged = _announce_to_node_dict(b"\xaa", b"Alice", identity=_FakeIdentity())
assert salvaged["nodeId"] == "!beef0001"
assert "destination" not in salvaged
# Neither a usable identity nor a usable destination -> nothing to key on.
assert _announce_to_node_dict(b"\xaa", b"Alice", identity=None) is None
# Identity missing but destination good: falls back to the destination.
fallback = _announce_to_node_dict(_DEST_HASH, b"Alice")
assert fallback["nodeId"] == "!aabbccdd"
def test_announce_to_node_dict_id_matches_node_id_helper():
"""The snapshot key derivation and the payload stay consistent."""
node = _announce_to_node_dict(
_DEST_HASH, None, identity=_FakeIdentity(), last_heard=1
)
assert node["nodeId"] == _reticulum_node_id(_IDENTITY_HASH)
assert node["user"]["shortName"] == _reticulum_node_id(_IDENTITY_HASH)[1:5]
# ---------------------------------------------------------------------------
# Aspect-derived roles (SPEC RD4)
# ---------------------------------------------------------------------------
def test_propagation_is_an_ingested_aspect():
"""lxmf.propagation joins the listened aspects so PROPAGATION has a source."""
assert set(_mod._ANNOUNCE_ASPECTS) == {
"lxmf.delivery",
"nomadnetwork.node",
"lxmf.propagation",
}
def test_every_ingested_aspect_maps_to_a_role():
"""No aspect may be listened to without a role, or nodes silently lose one."""
assert set(_mod._ASPECT_ROLES) == set(_mod._ANNOUNCE_ASPECTS)
assert _mod._ASPECT_ROLES["lxmf.delivery"] == "PEER"
assert _mod._ASPECT_ROLES["nomadnetwork.node"] == "NODE"
assert _mod._ASPECT_ROLES["lxmf.propagation"] == "PROPAGATION"
def test_transport_is_a_reserved_slot_with_no_source():
"""TRANSPORT ships in the UI ramp but no announce may populate it (RD4).
Deriving it from our own path table would make it a property of this
ingestor's vantage point rather than of the node, so two ingestors would
disagree — breaking the CONTRACTS sender-side determinism rule.
"""
# No announce aspect maps to it, so nothing populates it today. Kept as a
# reserved slot in the UI ramp; there is no rank to place it in any more,
# because one row now carries exactly one aspect.
assert "TRANSPORT" not in _mod._ASPECT_ROLES.values()
def test_announce_to_node_dict_omits_an_absent_role():
"""A record never asserts a role it could not determine."""
node = _announce_to_node_dict(
_DEST_HASH, b"A", identity=_FakeIdentity(), last_heard=1
)
assert "role" not in node["user"]
# ---------------------------------------------------------------------------
# _ReticulumAnnounceHandler
# ---------------------------------------------------------------------------
def test_announce_handler_signature_matches_rns_dispatch():
"""RNS dispatches on parameter count; the bound method must expose 3."""
handler = _ReticulumAnnounceHandler(
"lxmf.delivery", _ReticulumInterface(target=None)
)
params = inspect.signature(handler.received_announce).parameters
assert len(params) == 3
assert list(params) == ["destination_hash", "announced_identity", "app_data"]
def test_received_announce_upserts_node(monkeypatch):
"""A valid announce queues a node upsert and updates the snapshot."""
fake, _state = _fake_rns(hops=1)
monkeypatch.setattr(_mod, "RNS", fake)
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
upserts: list = []
seen = {"count": 0}
monkeypatch.setattr(
_mod.handlers, "upsert_node", lambda nid, node: upserts.append((nid, node))
)
monkeypatch.setattr(
_mod.handlers,
"_mark_packet_seen",
lambda: seen.__setitem__("count", seen["count"] + 1),
)
iface = _ReticulumInterface(target=None)
handler = _ReticulumAnnounceHandler("lxmf.delivery", iface)
handler.received_announce(
destination_hash=_DEST_HASH,
announced_identity=_FakeIdentity(),
app_data=b"Alice",
)
assert seen["count"] == 1
assert len(upserts) == 1
node_id, node = upserts[0]
# Keyed on the announcing identity (SPEC RE7).
assert node_id == "!beef0001"
assert node["protocol"] == "reticulum"
assert node["user"]["longName"] == "Alice"
assert node["hopsAway"] == 1
assert iface.nodes_snapshot() == [(node_id, node)]
def test_received_announce_recalls_identity_when_callback_omits_it(monkeypatch):
"""A handler invoked without an identity falls back to RNS.Identity.recall."""
fake, _state = _fake_rns(hops=1, recalled=_FakeIdentity())
monkeypatch.setattr(_mod, "RNS", fake)
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
upserts: list = []
monkeypatch.setattr(
_mod.handlers, "upsert_node", lambda nid, node: upserts.append((nid, node))
)
monkeypatch.setattr(_mod.handlers, "_mark_packet_seen", lambda: None)
handler = _ReticulumAnnounceHandler(
"lxmf.delivery", _ReticulumInterface(target=None)
)
handler.received_announce(
destination_hash=_DEST_HASH, announced_identity=None, app_data=b"Alice"
)
assert [nid for nid, _ in upserts] == ["!beef0001"]
def test_received_announce_skips_non_allowlisted_interface(monkeypatch):
"""With an allowlist set, an off-list interface is not ingested (#888)."""
fake, _state = _fake_rns(interface="AutoInterface[Default Interface]")
monkeypatch.setattr(_mod, "RNS", fake)
monkeypatch.setattr(_mod.config, "RETICULUM_INTERFACES", ("rnode",))
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
upserts: list = []
seen = {"count": 0}
monkeypatch.setattr(
_mod.handlers, "upsert_node", lambda nid, node: upserts.append((nid, node))
)
monkeypatch.setattr(
_mod.handlers,
"_mark_packet_seen",
lambda: seen.__setitem__("count", seen["count"] + 1),
)
iface = _ReticulumInterface(target=None)
_ReticulumAnnounceHandler("lxmf.delivery", iface).received_announce(
destination_hash=_DEST_HASH,
announced_identity=_FakeIdentity(),
app_data=b"Alice",
)
assert upserts == []
assert iface.nodes_snapshot() == []
# A filtered-out announce is not this mesh's traffic, so it is not counted.
assert seen["count"] == 0
def test_received_announce_ingests_allowlisted_interface(monkeypatch):
"""An interface matching the allowlist is ingested normally."""
fake, _state = _fake_rns(interface="RNodeInterface[RNode LoRa]")
monkeypatch.setattr(_mod, "RNS", fake)
monkeypatch.setattr(_mod.config, "RETICULUM_INTERFACES", ("rnode",))
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
upserts: list = []
monkeypatch.setattr(
_mod.handlers, "upsert_node", lambda nid, node: upserts.append((nid, node))
)
monkeypatch.setattr(_mod.handlers, "_mark_packet_seen", lambda: None)
_ReticulumAnnounceHandler(
"lxmf.delivery", _ReticulumInterface(target=None)
).received_announce(
destination_hash=_DEST_HASH,
announced_identity=_FakeIdentity(),
app_data=b"Alice",
)
assert [nid for nid, _ in upserts] == ["!beef0001"]
def test_received_announce_counts_frame_even_when_unmappable(monkeypatch):
"""An announce with an unusable hash is still counted as a received frame."""
fake, _state = _fake_rns()
monkeypatch.setattr(_mod, "RNS", fake)
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
upserts: list = []
seen = {"count": 0}
monkeypatch.setattr(
_mod.handlers, "upsert_node", lambda nid, node: upserts.append((nid, node))
)
monkeypatch.setattr(
_mod.handlers,
"_mark_packet_seen",
lambda: seen.__setitem__("count", seen["count"] + 1),
)
iface = _ReticulumInterface(target=None)
handler = _ReticulumAnnounceHandler("nomadnetwork.node", iface)
handler.received_announce(
destination_hash=b"\xaa", announced_identity=None, app_data=None
)
assert seen["count"] == 1
assert upserts == []
assert iface.nodes_snapshot() == []
def test_received_announce_swallows_handler_errors(monkeypatch):
"""An exception inside the ingest path must never escape to RNS."""
fake, _state = _fake_rns()
monkeypatch.setattr(_mod, "RNS", fake)
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
monkeypatch.setattr(_mod.handlers, "_mark_packet_seen", lambda: None)
calls: list = []
def _boom(*args, **_k):
calls.append(args)
raise RuntimeError("queue down")
monkeypatch.setattr(_mod.handlers, "upsert_node", _boom)
iface = _ReticulumInterface(target=None)
handler = _ReticulumAnnounceHandler("lxmf.delivery", iface)
# Must not raise: the identity resolves, so the failing upsert is reached.
handler.received_announce(
destination_hash=_DEST_HASH, announced_identity=_FakeIdentity(), app_data=b"X"
)
# Reached exactly once: an announce that is never admitted would pass the
# "must not raise" check above without testing the error path at all.
assert len(calls) == 1
def test_received_announce_latest_announce_wins_in_snapshot(monkeypatch):
"""A re-announce replaces the snapshot entry for the same node id."""
fake, _state = _fake_rns()
monkeypatch.setattr(_mod, "RNS", fake)
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
monkeypatch.setattr(_mod.handlers, "upsert_node", lambda *_a, **_k: None)
monkeypatch.setattr(_mod.handlers, "_mark_packet_seen", lambda: None)
iface = _ReticulumInterface(target=None)
handler = _ReticulumAnnounceHandler("lxmf.delivery", iface)
handler.received_announce(
destination_hash=_DEST_HASH,
announced_identity=_FakeIdentity(),
app_data=b"Old",
)
handler.received_announce(
destination_hash=_DEST_HASH,
announced_identity=_FakeIdentity(),
app_data=b"New",
)
snapshot = iface.nodes_snapshot()
assert len(snapshot) == 1
assert snapshot[0][1]["user"]["longName"] == "New"
# ---------------------------------------------------------------------------
# ReticulumProvider.connect / close
# ---------------------------------------------------------------------------
def test_connect_starts_reticulum_with_config_dir(monkeypatch):
"""connect() instantiates RNS.Reticulum with RETICULUM_CONFIG_DIR."""
fake, state = _fake_rns(existing_instance=None)
monkeypatch.setattr(_mod, "RNS", fake)
monkeypatch.setattr(_mod.config, "RETICULUM_CONFIG_DIR", "/tmp/rns-config")
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
iface, resolved, next_candidate = ReticulumProvider().connect(active_candidate=None)
assert state["created"] == ["/tmp/rns-config"]
assert resolved == "reticulum:///tmp/rns-config"
assert next_candidate is None
assert iface.isConnected is True
assert iface._rns is not None
def test_connect_logs_pending_host_id_with_a_retry_note(monkeypatch):
"""A startup line reading node_id=None reads as a failure, not a wait.
It printed ``None`` on *every* run, because it logged
``iface.host_node_id`` -- a constant ``None`` -- rather than the resolved
id. The daemon retries each loop, so an unresolved id is a pending lookup
and the log now says so (review follow-up to SPEC RE8).
"""
fake, _state = _fake_rns(existing_instance=object())
fake.Reticulum.get_instance = staticmethod(lambda: None) # no path table
monkeypatch.setattr(_mod, "RNS", fake)
monkeypatch.setattr(_mod.config, "INGESTOR_NODE_ID", None)
logs: list = []
monkeypatch.setattr(
_mod.config, "_debug_log", lambda msg, **kw: logs.append((msg, kw))
)
ReticulumProvider().connect(active_candidate=None)
registered = [kw for msg, kw in logs if "listener registered" in msg]
assert registered and registered[0]["node_id"] == "pending"
assert any("retrying until a local destination is heard" in msg for msg, _ in logs)
def test_connect_logs_the_resolved_host_id(monkeypatch):
"""Once discovery answers, the startup line names the real id."""
_local_stack(
monkeypatch,
{_FIELD_LXMF: _FIELD_PRIMARY, _FIELD_NOMADNET: _FIELD_PRIMARY},
)
monkeypatch.setattr(_mod.config, "INGESTOR_NODE_ID", None)
monkeypatch.setattr(_mod.config, "RETICULUM_CONFIG_DIR", "/tmp/rns-config")
logs: list = []
monkeypatch.setattr(
_mod.config, "_debug_log", lambda msg, **kw: logs.append((msg, kw))
)
ReticulumProvider().connect(active_candidate=None)
registered = [kw for msg, kw in logs if "listener registered" in msg]
assert registered and registered[0]["node_id"] == "!27716218"
assert not any("retrying until" in msg for msg, _ in logs)
def test_connect_reuses_running_instance(monkeypatch):
"""connect() attaches to an existing RNS instance without re-initialising."""
existing = object()
fake, state = _fake_rns(existing_instance=existing)
monkeypatch.setattr(_mod, "RNS", fake)
monkeypatch.setattr(
_mod.config, "RETICULUM_CONFIG_DIR", "/cfg/potato-mesh/reticulum"
)
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
iface, resolved, _ = ReticulumProvider().connect(active_candidate=None)
assert state["created"] == []
assert iface._rns is existing
assert resolved == "reticulum:///cfg/potato-mesh/reticulum"
def test_connect_registers_announce_handlers_per_aspect(monkeypatch):
"""One announce handler per aspect is registered with RNS.Transport."""
fake, state = _fake_rns()
monkeypatch.setattr(_mod, "RNS", fake)
monkeypatch.setattr(
_mod.config, "RETICULUM_CONFIG_DIR", "/cfg/potato-mesh/reticulum"
)
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
iface, _, _ = ReticulumProvider().connect(active_candidate=None)
aspects = [h.aspect_filter for h in state["registered"]]
assert aspects == list(_ANNOUNCE_ASPECTS)
assert "lxmf.delivery" in aspects
assert "nomadnetwork.node" in aspects
assert iface._announce_handlers == state["registered"]
def test_connect_passes_active_candidate_through(monkeypatch, tmp_path):
"""The candidate string is returned unchanged (no serial-candidate concept)."""
fake, _state = _fake_rns()
monkeypatch.setattr(_mod, "RNS", fake)
_no_ingestor_node_id(monkeypatch, tmp_path)
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
_iface, _resolved, next_candidate = ReticulumProvider().connect(
active_candidate="whatever"
)
assert next_candidate == "whatever"
def test_connect_is_silent_when_ingestor_node_id_is_unset(monkeypatch, tmp_path):
"""An unset ``INGESTOR_NODE_ID`` is not a warning (SPEC RE5/RE8).
It used to leave the heartbeat unregistered, so connect warned about it.
The id is now derived from the host's primary identity once a local app
announces, and connect logs ``node_id='pending'`` at info until then.
"""
fake, _state = _fake_rns()
monkeypatch.setattr(_mod, "RNS", fake)
_no_ingestor_node_id(monkeypatch, tmp_path)
calls = []
monkeypatch.setattr(
_mod.config,
"_debug_log",
lambda message, **kwargs: calls.append((message, kwargs)),
)
ReticulumProvider().connect(active_candidate=None)
assert [call for call in calls if call[1].get("severity") == "warn"] == []
def test_connect_does_not_warn_when_ingestor_node_id_set(monkeypatch):
"""connect() emits no INGESTOR_NODE_ID warning when the id is supplied."""
fake, _state = _fake_rns()
monkeypatch.setattr(_mod, "RNS", fake)
monkeypatch.setattr(_mod.config, "RETICULUM_CONFIG_DIR", None)
monkeypatch.setattr(_mod.config, "INGESTOR_NODE_ID", "!aabbccdd")
calls = []
monkeypatch.setattr(
_mod.config,
"_debug_log",
lambda message, **kwargs: calls.append((message, kwargs)),
)
ReticulumProvider().connect(active_candidate=None)
assert not any("INGESTOR_NODE_ID" in call[0] for call in calls)
def test_close_deregisters_announce_handlers(monkeypatch):
"""close() deregisters every announce handler and marks disconnected."""
fake, state = _fake_rns()
monkeypatch.setattr(_mod, "RNS", fake)
monkeypatch.setattr(
_mod.config, "RETICULUM_CONFIG_DIR", "/cfg/potato-mesh/reticulum"
)
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
iface, _, _ = ReticulumProvider().connect(active_candidate=None)
registered = list(state["registered"])
iface.close()
assert state["deregistered"] == registered
assert iface.isConnected is False
assert iface._announce_handlers == []
# Second close is a no-op, not a double-deregister.
iface.close()
assert state["deregistered"] == registered
@pytest.mark.parametrize(
"raised",
[BrokenPipeError(32, "Broken pipe"), KeyboardInterrupt()],
ids=["closed_stdout", "interrupt"],
)
def test_connect_failure_after_registration_deregisters_its_handlers(
monkeypatch, tmp_path, raised
):
"""A connect that raises after registering closes its interface (SPEC RG5).
The daemon never receives the interface of a connect that raised, so
nothing else would deregister its three handlers: the next connect adds
three more, and every announce then posts twice and counts twice (MA1).
The raise here is the startup line's print hitting a closed stdout, and a
``KeyboardInterrupt``, which ``except Exception`` would let through.
Against 1dbcdc3 this fails with ``assert 3 == 0``.
"""
fake, state = _fake_rns()
monkeypatch.setattr(_mod, "RNS", fake)
_no_ingestor_node_id(monkeypatch, tmp_path)
def _print_fails(message, **_kwargs):
if message == "Reticulum announce listener registered":
raise raised
monkeypatch.setattr(_mod.config, "_debug_log", _print_fails)
with pytest.raises(type(raised)):
ReticulumProvider().connect(active_candidate=None)
assert len(state["registered"]) == 3
orphans = [h for h in state["registered"] if h not in state["deregistered"]]
assert len(orphans) == 0
assert len(state["deregistered"]) == 3 # each handler exactly once
assert all(h._iface.isConnected is False for h in state["registered"])
def test_close_swallows_deregistration_errors(monkeypatch):
"""A deregistration failure must not escape close()."""
fake, _state = _fake_rns()
fake.Transport.deregister_announce_handler = lambda _h: (_ for _ in ()).throw(
RuntimeError("transport gone")
)
monkeypatch.setattr(_mod, "RNS", fake)
iface = _ReticulumInterface(target=None)
iface._announce_handlers.append(_ReticulumAnnounceHandler("lxmf.delivery", iface))
iface.close() # must not raise
assert iface.isConnected is False
# ---------------------------------------------------------------------------
# ReticulumProvider.node_snapshot_items
# ---------------------------------------------------------------------------
def test_node_snapshot_items_returns_heard_announces(monkeypatch):
"""Announces recorded on the interface surface through the snapshot."""
fake, _state = _fake_rns(hops=2)
monkeypatch.setattr(_mod, "RNS", fake)
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
monkeypatch.setattr(_mod.handlers, "upsert_node", lambda *_a, **_k: None)
monkeypatch.setattr(_mod.handlers, "_mark_packet_seen", lambda: None)
iface = _ReticulumInterface(target=None)
handler = _ReticulumAnnounceHandler("lxmf.delivery", iface)
other_hash = bytes.fromhex("11223344" + "00" * 12)
other_identity = _FakeIdentity(bytes.fromhex("c0ffee00" + "22" * 12))
handler.received_announce(
destination_hash=_DEST_HASH,
announced_identity=_FakeIdentity(),
app_data=b"Alice",
)
handler.received_announce(
destination_hash=other_hash, announced_identity=other_identity, app_data=None
)
items = ReticulumProvider().node_snapshot_items(iface)
as_dict = dict(items)
assert set(as_dict) == {"!beef0001", "!c0ffee00"}
assert as_dict["!beef0001"]["user"]["longName"] == "Alice"
# Name-less announce falls back to a placeholder built from the
# destination it arrived on (SPEC RA10(b)), never from another
# destination's hex.
assert as_dict["!c0ffee00"]["user"]["longName"] == "Reticulum 1122"
def test_update_node_ignores_a_falsy_node_id():
"""A falsy node id is never recorded in the snapshot."""
iface = _ReticulumInterface(target=None)
iface._update_node(None, {"protocol": "reticulum"})
iface._update_node("", {"protocol": "reticulum"})
assert iface.nodes_snapshot() == []
def test_node_snapshot_items_empty_before_any_announce():
"""A fresh interface yields an empty snapshot."""
iface = _ReticulumInterface(target=None)
assert ReticulumProvider().node_snapshot_items(iface) == []
# ---------------------------------------------------------------------------
# config / daemon wiring
# ---------------------------------------------------------------------------
def test_known_protocols_includes_reticulum():
"""PROTOCOL=reticulum must pass config validation."""
assert "reticulum" in config._KNOWN_PROTOCOLS
def test_config_exports_reticulum_config_dir():
"""RETICULUM_CONFIG_DIR is part of the config surface (None by default)."""
assert "RETICULUM_CONFIG_DIR" in config.__all__
assert hasattr(config, "RETICULUM_CONFIG_DIR")
def test_daemon_main_selects_reticulum_provider(monkeypatch):
"""PROTOCOL=reticulum makes daemon.main() build a ReticulumProvider."""
from data.mesh_ingestor import daemon
monkeypatch.setattr(daemon.config, "PROTOCOL", "reticulum")
# No instances configured -> main() exits right after provider selection,
# exercising only the branch under test.
monkeypatch.setattr(daemon.config, "INSTANCES", ())
monkeypatch.setattr(daemon.config, "INSTANCE", "")
subscribed: list = []
real_subscribe = ReticulumProvider.subscribe
def _tracking_subscribe(self):
subscribed.append(type(self).__name__)
return real_subscribe(self)
monkeypatch.setattr(ReticulumProvider, "subscribe", _tracking_subscribe)
daemon.main()
assert subscribed == ["ReticulumProvider"]
# ---------------------------------------------------------------------------
# Contract guards against the real RNS library (#888)
#
# The tests above exercise the provider through a fake RNS namespace. These
# pin the *mapping itself* to the library's real Identity/Destination maths,
# so a change in how RNS derives destination hashes cannot silently reinstate
# the split-row bug these guards were written for.
# ---------------------------------------------------------------------------
def _identity_pair():
"""Return (identity, lxmf_dest_hash, nomad_dest_hash) from real RNS."""
import RNS
idn = RNS.Identity()
return (
idn,
RNS.Destination.hash(idn, "lxmf", "delivery"),
RNS.Destination.hash(idn, "nomadnetwork", "node"),
)
def test_public_key_is_the_identity_public_key():
"""RT-A2: user.publicKey carries the identity key, not a destination hash."""
idn, lxmf, _ = _identity_pair()
node = _mod._announce_to_node_dict(lxmf, b"Kelly", identity=idn)
assert node["user"]["publicKey"] == idn.get_public_key().hex()
def test_interface_allowlist_filters_announces(monkeypatch):
"""RT-A5: with an allowlist set, off-list interfaces are not ingested."""
monkeypatch.setattr(config, "RETICULUM_INTERFACES", ("rnode",), raising=False)
assert _mod._announce_admitted(1, "AutoInterface[Default Interface]") is False
assert _mod._announce_admitted(1, "RNodeInterface[RNode LoRa]") is True
def test_interface_allowlist_empty_is_the_rnode_scope(monkeypatch):
"""RT-A5, amended (SPEC RN4): an empty allowlist admits RNode interfaces only.
No stack runs here, so the name decides: a single-radio RNode prints as
``RNodeInterface[...]``. The class rule itself is covered in
``tests/test_reticulum_scope_unit.py``.
"""
monkeypatch.setattr(config, "RETICULUM_INTERFACES", (), raising=False)
assert _mod._announce_admitted(1, "AutoInterface[Default Interface]") is False
assert _mod._announce_admitted(1, None) is False
assert _mod._announce_admitted(1, "RNodeInterface[RNode LoRa]") is True
# ---------------------------------------------------------------------------
# Packaging surface (SPEC RN3 / MA10)
# ---------------------------------------------------------------------------
class TestReticulumDeploymentSurface:
"""A knob nobody can set is a knob that does not exist.
Mirrors ``test_tx_policy_unit.TestDeploymentSurface``: every packaged path
must be able to deliver these variables. The ``${VAR:-""}`` literal-text
trap that made a Compose default mean "one entry matching nothing" is
guarded for the whole file by
``test_config_unit.TestComposeDefaults``, so it is not repeated per
variable here.
"""
_COMPOSE = REPO_ROOT / "docker-compose.yml"
@pytest.mark.parametrize(
"name", ["RETICULUM_CONFIG_DIR", "RETICULUM_INTERFACES", "INGESTOR_NODE_ID"]
)
def test_compose_passes_the_variable_through(self, name):
"""The base compose file maps the variable from the host env."""
text = self._COMPOSE.read_text(encoding="utf-8")
assert re.search(
rf"^\s*{name}:\s*\$\{{{name}", text, re.MULTILINE
), f"docker-compose.yml does not pass {name} through to the ingestor"
def test_quote_only_allowlist_cannot_blackout_ingestion(self):
"""Defence in depth for the above: a quoted empty value is the default.
It parses like unset, to the RNode scope (SPEC RN4), never to a
one-entry allowlist matching nothing.
"""
assert config._parse_reticulum_interfaces('""') == ()
def test_image_declares_the_reticulum_defaults(self):
"""Both image stages declare the variables, so the knob exists there too."""
text = (REPO_ROOT / "data" / "Dockerfile").read_text(encoding="utf-8")
for name in ("RETICULUM_CONFIG_DIR", "RETICULUM_INTERFACES"):
assert (
text.count(f"{name}=") == 2
), f"{name} missing from a Dockerfile stage"
def test_reticulum_config_dir_is_not_the_shared_config_volume_root(self):
"""The RNS config gets its own volume, not the web-initialised shared one.
``potatomesh_config`` is seeded by the web image, whose user is pinned to
uid 1000; this image's user is not, so RNS could not create a directory
in that volume's root.
"""
text = self._COMPOSE.read_text(encoding="utf-8")
assert "potatomesh_reticulum:/app/.config/potato-mesh/reticulum" in text
assert re.search(r"^\s{2}potatomesh_reticulum:", text, re.MULTILINE)
def test_env_example_documents_the_variables(self):
"""The copy-this-to-.env template mentions each variable."""
text = (REPO_ROOT / ".env.example").read_text(encoding="utf-8")
for name in (
"RETICULUM_CONFIG_DIR",
"RETICULUM_INTERFACES",
"INGESTOR_NODE_ID",
):
assert re.search(
rf"^#\s*{name}=", text, re.MULTILINE
), f"{name} is not documented in .env.example"
def test_docs_drop_the_blanket_ingestor_node_id_requirement(self):
"""No doc says every Reticulum setup needs the variable (SPEC RE5/RE8).
The id is derived wherever something on the ingestor's RNS stack
announces, so the old unconditional wording would send operators
hunting for a value the ingestor finds itself. The docs name the cases
that need it instead: Docker, no local announcer, a tie.
"""
for name in ("README.md", ".env.example"):
text = (REPO_ROOT / name).read_text(encoding="utf-8")
assert not re.search(
r"reticulum listener also needs INGESTOR_NODE_ID|"
r"^Set `INGESTOR_NODE_ID`\.",
text,
re.MULTILINE | re.IGNORECASE,
), f"{name} still says every reticulum setup needs INGESTOR_NODE_ID"
def test_docs_do_not_key_the_ingestor_on_the_transport_identity(self):
"""RE8 excludes the transport identity from the pick; docs must agree.
RNS generates the transport identity as an independent keypair, so it
matches none of the operator's announced destinations -- keying on it
registered a node id the operator could not recognise. A doc still
naming it would send them looking for the wrong hash in ``rnstatus``.
"""
for name in ("README.md", ".env.example"):
text = (REPO_ROOT / name).read_text(encoding="utf-8")
assert (
"from the transport identity" not in text
), f"{name} still derives the ingestor node id from the transport identity"
assert re.search(
r"primary identity", text, re.IGNORECASE
), f"{name} does not name the primary identity as the id's source"
def test_docs_warn_that_changing_the_id_strands_the_old_row(self):
"""Moving the id leaves a heartbeat-less row behind, so operators are told.
Nothing in code warns: that would need a durable record of the
previously registered id, which this provider does not keep. The
operator-facing docs are the only place the caveat can live.
"""
for name in ("README.md", ".env.example"):
text = (REPO_ROOT / name).read_text(encoding="utf-8")
assert re.search(
r"strands? the old row", text, re.IGNORECASE
), f"{name} does not warn that changing the id strands the old row"
assert re.search(
r"ages out", text, re.IGNORECASE
), f"{name} does not say the stranded row ages out"
def test_docs_state_that_connection_does_not_apply(self):
"""RN10's answer has to reach the operator, not just the log."""
for name in ("README.md", ".env.example"):
text = (REPO_ROOT / name).read_text(encoding="utf-8")
assert "CONNECTION" in text and re.search(
r"CONNECTION`? does not apply", text
), f"{name} does not say CONNECTION is inapplicable to reticulum"
# ---------------------------------------------------------------------------
# The ingestor's own node id (SPEC RE5)
# ---------------------------------------------------------------------------
def test_announces_are_still_ingested_without_a_node_id(monkeypatch, tmp_path):
"""A missing node id costs the heartbeat, never the ingestion."""
fake, state = _fake_rns(identity_write_error=OSError("read-only filesystem"))
monkeypatch.setattr(_mod, "RNS", fake)
_no_ingestor_node_id(monkeypatch, tmp_path)
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
iface, _target, _next = ReticulumProvider().connect(active_candidate=None)
assert iface.isConnected
assert len(state["registered"]) == len(_ANNOUNCE_ASPECTS)
def test_an_underivable_identity_hash_yields_no_node_id(monkeypatch, tmp_path):
"""A hash too short to map is no id, not a truncated one."""
fake, _state = _fake_rns(self_identity_hash=b"\x01")
monkeypatch.setattr(_mod, "RNS", fake)
_no_ingestor_node_id(monkeypatch, tmp_path)
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
iface, _target, _next = ReticulumProvider().connect(active_candidate=None)
assert iface.host_node_id is None
def test_extract_host_node_id_falls_back_to_the_env_for_a_foreign_iface(monkeypatch):
"""The operator's value is honoured whatever interface object is passed."""
monkeypatch.setattr(_mod.config, "INGESTOR_NODE_ID", "!operator")
assert ReticulumProvider().extract_host_node_id(object()) == "!operator"
def test_extract_host_node_id_is_none_when_nothing_resolves(monkeypatch):
"""No resolved id and no env value is None, not an empty string."""
monkeypatch.setattr(_mod.config, "INGESTOR_NODE_ID", None)
assert ReticulumProvider().extract_host_node_id(object()) is None
# ---------------------------------------------------------------------------
# CONNECTION does not apply to Reticulum (SPEC RN10)
# ---------------------------------------------------------------------------
def test_connection_is_reported_as_inapplicable(monkeypatch, tmp_path):
"""The image ships a serial default for every protocol; say it is ignored."""
messages = []
fake, _state = _fake_rns()
monkeypatch.setattr(_mod, "RNS", fake)
_no_ingestor_node_id(monkeypatch, tmp_path)
monkeypatch.setattr(_mod.config, "CONNECTION", "/dev/ttyACM0")
monkeypatch.setattr(
_mod.config,
"_debug_log",
lambda message, **_k: messages.append(message),
)
ReticulumProvider().connect(active_candidate=None)
said = [m for m in messages if "does not apply to PROTOCOL=reticulum" in m]
assert said, "a set CONNECTION was passed over in silence"
# Naming the replacements is the point: the operator needs somewhere to go.
assert "RETICULUM_CONFIG_DIR" in said[0] and "RETICULUM_INTERFACES" in said[0]
def test_connection_is_not_mentioned_when_unset(monkeypatch, tmp_path):
"""Nothing to correct means nothing to say."""
messages = []
fake, _state = _fake_rns()
monkeypatch.setattr(_mod, "RNS", fake)
_no_ingestor_node_id(monkeypatch, tmp_path)
monkeypatch.setattr(
_mod.config,
"_debug_log",
lambda message, **_k: messages.append(message),
)
ReticulumProvider().connect(active_candidate=None)
assert not any("does not apply to PROTOCOL=reticulum" in m for m in messages)
def test_connection_never_becomes_the_reticulum_target(monkeypatch, tmp_path):
"""The resolved target stays the config dir, whatever CONNECTION says."""
fake, _state = _fake_rns()
monkeypatch.setattr(_mod, "RNS", fake)
_no_ingestor_node_id(monkeypatch, tmp_path)
monkeypatch.setattr(_mod.config, "CONNECTION", "/dev/ttyACM0")
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
_iface, target, _next = ReticulumProvider().connect(active_candidate=None)
assert target == f"reticulum://{tmp_path}"
# ---------------------------------------------------------------------------
# Discovery failure paths (SPEC RE8)
#
# Every accessor these helpers call reaches a *running* RNS stack, which can be
# absent, mid-shutdown, or answering over RPC. Each failure must degrade to
# "nothing discovered" rather than kill the announce thread, so each branch is
# exercised rather than assumed.
# ---------------------------------------------------------------------------
class _Boom:
"""Namespace whose every accessor raises, standing in for a dead stack."""
def __getattr__(self, _name):
raise RuntimeError("stack is gone")
def test_local_path_entries_survives_a_dead_or_silent_stack(monkeypatch):
"""No instance, a raising accessor, or a non-list reply all yield []."""
import RNS
monkeypatch.setattr(RNS.Reticulum, "get_instance", staticmethod(lambda: None))
assert _mod._local_path_entries() == []
def _raise():
raise RuntimeError("no instance")
monkeypatch.setattr(RNS.Reticulum, "get_instance", staticmethod(_raise))
assert _mod._local_path_entries() == []
bad = types.SimpleNamespace(
get_path_table=lambda max_hops=None: (_ for _ in ()).throw(OSError("rpc down"))
)
monkeypatch.setattr(RNS.Reticulum, "get_instance", staticmethod(lambda: bad))
assert _mod._local_path_entries() == []
not_a_list = types.SimpleNamespace(get_path_table=lambda max_hops=None: "nope")
monkeypatch.setattr(RNS.Reticulum, "get_instance", staticmethod(lambda: not_a_list))
assert _mod._local_path_entries() == []
def test_local_identity_destinations_skips_unusable_entries(monkeypatch):
"""A malformed entry or an unrecallable destination is skipped, not fatal."""
import RNS
table = [
{"hash": "not-bytes", "hops": 0},
{"hash": bytes.fromhex(_FIELD_LXMF), "hops": 0},
{"hash": bytes.fromhex(_FIELD_NOMADNET), "hops": 0, "interface": "RNode[X]"},
]
monkeypatch.setattr(
RNS.Reticulum,
"get_instance",
staticmethod(lambda: types.SimpleNamespace(get_path_table=lambda **_k: table)),
)
monkeypatch.setattr(
RNS.Transport,
"internal_identity",
staticmethod(lambda: _FakeIdentity(bytes.fromhex(_FIELD_TRANSPORT))),
)
def _recall(dh, **_k):
if bytes(dh).hex() == _FIELD_LXMF:
raise RuntimeError("unknown destination")
return _FakeIdentity(bytes.fromhex(_FIELD_PRIMARY))
monkeypatch.setattr(RNS.Identity, "recall", staticmethod(_recall))
groups = _mod._local_identity_destinations()
# Only the nomadnet entry survives, and it carries its interface.
assert groups == {_FIELD_PRIMARY: {_FIELD_NOMADNET: "RNode[X]"}}
def test_local_identity_destinations_skips_an_unidentifiable_owner(monkeypatch):
"""A recall returning no usable hash yields no group."""
import RNS
table = [{"hash": bytes.fromhex(_FIELD_LXMF), "hops": 0}]
monkeypatch.setattr(
RNS.Reticulum,
"get_instance",
staticmethod(lambda: types.SimpleNamespace(get_path_table=lambda **_k: table)),
)
monkeypatch.setattr(
RNS.Transport, "internal_identity", staticmethod(lambda: _FakeIdentity(b""))
)
monkeypatch.setattr(RNS.Identity, "recall", staticmethod(lambda _dh, **_k: None))
assert _mod._local_identity_destinations() == {}
def test_recalled_display_name_is_none_when_the_stack_cannot_answer(monkeypatch):
"""A raising recall_app_data degrades to the placeholder, not a crash."""
import RNS
def _raise(_dh, **_k):
raise RuntimeError("no app data")
monkeypatch.setattr(RNS.Identity, "recall_app_data", staticmethod(_raise))
assert _mod._recalled_display_name(_FIELD_LXMF) is None
def test_aspect_destination_hex_rejects_a_malformed_aspect(monkeypatch):
"""An aspect without an app/aspect split, or an unhashable identity, is None."""
assert _mod._aspect_destination_hex(_FIELD_PRIMARY, "nodots") is None
assert _mod._aspect_destination_hex(_FIELD_PRIMARY, "") is None
# A non-hex identity cannot be turned into hash material.
assert _mod._aspect_destination_hex("zzzz", "lxmf.delivery") is None
def test_host_destination_nodes_none_without_a_usable_identity():
"""An unusable identity hash yields no host records at all."""
assert _mod._host_destination_nodes("ab") == []
def test_transport_enabled_asks_the_shared_instance(monkeypatch):
"""Field regression: a client of a transport-enabled rnsd read False.
``Reticulum.transport_enabled()`` answers "does *this process* route". On
connecting to a shared instance RNS forces the client's flag to False
whatever the config says, so an ingestor attached to rnsd never saw
``enable_transport = Yes`` and its host never grew a TRANSPORT destination.
The stack's answer comes over RPC instead (SPEC RE9).
"""
import RNS
monkeypatch.setattr(RNS.Reticulum, "transport_enabled", staticmethod(lambda: False))
# rnsd is routing: its interface stats carry a transport_id.
routing = types.SimpleNamespace(
get_interface_stats=lambda: {"transport_id": b"\x01" * 16}
)
monkeypatch.setattr(RNS.Reticulum, "get_instance", staticmethod(lambda: routing))
assert _mod._transport_enabled() is True
# rnsd is not routing: no transport_id in the stats it returns.
quiet = types.SimpleNamespace(get_interface_stats=lambda: {"rxb": 0})
monkeypatch.setattr(RNS.Reticulum, "get_instance", staticmethod(lambda: quiet))
assert _mod._transport_enabled() is False
# A stack that cannot be asked at all is not routing as far as we know.
monkeypatch.setattr(RNS.Reticulum, "get_instance", staticmethod(lambda: None))
assert _mod._transport_enabled() is False
boom = types.SimpleNamespace(
get_interface_stats=lambda: (_ for _ in ()).throw(OSError("rpc down"))
)
monkeypatch.setattr(RNS.Reticulum, "get_instance", staticmethod(lambda: boom))
assert _mod._transport_enabled() is False
def test_transport_enabled_short_circuits_on_a_standalone_instance(monkeypatch):
"""A standalone transport node answers for itself, with no RPC."""
import RNS
monkeypatch.setattr(RNS.Reticulum, "transport_enabled", staticmethod(lambda: True))
def _never():
raise AssertionError("must not consult the instance when we route ourselves")
monkeypatch.setattr(RNS.Reticulum, "get_instance", staticmethod(_never))
assert _mod._transport_enabled() is True
def test_transport_enabled_is_false_when_the_stack_cannot_answer(monkeypatch):
"""An unreadable stack reports transport off - the safe answer, since the
role is only emitted when transport is definitely on (SPEC RE9)."""
import RNS
def _raise():
raise RuntimeError("no config")
monkeypatch.setattr(RNS.Reticulum, "transport_enabled", staticmethod(_raise))
assert _mod._transport_enabled() is False
def test_announce_interface_name_prefers_the_rpc_answer(monkeypatch):
"""The shared instance's view wins over this process's path table (RE3)."""
import RNS
instance = types.SimpleNamespace(
get_next_hop_if_name=lambda _dh: "RNodeInterface[RNode Reticulum Berlin]"
)
monkeypatch.setattr(RNS.Reticulum, "get_instance", staticmethod(lambda: instance))
assert (
_mod._announce_interface_name(bytes.fromhex(_FIELD_LXMF))
== "RNodeInterface[RNode Reticulum Berlin]"
)
def test_announce_interface_name_falls_back_when_rpc_is_unavailable(monkeypatch):
"""A dead instance or a blank RPC reply falls back to the local table."""
import RNS
def _raise():
raise RuntimeError("gone")
monkeypatch.setattr(RNS.Reticulum, "get_instance", staticmethod(_raise))
monkeypatch.setattr(
RNS.Transport,
"next_hop_interface",
staticmethod(lambda _dh: "LocalInterface[x]"),
)
assert (
_mod._announce_interface_name(bytes.fromhex(_FIELD_LXMF)) == "LocalInterface[x]"
)
# Instance present but RPC raises, and separately returns nothing usable.
boom = types.SimpleNamespace(
get_next_hop_if_name=lambda _dh: (_ for _ in ()).throw(OSError("rpc"))
)
monkeypatch.setattr(RNS.Reticulum, "get_instance", staticmethod(lambda: boom))
assert (
_mod._announce_interface_name(bytes.fromhex(_FIELD_LXMF)) == "LocalInterface[x]"
)
blank = types.SimpleNamespace(get_next_hop_if_name=lambda _dh: None)
monkeypatch.setattr(RNS.Reticulum, "get_instance", staticmethod(lambda: blank))
assert (
_mod._announce_interface_name(bytes.fromhex(_FIELD_LXMF)) == "LocalInterface[x]"
)
def test_snapshot_does_not_duplicate_a_host_destination_already_heard(monkeypatch):
"""A host aspect that also arrived as an announce is listed once.
The host's records are folded into every snapshot, so without the guard a
destination the ingestor genuinely heard would be reported twice.
"""
_local_stack(
monkeypatch,
{_FIELD_LXMF: _FIELD_PRIMARY},
app_data={_FIELD_LXMF: b"Afri Nomad Orion"},
)
iface = _ReticulumInterface(target=None)
iface._update_node(
"!27716218",
{
"nodeId": "!27716218",
"protocol": "reticulum",
"destination": {"id": _FIELD_LXMF, "aspect": "lxmf.delivery"},
"user": {"longName": "Afri Nomad Orion"},
},
)
items = ReticulumProvider().node_snapshot_items(iface)
ids = [n.get("destination", {}).get("id") for _nid, n in items]
assert ids.count(_FIELD_LXMF) == 1
def test_snapshot_includes_host_destinations_not_heard_as_announces(monkeypatch):
"""The host's own aspects reach the snapshot even with nothing heard.
A local app's announce reaches the ingestor only while both are attached,
one made before connect is not replayed, and ``rns.transport`` never
announces, so without folding the discovered records in, the ingestor's
own node could go unreported (SPEC RE8).
"""
_local_stack(
monkeypatch,
{_FIELD_LXMF: _FIELD_PRIMARY, _FIELD_NOMADNET: _FIELD_PRIMARY},
app_data={_FIELD_NOMADNET: b"Department of Decentralization"},
)
iface = _ReticulumInterface(target=None)
items = ReticulumProvider().node_snapshot_items(iface)
assert {nid for nid, _ in items} == {"!27716218"}
by_aspect = {n["destination"]["aspect"]: n for _nid, n in items}
assert set(by_aspect) == {"lxmf.delivery", "nomadnetwork.node"}
assert (
by_aspect["nomadnetwork.node"]["user"]["longName"]
== "Department of Decentralization"
)
# ---------------------------------------------------------------------------
# Radio metadata from the shared RNS config (SPEC RL1/RL2)
# ---------------------------------------------------------------------------
# RNS's own annotated example (RNS/Utilities/rnsd.py): "Set frequency to
# 867.2 MHz" -> 867200000, "Set LoRa bandwidth to 125 KHz" -> 125000. Both are
# stored in Hz, which is why the parser converts.
_RNS_CONFIG = """
[reticulum]
enable_transport = Yes
rpc_key = deadbeefdeadbeefdeadbeefdeadbeef
[interfaces]
[[Default Interface]]
type = AutoInterface
interface_enabled = True
[[RNode LoRa Interface]]
type = RNodeInterface
interface_enabled = True
port = /dev/ttyUSB0
frequency = 867200000
bandwidth = 125000
txpower = 7
spreadingfactor = 8
codingrate = 5
"""
def test_rnode_radio_config_is_parsed_in_the_units_rns_stores(tmp_path, monkeypatch):
"""Frequency and bandwidth are Hz in the config; MHz and kHz downstream."""
parsed = _mod._parse_rnode_radio_config(_RNS_CONFIG)
assert parsed == {
# Floored integer MHz: the column is INTEGER and the field orients a
# reader in a band rather than stating an exact frequency.
"frequency_mhz": 867,
"bandwidth_khz": 125,
"sf": 8,
"cr": 5,
}
def test_rnode_radio_config_ignores_non_rnode_interfaces():
"""An AutoInterface carries no radio, and must not be read as one."""
only_auto = """
[interfaces]
[[Default Interface]]
type = AutoInterface
interface_enabled = True
"""
assert _mod._parse_rnode_radio_config(only_auto) is None
assert _mod._parse_rnode_radio_config("") is None
def test_rnode_radio_config_survives_a_malformed_value():
"""A non-integer value is skipped rather than crashing the connect path."""
broken = """
[interfaces]
[[RNode]]
type = RNodeInterface
frequency = not-a-number
bandwidth = 125000
spreadingfactor = 8
codingrate = 5
"""
parsed = _mod._parse_rnode_radio_config(broken)
assert parsed["frequency_mhz"] is None
assert parsed["bandwidth_khz"] == 125
def test_reticulum_radio_metadata_prefers_the_env_override(tmp_path, monkeypatch):
"""RETICULUM_FREQ/RETICULUM_PRESET win over the parsed config (SPEC RL1)."""
(tmp_path / "config").write_text(_RNS_CONFIG, encoding="utf-8")
monkeypatch.setattr(_mod.config, "RETICULUM_CONFIG_DIR", str(tmp_path))
monkeypatch.setattr(_mod.config, "RETICULUM_FREQ", None)
monkeypatch.setattr(_mod.config, "RETICULUM_PRESET", None)
assert _mod._read_reticulum_radio_metadata() == (867, "SF8/BW125/CR5")
monkeypatch.setattr(_mod.config, "RETICULUM_FREQ", "868MHz")
monkeypatch.setattr(_mod.config, "RETICULUM_PRESET", "Custom")
assert _mod._read_reticulum_radio_metadata() == (868, "Custom")
def test_reticulum_radio_metadata_is_none_without_a_readable_config(monkeypatch):
"""No config, no numbers -- the columns keep their dash (SPEC RL1)."""
monkeypatch.setattr(_mod.config, "RETICULUM_FREQ", None)
monkeypatch.setattr(_mod.config, "RETICULUM_PRESET", None)
monkeypatch.setattr(_mod.config, "RETICULUM_CONFIG_DIR", "/nonexistent/rns-dir")
assert _mod._read_reticulum_radio_metadata() == (None, None)
# An unset config dir must not raise on the connect path either.
monkeypatch.setattr(_mod.config, "RETICULUM_CONFIG_DIR", None)
assert _mod._read_reticulum_radio_metadata() == (None, None)
def test_preset_label_names_a_meshtastic_preset_only_on_an_exact_match():
"""SPEC RL2, with each row's provenance stated.
The table is hand-maintained: the Meshtastic Python package defines the
preset *enum* but not its radio parameters, which live in firmware, so
nothing in the dependency tree can verify these. A mismatch with upstream
is a bug in the table, not in this test.
"""
label = _mod._reticulum_preset_label
assert label(250, 11, 5) == "LongFast"
assert label(250, 10, 5) == "MediumSlow"
assert label(250, 9, 5) == "MediumFast"
assert label(250, 8, 5) == "ShortSlow"
assert label(250, 7, 5) == "ShortFast"
assert label(500, 7, 5) == "ShortTurbo"
assert label(125, 12, 8) == "LongSlow"
# One parameter off the triple is not that preset.
assert label(125, 11, 5) == "SF11/BW125/CR5"
assert label(250, 11, 8) == "SF11/BW250/CR8"
# Missing or unusable values name nothing rather than guessing.
assert label(None, 11, 5) is None
assert label(250, 0, 5) is None
assert label("x", 11, 5) is None
def test_preset_fallback_matches_the_shape_the_repo_already_emits():
"""The fallback is the existing SF/BW/CR format, not a second one."""
from data.mesh_ingestor.interfaces import radio as radio_mod
class _Lora:
spread_factor = 8
bandwidth = 125
coding_rate = 5
assert radio_mod._custom_preset_label(_Lora()) == "SF8/BW125/CR5"
assert _mod._reticulum_preset_label(125, 8, 5) == "SF8/BW125/CR5"
def test_rnode_radio_config_takes_the_first_rnode_of_several():
"""Two RNodes: the first wins, and a later section cannot overwrite it.
Exercises the section boundary -- a second interface block must close the
first rather than merging keys across them.
"""
two = """
[interfaces]
[[RNode A]]
type = RNodeInterface
frequency = 867200000
bandwidth = 125000
spreadingfactor = 8
codingrate = 5
[[RNode B]]
type = RNodeInterface
frequency = 915000000
bandwidth = 500000
spreadingfactor = 7
codingrate = 5
"""
parsed = _mod._parse_rnode_radio_config(two)
assert parsed["frequency_mhz"] == 867
assert parsed["bandwidth_khz"] == 125
# A line with no '=' (a bare token) is skipped rather than mis-split.
assert _mod._parse_rnode_radio_config("[interfaces]\n bare-token\n") is None
def test_connect_populates_radio_metadata_from_the_config(tmp_path, monkeypatch):
"""The connect path fills LORA_FREQ/MODEM_PRESET for the heartbeat (RL1)."""
(tmp_path / "config").write_text(_RNS_CONFIG, encoding="utf-8")
fake, _state = _fake_rns(existing_instance=object())
monkeypatch.setattr(_mod, "RNS", fake)
monkeypatch.setattr(_mod.config, "RETICULUM_CONFIG_DIR", str(tmp_path))
monkeypatch.setattr(_mod.config, "RETICULUM_FREQ", None)
monkeypatch.setattr(_mod.config, "RETICULUM_PRESET", None)
monkeypatch.setattr(_mod.config, "LORA_FREQ", None)
monkeypatch.setattr(_mod.config, "MODEM_PRESET", None)
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
ReticulumProvider().connect(active_candidate=None)
assert _mod.config.LORA_FREQ == 867
assert _mod.config.MODEM_PRESET == "SF8/BW125/CR5"
def test_connect_never_overwrites_metadata_already_resolved(tmp_path, monkeypatch):
"""An operator-set or already-detected value is left alone."""
(tmp_path / "config").write_text(_RNS_CONFIG, encoding="utf-8")
fake, _state = _fake_rns(existing_instance=object())
monkeypatch.setattr(_mod, "RNS", fake)
monkeypatch.setattr(_mod.config, "RETICULUM_CONFIG_DIR", str(tmp_path))
monkeypatch.setattr(_mod.config, "RETICULUM_FREQ", None)
monkeypatch.setattr(_mod.config, "RETICULUM_PRESET", None)
monkeypatch.setattr(_mod.config, "LORA_FREQ", 915.0)
monkeypatch.setattr(_mod.config, "MODEM_PRESET", "AlreadySet")
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
ReticulumProvider().connect(active_candidate=None)
assert _mod.config.LORA_FREQ == 915.0
assert _mod.config.MODEM_PRESET == "AlreadySet"
def test_node_records_carry_the_resolved_radio_metadata(monkeypatch):
"""SPEC RL3: the values must ride on the node, not just the heartbeat.
Meshtastic and MeshCore reach ``nodes.lora_freq`` / ``nodes.modem_preset``
through their position and telemetry payloads. A Reticulum announce carries
neither, so without stamping them here the table's Frequency and LoRa Preset
columns and the chat/log tags all stayed blank while the ingestor knew the
answer -- the symptom this feature was opened for.
"""
monkeypatch.setattr(_mod.config, "LORA_FREQ", 867)
monkeypatch.setattr(_mod.config, "MODEM_PRESET", "SF8/BW125/CR5")
node = _announce_to_node_dict(
_DEST_HASH, b"Alice", identity=_FakeIdentity(), aspect="lxmf.delivery"
)
assert node["lora_freq"] == 867
assert node["modem_preset"] == "SF8/BW125/CR5"
def test_node_records_omit_radio_metadata_when_unresolved(monkeypatch):
"""An absent value is left out entirely, so the column keeps its dash."""
monkeypatch.setattr(_mod.config, "LORA_FREQ", None)
monkeypatch.setattr(_mod.config, "MODEM_PRESET", None)
node = _announce_to_node_dict(
_DEST_HASH, b"Alice", identity=_FakeIdentity(), aspect="lxmf.delivery"
)
assert "lora_freq" not in node
assert "modem_preset" not in node
def test_host_destination_records_carry_radio_metadata_too(monkeypatch):
"""The host's own discovered aspects are node records like any other."""
_local_stack(
monkeypatch,
{_FIELD_LXMF: _FIELD_PRIMARY, _FIELD_NOMADNET: _FIELD_PRIMARY},
transport_enabled=True,
)
monkeypatch.setattr(_mod.config, "LORA_FREQ", 867)
monkeypatch.setattr(_mod.config, "MODEM_PRESET", "LongFast")
records = ReticulumProvider().host_destination_nodes()
assert records, "expected host destinations"
assert all(r["lora_freq"] == 867 for r in records)
assert all(r["modem_preset"] == "LongFast" for r in records)
def test_radio_metadata_partial_override_still_reads_the_config(tmp_path, monkeypatch):
"""One variable set and the other unset is the case an operator hits.
The earlier test covered only both-unset and both-set, leaving the mixed
branches uncovered -- and it is the mixed case that decides whether the
config is consulted at all.
"""
(tmp_path / "config").write_text(_RNS_CONFIG, encoding="utf-8")
monkeypatch.setattr(_mod.config, "RETICULUM_CONFIG_DIR", str(tmp_path))
# Frequency pinned, preset still derived from the config.
monkeypatch.setattr(_mod.config, "RETICULUM_FREQ", "868MHz")
monkeypatch.setattr(_mod.config, "RETICULUM_PRESET", None)
assert _mod._read_reticulum_radio_metadata() == (868, "SF8/BW125/CR5")
# Preset pinned, frequency still derived.
monkeypatch.setattr(_mod.config, "RETICULUM_FREQ", None)
monkeypatch.setattr(_mod.config, "RETICULUM_PRESET", "LongFast")
assert _mod._read_reticulum_radio_metadata() == (867, "LongFast")
def test_band_coercion_survives_the_integer_column():
"""`nodes.lora_freq` is INTEGER, so a float override stored as NULL.
The documented `RETICULUM_FREQ="867.2MHz"` form did nothing at all until it
was floored here: `coerce_integer` rejects the suffixed string outright and
truncates a float, so the column dashed or silently lost 200 kHz.
"""
coerce = _mod._coerce_band_mhz
assert coerce("867.2MHz") == 867
assert coerce("868") == 868
assert coerce(915.9) == 915
assert coerce("433 mhz") == 433
assert coerce("") is None
assert coerce(None) is None
assert coerce("not-a-frequency") is None
assert coerce("-5") is None
def test_radio_metadata_is_scoped_to_the_lora_interface(monkeypatch):
"""A peer heard over IP must not be stamped with the operator's radio.
The resolved values describe one interface -- the RNodeInterface they were
parsed from -- and an RNS stack routinely carries IP interfaces alongside
it. Attributing them to a peer heard over AutoInterface publishes, and
federates, a claim about that peer that is untrue (SPEC RL1).
"""
monkeypatch.setattr(_mod.config, "LORA_FREQ", 867)
monkeypatch.setattr(_mod.config, "MODEM_PRESET", "SF8/BW125/CR5")
def node_for(interface):
return _announce_to_node_dict(
_DEST_HASH,
b"A",
identity=_FakeIdentity(),
aspect="lxmf.delivery",
interface=interface,
)
lora = node_for("RNodeInterface[RNode Reticulum Berlin]")
assert lora["lora_freq"] == 867 and lora["modem_preset"] == "SF8/BW125/CR5"
for other in (
"AutoInterface[Default Interface]",
"TCPInterface[peer]",
"LocalInterface[rns/default]",
):
node = node_for(other)
assert "lora_freq" not in node, other
assert "modem_preset" not in node, other
# The host's own discovered destinations carry no interface and keep them.
own = node_for(None)
assert own["lora_freq"] == 867
def test_rnode_radio_config_skips_a_line_without_an_assignment():
"""A bare token inside an interface block is skipped, not mis-split."""
odd = """
[interfaces]
[[RNode]]
type = RNodeInterface
bare-token-with-no-equals
frequency = 867200000
"""
assert _mod._parse_rnode_radio_config(odd)["frequency_mhz"] == 867
def test_radio_metadata_short_circuits_when_both_are_overridden(monkeypatch):
"""With both pinned the config is never opened at all."""
monkeypatch.setattr(_mod.config, "RETICULUM_FREQ", "868")
monkeypatch.setattr(_mod.config, "RETICULUM_PRESET", "LongFast")
def _explode(*_a, **_k):
raise AssertionError("the config must not be read when both are pinned")
monkeypatch.setattr(_mod, "_parse_rnode_radio_config", _explode)
assert _mod._read_reticulum_radio_metadata() == (868, "LongFast")