Files
MeshCore/test/test_repeater_discovery.py
T

465 lines
20 KiB
Python

#!/usr/bin/env python3
"""Execute repeater discovery policy and wire replies from the production source.
The real handler, hidden-node predicate and rate limiter run unchanged. Only
the radio, packet allocation, identity and clock boundaries are host doubles.
"""
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <initializer_list>
#include "examples/simple_repeater/RateLimiter.h"
#include "src/RadioProfiles.h"
@CONSTANTS@
#define MESH_DEBUG_PRINTLN(...) ((void)0)
static uint32_t now_ms = 100;
bool millisHasNowPassed(uint32_t deadline) {
return static_cast<int32_t>(now_ms - deadline) > 0;
}
static void require(bool condition, const char* message) {
if (!condition) {
std::fprintf(stderr, "discovery contract: %s\n", message);
std::exit(1);
}
}
namespace mesh {
struct Identity {
uint8_t pub_key[PUB_KEY_SIZE] = {};
Identity() = default;
explicit Identity(const uint8_t* key) { std::memcpy(pub_key, key, PUB_KEY_SIZE); }
bool matches(const Identity& other) const {
return std::memcmp(pub_key, other.pub_key, PUB_KEY_SIZE) == 0;
}
};
struct Packet {
uint8_t payload[64] = {};
uint8_t payload_len = 0;
uint8_t tx_radio = RADIO_TX_AUTO;
uint8_t radio_profile = 1;
bool radio_reply = true, radio_bound = false, radio_reply_force = false, radio_local = true;
int8_t _snr = -20;
uint8_t getPayloadType() const { return PAYLOAD_TYPE_CONTROL; }
float getSNR() const { return _snr / 4.0f; }
int getRSSI() const { return -90; }
};
struct Radio {
mesh::RadioProfiles state;
bool available = true;
const mesh::RadioProfiles* profiles() const { return available ? &state : nullptr; }
};
struct Dispatcher {
const Radio* _radio;
uint8_t getTransmitProfileMask(const Packet* packet) const;
};
@DISPATCHER@
}
struct MyMesh {
struct {
uint16_t advert_interval = 30, flood_advert_interval = 0;
bool disable_fwd = false;
uint32_t discovery_mod_timestamp = 500;
} _prefs;
struct Clock {
uint32_t now = 1000;
uint32_t getCurrentTime() const { return now; }
} rtc_clock;
RateLimiter @LIMITER@;
mesh::Radio radio;
mesh::Radio* _radio = &radio;
mesh::Identity self_id, neighbour;
mesh::Packet response;
uint32_t pending_discover_tag = 0, pending_discover_until = 1000;
unsigned create_attempts = 0, sent_packets = 0, neighbours = 0;
uint32_t sent_delay = 0, neighbour_timestamp = 0;
float neighbour_snr = 0;
int neighbour_rssi = 0;
bool fail_allocation = false;
MyMesh() {
for (size_t i = 0; i < PUB_KEY_SIZE; ++i) self_id.pub_key[i] = 0x60 + i;
}
@HIDDEN@
void onControlDataRecv(mesh::Packet* packet);
mesh::Packet* createControlData(const uint8_t* bytes, size_t length) {
++create_attempts;
if (fail_allocation) return nullptr;
require(length <= sizeof(response.payload), "reply exceeded packet capacity");
std::memcpy(response.payload, bytes, length);
response.payload_len = length;
return &response;
}
uint32_t getRetransmitDelay(mesh::Packet* packet) {
require(packet == &response, "delay used the wrong reply packet");
return 7;
}
void sendZeroHop(mesh::Packet* packet, uint32_t delay) {
require(packet == &response, "radio sent the wrong reply packet");
require(packet->tx_radio == mesh::RADIO_TX_PRIMARY,
"discovery response was not restricted to the main radio");
require(mesh::Dispatcher{_radio}.getTransmitProfileMask(packet) == 1,
"dispatcher would send discovery response on radio2 or both profiles");
++sent_packets;
sent_delay = delay;
}
void putNeighbour(const mesh::Identity& id, uint32_t timestamp, float snr, int rssi) {
++neighbours;
neighbour = id;
neighbour_timestamp = timestamp;
neighbour_snr = snr;
neighbour_rssi = rssi;
}
};
@HANDLER@
static mesh::Packet request(bool prefix = false, uint32_t since = 0,
bool include_since = false, bool include_radio2 = false) {
mesh::Packet packet;
packet.payload[0] = CTL_TYPE_NODE_DISCOVER_REQ | (prefix ? 1 : 0)
| (include_radio2 ? 2 : 0);
packet.payload[1] = 1 << ADV_TYPE_REPEATER;
const uint32_t tag = 0x78563412;
std::memcpy(packet.payload + 2, &tag, sizeof(tag));
if (include_since) std::memcpy(packet.payload + 6, &since, sizeof(since));
packet.payload_len = include_since ? 10 : 6;
return packet;
}
static void policy() {
for (uint16_t local : {uint16_t(0), uint16_t(30)}) {
for (uint16_t flood : {uint16_t(0), uint16_t(30)}) {
for (bool repeat_off : {false, true}) {
for (bool prefix : {false, true}) {
for (bool include_radio2 : {false, true}) {
MyMesh receiver;
receiver._prefs.advert_interval = local;
receiver._prefs.flood_advert_interval = flood;
receiver._prefs.disable_fwd = repeat_off;
const bool hidden = local == 0 && flood == 0;
require(receiver.isHiddenNode() == hidden, "hidden predicate misread advert intervals");
auto packet = request(prefix, 0, false, include_radio2);
receiver.onControlDataRecv(&packet);
if (hidden) {
require(receiver.create_attempts == 0 && receiver.sent_packets == 0,
"hidden node emitted a discovery response");
} else {
require(receiver.create_attempts == 1 && receiver.sent_packets == 1,
"visible node or repeat-off observer lost discovery");
}
}
}
}
}
}
}
static void gates() {
for (unsigned flags = 0; flags < 4; ++flags) {
for (size_t length = 0; length < 6; ++length) {
MyMesh receiver;
auto packet = request(flags & 1, 0, false, flags & 2);
packet.payload_len = length;
receiver.onControlDataRecv(&packet);
require(receiver.create_attempts == 0, "truncated request produced a reply");
}
for (uint8_t filter : {uint8_t(0), uint8_t(1 << 3)}) {
MyMesh receiver;
auto packet = request(flags & 1, 0, false, flags & 2);
packet.payload[1] = filter;
receiver.onControlDataRecv(&packet);
require(receiver.create_attempts == 0, "unrelated node filter produced a reply");
}
for (uint32_t since : {499U, 500U, 501U}) {
MyMesh receiver;
auto packet = request(flags & 1, since, true, flags & 2);
receiver.onControlDataRecv(&packet);
require(receiver.sent_packets == (since <= 500 ? 1U : 0U),
"discovery timestamp boundary changed");
}
}
MyMesh limited;
auto packet = request();
for (unsigned count = 1; count <= 5; ++count) {
// The optional trailer and key-prefix flags share one limiter.
packet.payload[0] = CTL_TYPE_NODE_DISCOVER_REQ | ((count - 1) & 3);
limited.onControlDataRecv(&packet);
require(limited.sent_packets == (count <= 4 ? count : 4),
"discovery rate limit changed");
}
limited.rtc_clock.now = 1119;
limited.onControlDataRecv(&packet);
require(limited.sent_packets == 4, "rate limit expired before two minutes");
limited.rtc_clock.now = 1120;
limited.onControlDataRecv(&packet);
require(limited.sent_packets == 5, "rate limit did not reopen at two minutes");
MyMesh unrelated;
packet.payload[0] = 0x70;
unrelated.onControlDataRecv(&packet);
require(unrelated.create_attempts == 0, "unrelated control type produced a reply");
}
static void wire() {
for (bool prefix : {false, true}) {
MyMesh receiver;
auto packet = request(prefix);
receiver.onControlDataRecv(&packet);
require(receiver.sent_packets == 1, "visible request lost its reply");
require(receiver.response.payload_len == 6 + (prefix ? 8 : PUB_KEY_SIZE),
"prefix/full reply length changed");
require(receiver.response.payload[0] == (CTL_TYPE_NODE_DISCOVER_RESP | ADV_TYPE_REPEATER),
"reply control type or node type changed");
require(receiver.response.payload[1] == static_cast<uint8_t>(packet._snr),
"reply did not preserve signed inbound SNR bytes");
require(std::memcmp(receiver.response.payload + 2, packet.payload + 2, 4) == 0,
"reply did not echo the request tag");
require(std::memcmp(receiver.response.payload + 6, receiver.self_id.pub_key,
prefix ? 8 : PUB_KEY_SIZE) == 0,
"reply did not preserve the public key or prefix");
require(receiver.sent_delay == 28, "discovery reply lost its widened random delay");
}
MyMesh exhausted;
exhausted.fail_allocation = true;
auto packet = request();
exhausted.onControlDataRecv(&packet);
require(exhausted.create_attempts == 1 && exhausted.sent_packets == 0,
"allocation failure attempted to transmit a reply");
}
static uint32_t little_endian(const uint8_t* bytes) {
return uint32_t(bytes[0]) | (uint32_t(bytes[1]) << 8)
| (uint32_t(bytes[2]) << 16) | (uint32_t(bytes[3]) << 24);
}
static void radio2_wire() {
for (bool prefix : {false, true}) {
for (bool temporary : {false, true}) {
for (auto mode : {mesh::RadioProfileMode::Rx, mesh::RadioProfileMode::RxTx}) {
for (float bandwidth : {62.5f, 125.0f, 250.0f}) {
MyMesh receiver;
auto& profiles = receiver.radio.state;
profiles.secondary.mode = mode;
// Strict decimal CLI parsing may land one float ULP below the
// literal. Both values must round back to the requested 911150 kHz.
profiles.secondary.params.freq = temporary ? nextafterf(911.150f, 0.0f) : 911.150f;
profiles.secondary.params.bw = bandwidth;
profiles.secondary.params.sf = 7;
profiles.secondary.params.cr = 5;
profiles.secondary_temporary = temporary;
// Ordinary discovery must remain byte-for-byte compatible, even
// while a temporary radio2 profile and cross-radio replies are active.
profiles.cross = mesh::RadioCrossMode::On;
profiles.reply_tx = mesh::RADIO_TX_BOTH;
profiles.reply_force = true;
auto original = request(prefix);
receiver.onControlDataRecv(&original);
const size_t offset = 6 + (prefix ? 8 : PUB_KEY_SIZE);
require(receiver.response.payload_len == offset,
"unsolicited radio2 trailer changed ordinary discovery");
uint8_t baseline[6 + PUB_KEY_SIZE];
std::memcpy(baseline, receiver.response.payload, offset);
auto opted_in = request(prefix, 0, false, true);
receiver.onControlDataRecv(&opted_in);
require(receiver.response.payload_len == offset + 12,
"active MeshCore radio2 reply has the wrong trailer length");
require(std::memcmp(baseline, receiver.response.payload, offset) == 0,
"radio2 trailer changed the existing discovery fields");
const uint8_t* trailer = receiver.response.payload + offset;
const uint8_t flags = mode == mesh::RadioProfileMode::Rx ? 3 : 5;
require(trailer[0] == 1 && trailer[1] == (flags | (temporary ? 8 : 0)),
"radio2 version, configured/mode or temporary flags changed");
require(little_endian(trailer + 2) == 911150,
"radio2 frequency was truncated or encoded in the wrong units/order");
require(little_endian(trailer + 6) == uint32_t(bandwidth * 1000),
"radio2 bandwidth lost fractional kHz or used the wrong units/order");
require(trailer[10] == 7 && trailer[11] == 5,
"radio2 spreading factor or coding rate changed");
require(receiver.sent_packets == 2 && receiver.sent_delay == 28,
"extended discovery changed zero-hop reply count or random delay");
// The unchanged full-key parser accepts optional trailing bytes.
MyMesh peer;
peer.pending_discover_tag = 0x78563412;
receiver.response.payload[6] ^= 1; // A different repeater identity.
peer.onControlDataRecv(&receiver.response);
require(peer.neighbours == (prefix ? 0U : 1U),
"optional trailer broke full-key discovery parsing");
}
}
}
}
}
static void radio2_disabled() {
for (bool prefix : {false, true}) {
for (int inactive_reason : {0, 1, 2, 3, 4}) {
MyMesh receiver;
auto& profiles = receiver.radio.state;
profiles.secondary.params.freq = 911.150f;
profiles.secondary.params.bw = 250;
profiles.secondary.params.sf = 7;
profiles.secondary.params.cr = 5;
profiles.secondary_temporary = true;
if (inactive_reason == 0) receiver.radio.available = false;
if (inactive_reason >= 2) {
profiles.secondary.mode = mesh::RadioProfileMode::RxTx;
profiles.secondary.params.protocol = inactive_reason == 2
? mesh::RadioProfileProtocol::Meshtext : mesh::RadioProfileProtocol::Ethernet;
}
if (inactive_reason == 4) {
profiles.secondary.mode = static_cast<mesh::RadioProfileMode>(3);
profiles.secondary.params.protocol = mesh::RadioProfileProtocol::MeshCore;
}
auto packet = request(prefix, 0, false, true);
receiver.onControlDataRecv(&packet);
const size_t offset = 6 + (prefix ? 8 : PUB_KEY_SIZE);
require(receiver.response.payload_len == offset + 2,
"unavailable/off/foreign radio2 advertised usable MeshCore settings");
require(receiver.response.payload[offset] == 1 && receiver.response.payload[offset + 1] == 0,
"disabled radio2 did not use the two-byte version/off trailer");
require(std::memcmp(receiver.response.payload + 6, receiver.self_id.pub_key,
prefix ? 8 : PUB_KEY_SIZE) == 0,
"disabled radio2 trailer overwrote the public key or prefix");
}
MyMesh exhausted;
exhausted.fail_allocation = true;
auto packet = request(prefix, 0, false, true);
exhausted.onControlDataRecv(&packet);
require(exhausted.create_attempts == 1 && exhausted.sent_packets == 0,
"extended discovery allocation failure attempted transmission");
}
}
static void received() {
MyMesh receiver;
receiver._prefs.advert_interval = 0;
receiver._prefs.flood_advert_interval = 0;
receiver.pending_discover_tag = 0x78563412;
auto packet = request();
packet.payload[0] = CTL_TYPE_NODE_DISCOVER_RESP | ADV_TYPE_REPEATER;
packet.payload_len = 6 + PUB_KEY_SIZE;
std::memset(packet.payload + 6, 0xAB, PUB_KEY_SIZE);
receiver.onControlDataRecv(&packet);
require(receiver.neighbours == 1 && receiver.neighbour_timestamp == 1000
&& receiver.neighbour_snr == -5.0f && receiver.neighbour_rssi == -90,
"hidden node lost matching discovery response bookkeeping");
require(std::memcmp(receiver.neighbour.pub_key, packet.payload + 6, PUB_KEY_SIZE) == 0,
"neighbour response public key changed");
packet.payload[2] ^= 1;
receiver.onControlDataRecv(&packet);
packet.payload[2] ^= 1;
packet.payload_len -= 1;
receiver.onControlDataRecv(&packet);
packet.payload_len += 1;
packet.payload[0] = CTL_TYPE_NODE_DISCOVER_RESP | 3;
receiver.onControlDataRecv(&packet);
packet.payload[0] = CTL_TYPE_NODE_DISCOVER_RESP | ADV_TYPE_REPEATER;
std::memcpy(packet.payload + 6, receiver.self_id.pub_key, PUB_KEY_SIZE);
receiver.onControlDataRecv(&packet);
require(receiver.neighbours == 1, "unrelated/truncated/self response added a neighbour");
now_ms = receiver.pending_discover_until + 1;
receiver.onControlDataRecv(&packet);
require(receiver.neighbours == 1 && receiver.pending_discover_tag == 0,
"expired discovery response retained its pending request");
}
int main(int argc, char** argv) {
if (argc != 2) return 2;
if (std::strcmp(argv[1], "policy") == 0) policy();
else if (std::strcmp(argv[1], "gates") == 0) gates();
else if (std::strcmp(argv[1], "wire") == 0) wire();
else if (std::strcmp(argv[1], "radio2_wire") == 0) radio2_wire();
else if (std::strcmp(argv[1], "radio2_disabled") == 0) radio2_disabled();
else if (std::strcmp(argv[1], "received") == 0) received();
else return 2;
}
'''
class RepeaterDiscoveryTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
compiler = shutil.which("g++") or shutil.which("clang++")
if compiler is None:
raise unittest.SkipTest("a host C++17 compiler is required")
source = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text()
header = (ROOT / "examples/simple_repeater/MyMesh.h").read_text()
handler = extract_braced(source, "void MyMesh::onControlDataRecv(")
dispatcher = extract_braced((ROOT / "src/Dispatcher.cpp").read_text(),
"uint8_t Dispatcher::getTransmitProfileMask(")
hidden = extract_braced(header, "bool isHiddenNode() const")
constants = []
for text, name in (
(source, "CTL_TYPE_NODE_DISCOVER_REQ"),
(source, "CTL_TYPE_NODE_DISCOVER_RESP"),
((ROOT / "src/helpers/AdvertDataHelpers.h").read_text(), "ADV_TYPE_REPEATER"),
((ROOT / "src/MeshCore.h").read_text(), "PUB_KEY_SIZE"),
((ROOT / "src/Packet.h").read_text(), "PAYLOAD_TYPE_CONTROL"),
((ROOT / "src/Packet.h").read_text(), "PAYLOAD_TYPE_OTA"),
):
constants.append(re.search(rf"^#define {name}\s+[^\n]+", text, re.M).group())
limiter = re.search(r"discover_limiter\(\d+,\s*\d+\)", source).group()
limiter = limiter.replace("(", "{", 1).replace(")", "}", 1)
mutant, replacements = re.subn(r"&&\s*!isHiddenNode\(\)", "", handler, count=1)
if replacements != 1:
raise AssertionError("production discovery handler must contain the hidden-node gate")
cls.temporary = tempfile.TemporaryDirectory(prefix="repeater-discovery-")
cls.addClassCleanup(cls.temporary.cleanup)
cls.binaries = {}
for name, body in (("production", handler), ("without_hidden_guard", mutant)):
code = (HARNESS.replace("@CONSTANTS@", "\n".join(constants))
.replace("@LIMITER@", limiter)
.replace("@HIDDEN@", hidden)
.replace("@DISPATCHER@", dispatcher)
.replace("@HANDLER@", body))
work = Path(cls.temporary.name)
cpp, binary = work / f"{name}.cpp", work / name
cpp.write_text(code)
compiled = subprocess.run([
compiler, "-std=c++17", "-Wall", "-Wextra", "-Wno-reorder",
*(["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else []),
"-I", str(ROOT), str(cpp), "-o", str(binary),
], capture_output=True, text=True, timeout=60)
if compiled.returncode != 0:
raise AssertionError(compiled.stderr)
cls.binaries[name] = binary
def run_case(self, case, binary="production"):
return subprocess.run([str(self.binaries[binary]), case], capture_output=True,
text=True, timeout=10)
def test_advert_visibility_and_repeat_off_observer_policy(self):
checked = self.run_case("policy")
self.assertEqual(checked.returncode, 0, checked.stderr)
def test_request_filter_timestamp_and_real_rate_limiter(self):
checked = self.run_case("gates")
self.assertEqual(checked.returncode, 0, checked.stderr)
def test_prefix_full_reply_bytes_delay_and_allocation_failure(self):
checked = self.run_case("wire")
self.assertEqual(checked.returncode, 0, checked.stderr)
def test_hidden_nodes_still_process_matching_discovery_responses(self):
checked = self.run_case("received")
self.assertEqual(checked.returncode, 0, checked.stderr)
def test_requested_active_radio2_trailer_bytes_and_legacy_parser(self):
checked = self.run_case("radio2_wire")
self.assertEqual(checked.returncode, 0, checked.stderr)
def test_unavailable_disabled_and_foreign_profiles_have_only_off_marker(self):
checked = self.run_case("radio2_disabled")
self.assertEqual(checked.returncode, 0, checked.stderr)
def test_removed_hidden_protection_fails_the_same_policy_contract(self):
checked = self.run_case("policy", "without_hidden_guard")
self.assertEqual(checked.returncode, 1, checked.stderr)
self.assertIn("hidden node emitted a discovery response", checked.stderr)
if __name__ == "__main__":
unittest.main()