Add per-profile TX routing and bridge filtering

Persist Companion TX preferences per contact and channel. Default repeater,
room, and sensor replies to both active TX profiles, with a reply-only force
option for an RX-only secondary profile. Track both reply copies before a
temporary-radio handoff and account for OTA copies under queue backpressure.

Add bridge/crossover filter modes and the third built-in wardriving filter,
plus compact filter CLI syntax, documentation, and CI coverage.

Fix quoted target names being interpreted as slot/key selectors, partial
recovery of invalid saved reply settings, filter suspension during tempradio2,
and unnecessary packet allocation while OTA traffic is throttled.

Validation: 1,477 native tests, 52 Python integration tests, 29 filter UI tests,
and five firmware builds covering ESP32, nRF52, and STM32 passed.
This commit is contained in:
mikecarper committed 2026-09-15 01:58:43 -07:00
1 parent 0e0aa8e014
commit 95aca52b4e
60 files changed
+3188 -362

No files matched your search

+2 -1
View File
@@ -48,6 +48,7 @@ python3 test/test_esp32_tinyusb_nonblocking.py # Native CDC stalled-host/64-byte
python3 test/test_esp32_tinyusb_role_hygiene.py # Repeater/room nonblocking console and paced large replies
python3 test/test_esp32_tinyusb_cooperative_output.py # Real role pumps: large logs/listings, EOF, backpressure
python3 test/test_temp_radio_reply_delivery_contract.py # TempRadio ACK path/barrier integration
python3 test/test_reply_tx_integration.py # Delayed OTA replies, backpressure, and all infrastructure roles
python3 test/test_repeater_radio_timing_integration.py # Real scheduler, watchdog, hourly/flood adverts and restoration
python3 test/test_tls_download_clock_gates.py # Fresh-NTP/TLS download integration contract
```
@@ -124,7 +125,7 @@ does not reflect the GoogleTest count -- run the built binary directly
| `test_atomic_file_writer` | `src/helpers/AtomicFileWriter.h` | verified temporary-file commit; short-write, readback, validation, and rename failures; preservation of the live file and stale-temp cleanup |
| `test_client_login_persistence` | `src/helpers/ClientLoginPersistence.h`, `LazyPersistence.h` | pre-allocation durable replay reservations; reboot, ACL-eviction, and revoked-admin tombstones; bounded-store policy (low-trust no-insert, privileged fail-closed); atomic replay-file recovery, including post-commit cleanup failure; preauthorized transient refresh; admin/guest promotion and downgrade; force-flood preservation; role masking and fixed-size secret copy |
| `test_client_path_persistence` | `src/helpers/ClientPathPersistence.h`, `LazyPersistence.h` | encoded path identity and byte lengths; unknown, zero-hop, force-flood, and nonpersistent clients; replay-unqualified RAM-only routes preserve an earlier operator route across unrelated save/reload |
| `test_temp_radio_reply_barrier` | `src/helpers/TempRadioReplyBarrier.h`, `TempRadioLeaseDeadline.h` | exact single-copy queued reply completion/failure handoff; suppression of untracked alternate/retry copies; rejection of foreign callbacks; cancellation; monotonic hard expiry that a backward wall-clock correction cannot extend; bounded remaining-time reporting |
| `test_temp_radio_reply_barrier` | `src/helpers/TempRadioReplyBarrier.h`, `TempRadioLeaseDeadline.h` | both profile copies drain before handoff; either successful TX permits switching; suppression of untracked alternate/retry copies; rejection of foreign callbacks; cancellation; monotonic hard expiry that a backward wall-clock correction cannot extend; bounded remaining-time reporting |
| `test_lazy_persistence` | `src/helpers/LazyPersistence.h` | first-write scheduling without postponement, zero-sentinel rollover preservation, capped exponential save-failure backoff that mutations cannot defeat, and reset only after success |
| `test_client_acl_file_transaction` | `src/helpers/ClientACLFileTransaction.h` | verified temp publication, preservation of the prior ACL on verification/rename failure, post-commit cleanup-failure handling, and recovery at every temp/backup/primary boundary |
| `test_cad_timing` | `src/helpers/radiolib/CadTiming.h`, `LR2021SideDetectorConfig.h`, `RadioAirtime.h` | Cascade and slow-profile CAD deadlines; invalid airtime handling; bounded LR2021 side-detector parsing and LDRO recomputation |
+21
View File
@@ -240,6 +240,9 @@ public:
#define MSG_SEND_SENT_DIRECT 2
#define ADV_TYPE_ROOM 3
class BaseChatMesh : public mesh::Mesh {
uint32_t getTransmitAirtime(const mesh::Packet* packet) const {
return _radio->getEstAirtimeFor(packet->getRawLength());
}
struct Radio { uint32_t getEstAirtimeFor(int) { return 20; } } radio;
struct Clock { uint32_t getCurrentTimeUnique() { return 100; } } clock;
struct Rng { void random(uint8_t* data, size_t size) { memset(data, 42, size); } } rng;
@@ -638,7 +641,25 @@ static void nrf52_migration_snapshots_and_failed_page_replacement() {
}
#endif
static void contact_tx_policy_round_trips_without_growing_records() {
Fixture f(1);
auto& contact = f.host.contacts[0];
assert(contact.tx_radio == mesh::RADIO_TX_AUTO);
for (uint8_t policy = mesh::RADIO_TX_AUTO; policy <= mesh::RADIO_TX_OFF; ++policy) {
contact.tx_radio = policy;
contact.shared_secret_valid = true;
uint8_t record[mesh::storage::CONTACT_RECORD_SIZE];
assert(serializeContactRecord(contact, record));
assert(sizeof(record) == 152 && record[66] == mesh::encodeRadioTxPolicy(policy));
ContactInfo restored;
assert(deserializeContactRecord(record, restored, 0));
assert(restored.tx_radio == policy && !restored.shared_secret_valid);
assert(contact.shared_secret_valid && contact.tx_radio == policy);
}
}
int main() {
contact_tx_policy_round_trips_without_growing_records();
#if defined(ESP32_PLATFORM)
routes_survive_eviction_and_full_sync();
snapshot_rollback_and_dirty_eviction();
+78 -1
View File
@@ -30,7 +30,7 @@ struct Fixture {
Radio radio;
mesh::RadioProfileCLI cli;
char reply[160];
Fixture() { g_mock_millis=0; cli.begin(&fs,&radio,&clock); }
Fixture(bool infrastructure = false) { g_mock_millis=0; cli.begin(&fs,&radio,&clock,infrastructure); }
const char* cmd(const char* text, bool ok=true) {
memset(reply,0x55,sizeof(reply));
assert(cli.handle(text,reply,sizeof(reply)));
@@ -43,6 +43,83 @@ struct Fixture {
void advance(uint32_t ms, bool epoch=true) { g_mock_millis+=ms; if(epoch)clock.epoch+=ms/1000; cli.loop(); }
};
int main() {
{
Fixture f(true);
f.cmd("set tx.reply off");
// Simulate an image whose CRC is valid but whose saved mode is invalid.
auto& image=f.fs.files["/radio_profiles"];
image[3]=9;
uint32_t crc=0xffffffffU;
for (size_t i=0;i<20;++i) {
crc ^= image[i];
for (unsigned bit=0;bit<8;++bit) crc=(crc>>1)^((crc&1)?0xedb88320U:0);
}
memcpy(image.data()+20,&crc,4);
const auto damaged=image;
Radio radio; mesh::RadioProfileCLI restored;
restored.begin(&f.fs,&radio,&f.clock,true);
restored.loop();
assert(radio.p.reply_tx==mesh::RADIO_TX_BOTH && !radio.p.reply_force);
assert(!radio.p.enabled());
assert(restored.handle("set tx.reply radio",f.reply));
assert(strstr(f.reply,"Error"));
assert(f.fs.files["/radio_profiles"]==damaged);
}
{
Fixture companion;
assert(companion.radio.p.reply_tx==mesh::RADIO_TX_AUTO);
assert(!companion.cli.handle("set tx.reply both force",companion.reply));
Fixture f(true);
assert(strstr(f.cmd("get tx.reply"),"> both; radio2 TX unavailable"));
f.cmd("set radio2 910.5,500,7,5,rx"); f.advance(2000);
f.cmd("set tx.reply both force");
assert(!strcmp(f.cmd("get tx.reply"),"> both force"));
assert(f.radio.p.reply_tx==mesh::RADIO_TX_BOTH && f.radio.p.reply_force);
assert(f.radio.p.secondary.mode==mesh::RadioProfileMode::Rx);
assert(f.fs.files["/radio_profiles"].size()==24);
// Every legal choice survives reboot, including explicit AUTO (distinct
// from the zero reserved byte in firmware predating reply routing).
const char* modes[]={"auto","radio","radio2","both","off","radio2 force","both force"};
const uint8_t policies[]={0,1,2,3,4,2,3};
for (unsigned i=0;i<7;++i) {
char command[64]; snprintf(command,sizeof(command),"set tx.reply %s",modes[i]);
f.cmd(command);
Radio reboot; mesh::RadioProfileCLI restored;
restored.begin(&f.fs,&reboot,&f.clock,true);
assert(reboot.p.reply_tx==policies[i] && reboot.p.reply_force==(i>=5));
}
f.fs.fail_write=true;
f.cmd("set tx.reply off",false);
assert(f.radio.p.reply_tx==mesh::RADIO_TX_BOTH && f.radio.p.reply_force);
f.fs.fail_write=false; f.fs.fail_rename=2;
f.cmd("set tx.reply off",false);
Radio reboot; mesh::RadioProfileCLI restored;
restored.begin(&f.fs,&reboot,&f.clock,true);
assert(reboot.p.reply_tx==mesh::RADIO_TX_BOTH && reboot.p.reply_force);
const char* invalid[]={"", "both junk", "auto force", "radio force", "off force",
"both force extra", "force", "bothforce", "radio2345678901234567"};
for (const char* args:invalid) {
char command[80]; snprintf(command,sizeof(command),"set tx.reply %s",args);
f.cmd(command,false);
assert(f.radio.p.reply_tx==mesh::RADIO_TX_BOTH && f.radio.p.reply_force);
}
assert(strstr(f.cmd("get tx.reply extra"),"Error"));
// A Companion neither activates nor erases infrastructure reply choices
// when it saves another profile setting.
Radio other; mesh::RadioProfileCLI other_cli;
other_cli.begin(&f.fs,&other,&f.clock);
assert(other.p.reply_tx==mesh::RADIO_TX_AUTO && !other.p.reply_force);
assert(other_cli.handle("set radio2.cross on",f.reply));
Radio back; mesh::RadioProfileCLI back_cli;
back_cli.begin(&f.fs,&back,&f.clock,true);
assert(back.p.reply_tx==mesh::RADIO_TX_BOTH && back.p.reply_force);
// Old images have a zero reserved byte and adopt BOTH on infrastructure.
Fixture legacy; legacy.cmd("set radio2 910.5,500,7,5,rxtx");
assert(legacy.fs.files["/radio_profiles"][19]==0);
Radio upgrade; mesh::RadioProfileCLI upgraded;
upgraded.begin(&legacy.fs,&upgrade,&legacy.clock,true);
assert(upgrade.p.reply_tx==mesh::RADIO_TX_BOTH && !upgrade.p.reply_force);
}
{
Fixture f;
assert(!strcmp(f.cmd("get radio2"),"> off"));
+348 -1
View File
@@ -12,6 +12,11 @@ class DualProfileTestRadio : public RetryCodingRateRadio {
void loop() override { ++carrier_services; }
bool recoverRadio(bool) override { ++recoveries; return true; }
uint32_t estimated_airtime = 10;
bool distinguish_airtime = false;
uint32_t getProfileAirtime(uint8_t profile, int bytes, uint8_t cr = 0) override {
return distinguish_airtime ? (profile ? 70 : 900)
: mesh::Radio::getProfileAirtime(profile, bytes, cr);
}
std::vector<uint8_t> incoming;
std::vector<uint8_t> transmissions;
DualProfileTestRadio() {
@@ -27,8 +32,11 @@ class DualProfileTestRadio : public RetryCodingRateRadio {
uint32_t getEstAirtimeFor(int) override { return estimated_airtime; }
uint8_t receiveProfile() const override { return selected; }
mesh::RadioParamApplyResult prepareTransmitProfile(uint8_t p) override {
return prepareTransmitProfile(p, false);
}
mesh::RadioParamApplyResult prepareTransmitProfile(uint8_t p, bool reply_rx_override) override {
if (!incoming.empty()) return mesh::RadioParamApplyResult::BUSY;
if (!config.canTransmit(p)) return mesh::RadioParamApplyResult::FAILED;
if (!config.canTransmit(p, reply_rx_override)) return mesh::RadioParamApplyResult::FAILED;
selected = p; cr = config.params(p).cr;
return mesh::RadioParamApplyResult::APPLIED;
}
@@ -51,6 +59,16 @@ class DualProfileTestRadio : public RetryCodingRateRadio {
class DualProfileTestMesh : public RetryCodingRateMesh {
public:
using RetryCodingRateMesh::RetryCodingRateMesh;
using RetryCodingRateMesh::isPacketRadioCurrent;
using RetryCodingRateMesh::getTransmitAirtime;
using RetryCodingRateMesh::getTransmitProfileMask;
using RetryCodingRateMesh::tryParsePacket;
bool cross_filter_allows = true;
unsigned cross_filter_calls = 0;
bool allowRadioProfileCross(const mesh::Packet*) override {
++cross_filter_calls;
return cross_filter_allows;
}
mesh::Packet* replyOn(uint8_t profile, uint32_t generation) {
ReceiveProfileScope context(*this, profile, generation);
auto* packet = obtainNewPacket();
@@ -82,6 +100,54 @@ class DualProfileTest : public testing::Test {
void tick(uint32_t ms = 1) { clock.now += ms; node.loop(); }
};
TEST_F(DualProfileTest, CrossFilterKeepsPrimaryTransmissionAndAvoidsCopyAllocation) {
node.cross_filter_allows = false;
ASSERT_NE(nullptr, queue());
ASSERT_EQ(1, manager.getOutboundTotal());
EXPECT_EQ(0, manager.getOutboundByIdx(0)->radio_profile);
EXPECT_EQ(1U, node.cross_filter_calls);
}
TEST_F(DualProfileTest, CrossFilterKeepsSecondaryReplyOnItsOrigin) {
radio.config.cross = mesh::RadioCrossMode::On;
node.cross_filter_allows = false;
auto* reply = node.replyOn(1, radio.config.generation[1]);
ASSERT_TRUE(node.sendPacket(reply, 0));
ASSERT_EQ(1, manager.getOutboundTotal());
EXPECT_EQ(1, manager.getOutboundByIdx(0)->radio_profile);
}
TEST_F(DualProfileTest, CrossFilterCannotRerouteAnRxOnlyOrigin) {
radio.config.cross = mesh::RadioCrossMode::On;
radio.config.secondary.mode = mesh::RadioProfileMode::Rx;
node.cross_filter_allows = false;
auto* reply = node.replyOn(1, radio.config.generation[1]);
ASSERT_FALSE(node.sendPacket(reply, 0));
EXPECT_EQ(0, manager.getOutboundTotal());
EXPECT_EQ(40, manager.getFreeCount());
}
TEST_F(DualProfileTest, BoundCrossRetryCannotBypassChangedFilter) {
node.cross_filter_allows = false;
auto* retry = node.obtainNewPacket();
ASSERT_NE(nullptr, retry);
*retry = makeFloodPacket(PAYLOAD_TYPE_GRP_TXT);
retry->radio_bound = true;
retry->radio_origin = 0;
retry->radio_profile = 1;
ASSERT_FALSE(node.sendPacket(retry, 0));
EXPECT_EQ(0, manager.getOutboundTotal());
EXPECT_EQ(40, manager.getFreeCount());
}
TEST_F(DualProfileTest, SingleRadioDoesNotConsultCrossFilter) {
radio.config.secondary.mode = mesh::RadioProfileMode::Off;
node.cross_filter_allows = false;
ASSERT_NE(nullptr, queue());
EXPECT_EQ(1, manager.getOutboundTotal());
EXPECT_EQ(0U, node.cross_filter_calls);
}
TEST(RadioProfiles, CrossPolicyAndReceiveOnlyMatrix) {
mesh::RadioProfiles p;
for (int a = 0; a < 2; ++a) for (int b = 0; b < 2; ++b) {
@@ -155,6 +221,244 @@ TEST_F(DualProfileTest, TransmitsIdenticalMessageOnBothProfilesOnce) {
EXPECT_EQ(40, manager.getFreeCount());
}
TEST_F(DualProfileTest, ExplicitTransmitChoicesOverrideCrossWithoutEnablingRxOnly) {
node.flood_attempts = 0;
for (auto cross : {mesh::RadioCrossMode::Auto, mesh::RadioCrossMode::On, mesh::RadioCrossMode::Off}) {
radio.config.cross = cross;
for (auto secondary : {mesh::RadioProfileMode::Off, mesh::RadioProfileMode::Rx, mesh::RadioProfileMode::RxTx}) {
radio.config.secondary.mode = secondary;
for (bool temporary : {false, true}) {
radio.config.secondary_temporary = temporary;
for (uint8_t policy = mesh::RADIO_TX_PRIMARY; policy <= mesh::RADIO_TX_OFF; ++policy) {
auto* packet = node.obtainNewPacket(); ASSERT_NE(nullptr, packet);
*packet = makeFloodPacket(PAYLOAD_TYPE_GRP_TXT);
packet->tx_radio = policy;
const uint8_t expected = mesh::explicitRadioTxMask(policy, secondary == mesh::RadioProfileMode::RxTx);
ASSERT_EQ(expected != 0, node.sendFlood(packet));
uint8_t actual = 0;
while (manager.getOutboundTotal()) {
auto* queued = manager.getOutboundByIdx(0);
actual |= 1U << queued->radio_profile;
EXPECT_EQ(policy, queued->tx_radio);
EXPECT_TRUE(node.isPacketRadioCurrent(queued));
manager.removeOutboundByIdx(0); node.releasePacket(queued);
}
EXPECT_EQ(expected, actual);
EXPECT_EQ(40, manager.getFreeCount());
}
}
}
}
}
TEST_F(DualProfileTest, ExplicitSecondaryRetriesStayOnSecondaryWithCrossOff) {
radio.config.cross = mesh::RadioCrossMode::Off;
radio.config.secondary_temporary = true;
auto* packet = node.obtainNewPacket(); ASSERT_NE(nullptr, packet);
*packet = makeFloodPacket(PAYLOAD_TYPE_GRP_TXT);
packet->tx_radio = mesh::RADIO_TX_SECONDARY;
ASSERT_TRUE(node.sendFlood(packet));
for (int i = 0; i < 50; ++i) { radio.complete = true; tick(1000); }
ASSERT_GE(radio.transmissions.size(), 2U);
for (auto profile : radio.transmissions) EXPECT_EQ(1, profile);
EXPECT_EQ(40, manager.getFreeCount());
}
TEST_F(DualProfileTest, InfrastructureReplyChoiceMatrix) {
for (auto cross : {mesh::RadioCrossMode::Auto, mesh::RadioCrossMode::On, mesh::RadioCrossMode::Off}) {
radio.config.cross = cross;
for (auto secondary : {mesh::RadioProfileMode::Off, mesh::RadioProfileMode::Rx, mesh::RadioProfileMode::RxTx}) {
radio.config.secondary.mode = secondary;
for (bool temporary : {false,true}) {
radio.config.secondary_temporary = temporary;
for (uint8_t policy=0;policy<=mesh::RADIO_TX_OFF;++policy) {
for (bool force : {false,true}) {
if (force && policy!=mesh::RADIO_TX_SECONDARY && policy!=mesh::RADIO_TX_BOTH) continue;
radio.config.reply_tx=policy; radio.config.reply_force=force;
for (uint8_t origin : {0,1}) {
auto* packet=node.replyOn(origin,radio.config.generation[origin]);
ASSERT_NE(nullptr,packet); ASSERT_TRUE(packet->radio_reply);
const bool can_second=secondary==mesh::RadioProfileMode::RxTx
|| (force && secondary==mesh::RadioProfileMode::Rx);
const uint8_t expected=policy==mesh::RADIO_TX_AUTO ? radio.config.transmitMask(origin)
: (policy==mesh::RADIO_TX_OFF ? 0 : policy & (can_second ? 3 : 1));
ASSERT_EQ(expected!=0,node.sendPacket(packet,0));
uint8_t actual=0;
while (manager.getOutboundTotal()) {
auto* queued=manager.removeOutboundByIdx(0);
actual |= 1U << queued->radio_profile;
EXPECT_EQ(policy,queued->tx_radio);
EXPECT_EQ(force,queued->radio_reply_force);
EXPECT_EQ(origin,queued->radio_origin);
EXPECT_TRUE(node.isPacketRadioCurrent(queued));
node.releasePacket(queued);
}
EXPECT_EQ(expected,actual); EXPECT_EQ(40,manager.getFreeCount());
}
}
}
}
}
}
}
TEST_F(DualProfileTest, ReplyForceDoesNotEnableForwardedOrUnsolicitedTraffic) {
node.flood_attempts=0;
radio.config.cross=mesh::RadioCrossMode::Off;
radio.config.secondary.mode=mesh::RadioProfileMode::Rx;
radio.config.reply_tx=mesh::RADIO_TX_BOTH; radio.config.reply_force=true;
auto raw_packet=makeFloodPacket(PAYLOAD_TYPE_GRP_TXT);
uint8_t raw[MAX_TRANS_UNIT]; const uint8_t length=raw_packet.writeTo(raw);
auto* received=node.replyOn(0,radio.config.generation[0]); ASSERT_NE(nullptr,received);
received->radio_reply_force=true; received->tx_radio=mesh::RADIO_TX_BOTH;
ASSERT_TRUE(node.tryParsePacket(received,raw,length));
ASSERT_FALSE(received->radio_reply); ASSERT_FALSE(received->radio_reply_force);
ASSERT_EQ(mesh::RADIO_TX_AUTO,received->tx_radio);
ASSERT_TRUE(node.sendPacket(received,0));
ASSERT_NE(nullptr,queue(PAYLOAD_TYPE_ADVERT));
ASSERT_EQ(2,manager.getOutboundTotal());
for (int i=0;i<2;++i) {
const auto* queued=manager.getOutboundByIdx(i);
EXPECT_EQ(0,queued->radio_profile); EXPECT_FALSE(queued->radio_reply);
}
for (int i=0;i<6;++i) { radio.complete=true; tick(); }
EXPECT_EQ((std::vector<uint8_t>{0,0}),radio.transmissions);
EXPECT_EQ(40,manager.getFreeCount());
}
TEST_F(DualProfileTest, ForcedReplySurvivesDriverRetryOnRxOnlySecondary) {
node.flood_attempts=0;
radio.config.cross=mesh::RadioCrossMode::Off;
radio.config.secondary.mode=mesh::RadioProfileMode::Rx;
radio.config.reply_tx=mesh::RADIO_TX_SECONDARY; radio.config.reply_force=true;
auto* packet=node.replyOn(0,radio.config.generation[0]); ASSERT_NE(nullptr,packet);
ASSERT_TRUE(node.sendPacket(packet,0));
radio.fail_next_send=true;
for (int i=0;i<12;++i) { radio.complete=true; tick(1000); }
EXPECT_EQ((std::vector<uint8_t>{1}),radio.transmissions);
EXPECT_EQ(mesh::RadioProfileMode::Rx,radio.config.secondary.mode);
EXPECT_EQ(40,manager.getFreeCount());
}
TEST_F(DualProfileTest, BothForcedReplyCopiesKeepSeparateFloodRetries) {
radio.config.cross=mesh::RadioCrossMode::Off;
radio.config.secondary.mode=mesh::RadioProfileMode::Rx;
radio.config.secondary_temporary=true;
radio.config.reply_tx=mesh::RADIO_TX_BOTH; radio.config.reply_force=true;
auto* packet=node.replyOn(1,radio.config.generation[1]); ASSERT_NE(nullptr,packet);
packet->header=PAYLOAD_TYPE_GRP_TXT << PH_TYPE_SHIFT;
ASSERT_TRUE(node.sendFlood(packet));
for (int i=0;i<50;++i) { radio.complete=true; tick(1000); }
unsigned counts[2]={};
for (auto profile:radio.transmissions) ++counts[profile];
EXPECT_GE(counts[0],2U); EXPECT_GE(counts[1],2U);
EXPECT_EQ(40,manager.getFreeCount());
}
TEST_F(DualProfileTest, ForcedReplyRespectsCrossFiltersAndProfileExpiry) {
radio.config.reply_tx=mesh::RADIO_TX_BOTH; radio.config.reply_force=true;
radio.config.secondary.mode=mesh::RadioProfileMode::Rx;
node.cross_filter_allows=false;
auto* reply=node.replyOn(0,radio.config.generation[0]); ASSERT_NE(nullptr,reply);
ASSERT_TRUE(node.sendPacket(reply,0)); ASSERT_EQ(1,manager.getOutboundTotal());
EXPECT_EQ(0,manager.getOutboundByIdx(0)->radio_profile);
node.releasePacket(manager.removeOutboundByIdx(0));
node.cross_filter_allows=true;
reply=node.replyOn(1,radio.config.generation[1]); ASSERT_NE(nullptr,reply);
ASSERT_TRUE(node.sendPacket(reply,0)); ASSERT_EQ(2,manager.getOutboundTotal());
radio.config.setSecondary({},false);
for (int i=0;i<6;++i) { radio.complete=true; tick(); }
EXPECT_TRUE(radio.transmissions.empty()); // both originated in an expired session
EXPECT_EQ(40,manager.getFreeCount());
auto* stale=node.replyOn(1,radio.config.generation[1]-1); ASSERT_NE(nullptr,stale);
EXPECT_FALSE(node.sendPacket(stale,0));
EXPECT_EQ(40,manager.getFreeCount());
}
TEST_F(DualProfileTest, ReplyFlagsAreLocalAndResetOnReadAndPoolReuse) {
auto packet=makeFloodPacket(PAYLOAD_TYPE_RESPONSE);
uint8_t before[MAX_TRANS_UNIT],after[MAX_TRANS_UNIT];
const auto size=packet.writeTo(before);
packet.radio_reply=packet.radio_reply_force=true;
packet.tx_radio=mesh::RADIO_TX_BOTH;
EXPECT_EQ(size,packet.writeTo(after)); EXPECT_EQ(0,memcmp(before,after,size));
EXPECT_TRUE(packet.readFrom(before,size));
EXPECT_FALSE(packet.radio_reply); EXPECT_FALSE(packet.radio_reply_force);
EXPECT_EQ(mesh::RADIO_TX_AUTO,packet.tx_radio);
for (int i=0;i<80;++i) {
auto* p=node.obtainNewPacket(); ASSERT_NE(nullptr,p);
EXPECT_FALSE(p->radio_reply); EXPECT_FALSE(p->radio_reply_force);
p->radio_reply=p->radio_reply_force=true; node.releasePacket(p);
}
}
TEST_F(DualProfileTest, ReplyAirtimeIncludesForcedRxProfileBeforeAdmission) {
radio.distinguish_airtime=true;
radio.config.secondary.mode=mesh::RadioProfileMode::Rx;
radio.config.reply_tx=mesh::RADIO_TX_BOTH;
auto* reply=node.replyOn(0,radio.config.generation[0]); ASSERT_NE(nullptr,reply);
EXPECT_EQ(900U,node.getTransmitAirtime(reply));
radio.config.reply_force=true;
EXPECT_EQ(970U,node.getTransmitAirtime(reply));
radio.config.reply_tx=mesh::RADIO_TX_SECONDARY;
EXPECT_EQ(70U,node.getTransmitAirtime(reply));
radio.config.reply_tx=mesh::RADIO_TX_OFF;
EXPECT_EQ(0U,node.getTransmitAirtime(reply));
node.releasePacket(reply);
}
TEST_F(DualProfileTest, ExplicitBothSurvivesCrossOffButDisablingSecondaryRetiresItsCopy) {
node.flood_attempts = 0;
radio.config.cross = mesh::RadioCrossMode::Off;
auto* packet = node.obtainNewPacket(); ASSERT_NE(nullptr, packet);
*packet = makeFloodPacket(PAYLOAD_TYPE_GRP_TXT); packet->tx_radio = mesh::RADIO_TX_BOTH;
ASSERT_TRUE(node.sendFlood(packet));
ASSERT_EQ(2, manager.getOutboundTotal());
auto* copy = manager.getOutboundByIdx(1);
EXPECT_TRUE(node.isPacketRadioCurrent(copy));
radio.config.secondary.mode = mesh::RadioProfileMode::Rx;
EXPECT_FALSE(node.isPacketRadioCurrent(copy));
for (int i = 0; i < 5; ++i) { radio.complete = true; tick(); }
EXPECT_EQ((std::vector<uint8_t>{0}), radio.transmissions);
EXPECT_EQ(40, manager.getFreeCount());
}
TEST_F(DualProfileTest, TransmitPolicyIsLocalAndPoolReuseResetsIt) {
auto* packet = node.obtainNewPacket(); ASSERT_NE(nullptr, packet);
*packet = makeFloodPacket(PAYLOAD_TYPE_GRP_TXT);
uint8_t before[MAX_TRANS_UNIT], after[MAX_TRANS_UNIT];
const auto length = packet->writeTo(before);
packet->tx_radio = mesh::RADIO_TX_OFF;
EXPECT_EQ(length, packet->writeTo(after));
EXPECT_EQ(0, memcmp(before, after, length));
node.releasePacket(packet);
for (int i = 0; i < 40; ++i) {
auto* reused = node.obtainNewPacket(); ASSERT_NE(nullptr, reused);
EXPECT_EQ(mesh::RADIO_TX_AUTO, reused->tx_radio);
reused->tx_radio = mesh::RADIO_TX_SECONDARY;
node.releasePacket(reused);
}
}
TEST_F(DualProfileTest, MessageTimeoutAirtimeUsesSelectedProfilesInsteadOfScanPosition) {
radio.distinguish_airtime = true;
radio.config.cross = mesh::RadioCrossMode::Off;
auto* packet = node.obtainNewPacket(); ASSERT_NE(nullptr, packet);
*packet = makeFloodPacket(PAYLOAD_TYPE_TXT_MSG);
for (uint8_t scan : {0, 1}) {
radio.selected = scan;
packet->tx_radio = mesh::RADIO_TX_SECONDARY;
EXPECT_EQ(70U, node.getTransmitAirtime(packet));
packet->tx_radio = mesh::RADIO_TX_PRIMARY;
EXPECT_EQ(900U, node.getTransmitAirtime(packet));
packet->tx_radio = mesh::RADIO_TX_BOTH;
EXPECT_EQ(970U, node.getTransmitAirtime(packet));
packet->tx_radio = mesh::RADIO_TX_AUTO;
EXPECT_EQ(900U, node.getTransmitAirtime(packet));
}
node.releasePacket(packet);
}
TEST_F(DualProfileTest, DefaultSeparatesNormalTrafficFromTemporaryOta) {
radio.config.secondary_temporary = true;
ASSERT_NE(nullptr, queue());
@@ -165,6 +469,19 @@ TEST_F(DualProfileTest, DefaultSeparatesNormalTrafficFromTemporaryOta) {
EXPECT_EQ(1, manager.getOutboundByIdx(1)->radio_profile);
}
TEST_F(DualProfileTest, TemporaryActivityIncludesEitherProfileAndEndsAfterBothExpire) {
for (bool primary : {false,true}) {
for (bool secondary : {false,true}) {
node.tempRadioActive=primary;
radio.config.secondary_temporary=secondary;
EXPECT_EQ(primary || secondary,node.isAnyTempRadioActive());
}
}
node.tempRadioActive=false;
radio.config.setSecondary({},false);
EXPECT_FALSE(node.isAnyTempRadioActive());
}
TEST_F(DualProfileTest, ReceiveOnlyNeverTransmitsSecondary) {
radio.config.secondary.mode = mesh::RadioProfileMode::Rx;
ASSERT_NE(nullptr, queue());
@@ -172,6 +489,36 @@ TEST_F(DualProfileTest, ReceiveOnlyNeverTransmitsSecondary) {
tick(); EXPECT_EQ((std::vector<uint8_t>{0}), radio.transmissions);
}
TEST_F(DualProfileTest, AsyncOtaResponseAdmissionMaskMatchesTheActualQueuedCopies) {
radio.config.cross=mesh::RadioCrossMode::Off;
radio.config.secondary_temporary=true;
radio.config.reply_tx=mesh::RADIO_TX_BOTH;
for (auto mode : {mesh::RadioProfileMode::Rx,mesh::RadioProfileMode::RxTx}) {
radio.config.secondary.mode=mode;
for (bool force : {false,true}) {
radio.config.reply_force=force;
for (uint8_t type : {mesh::ota::OTA_REQ,mesh::ota::OTA_DATA,mesh::ota::OTA_PROOF,
mesh::ota::OTA_HAVE,mesh::ota::OTA_QUERY}) {
auto* p=node.obtainNewPacket(); ASSERT_NE(nullptr,p);
*p=makeFloodPacket(PAYLOAD_TYPE_OTA); p->payload[0]=type;
p->radio_reply=mesh::ota::ota_is_response_message(type);
ASSERT_TRUE(p->radio_local); // no RX call stack
const uint8_t mask=node.getTransmitProfileMask(p);
EXPECT_EQ(p->radio_reply ? ((force || mode==mesh::RadioProfileMode::RxTx) ? 3 : 1)
: (mode==mesh::RadioProfileMode::RxTx ? 2 : 0),mask);
ASSERT_EQ(mask!=0,node.sendPacket(p,0));
uint8_t actual=0;
while (manager.getOutboundTotal()) {
auto* queued=manager.removeOutboundByIdx(0);
actual |= 1U << queued->radio_profile;
node.releasePacket(queued);
}
EXPECT_EQ(mask,actual); EXPECT_EQ(40,manager.getFreeCount());
}
}
}
}
TEST_F(DualProfileTest, ReceiveOnlyTemporaryOtaDoesNotLeakOntoNormalChannel) {
radio.config.secondary_temporary = true;
radio.config.secondary.mode = mesh::RadioProfileMode::Rx;
+4 -1
View File
@@ -14,6 +14,8 @@ class File {
File(MemoryFS* fs, const char* path) : fs_(fs), path_(path) {}
operator bool() const { return fs_ != nullptr; }
size_t size() const;
size_t available() const { return size() - cursor_; }
bool isDirectory() const { return false; }
int read(uint8_t* data, size_t size);
size_t write(const uint8_t* data, size_t size);
void flush() {}
@@ -24,6 +26,7 @@ class MemoryFS {
std::map<std::string, std::vector<uint8_t>> files;
bool fail_write = false;
int fail_rename = 0;
bool mkdir(const char*) { return true; }
bool exists(const char* path) const { return files.count(path); }
bool remove(const char* path) { return files.erase(path); }
bool rename(const char* from, const char* to) {
@@ -31,7 +34,7 @@ class MemoryFS {
if (!exists(from) || exists(to)) return false;
files[to] = files[from]; files.erase(from); return true;
}
File open(const char* path, const char* mode) {
File open(const char* path, const char* mode = "r", bool = false) {
if (*mode == 'w') files[path].clear();
if (!exists(path)) return {};
return File(this, path);
+173
View File
@@ -0,0 +1,173 @@
"""Compile real Companion TX-route CLI and channel persistence; no radio required."""
from pathlib import Path
import subprocess
import tempfile
import unittest
from test_radio_receive_contract import method
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <cassert>
#include <cstdio>
#include <cstring>
#include <vector>
#include <RadioProfiles.h>
#include <helpers/CLICommandUtils.h>
#include <helpers/CompanionTxRoutingCLI.h>
#include <helpers/IdentityStore.h>
#define MAX_GROUP_CHANNELS 4
#define MAX_ANON_CONTACTS 1
#define ADV_TYPE_NONE 0
namespace mesh {
struct Identity { uint8_t pub_key[32] = {}; };
struct GroupChannel { uint8_t secret[32] = {}, hash[1] = {}, tx_radio = RADIO_TX_AUTO; };
struct Utils {
static void toHex(char* out, const uint8_t* in, size_t n) {
for (size_t i=0;i<n;++i) sprintf(out+2*i,"%02X",in[i]);
}
};
}
struct ContactInfo { mesh::Identity id; char name[32] = {}; uint8_t type=1, tx_radio=0; };
struct ChannelDetails { mesh::GroupChannel channel; char name[32] = {}; };
struct DataStoreHost {
virtual bool onChannelLoaded(uint8_t, const ChannelDetails&)=0;
virtual bool getChannelForSave(uint8_t, ChannelDetails&)=0;
};
struct Radio { mesh::RadioProfiles config; mesh::RadioProfiles* profiles() { return &config; } };
struct DataStore {
FILESYSTEM fs;
FILESYSTEM* _getContactsChannelsFS() { return &fs; }
File openRead(FILESYSTEM* fs, const char* name) { return fs->open(name,"r"); }
void loadChannels(DataStoreHost*);
bool saveChannels(DataStoreHost*);
};
File openWrite(FILESYSTEM* fs, const char* name) { return fs->open(name,"w"); }
class MyMesh : public DataStoreHost {
public:
std::vector<ContactInfo> contacts{4};
ChannelDetails channels[MAX_GROUP_CHANNELS];
Radio radio; Radio* _radio=&radio;
DataStore store;
bool writable=true, writes_ok=true; unsigned writes=0;
bool canMutateContacts() const { return writable; }
int getTotalContactSlots() const { return contacts.size(); }
ContactInfo* getContactPtrByIdx(int i) { return &contacts.at(i); }
bool getChannel(int i, ChannelDetails& c) { if (i<0 || i>=MAX_GROUP_CHANNELS) return false; c=channels[i]; return true; }
bool setChannel(int i, const ChannelDetails& c) { if (i<0 || i>=MAX_GROUP_CHANNELS) return false; channels[i]=c; return true; }
bool saveChannels() { ++writes; return writes_ok && store.saveChannels(this); }
bool scheduleContactWrite(const ContactInfo&) { ++writes; return writes_ok; }
bool flushContactsBeforeReboot() { return writes_ok; }
bool onChannelLoaded(uint8_t i,const ChannelDetails& c) override { return setChannel(i,c); }
bool getChannelForSave(uint8_t i,ChannelDetails& c) override { return getChannel(i,c); }
bool handleTxRoutingCommand(const char*,char*,size_t);
};
@METHODS@
int main() {
MyMesh node;
strcpy(node.contacts[1].name,"Alice Jones"); strcpy(node.contacts[2].name,"Bob");
strcpy(node.contacts[3].name,"Bob");
for (int i=1;i<4;++i) { memset(node.contacts[i].id.pub_key,0x11,32); node.contacts[i].id.pub_key[31]=i; }
strcpy(node.channels[0].name,"Public"); strcpy(node.channels[1].name,"#wardriving");
node.channels[1].channel.secret[0]=17;
node.radio.config.secondary.mode=mesh::RadioProfileMode::RxTx;
node.radio.config.cross=mesh::RadioCrossMode::Off;
node.radio.config.secondary_temporary=true;
char reply[160];
auto cmd=[&](const char* text) { assert(node.handleTxRoutingCommand(text,reply,sizeof(reply))); };
cmd("set tx.user \"Alice Jones\" radio2"); assert(!strncmp(reply,"OK",2));
assert(strstr(reply,"active=radio2") && node.contacts[1].tx_radio==mesh::RADIO_TX_SECONDARY);
assert(node.contacts[2].tx_radio==mesh::RADIO_TX_AUTO);
cmd("get tx.user Alice Jones"); assert(strstr(reply,"radio2"));
cmd("set tx.user Bob off"); assert(strstr(reply,"ambiguous"));
cmd("set tx.user key:111111111111 off"); assert(strstr(reply,"ambiguous"));
char key[65], command[140]; mesh::Utils::toHex(key,node.contacts[2].id.pub_key,32);
snprintf(command,sizeof(command),"set tx.user key:%s off",key); cmd(command);
assert(!strncmp(reply,"OK",2) && node.contacts[2].tx_radio==mesh::RADIO_TX_OFF);
assert(node.contacts[3].tx_radio==mesh::RADIO_TX_AUTO);
// Quoting selects a literal name, even when it resembles a slot or key.
strcpy(node.channels[2].name,"1");
cmd("set tx.channel \"1\" off"); assert(!strncmp(reply,"OK",2));
assert(node.channels[2].channel.tx_radio==mesh::RADIO_TX_OFF);
assert(node.channels[1].channel.tx_radio==mesh::RADIO_TX_AUTO);
cmd("set tx.channel 1 radio"); assert(!strncmp(reply,"OK",2));
assert(node.channels[1].channel.tx_radio==mesh::RADIO_TX_PRIMARY);
cmd("set tx.channel \"1\" auto");
node.channels[1].channel.tx_radio=mesh::RADIO_TX_AUTO;
strcpy(node.contacts[3].name,"key:111111111111");
cmd("set tx.user \"key:111111111111\" both"); assert(!strncmp(reply,"OK",2));
assert(node.contacts[3].tx_radio==mesh::RADIO_TX_BOTH);
cmd("set tx.user key:111111111111 off"); assert(strstr(reply,"ambiguous"));
node.contacts[3].tx_radio=mesh::RADIO_TX_AUTO;
strcpy(node.contacts[3].name,"Bob");
for (auto text : {"set tx.user Alice Jones", "set tx.user Alice Jones radio3", "set tx.user key:12 radio", "set tx.user \"Alice radio"}) {
cmd(text); assert(!strncmp(reply,"Error",5));
}
assert(!node.handleTxRoutingCommand("get tx.username",reply,sizeof(reply)));
cmd("set tx.channel 0 both"); assert(strstr(reply,"active=both"));
cmd("set tx.channel #wardriving radio2"); assert(strstr(reply,"active=radio2"));
auto disk=node.store.fs.files["/channels2"];
assert(disk.size()==MAX_GROUP_CHANNELS*68);
assert(disk[0]==mesh::encodeRadioTxPolicy(mesh::RADIO_TX_BOTH));
assert(disk[68]==mesh::encodeRadioTxPolicy(mesh::RADIO_TX_SECONDARY));
node.channels[0].channel.tx_radio=node.channels[1].channel.tx_radio=0;
node.store.loadChannels(&node);
assert(node.channels[0].channel.tx_radio==mesh::RADIO_TX_BOTH);
assert(node.channels[1].channel.tx_radio==mesh::RADIO_TX_SECONDARY);
node.radio.config.secondary.mode=mesh::RadioProfileMode::Rx;
cmd("get tx.channel 1"); assert(strstr(reply,"active=off") && strstr(reply,"unavailable"));
cmd("get tx.channel 0"); assert(strstr(reply,"active=radio "));
node.writes_ok=false;
cmd("set tx.channel 0 off"); assert(strstr(reply,"not saved"));
assert(node.channels[0].channel.tx_radio==mesh::RADIO_TX_BOTH && node.store.fs.files["/channels2"]==disk);
cmd("set tx.user Alice Jones both"); assert(strstr(reply,"not saved"));
assert(node.contacts[1].tx_radio==mesh::RADIO_TX_SECONDARY);
node.writes_ok=true; node.writable=false;
cmd("set tx.channel 0 auto"); assert(strstr(reply,"storage unavailable"));
node.writable=true;
cmd("set tx.channel 0 auto"); assert(!strncmp(reply,"OK",2));
cmd("get tx.channel"); assert(strstr(reply,"1 overrides"));
for (unsigned i=0;i<256;++i) {
auto policy=mesh::decodeRadioTxPolicy(i);
assert(policy==((i>=0xa1 && i<=0xa4) ? (i&7) : 0));
}
for (auto& byte : node.store.fs.files["/channels2"]) byte=0;
node.store.loadChannels(&node);
for (auto& c : node.channels) assert(c.channel.tx_radio==mesh::RADIO_TX_AUTO);
}
'''
class CompanionTxRoutingTest(unittest.TestCase):
def test_cli_resolution_persistence_and_failures(self):
companion = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text()
store = (ROOT / 'examples/companion_radio/DataStore.cpp').read_text()
methods = method(companion, 'bool MyMesh::handleTxRoutingCommand(')
methods += '\n' + method(store, 'void DataStore::loadChannels(')
methods += '\n' + method(store, 'bool DataStore::saveChannels(')
with tempfile.TemporaryDirectory() as directory:
work = Path(directory)
(work / 'test.cpp').write_text(HARNESS.replace('@METHODS@', methods))
build = subprocess.run(['g++', '-std=c++17', '-Wall', '-Wextra',
'-fsanitize=address,undefined', '-fno-pie', '-no-pie',
'-I', str(ROOT / 'test/fixtures/radio_profiles/mocks'),
'-I', str(ROOT / 'test/mocks'), '-I', str(ROOT / 'src'),
str(work / 'test.cpp'), '-o', str(work / 'test')], capture_output=True, text=True)
self.assertEqual(build.returncode, 0, build.stdout + build.stderr)
run = subprocess.run([str(work / 'test')], capture_output=True, text=True)
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)
def test_phone_protocol_updates_preserve_same_channel_policy(self):
source = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text()
command = source[source.index('const bool have_previous = getChannel(channel_idx, previous);'):]
command = command[:command.index('} else if (cmd_frame[0] == CMD_SIGN_START')]
self.assertIn('!memcmp(channel.channel.secret, previous.channel.secret, sizeof(channel.channel.secret))', command)
self.assertIn('channel.channel.tx_radio = previous.channel.tx_radio;', command)
self.assertIn('setChannel(channel_idx, previous);', command)
self.assertIn('handleTxRoutingCommand(command, reply, reply_size)',
method(source, 'bool MyMesh::handleLocalControlCommand('))
if __name__ == '__main__':
unittest.main()
@@ -23,6 +23,10 @@ public:
};
class Radio : public mesh::Radio {
public:
bool dual = false;
mesh::RadioProfiles config;
mesh::RadioProfiles* profiles() override { return dual ? &config : nullptr; }
const mesh::RadioProfiles* profiles() const override { return dual ? &config : nullptr; }
int recvRaw(uint8_t*, int) override { return 0; }
uint32_t getEstAirtimeFor(int) override { return 10; }
float packetScore(float, int) override { return 0; }
@@ -40,6 +44,15 @@ public:
};
class Chat : public BaseChatMesh {
public:
uint8_t getFloodRetryMaxAttempts(const mesh::Packet*) const override { return 0; }
uint8_t getDirectRetryMaxAttempts(const mesh::Packet*) const override { return 0; }
void receivedTextFromFirstContact(mesh::Packet& packet) {
uint8_t hash[PATH_HASH_SIZE]; getContactPtrByIdx(MAX_ANON_CONTACTS)->id.copyHashTo(hash);
ASSERT_GT(searchPeersByHash(hash), 0);
uint8_t secret[32] = {}, data[32] = {};
data[0] = 10; memcpy(data + 5, "hello", 5);
onPeerDataRecv(&packet, PAYLOAD_TYPE_TXT_MSG, 0, secret, data, 10);
}
bool auto_add = false;
bool writable = true;
uint8_t max_hops = 1;
@@ -215,6 +228,84 @@ TEST_F(ContactImport, MalformedAndNonAdvertPacketsDoNotLeakAllocations) {
EXPECT_FALSE(queue(packet));
EXPECT_EQ(pool.getFreeCount(), 8);
}
TEST_F(ContactImport, ContactMessagesUseFullIdentityAndPreservePolicyAcrossAdvertRefresh) {
radio.dual = true; radio.config.secondary.mode = mesh::RadioProfileMode::RxTx;
radio.config.cross = mesh::RadioCrossMode::Off;
ContactInfo first, second;
memset(first.id.pub_key, 0, PUB_KEY_SIZE); first.id.pub_key[0] = 17;
first.type = ADV_TYPE_CHAT; first.tx_radio = mesh::RADIO_TX_SECONDARY;
second = first; second.id.pub_key[31] = 9; second.tx_radio = mesh::RADIO_TX_PRIMARY;
ASSERT_TRUE(chat.addContact(first)); ASSERT_TRUE(chat.addContact(second));
uint32_t ack=0, timeout=0;
EXPECT_EQ(MSG_SEND_SENT_DIRECT, chat.sendMessage(first, 1, 0, "one", ack, timeout));
EXPECT_EQ(MSG_SEND_SENT_DIRECT, chat.sendMessage(second, 2, 0, "two", ack, timeout));
ASSERT_EQ(2, pool.getOutboundTotal());
EXPECT_EQ(1, pool.getOutboundByIdx(0)->radio_profile);
EXPECT_EQ(0, pool.getOutboundByIdx(1)->radio_profile);
auto refresh = card(17, 0, 20); chat.receive(refresh);
EXPECT_EQ(mesh::RADIO_TX_SECONDARY, chat.getContactPtrByIdx(MAX_ANON_CONTACTS)->tx_radio);
}
TEST_F(ContactImport, OffContactAndChannelReportFailureWithoutLeakingPackets) {
ContactInfo contact;
memset(contact.id.pub_key, 1, PUB_KEY_SIZE); contact.type = ADV_TYPE_CHAT;
contact.tx_radio = mesh::RADIO_TX_OFF;
ASSERT_TRUE(chat.addContact(contact));
uint32_t ack=0, timeout=0, tag=0;
const uint8_t data[] = {1};
for (uint8_t path : {uint8_t(0), uint8_t(OUT_PATH_UNKNOWN)}) {
contact.out_path_len = path;
EXPECT_EQ(MSG_SEND_FAILED, chat.sendMessage(contact, 1, 0, "test", ack, timeout));
EXPECT_EQ(MSG_SEND_FAILED, chat.sendCommandData(contact, 1, 0, TXT_TYPE_CLI_COMMAND, "test", timeout));
EXPECT_EQ(MSG_SEND_FAILED, chat.sendLogin(contact, "", timeout));
EXPECT_EQ(MSG_SEND_FAILED, chat.sendAnonReq(contact, data, sizeof(data), tag, timeout));
EXPECT_EQ(MSG_SEND_FAILED, chat.sendRequest(contact, data, sizeof(data), tag, timeout));
EXPECT_EQ(MSG_SEND_FAILED, chat.sendRequest(contact, uint8_t(1), tag, timeout));
mesh::GroupChannel channel; memset(channel.secret, 0, sizeof(channel.secret));
channel.hash[0] = 17; channel.tx_radio = mesh::RADIO_TX_OFF;
EXPECT_FALSE(chat.sendGroupMessage(1, channel, "me", "test", 4));
EXPECT_FALSE(chat.sendGroupData(channel, nullptr, path, 1, data, sizeof(data)));
EXPECT_EQ(0, pool.getOutboundTotal()); EXPECT_EQ(8, pool.getFreeCount());
}
}
TEST_F(ContactImport, SameHashChannelsKeepIndependentTransmitChoices) {
radio.dual = true; radio.config.secondary.mode = mesh::RadioProfileMode::RxTx;
radio.config.cross = mesh::RadioCrossMode::Off;
mesh::GroupChannel a, b;
memset(a.secret, 1, sizeof(a.secret)); memset(b.secret, 2, sizeof(b.secret));
a.hash[0] = b.hash[0] = 17;
a.tx_radio = mesh::RADIO_TX_PRIMARY; b.tx_radio = mesh::RADIO_TX_SECONDARY;
ASSERT_TRUE(chat.sendGroupMessage(1, a, "me", "one", 3));
ASSERT_TRUE(chat.sendGroupMessage(2, b, "me", "two", 3));
const uint8_t data[] = {1};
ASSERT_TRUE(chat.sendGroupData(b, nullptr, 0, 1, data, sizeof(data)));
ASSERT_EQ(3, pool.getOutboundTotal());
EXPECT_EQ(0, pool.getOutboundByIdx(0)->radio_profile);
EXPECT_EQ(1, pool.getOutboundByIdx(1)->radio_profile);
EXPECT_EQ(1, pool.getOutboundByIdx(2)->radio_profile);
}
TEST_F(ContactImport, ContactRoutingAlsoControlsAcknowledgementsAndReturnedPaths) {
radio.dual = true; radio.config.secondary.mode = mesh::RadioProfileMode::RxTx;
radio.config.cross = mesh::RadioCrossMode::Off;
ContactInfo contact; memset(contact.id.pub_key, 1, PUB_KEY_SIZE);
contact.type = ADV_TYPE_CHAT; contact.tx_radio = mesh::RADIO_TX_SECONDARY;
ASSERT_TRUE(chat.addContact(contact));
// A same-key anonymous slot must not bypass the saved contact's choice.
*chat.getContactPtrByIdx(0) = contact;
chat.getContactPtrByIdx(0)->type = ADV_TYPE_NONE;
chat.getContactPtrByIdx(0)->tx_radio = mesh::RADIO_TX_AUTO;
mesh::Packet incoming;
incoming.header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_TXT_MSG << PH_TYPE_SHIFT);
chat.receivedTextFromFirstContact(incoming);
incoming.header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_TXT_MSG << PH_TYPE_SHIFT);
chat.receivedTextFromFirstContact(incoming);
ASSERT_EQ(2, pool.getOutboundTotal());
EXPECT_EQ(1, pool.getOutboundByIdx(0)->radio_profile);
EXPECT_EQ(1, pool.getOutboundByIdx(1)->radio_profile);
}
} // namespace
int main(int argc, char** argv) {
@@ -12,8 +12,9 @@ TEST(FloodFilterPersistence, DefaultRowsTrimTheInactiveTail) {
FloodPersistenceRow rows[63] = {};
rows[0].active = true;
rows[1].active = true;
rows[2].active = true;
EXPECT_EQ(2, FloodFilterPolicy::forwardPersistenceCount(
EXPECT_EQ(3, FloodFilterPolicy::forwardPersistenceCount(
rows, 63, false));
}
+22
View File
@@ -72,6 +72,7 @@ struct RadioLibWrapper {
void serviceProfileScan();
mesh::RadioParamApplyResult tuneProfile(uint8_t);
mesh::RadioParamApplyResult prepareTransmitProfile(uint8_t);
mesh::RadioParamApplyResult prepareTransmitProfile(uint8_t, bool);
mesh::RadioParamApplyResult trySetParams(float,float,uint8_t,uint8_t,const uint32_t* = nullptr);
mesh::RadioParamApplyResult trySetPrimaryParams(const mesh::RadioProfileParams&,bool,const uint32_t* = nullptr);
void enable() {
@@ -84,6 +85,25 @@ struct RadioLibWrapper {
@METHODS@
int main() {
using Result=mesh::RadioParamApplyResult;
{
// Force only admits a reply on an active RX-only profile. It cannot
// enable a disabled profile or erase an in-progress reception.
RadioLibWrapper w; w.enable();
w._profiles.secondary.mode=mesh::RadioProfileMode::Rx;
assert(w.prepareTransmitProfile(1)==Result::FAILED);
assert(w.prepareTransmitProfile(1,false)==Result::FAILED);
w.packet=true;
assert(w.prepareTransmitProfile(1,true)==Result::BUSY);
w.packet=false; w.busy=true;
assert(w.prepareTransmitProfile(1,true)==Result::BUSY);
w.busy=false;
assert(w.prepareTransmitProfile(1,true)==Result::APPLIED);
assert(w._active_profile==1 && w._profiles.secondary.mode==mesh::RadioProfileMode::Rx);
assert(w.prepareTransmitProfile(1)==Result::FAILED);
w._profiles.setSecondary({},false);
assert(w.prepareTransmitProfile(1,true)==Result::FAILED);
assert(w.prepareTransmitProfile(2,true)==Result::FAILED);
}
{
// Applying the tuple is not success if RX resume failed. Roll back the
// profile and caches, and request recovery if rollback RX also fails.
@@ -194,6 +214,8 @@ class ProfileScanTest(unittest.TestCase):
('void','serviceProfileScan')]+[('mesh::RadioParamApplyResult',name) for name in
['tuneProfile','prepareTransmitProfile','trySetParams','trySetPrimaryParams']]
methods='\n'.join(method(source,f'{kind} RadioLibWrapper::{name}(') for kind,name in names)
methods+='\n'+method(source,
'mesh::RadioParamApplyResult RadioLibWrapper::prepareTransmitProfile(uint8_t profile, bool')
wrapper=(ROOT/'src/helpers/radiolib/CustomSX1262Wrapper.h').read_text()
methods+='\n'+method(wrapper,'void setProfileStandbyWarm(').replace(
'void setProfileStandbyWarm(bool enabled) override',
+124
View File
@@ -0,0 +1,124 @@
"""Exercise delayed OTA response classification and infrastructure reply wiring."""
from pathlib import Path
import os
import re
import subprocess
import tempfile
import unittest
from test_radio_receive_contract import method
ROOT = Path(__file__).resolve().parents[1]
class ReplyTxIntegrationTest(unittest.TestCase):
def test_async_ota_adapter_marks_only_response_types_and_retains_backpressure(self):
source = (ROOT / 'src/Mesh.cpp').read_text()
adapter = method(source, 'bool Mesh::otaSendAdapter(')
paced = method(source, 'static bool isPacedOtaResponse(')
harness = r'''
#include <cassert>
#include <cstdint>
#include <initializer_list>
#include <helpers/ota/OtaFormat.h>
namespace mesh {
struct Packet { bool radio_reply=false; };
struct Manager { int free=10; int getFreeCount() { return free; } };
static int queued;
static int queuedPacedOtaResponses(Manager*) { return queued; }
static constexpr int OTA_EGRESS_QUEUE_CREDIT=@CREDIT@, OTA_EGRESS_MIN_FREE=@RESERVE@;
@PACED@
struct Mesh {
Manager manager; Manager* _mgr=&manager;
Packet packet;
bool temporary=true, allocation=true, admit=true, was_reply=false;
uint8_t mask=1;
int allocations=0, sends=0;
bool isAnyTempRadioActive() { return temporary; }
Packet* createOtaPacket(const uint8_t*,uint16_t) {
++allocations;
// Deliberately opposite to expected for half the types: classification
// must not accidentally depend on the receive call stack or a prior send.
packet.radio_reply=!packet.radio_reply;
if (!allocation || !manager.free) return nullptr;
--manager.free;
return &packet;
}
uint8_t getTransmitProfileMask(const Packet*) { return mask; }
void releasePacket(Packet*) { ++manager.free; }
bool sendOtaFlood(Packet* p) { ++sends; was_reply=p->radio_reply; releasePacket(p); return admit; }
static bool otaSendAdapter(void*,const uint8_t*,uint16_t,bool);
};
@ADAPTER@
}
int main() {
mesh::Mesh node;
for (unsigned type=0;type<256;++type) {
const uint8_t msg=type;
bool reply=type==0x03 || type==0x05 || type==0x07 || type==0x09 || type==0x0b;
assert(node.otaSendAdapter(&node,&msg,1,true));
assert(node.was_reply==reply);
}
const uint8_t data=mesh::ota::OTA_DATA, proof=mesh::ota::OTA_PROOF;
for (const uint8_t type : {data,proof}) {
for (uint8_t mask : {1,2,3}) {
for (int queued=0;queued<=mesh::OTA_EGRESS_QUEUE_CREDIT+1;++queued) {
for (int free=0;free<=mesh::OTA_EGRESS_MIN_FREE+3;++free) {
node.mask=mask; mesh::queued=queued; node.manager.free=free;
const int copies=mask==3 ? 2 : 1;
const bool allowed=queued+copies<=mesh::OTA_EGRESS_QUEUE_CREDIT
&& free>=mesh::OTA_EGRESS_MIN_FREE+copies;
const int allocations=node.allocations;
assert(node.otaSendAdapter(&node,&type,1,true)==allowed);
assert(node.manager.free==free); // failure releases, simulated successful TX drains
if (queued>=mesh::OTA_EGRESS_QUEUE_CREDIT || free<=mesh::OTA_EGRESS_MIN_FREE)
assert(node.allocations==allocations); // normal backpressure is not pool exhaustion
}
}
}
}
node.mask=0; node.manager.free=10; mesh::queued=0;
assert(!node.otaSendAdapter(&node,&data,1,true));
assert(node.manager.free==10);
node.mask=3;
int allocated=node.allocations;
node.manager.free=10; node.temporary=false;
assert(!node.otaSendAdapter(&node,&data,1,true));
assert(node.allocations==allocated);
node.temporary=true; node.allocation=false;
int sends=node.sends;
assert(!node.otaSendAdapter(&node,&data,1,true)); assert(node.sends==sends);
node.allocation=true; node.admit=false;
assert(!node.otaSendAdapter(&node,&data,1,true));
}
'''
with tempfile.TemporaryDirectory() as folder:
cpp = Path(folder) / 'reply.cpp'
exe = Path(folder) / 'reply'
header = (ROOT / 'src/Mesh.h').read_text()
credit = re.search(r'#define OTA_EGRESS_QUEUE_CREDIT (\d+)', header).group(1)
reserve = re.search(r'#define OTA_EGRESS_MIN_FREE (\d+)', header).group(1)
cpp.write_text(harness.replace('@ADAPTER@', adapter).replace('@PACED@', paced)
.replace('@CREDIT@', credit).replace('@RESERVE@', reserve))
result = subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17',
'-Wall', '-Wextra', '-I', str(ROOT / 'src'), str(cpp), '-o', str(exe)],
capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
subprocess.run([str(exe)], check=True)
def test_all_infrastructure_roles_enable_replies_and_mark_delayed_completions(self):
common = (ROOT / 'src/helpers/CommonCLI.cpp').read_text()
self.assertIn('_radio_profiles.begin(fs, _callbacks->getProfileRadio(), _rtc, true);', common)
for path in ['examples/simple_repeater/MyMesh.cpp',
'examples/simple_room_server/MyMesh.cpp',
'examples/simple_sensor/SensorMesh.cpp']:
with self.subTest(path=path):
text = (ROOT / path).read_text()
self.assertIn('_cli.loadPrefs(', text)
self.assertIn('packet->radio_reply = true;', text)
room = (ROOT / 'examples/simple_room_server/MyMesh.cpp').read_text()
self.assertIn('reply->radio_reply = true; // asynchronous response to the room subscription', room)
if __name__ == '__main__':
unittest.main()
@@ -34,8 +34,8 @@ TEST(TempRadioReplyBarrier, UntrackedAlternateCannotResolveAuthoritativeReply) {
barrier.arm(&primary_reply);
// TempRadio mutation acknowledgements deliberately queue only the primary
// copy, without an alternate-path copy or a transport retry. If an
// TempRadio mutation acknowledgements omit alternate-path copies and
// transport retries. If an
// obsolete/foreign packet callback arrives anyway, it must not release or
// fail the authoritative handoff.
EXPECT_FALSE(barrier.complete(&alternate_reply));
@@ -60,6 +60,41 @@ TEST(TempRadioReplyBarrier, ClearCancelsAnObsoleteHandoff) {
EXPECT_TRUE(barrier.complete(&new_reply));
}
TEST(TempRadioReplyBarrier, BothProfilesDrainAndEitherSuccessPermitsHandoff) {
for (int successes=0;successes<4;++successes) {
for (bool reversed : {false,true}) {
mesh::TempRadioReplyBarrier barrier;
int a=1,b=2,unrelated=3;
barrier.prepare(&a);
EXPECT_FALSE(barrier.fail(&a)); // admission can retire an old retry
barrier.trackCopy(&unrelated,&unrelated);
barrier.trackCopy(&a,&b);
barrier.arm(&a);
const void* first = reversed ? &b : &a;
const void* second = reversed ? &a : &b;
if (successes & 1) EXPECT_TRUE(barrier.complete(first));
else EXPECT_FALSE(barrier.fail(first));
EXPECT_TRUE(barrier.waiting());
EXPECT_FALSE(barrier.complete(&unrelated));
if (successes & 2) EXPECT_TRUE(barrier.complete(second));
else EXPECT_EQ(successes==0,barrier.fail(second));
EXPECT_FALSE(barrier.waiting());
EXPECT_FALSE(barrier.fail(first));
}
}
}
TEST(TempRadioReplyBarrier, CancelDiscardsBothProfilesAndPointerReuse) {
mesh::TempRadioReplyBarrier barrier;
int a=1,b=2;
barrier.prepare(&a); barrier.trackCopy(&a,&b); barrier.arm(&a);
barrier.clear();
EXPECT_FALSE(barrier.complete(&a)); EXPECT_FALSE(barrier.fail(&b));
barrier.arm(&b);
EXPECT_FALSE(barrier.complete(&a));
EXPECT_TRUE(barrier.fail(&b));
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Static integration contract for TempRadio's single-copy reply barrier."""
"""Integration contract for tracked TempRadio replies on both profiles."""
from pathlib import Path
import unittest
@@ -33,6 +33,11 @@ class TempRadioReplyDeliveryContractTest(unittest.TestCase):
"const bool allow_redundant_copies = queued_packet == NULL;", reply
)
self.assertIn("fallback_scope,\n allow_redundant_copies", reply)
self.assertLess(reply.index('temp_radio_reply_barrier.prepare(packet)'),
reply.index('const bool queued = sendClientReplyWithFallbackScope'))
self.assertIn('if (!queued && !allow_redundant_copies) temp_radio_reply_barrier.clear();', reply)
self.assertIn('temp_radio_reply_barrier.trackCopy(original, packet);', source)
self.assertIn('mesh::Mesh::onRadioProfileCopyQueued(packet, original, priority);', source)
self.assertIn(
"if (allow_redundant_copies\n"
" && mesh::Packet::isValidPathLen(client->alt_path_len)",
@@ -0,0 +1,333 @@
#include <gtest/gtest.h>
#include <helpers/FloodFilterPolicy.h>
#include "../fixtures/radio_profiles/mocks/helpers/IdentityStore.h"
// Compile the production parser, authenticated matcher, ordering, rate handling
// and atomic FPF7 reader/writer against an in-memory filesystem. Crypto and
// packets are the real native-build implementations. These tests use no regions.
#define MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE 1
#include "../../examples/simple_room_server/FloodRuleEngine.cpp"
RegionEntry* RegionMap::findByName(const char*) { return nullptr; }
RegionEntry* RegionMap::findByNamePrefix(const char*) { return nullptr; }
int RegionMap::getTransportKeysFor(const RegionEntry&, TransportKey[], int) { return 0; }
uint16_t TransportKey::calcTransportCode(const mesh::Packet*) const { return 0; }
bool TransportKey::isNull() const { return true; }
bool RegionMap::is_name_char(uint8_t c) {
return c == '-' || c == '$' || c == '#' || (c >= '0' && c <= '9') || c >= 'A';
}
using namespace FloodFilterPolicy;
int main(int argc, char** argv) {
testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
TEST(TransportModes, StoragePreservesLegacyAndEverySupportedCombination) {
for (uint8_t modes : {0, 2, 4, 6}) {
bool active = false;
uint8_t restored = 255;
ASSERT_TRUE(decodeStoredRuleActive(encodeStoredRuleActive(true, modes), active, restored));
EXPECT_TRUE(active);
EXPECT_EQ(modes, restored);
ASSERT_TRUE(decodeStoredRuleActive(encodeStoredRuleActive(false, modes), active, restored));
EXPECT_FALSE(active);
EXPECT_EQ(RULE_MODE_RADIO, restored);
}
for (uint8_t invalid : {2, 4, 6, 8, 9, 255}) {
bool active; uint8_t modes;
EXPECT_FALSE(decodeStoredRuleActive(invalid, active, modes));
}
}
TEST(TransportModes, SelectorsAreStrictAndCanonical) {
for (const char* text : {"radio", "bridge", "cross", "bridge,cross", "CROSS,BRIDGE"}) {
uint8_t modes;
ASSERT_TRUE(parseRuleModes(text, modes));
uint8_t roundtrip;
ASSERT_TRUE(parseRuleModes(ruleModeName(modes), roundtrip));
EXPECT_EQ(modes, roundtrip);
}
for (const char* text : {"", "all", "bridge,", ",cross", "bridge,bridge", "radio,cross", "cross,unknown"}) {
uint8_t modes;
EXPECT_FALSE(parseRuleModes(text, modes));
}
}
class TransportFilter : public testing::Test {
protected:
MemoryFS fs;
FloodRuleEngine rules;
char reply[160] = {};
void SetUp() override { rules.begin(&fs, nullptr); }
void command(const char* text) { ASSERT_TRUE(rules.handleCommand(text, reply)); }
void install(const char* modes = "bridge,cross") {
std::string cmd = "set flood.rule.3 type=any channel=#wardriving hops=all mode=";
cmd += modes; cmd += " drop";
command(cmd.c_str()); ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
}
mesh::Packet packet(const char* channel = "#wardriving", uint8_t type = PAYLOAD_TYPE_GRP_TXT,
uint8_t route = ROUTE_TYPE_FLOOD, uint8_t hops = 0) {
mesh::Packet p;
p.header = (type << PH_TYPE_SHIFT) | route;
p.setPathHashSizeAndCount(1, hops);
memset(p.path, 0x12, hops);
uint8_t key[PUB_KEY_SIZE] = {};
mesh::Utils::sha256(key, CIPHER_KEY_SIZE, (const uint8_t*)channel, strlen(channel));
mesh::Utils::sha256(p.payload, 1, key, CIPHER_KEY_SIZE);
const uint8_t text[] = "wardrive: sample";
p.payload_len = 1 + mesh::Utils::encryptThenMAC(key, p.payload + 1, text, sizeof(text));
return p;
}
bool blocked(mesh::Packet& p, uint8_t mode, bool temporary = false) {
auto mask = rules.evaluate(&p, temporary, true, nullptr, mode);
return rules.shouldBlock(&p, mask, 100);
}
};
TEST_F(TransportFilter, OneRuleBlocksBothDirectionsAndKeepsRadioEligible) {
install();
for (uint8_t type : {PAYLOAD_TYPE_GRP_TXT, PAYLOAD_TYPE_GRP_DATA})
for (uint8_t route : {ROUTE_TYPE_FLOOD, ROUTE_TYPE_TRANSPORT_FLOOD, ROUTE_TYPE_DIRECT, ROUTE_TYPE_TRANSPORT_DIRECT})
for (uint8_t hops : {0, 4, 5, 63}) {
auto p = packet("#wardriving", type, route, hops);
EXPECT_TRUE(blocked(p, RULE_MODE_BRIDGE));
EXPECT_TRUE(blocked(p, RULE_MODE_CROSS, true));
EXPECT_FALSE(blocked(p, RULE_MODE_RADIO));
}
command("get flood.rule.3");
EXPECT_NE(nullptr, strstr(reply, "mode=bridge,cross"));
command("get flood.rule");
EXPECT_NE(nullptr, strstr(reply, "~bridge,cross"));
}
TEST_F(TransportFilter, TemporaryWindowSuspendsOtaRuleWithoutDisablingWardrivingRule) {
install();
auto ota = packet("#wardriving", PAYLOAD_TYPE_OTA);
EXPECT_TRUE(blocked(ota, RULE_MODE_RADIO, false));
EXPECT_FALSE(blocked(ota, RULE_MODE_RADIO, true));
auto wardriving = packet();
EXPECT_TRUE(blocked(wardriving, RULE_MODE_BRIDGE, true));
EXPECT_TRUE(blocked(wardriving, RULE_MODE_CROSS, true));
EXPECT_TRUE(blocked(ota, RULE_MODE_RADIO, false)); // normal rule resumes on expiry
}
TEST_F(TransportFilter, AuthenticationRejectsHashCollisionAndBadMac) {
install();
auto wardriving = packet();
mesh::Packet collision;
bool found = false;
for (unsigned i = 0; i < 10000; i++) {
std::string name = "#different-" + std::to_string(i);
collision = packet(name.c_str());
if (collision.payload[0] == wardriving.payload[0]) { found = true; break; }
}
ASSERT_TRUE(found);
EXPECT_FALSE(blocked(collision, RULE_MODE_BRIDGE));
EXPECT_FALSE(blocked(collision, RULE_MODE_CROSS));
wardriving.payload[1] ^= 1;
EXPECT_FALSE(blocked(wardriving, RULE_MODE_BRIDGE));
wardriving.payload_len = 1;
EXPECT_FALSE(blocked(wardriving, RULE_MODE_CROSS));
auto other_type = packet("#wardriving", PAYLOAD_TYPE_ADVERT);
EXPECT_FALSE(blocked(other_type, RULE_MODE_CROSS));
}
TEST_F(TransportFilter, RadioHopLimitAndStopCannotOverrideTransportDrop) {
install();
command("set flood.rule.2 type=any channel=#wardriving hops=5+ drop");
auto near = packet(); auto far = packet("#wardriving", PAYLOAD_TYPE_GRP_TXT, ROUTE_TYPE_FLOOD, 5);
EXPECT_FALSE(blocked(near, RULE_MODE_RADIO));
EXPECT_TRUE(blocked(far, RULE_MODE_RADIO));
command("set flood.rule.4 type=any priority=255 stop");
EXPECT_FALSE(blocked(far, RULE_MODE_RADIO));
EXPECT_TRUE(blocked(near, RULE_MODE_BRIDGE));
EXPECT_TRUE(blocked(far, RULE_MODE_CROSS));
command("set flood.rule.4 type=any mode=bridge priority=255 stop");
EXPECT_FALSE(blocked(near, RULE_MODE_BRIDGE));
EXPECT_TRUE(blocked(near, RULE_MODE_CROSS));
}
TEST_F(TransportFilter, PersistenceDeletionAndSeparateModes) {
install("cross,bridge");
rules.begin(&fs, nullptr);
auto p = packet();
EXPECT_TRUE(blocked(p, RULE_MODE_BRIDGE));
EXPECT_TRUE(blocked(p, RULE_MODE_CROSS));
EXPECT_FALSE(blocked(p, RULE_MODE_RADIO));
install("bridge");
EXPECT_TRUE(blocked(p, RULE_MODE_BRIDGE));
EXPECT_FALSE(blocked(p, RULE_MODE_CROSS));
install("cross");
EXPECT_FALSE(blocked(p, RULE_MODE_BRIDGE));
EXPECT_TRUE(blocked(p, RULE_MODE_CROSS));
command("del flood.rule.3");
rules.begin(&fs, nullptr);
EXPECT_FALSE(blocked(p, RULE_MODE_BRIDGE));
EXPECT_FALSE(blocked(p, RULE_MODE_CROSS));
}
TEST_F(TransportFilter, LegacyFpf6RemainsRadioOnlyAfterUpgrade) {
// One old-format generic group-data deny, with no optional match/action fields.
fs.files.clear();
auto& old = fs.files["/flood_filter"];
old = {'F', 'P', 'F', '6', 1, 1, PAYLOAD_TYPE_GRP_DATA, 0, 63, 0};
old.resize(45, 0); // 32-byte scope name and three legacy boolean selectors
rules.begin(&fs, nullptr);
auto p = packet("#wardriving", PAYLOAD_TYPE_GRP_DATA);
EXPECT_TRUE(blocked(p, RULE_MODE_RADIO));
EXPECT_FALSE(blocked(p, RULE_MODE_BRIDGE));
EXPECT_FALSE(blocked(p, RULE_MODE_CROSS));
install(); // Saving a transport rule upgrades the same table to FPF7.
rules.begin(&fs, nullptr);
EXPECT_TRUE(blocked(p, RULE_MODE_RADIO));
EXPECT_TRUE(blocked(p, RULE_MODE_BRIDGE));
EXPECT_TRUE(blocked(p, RULE_MODE_CROSS));
command("get flood.rule.1");
EXPECT_NE(nullptr, strstr(reply, "mode=radio"));
}
TEST_F(TransportFilter, InvalidModesAndActionsDoNotReplaceSavedRule) {
install();
const auto saved = fs.files;
for (const char* tail : {"mode=bridge,", "mode=all", "mode=radio,cross",
"mode=bridge mode=cross", "mode=cross retry", "mode=bridge scope=test", "mode=cross tx=slow"}) {
std::string cmd = "set flood.rule.3 type=any "; cmd += tail; cmd += " drop";
command(cmd.c_str()); EXPECT_EQ(0, strncmp(reply, "Err", 3)) << reply;
EXPECT_EQ(saved, fs.files);
}
fs.fail_write = true;
command("set flood.rule.3 type=any mode=radio stop");
EXPECT_EQ(0, strncmp(reply, "Err", 3));
auto p = packet();
EXPECT_TRUE(blocked(p, RULE_MODE_CROSS));
EXPECT_FALSE(blocked(p, RULE_MODE_RADIO));
}
TEST_F(TransportFilter, CombinedModeSharesRateAndResetsAfterMinute) {
command("set flood.rule.3 type=any mode=bridge,cross rate=1/min");
ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
auto p = packet();
auto mask = rules.evaluate(&p, false, true, nullptr, RULE_MODE_BRIDGE);
EXPECT_FALSE(rules.shouldBlock(&p, mask, 100));
rules.commitRates(&p, mask, 100);
mask = rules.evaluate(&p, false, true, nullptr, RULE_MODE_CROSS);
EXPECT_TRUE(rules.shouldBlock(&p, mask, 101));
EXPECT_FALSE(rules.shouldBlock(&p, mask, 60100));
mask = rules.evaluate(&p, false, true, nullptr, RULE_MODE_RADIO);
EXPECT_FALSE(rules.shouldBlock(&p, mask, 101));
}
TEST_F(TransportFilter, ShorthandAndLongCommandsSaveIdenticalRules) {
struct Pair { const char* full; const char* short_form; };
for (const auto& pair : {
Pair{"set flood.rule.3 type=any channel=#wardriving hops=all mode=bridge,cross drop",
"set fr.3 * c=#wardriving m=bc d"},
Pair{"set flood.rule.3 type=ota hops=all drop suspend=tempradio",
"set fr.3 t=12 h=* d f=t"},
Pair{"set flood.rule.3 type=grp_data hops=2-5 channel=#local in=none scope=test priority=100 stop tx=slow suspend=tempradio",
"set fr.3 6 h=2-5 c=#local i=n s=test pri=100 s f=st"},
Pair{"set flood.rule.3 type=any channel=hash:A7 prefix=1234,5678 in=scoped rate=3/min retry stop",
"set fr.3 any c=hash:A7 p=1234,5678 i=s q=3 r s"}}) {
command(pair.full); ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
const auto full = fs.files["/flood_filter"];
command(pair.short_form); ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
EXPECT_EQ(full, fs.files["/flood_filter"]);
// get fr.N emits a complete setter, with every non-default condition/action.
command("get fr.3"); ASSERT_EQ(0, strncmp(reply, "set fr.3 ", 9)) << reply;
const std::string paste = reply;
command("del fr.3"); ASSERT_EQ(0, strncmp(reply, "OK", 2));
command(paste.c_str()); ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
EXPECT_EQ(full, fs.files["/flood_filter"]);
}
}
TEST_F(TransportFilter, AliasesCanMixWithLongCommandsAndKeepStrictActions) {
install();
const auto full = fs.files["/flood_filter"];
for (const char* modes : {"b,c", "c,b", "bc", "cb", "BC", "bridge,cross", "cross,bridge"}) {
std::string cmd = "set flood.rule.3 t=any c=#wardriving h=all m=";
cmd += modes; cmd += " d";
command(cmd.c_str()); ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
EXPECT_EQ(full, fs.files["/flood_filter"]);
}
command("get flood.rule.3");
EXPECT_NE(nullptr, strstr(reply, "mode=bridge,cross"));
command("get fr.3");
EXPECT_STREQ("set fr.3 any m=bc c=#wardriving d", reply);
auto p = packet();
EXPECT_TRUE(blocked(p, RULE_MODE_CROSS));
EXPECT_FALSE(blocked(p, RULE_MODE_RADIO));
for (const char* tail : {"m=b mode=c d", "h=all hops=0 d", "s stop", "r retry",
"d drop", "c=#local channel=#other d", "s=test scope=other", "m=radio,c d",
"m=bc retry", "s=test d", "m=bc", "p=bl d", "r=unknown"}) {
std::string cmd = "set fr.3 any "; cmd += tail;
command(cmd.c_str()); EXPECT_EQ(0, strncmp(reply, "Err", 3)) << cmd << ": " << reply;
EXPECT_EQ(full, fs.files["/flood_filter"]);
}
}
TEST_F(TransportFilter, ShorthandAddReusesLongRuleAndDeletePersists) {
install();
const auto before = fs.files["/flood_filter"];
command("set fr any c=#wardriving m=bc d");
ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
EXPECT_EQ(before, fs.files["/flood_filter"]);
command("get fr"); EXPECT_NE(nullptr, strstr(reply, "~bridge,cross"));
command("del fr.3");
rules.begin(&fs, nullptr);
command("get fr.3"); EXPECT_EQ(0, strncmp(reply, "Err", 3));
for (const char* text : {"get fridge", "set frequency 910", "del frx.3", "get frx"})
EXPECT_FALSE(rules.handleCommand(text, reply));
}
TEST_F(TransportFilter, OversizedCompactReplyNeverReturnsPartialSetter) {
// A valid short input can fit in 192 command bytes but exceed a 160-byte reply.
const std::string channel = "#" + std::string(30, 'b'), name(30, 'a');
std::string cmd = "set fr.3 any 10+ c=" + channel + " p=112233,445566,778899 i=s:" + name
+ " s=" + name + " q=65534 pri=255 s f=str";
ASSERT_LT(cmd.size() - strlen("set fr.3 "), 192U);
command(cmd.c_str()); ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
command("get fr.3");
EXPECT_STREQ("Err - compact command exceeds reply size", reply);
EXPECT_EQ(nullptr, strstr(reply, "set fr."));
}
TEST_F(TransportFilter, PrivateKeyReferencesCopyLocallyWithoutDisclosingKey) {
const std::string key(64, '1');
command(("set fr.3 any c=" + key + " m=bc d").c_str());
ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
const auto saved = fs.files["/flood_filter"];
command("get fr.3");
const std::string paste = reply;
ASSERT_NE(std::string::npos, paste.find("c=key:"));
EXPECT_EQ(std::string::npos, paste.find(key));
command(paste.c_str()); ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
EXPECT_EQ(saved, fs.files["/flood_filter"]);
std::string copy = paste; copy[7] = '4'; // set fr.4 ...
command(copy.c_str()); ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
command("del fr.3");
command(paste.c_str()); ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
command("del fr all");
const auto empty = fs.files["/flood_filter"];
command(paste.c_str()); EXPECT_EQ(0, strncmp(reply, "Err - unknown", 13)) << reply;
EXPECT_EQ(empty, fs.files["/flood_filter"]);
}
TEST(TransportModes, KeyFingerprintCollisionsCannotSelectAnArbitrarySecret) {
struct Row {
bool active = true;
uint8_t channel_key_len = 16, channel_hash = 1;
uint8_t channel_secret[32] = {};
char channel_name[32] = "key:12345678";
} rows[2];
uint8_t key_len = 0, hash = 0, secret[32] = {};
char name[32] = {};
EXPECT_TRUE(FloodRuleCLI::copyChannelReference("KEY:12345678", rows, 2,
key_len, hash, secret, name, sizeof(name)));
rows[1].channel_secret[0] = 9;
EXPECT_FALSE(FloodRuleCLI::copyChannelReference("key:12345678", rows, 2,
key_len, hash, secret, name, sizeof(name)));
EXPECT_FALSE(FloodRuleCLI::copyChannelReference("key:unknown", rows, 2,
key_len, hash, secret, name, sizeof(name)));
}
+210
View File
@@ -0,0 +1,210 @@
"""Execute real bridge admission methods and check boot/restart policy wiring."""
from pathlib import Path
import subprocess
import tempfile
import unittest
import re
from test_radio_receive_contract import method
ROOT = Path(__file__).resolve().parents[1]
PACKET = r'''
#pragma once
#include <cstdint>
#include <cstddef>
#include <cstring>
namespace mesh {
struct Packet {
uint8_t payload[64] = {}, payload_len = 32, type = 5;
uint8_t getPayloadType() const { return type; }
int getRawLength() const { return 32; }
uint16_t writeTo(uint8_t* bytes) const { memcpy(bytes,payload,32);return 32; }
};
}
'''
HARNESS = r'''
#include <helpers/AbstractBridge.h>
#include <cassert>
#define BRIDGE_DEBUG_PRINTLN(...) ((void)0)
#define PUB_KEY_SIZE 32
#define PAYLOAD_TYPE_ADVERT 4
#define MAX_SERIAL_PACKET_SIZE 300
#define MAX_TRANS_UNIT 256
#define SERIAL_OVERHEAD 6
#define BRIDGE_PACKET_MAGIC 0xabcd
#define BRIDGE_MAGIC_SIZE 2
#define BRIDGE_CHECKSUM_SIZE 2
#define MAX_ESPNOW_PACKET_SIZE 256
unsigned millis() { return 100; }
namespace mesh {
namespace espnow {
struct ESPNowRawFrames {uint8_t count=0;uint16_t lengths[2]={};uint8_t data[2][256]={};};
bool encodeEspNowRawFrames(const uint8_t*,size_t,ESPNowRawFrames& f) {f.count=1;return true;}
}
namespace bridge {
enum {ESPNOW_FORMAT_RAW=0};
size_t espNowMaxMeshPacketSize(int) {return 256;}
const char* espNowFormatName(int) {return "raw";}
}
}
struct Seen {
unsigned checks=0,marks=0;
bool wasSeen(mesh::Packet*) {++checks;return false;}
void markSeen(mesh::Packet*) {++marks;}
};
struct Manager {
unsigned freed=0,queued=0;
void free(mesh::Packet*) {++freed;}
void queueInbound(mesh::Packet*,unsigned) {++queued;}
};
struct Prefs {unsigned bridge_delay=10;};
struct BridgeBase : AbstractBridge {
bool _initialized=true;
Seen _seen_packets;
Manager manager; Manager* _mgr=&manager;
Prefs prefs; Prefs* _prefs=&prefs;
unsigned queued=0;
void begin() override {} void end() override {} void loop() override {}
bool isRunning() const override {return _initialized;}
void sendPacket(mesh::Packet*) override {}
void onPacketReceived(mesh::Packet* p) override {handleReceivedPacket(p);}
void handleReceivedPacket(mesh::Packet*);
uint16_t fletcher16(const uint8_t*,size_t) {return 0;}
};
struct Serial {unsigned writes=0;void write(const uint8_t*,size_t) {++writes;}};
struct RS232Bridge : BridgeBase {
Serial serial; Serial* _serial=&serial;
void sendPacket(mesh::Packet*) override;
};
struct ESPNowBridge : BridgeBase {
int _active_format=0;
bool xorCrypt(uint8_t*,size_t) {return true;}
bool queueTransmitFrames(mesh::espnow::ESPNowRawFrames&,const mesh::Packet&) {++queued;return true;}
void sendPacket(mesh::Packet*) override;
};
struct MQTTBridge : BridgeBase {
struct Obs {bool mqtt_packets_enabled=true,mqtt_rx_enabled=true;uint8_t mqtt_tx_enabled=1;} obs;
Obs* _obs=&obs;
struct Identity {uint8_t pub_key[32]={};} id;
Identity* _identity=&id;
unsigned _filtered_packets=0;
bool _staged_raw_valid=false;
bool shouldQueuePacketType(uint8_t,bool&) {return true;}
void queuePacket(mesh::Packet*,bool) {++queued;}
void onPacketReceived(mesh::Packet*) override;
void sendPacket(mesh::Packet*) override;
};
@METHODS@
int main() {
bool allowed=false;
auto filter=[](void* context,const mesh::Packet*) {return *static_cast<bool*>(context);};
mesh::Packet p;
BridgeBase inbound; RS232Bridge serial; ESPNowBridge espnow; MQTTBridge mqtt;
AbstractBridge* bridges[] = {&inbound,&serial,&espnow,&mqtt};
for (AbstractBridge* b : bridges)
b->setPacketFilter(filter,&allowed);
// RX denial frees exactly once, before the seen table or mesh inbound queue.
inbound.onPacketReceived(&p);
assert(inbound.manager.freed==1 && inbound.manager.queued==0);
assert(inbound._seen_packets.checks==0 && inbound._seen_packets.marks==0);
serial.sendPacket(&p);espnow.sendPacket(&p);mqtt.sendPacket(&p);mqtt.onPacketReceived(&p);
assert(serial.serial.writes==0 && espnow.queued==0 && mqtt.queued==0);
assert(serial._seen_packets.marks==0 && espnow._seen_packets.marks==0);
assert(mqtt._filtered_packets==2);
// Removing the drop immediately restores every path, including wrapped ESP-NOW.
allowed=true;
inbound.onPacketReceived(&p);serial.sendPacket(&p);espnow.sendPacket(&p);
espnow._active_format=1;espnow.sendPacket(&p);
mqtt.onPacketReceived(&p);mqtt.sendPacket(&p);
assert(inbound.manager.queued==1 && inbound._seen_packets.marks==1);
assert(serial.serial.writes==1 && espnow.queued==2 && mqtt.queued==2);
assert(serial._seen_packets.marks==1 && espnow._seen_packets.marks==2);
BridgeBase no_policy;no_policy.onPacketReceived(&p);assert(no_policy.manager.queued==1);
}
'''
class TransportFilterWiring(unittest.TestCase):
def test_temporary_filter_suspension_checks_both_profiles_in_all_role_paths(self):
repeater = (ROOT/'examples/simple_repeater/MyMesh.cpp').read_text()
suspension = re.findall(r'entry\.suspend_on_temp_radio && (\w+)\(\)', repeater)
self.assertEqual(suspension, ['isAnyTempRadioActive', 'isAnyTempRadioActive'])
room = (ROOT/'examples/simple_room_server/MyMesh.cpp').read_text()
evaluations = re.findall(r'flood_rules\.evaluate\(\s*\w+,\s*(\w+)\(\)', room)
self.assertEqual(evaluations, ['isAnyTempRadioActive'] * 3)
def test_shorthand_uses_the_existing_filter_manager_permissions(self):
source = (ROOT/'examples/simple_repeater/MyMesh.cpp').read_text()
helpers = '\n'.join(method(source, signature) for signature in [
'static bool commandFamilyMatches(', 'static bool isCommonManagerReadOnlyAllowed(',
'static bool isRegionMgrAllowed(', 'static bool isFilterMgrAllowed('])
code = '#include <cstring>\n#include <cassert>\n#include <initializer_list>\n' + helpers + r'''
int main() {
for (const char* cmd : {"get fr", "get fr.3", "set fr.3 any m=bc d", "del fr.3", "del fr all"}) {
assert(isFilterMgrAllowed(cmd) == (MESH_ENABLE_FLOOD_RULE_ENGINE != 0));
assert(!isRegionMgrAllowed(cmd));
}
for (const char* cmd : {"get fridge", "set frwifi.password secret", "get wifi.password", "del frx"})
assert(!isFilterMgrAllowed(cmd));
}
'''
with tempfile.TemporaryDirectory() as directory:
work = Path(directory)
(work/'test.cpp').write_text(code)
for enabled in [0, 1]:
subprocess.run(['g++', '-std=c++17', f'-DMESH_ENABLE_FLOOD_RULE_ENGINE={enabled}',
str(work/'test.cpp'), '-o', str(work/'test')], check=True)
subprocess.run([str(work/'test')], check=True)
for verb, action in [('get', 'formatFloodPacketFilters'), ('set', 'setFloodPacketFilter'),
('del', 'deleteFloodPacketFilter')]:
anchor = f'commandFamilyMatches(command, "{verb} fr")'
dispatch = source[source.index(anchor):].split('} else if', 1)[0]
self.assertIn(action, dispatch)
def test_production_bridge_admission(self):
methods = []
for name, signatures in {
'BridgeBase': ['void BridgeBase::handleReceivedPacket('],
'RS232Bridge': ['void RS232Bridge::sendPacket('],
'ESPNowBridge': ['void ESPNowBridge::sendPacket('],
'MQTTBridge': ['void MQTTBridge::sendPacket(', 'void MQTTBridge::onPacketReceived('],
}.items():
source = (ROOT/f'src/helpers/bridges/{name}.cpp').read_text()
methods.extend(method(source, signature) for signature in signatures)
with tempfile.TemporaryDirectory() as directory:
work = Path(directory)
(work/'Mesh.h').write_text(PACKET)
code = '#include <initializer_list>\n' + HARNESS.replace('@METHODS@', '\n'.join(methods))
(work/'test.cpp').write_text(code)
result = subprocess.run(['g++', '-std=c++17', '-Wall', '-Wextra', '-Werror',
'-I', str(work), '-I', str(ROOT/'src'), str(work/'test.cpp'),
'-o', str(work/'test')], text=True, capture_output=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
subprocess.run([str(work/'test')], check=True)
def test_roles_wire_filter_at_every_bridge_start_and_cross_hook(self):
for role in ['simple_repeater', 'simple_room_server']:
header = (ROOT/f'examples/{role}/MyMesh.h').read_text()
source = (ROOT/f'examples/{role}/MyMesh.cpp').read_text()
for text in [header, source]:
lines = text.splitlines()
for index, line in enumerate(lines):
if 'bridge->begin();' in line:
self.assertIn('configureBridgeFilter(', lines[index-1], role)
cross = method(header, 'bool allowRadioProfileCross(')
self.assertIn('allowTransportPacket', cross)
admission = method(source, 'bool MyMesh::allowTransportPacket(')
self.assertIn('incoming_region, context)', admission)
self.assertNotIn('recv_pkt_filter_match_mask =', admission)
self.assertNotIn('recv_pkt_rule_match_mask =', admission)
source = (ROOT/'examples/simple_repeater/MyMesh.cpp').read_text()
defaults = method(source, 'void MyMesh::seedDefaultFloodPacketFilters(')
self.assertIn('flood_packet_filters[2]', defaults)
self.assertIn('bridge_wardriving = wardriving', defaults)
self.assertIn('bridge_wardriving.min_hops = 0', defaults)
self.assertIn('RULE_MODE_BRIDGE', defaults)
self.assertIn('RULE_MODE_CROSS', defaults)
self.assertNotIn('flood_packet_filters[3]', defaults)
if __name__ == '__main__':
unittest.main()