Extend forwarding filters and save moderation rules atomically

This commit is contained in:
mikecarper committed 2026-10-21 01:10:12 -07:00
1 parent 585393e36c
commit 953ad6defb
31 files changed
+3453 -342

No files matched your search

+2 -1
View File
@@ -71,7 +71,8 @@ class ClientAclCliTest(unittest.TestCase):
sources = {role: path.read_text() for role, path in ROLES.items()}
utils = (ROOT / "src/Utils.cpp").read_text()
hex_chars = re.search(r"static const char hex_chars\[\].*;", utils).group()
generated = "namespace mesh {\n" + hex_chars + "\n"
generated = f'#include "{(ROOT / "src/helpers/FloodCommandAliases.h").as_posix()}"\n'
generated += "namespace mesh {\n" + hex_chars + "\n"
generated += extract_braced(utils, "void Utils::toHex(") + "\n"
for signature in ("void Utils::printHex(", "static uint8_t hexVal(",
"bool Utils::isHexChar(", "bool Utils::fromHex("):
+6 -6
View File
@@ -357,7 +357,7 @@ int main() {
"ENABLE_OTA": 1, "OTA_HEAP_CONTEXT": 1,
}, "GEPRC_Linkflow_900_repeater")
self.assertEqual(policy["components"]["client_table"], 32 * 320 + 16)
self.assertEqual(policy["components"]["flood_filter_table"], 63 * 192 + 16)
self.assertEqual(policy["components"]["flood_filter_table"], 63 * 336 + 16)
self.assertEqual(policy["components"]["ota_context"], 16384 + 16)
self.assertGreaterEqual(policy["required_contiguous_bytes"], 16384 + 16)
reduced = ram.requirements("STM32_PLATFORM", {
@@ -589,7 +589,7 @@ int main() {
self.assertEqual(components["display_pixels_and_driver"], 2048)
self.assertEqual(combined["required_heap_bytes"] - legacy["required_heap_bytes"], 3072 - 2048)
self.assertEqual(components["loop_and_callback_stacks"], 8192 + 3072)
self.assertEqual(components["flood_filter_table"], 63 * 192 + 16)
self.assertEqual(components["flood_filter_table"], 63 * 336 + 16)
# An alternate port selects the same bridge instead of allocating two.
alternate = ram.requirements("NRF52_PLATFORM", {
**defines, "RAK4631_COMBINED_ETHERNET": 1,
@@ -700,7 +700,7 @@ int main() {
alias = ram.requirements("ESP32_PLATFORM", {"MESH_CLIENT_REPEATER_ONLY": 1},
"custom_companion_alias")
self.assertEqual(alias["components"]["client_table"], 32 * 284 + 16)
self.assertEqual(alias["components"]["flood_filter_table"], 63 * 192 + 16)
self.assertEqual(alias["components"]["flood_filter_table"], 63 * 336 + 16)
self.assertEqual(alias["components"]["neighbor_history"], 2048 * 9 + 16)
self.assertEqual(alias["components"]["radio_packet_pool"], 10240)
@@ -732,13 +732,13 @@ int main() {
}
full = ram.requirements("ESP32_PLATFORM", defines, "Heltec_v3_repeater_observer_mqtt")
parts = full["components"]
self.assertEqual(full["required_heap_bytes"], 209696)
self.assertEqual(full["required_heap_bytes"], 218768)
self.assertEqual(parts["display_pixels_and_driver"], 2048)
self.assertEqual(parts["uart_bridge_and_driver"], 3072 + 4096)
self.assertEqual(parts["wireless_stacks"], 49152)
self.assertEqual(parts["mqtt_startup_allowance"], 24576)
self.assertEqual(parts["allocation_and_transient_margin"], 16384)
self.assertEqual(full["required_contiguous_bytes"], 18464)
self.assertEqual(full["required_contiguous_bytes"], 21184)
for driver, expected in (("SH1106Display", 2048), ("SH1107Display", 4096),
("ST7735Display", 25602)):
with self.subTest(driver=driver):
@@ -749,7 +749,7 @@ int main() {
self.assertEqual(other["components"], {
**parts, "display_pixels_and_driver": expected,
})
self.assertEqual(other["required_contiguous_bytes"], max(18464, expected))
self.assertEqual(other["required_contiguous_bytes"], max(21184, expected))
def test_sh1106_full_mqtt_release_profile_is_linked_in_ci(self):
workflow = (ROOT / ".github/workflows/run-unit-tests.yml").read_text()
@@ -117,6 +117,8 @@ TEST_F(FleetCommandTest, ExplicitRadioAndFilterFamiliesAreAllowed) {
"del flood.filter.blacklist.2", "del flood.filter all",
"get flood.rule.2", "set flood.rule.2 type=any rate=10/min stop",
"del flood.rule.2", "del flood.rule all",
"get fr", "get fr.2", "set fr.2 any c=public q=10 s",
"del fr.2", "del fr all",
"get flood.moderation.2", "set flood.moderation public '*' rate=2/min",
"del flood.moderation all", "get flood.channel.scope.3",
"set flood.channel.scope public scope=usa", "del flood.channel.scope.3",
@@ -246,6 +248,9 @@ TEST_F(FleetCommandTest, NoGenericAdminNamespaceOrPrefixEscapes) {
"set tx.reply both", "get tempradio", "set tempradio 910.5,500,5,5,10",
"get flood.filter.secret", "get flood.filtering", "get flood.rulebook",
"get flood.rule.1.secret", "get flood.filter.blacklistx",
"get fridge", "get fr.secret", "get fr.1.secret", "get fr.0",
"get fr.01", "get fr.256", "get fr.9999999999", "get fr.2 all",
"set frwifi.password secret", "set fr.2", "del fr.2 extra",
"get flood.channel.scope.required", "get flood.channel.data.secret",
"get flood.max.secret", "get flood.max.unscoped.extra", "get flood.rule.0",
"get flood.rule.01", "get flood.rule.256", "get flood.rule.9999999999",
+45
View File
@@ -377,6 +377,51 @@ class FleetCommandCryptoTests(unittest.TestCase):
with self.subTest(command=command):
self.assertEqual(self.decode(envelope(command)), "reject")
def test_filter_shorthand_uses_the_same_signed_management_envelope(self):
for command in ["get fr", "get fr.2", "get fr.2 n", "get flood.rule.2 text",
"set fr.2 any c=public q=10 s",
"del fr.2", "del fr all", "get fm", "get fs.1", "get fsr.1",
"get fb", "set fm.2 public Alice drop", "set fs.2 public scope=local",
"set fsr.2 public", "set fb.2 AABBCC", "del fm.2", "del fs.2",
"del fsr.2", "del fb.2", "get fa", "get fa 2", "get fa key 1",
"clear fa all", "clear flood.advert " + "11" * 32]:
with self.subTest(command=command):
expected = envelope(command)
actual = self.run_tool("encode", CHANNEL.hex(), "all", NOW, NOW + 120, command)
self.assertEqual(actual, expected.hex())
self.assertEqual(self.decode(expected), f"{NOW}\n{NOW + 120}\n{command}")
for command in ["get fridge", "get fr.secret", "get fr.1.secret", "get fr.0",
"get fr.01", "get fr.256", "get fr.9999999999", "get fr.2 all",
"set frwifi.password secret", "set fr.2", "del fr.2 extra",
"get fmore", "get fsr.secret", "get fb.0", "set fa off",
"clear fa", "clear fa 0102", "clear fa ALL", "get fa key",
"get fa 0", "get fa 01", "get fa 65536", "get fa key 1 extra"]:
with self.subTest(command=command):
self.assertEqual(self.decode(envelope(command)), "reject")
def test_filter_patterns_preserve_alternation_and_escapes_without_new_command_namespaces(self):
for family in ["fr.4", "flood.rule.4", "flood.filter.4"]:
for fields in [r'sender=^(Alice|Bob)$ text=^BEACON\.[0-9]+$',
r'n="^(Alice|Bob) Smith$" x="^RX in place\?"',
r'n=^A\|B$ x="a\\b"']:
command = f"set {family} any c=public {fields} d"
with self.subTest(command=command):
self.assertEqual(self.decode(envelope(command)), f"{NOW}\n{NOW + 120}\n{command}")
self.assertEqual(self.run_tool("encode", CHANNEL.hex(), "all", NOW, NOW + 120, command),
envelope(command).hex())
for command in [r'set flood.rule.1 any c=#a|reboot d',
r'set fr.1 any c=#a\reboot d', r'set fr.1 any x="a|b" c=#a|reboot d',
r'set fr.1 any n="a|b"\reboot d', r'set fr.1 any n="a|b d',
r'set fr.1 any n="a|b"reboot d', r'set fm.1 public a|b drop',
r'set flood.channel.scope public scope=a|b',
r'set fr.1 any text="a;reboot" d', r'set fr.1 any text="a&b" d',
r'set fr.1 any text="a`b" d', r'get radio2|reboot',
r'set flood.max a|b', r'get fr.1|reboot']:
with self.subTest(command=command):
self.assertEqual(self.decode(envelope(command)), "reject")
def test_scheduled_radio_commands_have_cross_language_real_signatures(self):
for command in [
"set radioat 910.5,500,5,5,+1,auto",
+1
View File
@@ -363,6 +363,7 @@ class FleetRoleWiringTests(unittest.TestCase):
common = body(management, "bool CommonCLI::handleFleetCommand(")
boot = body(body(management, "void CommonCLI::beginManagement("),
"if (_callbacks->supportsFleetControl()")
helpers = f'#include "{(ROOT / "src/helpers/FloodCommandAliases.h").as_posix()}"\n' + helpers
harness = AUTHORIZATION.replace("@MANAGER_HELPERS@", helpers)
harness = harness.replace("@COMMON_FLEET@", common).replace("@MANAGER_GUARD@", guard)
harness = harness.replace("@BOOT_FLEET@", boot)
+469
View File
@@ -0,0 +1,469 @@
"""Execute real moderation decoding and FGM1 saves with reset and I/O faults."""
from pathlib import Path
import os
import re
import shutil
import subprocess
import tempfile
import unittest
from test_radio_receive_contract import method
from test_serial_wifi_sessions import SANITIZER_FLAGS
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <helpers/FloodGroupPlainText.h>
#include <algorithm>
#include <cassert>
#include <cstdio>
#include <map>
#include <stdexcept>
#include <string>
#include <vector>
#define PUB_KEY_SIZE 32
#define CIPHER_KEY_SIZE 16
#define CIPHER_BLOCK_SIZE 16
#define CIPHER_MAC_SIZE 2
#define PATH_HASH_SIZE 1
#define MAX_PACKET_PAYLOAD 250
#define TXT_TYPE_PLAIN 0
#define PAYLOAD_TYPE_GRP_TXT 5
#define FLOOD_GROUP_MODERATION_SLOTS 16
#define FLOOD_CHANNEL_KEY_PREFIX_LEN 8
#define FLOOD_GROUP_MODERATION_NAME_LEN 32
#define FLOOD_GROUP_MODERATION_PATH_HOPS_MAX 3
#define FLOOD_GROUP_MODERATION_PATH_BYTES_MAX 9
#define FLOOD_GROUP_MODERATION_HOPS_ALL 255
#define FLOOD_GROUP_MODERATION_RATE_UNLIMITED 65535
#define FLOOD_PACKET_FILTER_MAX_HOPS 63
#define FLOOD_CHANNEL_SCOPE_SLOTS 31
#define FLOOD_RETRY_BRIDGE_BUCKETS 6
#define FLOOD_GROUP_MODERATION_FILE "/flood_grp_mod"
#define FLOOD_GROUP_MODERATION_TEMP_FILE "/flood_grp_mod.tmp"
#define FLOOD_GROUP_MODERATION_BACKUP_FILE "/flood_grp_mod.bak"
#define FILE_O_WRITE 1
#define MESH_DEBUG_PRINTLN(...) ((void)0)
struct PowerCut {};
struct MockFS;
struct File {
MockFS* fs = nullptr;
std::string path;
size_t cursor = 0;
bool valid = false;
explicit operator bool() const { return valid; }
size_t size() const;
size_t read(uint8_t*, size_t);
size_t write(const uint8_t*, size_t);
void close();
void flush();
};
struct MockFS {
std::map<std::string, std::vector<uint8_t>> files;
unsigned operations = 0, cut_after = 0;
bool short_write = false, corrupt_write = false;
std::string fail_open_path, fail_read_path, fail_rename_from;
std::string false_after_rename, noop_rename_from, corrupt_rename_to;
void checkpoint() { if (++operations == cut_after) throw PowerCut{}; }
bool exists(const char* path) { bool found = files.count(path); checkpoint(); return found; }
bool remove(const char* path) { bool removed = files.erase(path); checkpoint(); return removed; }
bool rename(const char* from, const char* to) {
if (fail_rename_from == from) { checkpoint(); return false; }
if (noop_rename_from == from) { checkpoint(); return true; }
if (!files.count(from) || files.count(to)) { checkpoint(); return false; }
files[to] = files[from]; files.erase(from);
if (corrupt_rename_to == to) files[to][0] ^= 0x40;
checkpoint(); return false_after_rename != from;
}
File open(const char* path, const char* mode, bool = false) {
if (fail_open_path == path) { checkpoint(); return {}; }
if (*mode == 'w') files[path].clear();
File file{this, path, 0, files.count(path) != 0}; checkpoint(); return file;
}
File open(const char* path, int mode) { return open(path, mode ? "w" : "r"); }
};
size_t File::size() const { return fs->files.at(path).size(); }
size_t File::read(uint8_t* out, size_t size) {
if (fs->fail_read_path == path) { fs->checkpoint(); return 0; }
size = std::min(size, fs->files[path].size() - cursor);
memcpy(out, fs->files[path].data() + cursor, size); cursor += size;
fs->checkpoint(); return size;
}
size_t File::write(const uint8_t* in, size_t size) {
if (fs->short_write) size /= 2;
auto& bytes = fs->files[path]; bytes.insert(bytes.end(), in, in + size);
if (fs->corrupt_write && size) bytes.back() ^= 0x80;
fs->checkpoint(); return size;
}
void File::close() { fs->checkpoint(); }
void File::flush() { fs->checkpoint(); }
#define FILESYSTEM MockFS
namespace mesh {
struct Packet {
uint8_t payload[MAX_PACKET_PAYLOAD] = {}, payload_len = 19;
uint8_t hops = 2, path[9] = {}, width = 1, type = PAYLOAD_TYPE_GRP_TXT;
bool flood = true;
bool isRouteFlood() const { return flood; }
uint8_t getPayloadType() const { return type; }
uint8_t getPathHashCount() const { return hops; }
uint8_t getPathHashSize() const { return width; }
};
static std::vector<uint8_t> plaintext;
static bool authenticated = true;
struct Utils {
static bool isHexChar(char c) { return (c>='0'&&c<='9')||(c>='A'&&c<='F')||(c>='a'&&c<='f'); }
static bool fromHex(uint8_t* dest, size_t count, const char* text) {
for(size_t i=0;i<count;++i) {
unsigned value=0;if(sscanf(text+i*2,"%2x",&value)!=1)return false;dest[i]=uint8_t(value);
} return true;
}
static void toHex(char* dest, const uint8_t* bytes, size_t size) {
for(size_t i=0;i<size;++i)sprintf(dest+i*2,"%02X",bytes[i]);
}
static void sha256(uint8_t* dest, size_t length, const uint8_t*, size_t) { memset(dest, 0x11, length); }
static int MACThenDecrypt(const uint8_t*, uint8_t* dest, const uint8_t*, size_t) {
if (!authenticated) return 0;
memcpy(dest, plaintext.data(), plaintext.size()); return int(plaintext.size());
}
};
}
struct Clock { uint32_t now = 10; uint32_t getMillis() const { return now; } };
namespace FloodFilterPolicy {
enum {SCOPE_PATH_NONE=0,SCOPE_PATH_BLACKLIST=1,SCOPE_PATH_BRIDGE_BUCKET_BASE=2};
}
static const char FLOOD_CHANNEL_SCOPE_USAGE[]="Err - scope usage";
struct MyMesh {
@ENTRY@
MockFS* _fs;
Clock clock; Clock* _ms = &clock;
FloodGroupModerationEntry flood_group_moderation[FLOOD_GROUP_MODERATION_SLOTS] = {};
explicit MyMesh(MockFS& fs) : _fs(&fs) {}
bool readFloodGroupModerationFile(const char*, FloodGroupModerationEntry*, bool* = nullptr) const;
bool recoverFloodGroupModeration();
void loadFloodGroupModeration();
bool saveFloodGroupModeration();
bool decodeFloodGroupPlainText(const mesh::Packet*,const uint8_t*,uint8_t,uint32_t&,char*,size_t) const;
bool decodeFloodGroupPlainTextSpan(const mesh::Packet*,const uint8_t*,uint8_t,uint8_t*,size_t,mesh::FloodGroupPlainText::View&) const;
bool shouldBlockFloodGroupTextForward(const mesh::Packet*);
void setFloodGroupModeration(const char*,char*);
void setFloodChannelScope(const char*,char*);
uint8_t scope_path=0;bool scope_slow=false;
void formatFloodGroupModerationDetail(int,char* reply,size_t size) const { snprintf(reply,size,"> saved"); }
};
namespace StrHelper { void strncpy(char* dest,const char* source,size_t size) { snprintf(dest,size,"%s",source); } }
const uint8_t FLOOD_PUBLIC_CHANNEL_SECRET[16]={};
@SPACES@
@ASCII@
@UNSIGNED@
@MOD_UNSIGNED@
@TOKEN@
@CHANNEL@
@MOD_PATH@
@NAME_VALID@
@SCOPE_INDEX@
static File openFloodSettingsRead(FILESYSTEM* fs, const char* path) { return fs->open(path,"r"); }
@WRITE@
@HASH@
@VERIFY@
@PATH@
@READ@
@RECOVER@
@LOAD@
@SAVE@
@DECODE@
@SPAN@
@BLOCK@
@SET@
@SET_SCOPE@
static const char* target = FLOOD_GROUP_MODERATION_FILE;
static const char* scratch = FLOOD_GROUP_MODERATION_TEMP_FILE;
static const char* backup = FLOOD_GROUP_MODERATION_BACKUP_FILE;
void configure(MyMesh& mesh, const char* name, uint16_t rate = 65535, uint8_t hops = 2) {
auto& entry = mesh.flood_group_moderation[0];
entry = {}; entry.active = true; entry.key_len = 16;
memset(entry.hash_prefix, 0x11, sizeof(entry.hash_prefix));
strcpy(entry.channel_name, "public"); strcpy(entry.sender, name);
entry.max_hops = hops; entry.rate_per_minute = rate;
}
std::vector<uint8_t> plain(const std::string& text) {
std::vector<uint8_t> bytes = {0x12,0x34,0x56,0x78,0};
bytes.insert(bytes.end(),text.begin(),text.end()); return bytes;
}
mesh::Packet packet(const std::string& name) {
mesh::plaintext = plain(name + ": body");
mesh::Packet packet; packet.payload[0] = 0x11;
packet.payload_len = uint8_t(3 + ((mesh::plaintext.size() + 15) / 16) * 16);
mesh::authenticated = true; return packet;
}
std::vector<uint8_t> golden(MyMesh& mesh, unsigned count = 16) {
std::vector<uint8_t> bytes = {'F','G','M','1',uint8_t(count)};
auto append = [&bytes](const void* ptr, size_t size) {
const auto* in = static_cast<const uint8_t*>(ptr); bytes.insert(bytes.end(),in,in+size);
};
for (unsigned i=0;i<count;++i) {
const auto& entry = mesh.flood_group_moderation[i];
bytes.push_back(entry.active); bytes.push_back(entry.key_len);
append(entry.secret,32); append(entry.channel_name,32); append(entry.sender,32);
bytes.push_back(entry.path_hash_size); bytes.push_back(entry.path_hops);
append(entry.path,9); bytes.push_back(entry.max_hops);
bytes.push_back(uint8_t(entry.rate_per_minute)); bytes.push_back(uint8_t(entry.rate_per_minute >> 8));
}
return bytes;
}
void spans() {
using namespace mesh::FloodGroupPlainText;
View view;
for (size_t size : {size_t(1),size_t(31),size_t(32),size_t(200)}) {
auto bytes = plain(std::string(size,'A') + " : body: more");
assert(parse(bytes.data(),bytes.size(),0,view) && view.has_sender);
assert(view.sender.size == size && view.text.size == 10);
assert(std::string((const char*)view.text.data,view.text.size) == "body: more");
}
for (const std::string& text : std::vector<std::string>{"no colon",": empty", " : empty", "Al\nice: body", std::string("A\0lice: body",12)}) {
auto bytes = plain(text); assert(parse(bytes.data(),bytes.size(),0,view) && !view.has_sender);
}
auto bytes = plain("Alice: "); assert(parse(bytes.data(),bytes.size(),0,view));
assert(senderEquals(view,"ALICE") && !senderEquals(view,"AliceX") && view.text.size == 0);
bytes=plain("Alice: body");bytes.resize(32,0);
assert(parse(bytes.data(),bytes.size(),0,view) && view.text.size==4);
bytes[4] = 4; assert(!parse(bytes.data(),bytes.size(),0,view));
for (size_t size=0;size<=5;++size) assert(!parse(bytes.data(),size,0,view));
assert(!parse(nullptr,100,0,view));
}
void moderation() {
MockFS fs; MyMesh mesh(fs); configure(mesh,"*");
for (size_t size : {size_t(31),size_t(32),size_t(200)}) {
auto p = packet(std::string(size,'A')); assert(mesh.shouldBlockFloodGroupTextForward(&p));
p.hops = 1; assert(!mesh.shouldBlockFloodGroupTextForward(&p));
}
configure(mesh,"Alice",0,255);
auto p = packet("aLiCe "); assert(mesh.shouldBlockFloodGroupTextForward(&p));
uint8_t key[32]={}; uint32_t timestamp=0; char sender[32];
assert(mesh.decodeFloodGroupPlainText(&p,key,16,timestamp,sender,sizeof(sender)));
assert(!strcmp(sender,"aLiCe") && timestamp==0x78563412);
configure(mesh,std::string(31,'A').c_str(),0,255);
p=packet(std::string(32,'A')); assert(!mesh.shouldBlockFloodGroupTextForward(&p));
assert(!mesh.decodeFloodGroupPlainText(&p,key,16,timestamp,sender,sizeof(sender)) && !sender[0]);
configure(mesh,"*"); p=packet("Alice"); mesh::authenticated=false;
assert(!mesh.shouldBlockFloodGroupTextForward(&p)); mesh::authenticated=true;
for (const std::string name : {"A\nlice", "", " "}) {
p=packet(name); assert(!mesh.shouldBlockFloodGroupTextForward(&p));
}
p=packet("Alice"); mesh::plaintext=plain("no colon");
assert(!mesh.shouldBlockFloodGroupTextForward(&p));
p=packet("Alice"); p.flood=false; assert(!mesh.shouldBlockFloodGroupTextForward(&p));
p.flood=true; p.type=6; assert(!mesh.shouldBlockFloodGroupTextForward(&p));
p.type=5; p.payload[0]^=1; assert(!mesh.shouldBlockFloodGroupTextForward(&p));
p.payload[0]^=1; p.payload_len-=1; assert(!mesh.shouldBlockFloodGroupTextForward(&p));
p=packet("Alice"); uint8_t too_small[15]; mesh::FloodGroupPlainText::View view;
assert(!mesh.decodeFloodGroupPlainTextSpan(&p,key,16,too_small,sizeof(too_small),view));
}
void formats() {
MockFS fs; MyMesh mesh(fs); configure(mesh,"Alice",3,5);
assert(mesh.saveFloodGroupModeration()); const auto bytes=fs.files[target];
assert(bytes==golden(mesh) && bytes.size()==1797);
for(unsigned count=0;count<=16;++count) {
fs.files[target]=golden(mesh,count); MyMesh boot(fs); boot.loadFloodGroupModeration();
assert(boot.flood_group_moderation[0].active==(count!=0));
}
for(size_t size=0;size<bytes.size();++size) {
fs.files.clear();fs.files[target]={bytes.begin(),bytes.begin()+size};
MyMesh boot(fs);boot.loadFloodGroupModeration();assert(!boot.flood_group_moderation[0].active);
}
for(unsigned invalid : {0U,4U,5U,6U,39U,71U,103U,104U,114U}) {
fs.files.clear();fs.files[target]=bytes;
if(invalid==39||invalid==71) std::fill(fs.files[target].begin()+invalid,fs.files[target].begin()+invalid+32,'X');
else fs.files[target][invalid]=invalid==114?64:0xff;
MyMesh boot(fs);boot.loadFloodGroupModeration();assert(!boot.flood_group_moderation[0].active);
}
fs.files.clear();fs.files[target]=bytes;fs.files[target].push_back(0);
MyMesh boot(fs);boot.loadFloodGroupModeration();assert(!boot.flood_group_moderation[0].active);
}
void failures() {
MockFS base; MyMesh initial(base); configure(initial,"Alice",3,5); assert(initial.saveFloodGroupModeration());
const auto old=base.files[target];
for(unsigned fault=0;fault<9;++fault) {
MockFS fs;fs.files=base.files;MyMesh mesh(fs);configure(mesh,"Bob",7,9);
if(fault==0)fs.short_write=true;
if(fault==1)fs.corrupt_write=true;
if(fault==2)fs.fail_open_path=scratch;
if(fault==3)fs.fail_read_path=scratch;
if(fault==4)fs.fail_rename_from=target;
if(fault==5)fs.fail_rename_from=scratch;
if(fault==6)fs.noop_rename_from=target;
if(fault==7)fs.noop_rename_from=scratch;
if(fault==8)fs.corrupt_rename_to=target;
assert(!mesh.saveFloodGroupModeration());
MockFS reboot;reboot.files=fs.files;MyMesh boot(reboot);boot.loadFloodGroupModeration();
assert(boot.flood_group_moderation[0].active && !strcmp(boot.flood_group_moderation[0].sender,"Alice"));
assert(reboot.files[target]==old);
}
for(const char* path : {target,backup}) {
MockFS fs;fs.files=base.files;if(path==backup){fs.files[backup]=old;fs.files.erase(target);}
fs.fail_read_path=path;MyMesh mesh(fs);configure(mesh,"Bob");
assert(!mesh.saveFloodGroupModeration() && fs.files[path]==old);
}
for(const char* path : {target,scratch}) {
MockFS fs;fs.files=base.files;fs.false_after_rename=path;MyMesh mesh(fs);configure(mesh,"Bob");
assert(mesh.saveFloodGroupModeration());MyMesh boot(fs);boot.loadFloodGroupModeration();
assert(!strcmp(boot.flood_group_moderation[0].sender,"Bob"));
}
}
void recovery() {
MockFS original;MyMesh mesh(original);configure(mesh,"Alice",3,5);assert(mesh.saveFloodGroupModeration());
auto old=original.files[target];configure(mesh,"Bob",7,9);assert(mesh.saveFloodGroupModeration());auto next=original.files[target];
for(unsigned state=0;state<6;++state) {
MockFS fs;
if(state==0){fs.files[target]=old;fs.files[scratch]=next;}
if(state==1){fs.files[backup]=old;fs.files[scratch]=next;}
if(state==2){fs.files[target]={'x'};fs.files[backup]=old;fs.files[scratch]=next;}
if(state==3){fs.files[target]=next;fs.files[backup]=old;}
if(state==4){fs.files[scratch]=next;}
if(state==5){fs.files[backup]=old;fs.fail_rename_from=backup;}
MyMesh boot(fs);boot.loadFloodGroupModeration();
assert(boot.flood_group_moderation[0].active);
assert(!strcmp(boot.flood_group_moderation[0].sender,state==3||state==4?"Bob":"Alice"));
if(state==5)assert(fs.files[backup]==old);
}
MockFS completed;completed.files[target]=old;MyMesh save(completed);configure(save,"Bob",7,9);
assert(save.saveFloodGroupModeration());unsigned steps=completed.operations;
for(unsigned cut=1;cut<=steps;++cut) {
MockFS fs;fs.files[target]=old;fs.cut_after=cut;MyMesh saving(fs);configure(saving,"Bob",7,9);
try {saving.saveFloodGroupModeration();} catch(const PowerCut&) {}
MockFS reboot;reboot.files=fs.files;MyMesh boot(reboot);boot.loadFloodGroupModeration();
assert(boot.flood_group_moderation[0].active);
assert(reboot.files[target]==old||reboot.files[target]==next);
}
}
void cli() {
MockFS first,second;MyMesh longform(first),shortform(second);char reply[160];
struct Case {const char* full;const char* compact;};
for(const Case& item : std::vector<Case>{
{" public Alice rate=3/min hops=5 path=A1B2C3,D4E5F6"," public Alice q=3 h=5 p=A1B2C3,D4E5F6"},
{" public Alice rate=7/min hops=all path=*"," public Alice q=7/min h=all p=*"},
{" public Alice drop path=71"," public Alice d p=71"},
{" public 'Field User' rate=8/m hops=0"," public 'Field User' q=8/m h=0"},
{" public '*' hops=2"," public '*' h=2"}}) {
longform.setFloodGroupModeration(item.full,reply);assert(!strncmp(reply,"OK",2));
shortform.setFloodGroupModeration(item.compact,reply);assert(!strncmp(reply,"OK",2));
assert(first.files[target]==second.files[target]);
}
const auto original=second.files[target];
for(const char* invalid : {" public Alice q=1 rate=2/min", " public Alice rate=1/min q=2",
" public Alice d q=2", " public Alice q=2 drop", " public Alice q=", " public Alice q=-1",
" public Alice q=65535", " public Alice q=7/sec", " public Alice q=1/min/min",
" public Alice h=64", " public Alice p=A1,B2C3 d", " public Alice p= d",
" public Alice d extra", " public Alice rate=7"}) {
shortform.setFloodGroupModeration(invalid,reply);assert(!strncmp(reply,"Err",3));
assert(second.files[target]==original);
}
second.short_write=true;shortform.setFloodGroupModeration(" public Alice q=4",reply);
assert(strstr(reply,"save unconfirmed")&&strstr(reply,"reboot may differ"));
for(const Case& item : std::vector<Case>{
{" public scope=local path=blacklist tx=slow"," public scope=local p=bl tx=s"},
{" public scope=local path=bucket:1 tx=fast"," public scope=local p=b1 tx=f"},
{" public scope=local path=bucket:6"," public scope=local p=b6"}}) {
longform.setFloodChannelScope(item.full,reply);assert(!strcmp(reply,"OK"));
shortform.setFloodChannelScope(item.compact,reply);assert(!strcmp(reply,"OK"));
assert(longform.scope_path==shortform.scope_path&&longform.scope_slow==shortform.scope_slow);
}
for(const char* invalid : {" public scope=local p=b0", " public scope=local p=b7",
" public scope=local p=b", " public scope=local p=b1junk", " public scope=local p=blacklist",
" public scope=local p=bl path=bucket:1", " public scope=local tx=s tx=fast",
" public scope=local tx=x", " public scope=local tx=f path=bucket:01x"}) {
shortform.setFloodChannelScope(invalid,reply);assert(!strncmp(reply,"Err",3));
}
}
int main(int argc,char** argv) {
assert(argc==2);std::string which=argv[1];
if(which=="spans")spans();else if(which=="moderation")moderation();
else if(which=="formats")formats();else if(which=="failures")failures();
else if(which=="recovery")recovery();else if(which=="cli")cli();else assert(false);
}
'''
class FloodModerationTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
compiler = os.environ.get('CXX') or shutil.which('g++') or shutil.which('clang++')
if not compiler:
raise unittest.SkipTest('native C++ compiler unavailable')
source = (ROOT / 'examples/simple_repeater/MyMesh.cpp').read_text()
header = (ROOT / 'examples/simple_repeater/MyMesh.h').read_text()
entry = re.search(r' struct FloodGroupModerationEntry \{.*?\n \};', header, re.S).group()
harness = HARNESS.replace('@ENTRY@', entry)
methods = {
'WRITE': 'static File openFloodSettingsWrite(',
'HASH': 'static uint32_t updateFloodSettingsHash(',
'VERIFY': 'static bool verifyFloodSettingsWrite(',
'PATH': 'static bool floodModerationPathMatches(',
'READ': 'bool MyMesh::readFloodGroupModerationFile(',
'RECOVER': 'bool MyMesh::recoverFloodGroupModeration(',
'LOAD': 'void MyMesh::loadFloodGroupModeration(',
'SAVE': 'bool MyMesh::saveFloodGroupModeration(',
'DECODE': 'bool MyMesh::decodeFloodGroupPlainText(',
'SPAN': 'bool MyMesh::decodeFloodGroupPlainTextSpan(',
'BLOCK': 'bool MyMesh::shouldBlockFloodGroupTextForward(',
'SPACES': 'static const char* skipFloodFilterSpaces(',
'ASCII': 'static bool floodFilterAsciiEqual(',
'UNSIGNED': 'static bool parseFloodFilterUnsigned(',
'MOD_UNSIGNED': 'static bool parseFloodModerationUnsigned(',
'TOKEN': 'static int takeFloodModerationToken(',
'CHANNEL': 'static bool parseFloodModerationChannel(',
'MOD_PATH': 'static bool parseFloodModerationPath(',
'NAME_VALID': 'static bool floodModerationSenderNameValid(',
'SET': 'void MyMesh::setFloodGroupModeration(',
'SCOPE_INDEX': 'static bool parseFloodChannelScopeIndex(',
}
for marker, signature in methods.items():
harness = harness.replace('@' + marker + '@', method(source[source.rfind(signature):], signature))
# Execute the complete production scope option parser before its
# unrelated region lookup and filesystem mutation phase.
setter = method(source, 'void MyMesh::setFloodChannelScope(')
setter = setter[:setter.index(' bool direct_target =')]
setter += ' scope_path=path_selector;scope_slow=slow_timing;strcpy(reply,"OK");\n}\n'
harness = harness.replace('@SET_SCOPE@', setter)
cls.temp = tempfile.TemporaryDirectory(prefix='meshcore-flood-moderation-')
cls.addClassCleanup(cls.temp.cleanup)
work = Path(cls.temp.name)
test = work / 'moderation.cpp'
test.write_text(harness)
cls.binaries = {}
for platform in ('NRF52', 'STM32', 'ESP32', 'RP2040'):
binary = work / platform
result = subprocess.run([compiler, '-std=c++17', '-Wall', '-Wextra', '-Werror',
*SANITIZER_FLAGS, '-D' + platform + '_PLATFORM=1', '-I', str(ROOT / 'src'),
str(test), '-o', str(binary)], capture_output=True, text=True, timeout=60)
if result.returncode:
raise AssertionError(result.stdout + result.stderr)
cls.binaries[platform] = binary
def scenario(self, name):
for platform, binary in self.binaries.items():
with self.subTest(platform=platform):
result = subprocess.run([str(binary), name], capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_full_length_sender_and_body_views(self):
self.scenario('spans')
def test_wildcard_moderation_cannot_be_bypassed_by_long_sender(self):
self.scenario('moderation')
def test_fgm1_compatibility_and_malformed_images(self):
self.scenario('formats')
def test_failed_saves_preserve_previous_rules_after_reboot(self):
self.scenario('failures')
def test_transaction_recovery_and_every_power_cut_boundary(self):
self.scenario('recovery')
def test_cli_shortforms_match_full_commands_and_preserve_validation(self):
self.scenario('cli')
if __name__ == '__main__':
unittest.main()
+66 -6
View File
@@ -35,20 +35,21 @@ PRELUDE = r'''
namespace mesh {
File openFileRead(FILESYSTEM* fs, const char* path) { return fs->open(path); }
struct Utils {
static void sha256(void*, size_t, const uint8_t*, size_t) {
assert(false && "crypto is outside these capacity fixtures");
static void sha256(void* out, size_t size, const uint8_t*, size_t key_size) {
// Cryptography has separate tests. Only the derived one-byte channel hash
// is required by these real storage paths; preserve the complete key.
assert(size == 1 && (key_size == 16 || key_size == 32));
std::memset(out,0,size);
}
};
}
struct RegionMap {
static bool is_name_char(uint8_t c) { return std::isalnum(c) || c=='_' || c=='-'; }
};
struct StrHelper {
static void strzcpy(char* out, const char* in, size_t size) {
void StrHelper::strzcpy(char* out, const char* in, size_t size) {
if (out!=in) std::memmove(out,in,std::min(size,std::strlen(in)+1));
if (size) out[size-1]=0;
}
};
}
'''
CLASS = r'''
class MyMesh {
@@ -101,6 +102,21 @@ template<class Entry> void canonicalRule(Entry& row) {
row.priority=7;row.stop_on_match=true;row.retry_on_match=true;
row.rate_window_started=0xfedcba98;row.rate_window_count=17;row.rate_window_active=true;
}
template<class Entry> void extendedRule(Entry& row) {
row={};row.active=true;row.payload_type=PAYLOAD_TYPE_GRP_TXT;row.max_hops=63;
row.channel_key_len=16;row.channel_secret[0]=0x42;
std::strcpy(row.channel_name,"Weather");
row.path_hash_size=3;row.path_hops=4;
for(unsigned index=0;index<9;++index)row.path[index]=uint8_t(index+1);
row.drop_on_match=true;
row.extra.min_payload=20;row.extra.max_payload=80;row.extra.payload_set=1;
row.extra.route=FloodRuleExtensions::FloodRoute;
row.extra.probability=17;row.extra.path_position=FloodRuleExtensions::Suffix;
row.extra.disabled=1;
row.extra.path_tail[0]=10;row.extra.path_tail[1]=11;row.extra.path_tail[2]=12;
std::strcpy(row.extra.sender,"^(Alice|Bob) [0-9]+$");
std::strcpy(row.extra.text,"weather (rain|sun)");
}
std::vector<uint8_t> fixture(uint8_t slots, uint8_t highest) {
MemoryFS fs;MyMesh writer(fs,slots);
for (uint8_t index : {uint8_t(0),highest}) {
@@ -120,7 +136,51 @@ std::map<std::string,std::vector<uint8_t>> snapshot(const MemoryFS& fs) {
for (const auto& row:fs.files) result[row.first]=*row.second;
return result;
}
void extendedStorage() {
// Disabled high slots remain allocated and durable. They must neither be
// trimmed from the file nor silently discarded by smaller-capacity builds.
MemoryFS fs;MyMesh writer(fs,63);extendedRule(writer.storage[62]);
assert(writer.saveFloodPacketFilters());
const auto image=fs.get(FLOOD_PACKET_FILTER_FILE);
assert(!std::memcmp(image.data(),"FPF8",4) && image[4]==63);
MyMesh reader(fs,63);assert(reader.loadFloodPacketFilters());
assert(FloodFilterPolicy::sameRuleConfiguration(writer.storage[62],reader.storage[62]));
assert(reader.storage[62].active && reader.storage[62].extra.disabled);
assert(reader.saveFloodPacketFilters() && fs.get(FLOOD_PACKET_FILTER_FILE)==image);
MyMesh smaller(fs,4);assert(!smaller.loadFloodPacketFilters());
assert(smaller.flood_policy_capacity_limited && !smaller.saveFloodPacketFilters());
assert(fs.get(FLOOD_PACKET_FILTER_FILE)==image);
// A room server serializes the same extension and the repeater consumes it.
MemoryFS room_fs;FloodRuleEngine room(room_fs);extendedRule(room.storage[3]);
assert(room.save());assert(!std::memcmp(room_fs.get(FLOOD_PACKET_FILTER_FILE).data(),"FPF8",4));
MyMesh from_room(room_fs,63);assert(from_room.loadFloodPacketFilters());
assert(FloodRuleExtensions::sameFields(room.storage[3].extra,from_room.storage[3].extra));
assert(from_room.storage[3].path_hops==4 && from_room.storage[3].path_hash_size==3);
assert(!std::memcmp(room.storage[3].path,from_room.storage[3].path,9));
// No truncated FPF8 forwarding row may activate a partial policy. The
// optional FPS1 extension follows the rows and has separate legacy rules.
for(size_t length=0;length<image.size()-9;++length) {
MemoryFS cut;cut.put(FLOOD_PACKET_FILTER_FILE,{image.begin(),image.begin()+length});
MyMesh boot(cut,63);assert(boot.loadFloodPacketFilters());
assert(boot.seeded==1 && !boot.flood_policy_capacity_limited);
assert(std::none_of(boot.storage.begin(),boot.storage.end(),[](const auto& row){return row.active;}));
}
// Verify failed staging and publication still leave a complete old or new
// FPF8 policy recoverable, including all extended fields.
for(const char* fault : {"open:w:/flood_filter.tmp", "open:r:/flood_filter.tmp",
"rename:/flood_filter:/flood_filter.bak", "rename:/flood_filter.tmp:/flood_filter"}) {
MemoryFS failed;failed.put(FLOOD_PACKET_FILTER_FILE,image);
MyMesh saving(failed,63);assert(saving.loadFloodPacketFilters());
saving.storage[62].extra.probability=18;failed.faults.insert(fault);
assert(!saving.saveFloodPacketFilters());
failed.faults.clear();MyMesh reboot(failed,63);assert(reboot.loadFloodPacketFilters());
assert(reboot.storage[62].active && reboot.storage[62].extra.disabled);
assert(reboot.storage[62].extra.probability==17 || reboot.storage[62].extra.probability==18);
assert(!std::strcmp(reboot.storage[62].extra.sender,writer.storage[62].extra.sender));
}
}
int main() {
extendedStorage();
// Canonical FPF7 bytes remain independent of the compact in-memory layout.
// Exercise every persisted packed flag plus the addressable retry flag.
{
+201
View File
@@ -0,0 +1,201 @@
"""Exercise the production repeater setter and packet-rule evaluation together."""
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
from test_flood_rule_extensions import UTILS
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
PRELUDE = r'''
#include <algorithm>
#include <cassert>
#include <cctype>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
#include <Utils.h>
#include <helpers/FloodRuleCLI.h>
#include <helpers/RegionNameUtils.h>
#define MESH_ENABLE_FLOOD_RULE_ENGINE 1
#define FLOOD_PACKET_FILTER_SCOPE_NAME_LEN 32
#define FLOOD_GROUP_MODERATION_NAME_LEN 32
#define FLOOD_CHANNEL_KEY_PREFIX_LEN 8
#define FLOOD_PACKET_FILTER_PATH_PREFIX_BYTES_MAX 9
#define FLOOD_PACKET_FILTER_ANY_TYPE 255
#define FLOOD_PACKET_FILTER_MAX_HOPS 63
#define FLOOD_PACKET_FILTER_SLOTS 63
#define FLOOD_GROUP_MODERATION_RATE_UNLIMITED 65535
#define REGION_DENY_FLOOD 1
static const char FLOOD_PACKET_FILTER_USAGE[]="Err - usage";
static const char FLOOD_PACKET_FILTER_DUPLICATE[]="Err - duplicate";
mesh::Packet::Packet() : header(0), payload_len(0), path_len(0), transport_codes{}, path{}, payload{},
_rssi(0), _snr(0), tx_cr(0), flood_retry_policy(0) {}
static std::vector<uint8_t> decrypted;
static bool authentic = true;
int mesh::Utils::MACThenDecrypt(const uint8_t* secret, uint8_t* out, const uint8_t*, int) {
if (!authentic || secret[0]!=0x42)return 0;
assert(decrypted.size()<=MAX_PACKET_PAYLOAD);
memcpy(out,decrypted.data(),decrypted.size());return int(decrypted.size());
}
struct RegionEntry { const char* name="Town"; uint8_t flags=0; bool isWildcard()const{return false;} };
struct TransportKey { bool isNull()const{return true;} uint16_t calcTransportCode(const mesh::Packet*)const{return 0;} };
struct RegionMap {
static bool is_name_char(uint8_t c){return std::isalnum(c)||c=='_'||c=='-';}
RegionEntry* findByNamePrefix(const char*){return nullptr;}
int getTransportKeysFor(const RegionEntry&,TransportKey*,unsigned){return 0;}
};
void StrHelper::strzcpy(char* out,const char* in,size_t size){if(size)snprintf(out,size,"%s",in);}
void StrHelper::strncpy(char* out,const char* in,size_t size){if(size)snprintf(out,size,"%s",in);}
static void deriveFloodFilterScopeKey(const char*,TransportKey&){}
// Channel cryptography/resolution is separately exercised. The setter still
// performs every production key-vs-hash and content/routing compatibility gate.
static bool parseFloodRuleChannel(const char* text,uint8_t* secret,uint8_t& key_len,
uint8_t* prefix,char* name,size_t capacity) {
memset(secret,0,PUB_KEY_SIZE);memset(prefix,0,FLOOD_CHANNEL_KEY_PREFIX_LEN);
uint8_t hash;
if(FloodFilterPolicy::parseChannelHashMatcher(text,hash)){
key_len=1;secret[0]=hash;prefix[0]=hash;snprintf(name,capacity,"hash:%02X",hash);return true;
}
if(strcmp(text,"#weather")&&strcmp(text,"public"))return false;
key_len=16;secret[0]=0x42;snprintf(name,capacity,"%s",text);return true;
}
class MyMesh {
public:
@ENTRY@;
FloodPacketFilterEntry rows[63]{};
FloodPacketFilterEntry* flood_packet_filters=rows;
uint8_t flood_packet_filter_slots=63,flood_channel_data_rule_slot=255;
uint32_t flood_filter_probability_seed=0x12345678;
RegionMap region_map;
bool save_ok=true;
unsigned saves=0;
void setFloodPacketFilter(const char*,char*,bool require_explicit_action=false);
bool saveFloodPacketFilters(){++saves;return save_ok;}
void formatFloodPacketFilterDetail(int,char* out,size_t size,bool=false)const{snprintf(out,size,"> saved");}
bool isAnyTempRadioActive()const{return false;}
bool floodPacketFilterBlacklistMatches(const mesh::Packet*)const{return false;}
bool floodPacketFilterFieldsMatch(const FloodPacketFilterEntry&,const mesh::Packet*,bool,uint16_t,bool,const RegionEntry*,uint8_t)const;
bool authenticateFloodPacketFilterChannel(const FloodPacketFilterEntry&,const mesh::Packet*)const;
bool resolveFloodPacketFilterTargetRegion(const char*,TransportKey&,const char*&){return false;}
uint64_t applyFloodPacketFilterStop(uint64_t);
uint64_t evaluateFloodPacketFilterMatches(const mesh::Packet*,bool,const RegionEntry*,uint8_t);
};
'''
SCENARIOS = r'''
static void set(MyMesh& mesh,const char* args,bool expected=true,bool strict=true){
struct Reply {uint8_t before[8];char text[160];uint8_t after[8];} reply;
memset(&reply,0xa5,sizeof(reply));
mesh.setFloodPacketFilter(args,reply.text,strict);
assert(!strncmp(reply.text,expected?"OK":"Err",expected?2:3));
for(uint8_t byte:reply.before)assert(byte==0xa5);
for(uint8_t byte:reply.after)assert(byte==0xa5);
}
static void plain(const std::string& text){
decrypted.assign(5,0);decrypted.insert(decrypted.end(),text.begin(),text.end());decrypted.push_back(0);
}
static void parserAndRollback(){
MyMesh mesh;
set(mesh,R"(.1 grp_txt channel=#weather sender="^(Alice|Bob) [0-9]+$" text='weather (rain|sun)' len=20-80 route=flood prob=25 path=AA>BB$ drop)");
const auto original=mesh.rows[0];
assert(original.active&&original.drop_on_match&&original.path_hops==2&&original.extra.path_position==FloodRuleExtensions::Suffix);
assert(original.extra.probability==25&&original.extra.payload_set&&original.extra.min_payload==20&&original.extra.max_payload==80);
set(mesh,R"(.2 t=grp_txt c=#weather n="^(Alice|Bob) [0-9]+$" x='weather (rain|sun)' l=20-80 rt=f pr=25 pa=AA>BB$ d)");
assert(FloodFilterPolicy::sameRuleConfiguration(mesh.rows[0],mesh.rows[1]));
set(mesh,".1 enabled=off");assert(mesh.rows[0].active&&mesh.rows[0].extra.disabled);
auto disabled=original;disabled.extra.disabled=1;
assert(FloodFilterPolicy::sameRuleConfiguration(mesh.rows[0],disabled));
const auto saves=mesh.saves;
set(mesh,".1 off");assert(mesh.saves==saves);
mesh.flood_channel_data_rule_slot=0;
mesh.save_ok=false;
set(mesh,".1 e=on",false);assert(mesh.rows[0].extra.disabled);
assert(mesh.flood_channel_data_rule_slot==0);
mesh.save_ok=true;
set(mesh,".1 e=on");assert(mesh.flood_channel_data_rule_slot==255);
mesh.save_ok=false;
set(mesh,".1 any route=direct drop",false);
assert(FloodFilterPolicy::sameRuleConfiguration(mesh.rows[0],original));
mesh.save_ok=true;set(mesh,".1 on");assert(!mesh.rows[0].extra.disabled);
assert(FloodFilterPolicy::sameRuleConfiguration(mesh.rows[0],original));
for(const char* invalid:{".3 grp_txt c=hash:00 n=Alice drop",".3 grp_txt n=Alice drop",
".3 advert c=#weather n=Alice drop",".3 grp_txt c=#weather n=[ drop",
".3 any route=direct scope=Town", ".3 any route=any retry",
".3 any l=1-2 len=2-3 drop", ".3 any pa=AA>BB>CC>DD>EE drop",
".3 any", ".3 off", ".0 any drop", ".64 any drop"}) {
unsigned previous=mesh.saves;set(mesh,invalid,false);assert(mesh.saves==previous&&!mesh.rows[2].active);
}
set(mesh,".3 any route=direct drop");assert(mesh.rows[2].extra.route==FloodRuleExtensions::DirectRoute);
set(mesh,".4 any",true,false);assert(mesh.rows[3].drop_on_match);
}
static void allowAndDirect(){
MyMesh mesh;
set(mesh,".1 grp_txt c=#weather n=^Alice$ a pri=2 rt=a");
set(mesh,".2 grp_txt c=#weather d pri=1 rt=a");
mesh::Packet packet;packet.header=ROUTE_TYPE_FLOOD|(PAYLOAD_TYPE_GRP_TXT<<PH_TYPE_SHIFT);packet.payload_len=19;
assert(mesh.rows[0].stop_on_match&&!mesh.rows[0].drop_on_match&&mesh.rows[1].drop_on_match);
plain("Alice: hello");assert(mesh.evaluateFloodPacketFilterMatches(&packet,true,nullptr,0)==1);
plain("Bob: hello");assert(mesh.evaluateFloodPacketFilterMatches(&packet,true,nullptr,0)==2);
plain(std::string(140,'x')+"Alice: hello");assert(mesh.evaluateFloodPacketFilterMatches(&packet,true,nullptr,0)==2);
packet.header=ROUTE_TYPE_DIRECT|(PAYLOAD_TYPE_GRP_TXT<<PH_TYPE_SHIFT);
plain("Alice: hello");assert(mesh.evaluateFloodPacketFilterMatches(&packet,true,nullptr,0)==1);
plain("Bob: hello");assert(mesh.evaluateFloodPacketFilterMatches(&packet,true,nullptr,0)==2);
set(mesh,".2 off");assert(mesh.evaluateFloodPacketFilterMatches(&packet,true,nullptr,0)==0);
set(mesh,".2 on");authentic=false;
assert(mesh.evaluateFloodPacketFilterMatches(&packet,true,nullptr,0)==0);authentic=true;
// Existing radio rules keep their flood-only behavior until route is stated.
MyMesh legacy;set(legacy,".1 any drop");
assert(legacy.evaluateFloodPacketFilterMatches(&packet,true,nullptr,0)==0);
set(legacy,".1 any rt=d drop");assert(legacy.evaluateFloodPacketFilterMatches(&packet,true,nullptr,0)==1);
}
static void budgetExhaustion(){
MyMesh mesh;
for(unsigned slot=1;slot<=63;++slot){
char command[128];snprintf(command,sizeof(command),".%u grp_txt c=#weather x=^(a|aa)*b$ d",slot);set(mesh,command);
}
mesh::Packet packet;packet.header=ROUTE_TYPE_FLOOD|(PAYLOAD_TYPE_GRP_TXT<<PH_TYPE_SHIFT);packet.payload_len=19;
plain("Alice: "+std::string(160,'a'));
// A whole-packet work limit returns the reserved suppression bit rather
// than accidentally permitting a packet after a partial rule evaluation.
assert(mesh.evaluateFloodPacketFilterMatches(&packet,true,nullptr,0)==(uint64_t(1)<<63));
}
int main(){parserAndRollback();allowAndDirect();budgetExhaustion();}
'''
class FloodRuleCliTests(unittest.TestCase):
def test_actual_repeater_parser_and_evaluator(self):
source = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text()
header = (ROOT / "examples/simple_repeater/MyMesh.h").read_text()
parts = [PRELUDE.replace("@ENTRY@", extract_braced(header, "struct FloodPacketFilterEntry"))]
for marker in ("static const char* skipFloodFilterSpaces(", "static bool floodFilterAsciiEqual(",
"static bool floodFilterAsciiStartsWith(", "static bool parseFloodFilterUnsigned(",
"static bool parseFloodFilterPayloadType(", "static bool parseFloodFilterHopSpec(",
"static bool normalizeFloodFilterScopeName("):
parts.append(extract_braced(source, marker))
marker = "static bool parseFloodModerationUnsigned("
parts.append(extract_braced(source[source.rindex(marker):], marker))
for marker in ("void MyMesh::setFloodPacketFilter(", "bool MyMesh::floodPacketFilterFieldsMatch(",
"bool MyMesh::authenticateFloodPacketFilterChannel(", "uint64_t MyMesh::applyFloodPacketFilterStop(",
"uint64_t MyMesh::evaluateFloodPacketFilterMatches("):
parts.append(extract_braced(source, marker))
parts.append(SCENARIOS)
with tempfile.TemporaryDirectory(prefix="flood-rule-cli-") as folder:
folder = Path(folder)
(folder / "Utils.h").write_text(UTILS)
cpp = folder / "cli.cpp"
cpp.write_text("\n".join(parts))
binary = folder / "cli"
flags = []
if os.name != "nt":
flags = ["-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-fno-pie", "-no-pie"]
subprocess.run(["g++", "-std=c++17", "-O1", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", *flags,
"-I" + str(folder), "-I" + str(ROOT / "src"), str(cpp), "-o", str(binary)], check=True)
subprocess.run([str(binary)], check=True)
if __name__ == "__main__":
unittest.main()
+307
View File
@@ -0,0 +1,307 @@
"""Run the production extended-rule parser, matching, and wire-field helpers."""
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
UTILS = r'''#pragma once
#include <stddef.h>
#include <stdint.h>
namespace mesh { struct Utils {
static int MACThenDecrypt(const uint8_t*, uint8_t*, const uint8_t*, int);
}; }
'''
PROGRAM = r'''
#include <algorithm>
#include <cassert>
#include <cstdarg>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
#include <Utils.h>
#include <helpers/FloodRuleExtensions.h>
using namespace FloodRuleExtensions;
mesh::Packet::Packet() : header(0), payload_len(0), path_len(0), transport_codes{}, path{}, payload{},
_rssi(0), _snr(0), tx_cr(0), flood_retry_policy(0) {}
static std::vector<uint8_t> plaintext;
static unsigned decrypts;
static bool decrypt_ok = true;
int mesh::Utils::MACThenDecrypt(const uint8_t* secret, uint8_t* out, const uint8_t*, int) {
++decrypts;
if (!decrypt_ok || secret[0] != 0x42) return 0;
assert(plaintext.size() <= MAX_PACKET_PAYLOAD);
memcpy(out, plaintext.data(), plaintext.size());
return int(plaintext.size());
}
struct StringWriter {
std::string text;
void append(const char* format, ...) {
char buffer[512];
va_list args; va_start(args, format);
int n = vsnprintf(buffer, sizeof(buffer), format, args); va_end(args);
assert(n >= 0 && n < int(sizeof(buffer)));
text.append(buffer, size_t(n));
}
};
static Fields parse(const char* command) {
char buffer[512], reply[160] = {};
assert(strlen(command) < sizeof(buffer));
strcpy(buffer, command);
char* tokens[24];
int count = tokenize(buffer, tokens, 24);
assert(count >= 0);
Fields fields{}; uint8_t seen = 0;
for (int i = 0; i < count; ++i) assert(parseToken(tokens[i], fields, seen, reply) == 1);
assert(validFields(fields));
return fields;
}
static void parsing() {
Fields normal = parse(R"(sender="^(Alice|Bob) [0-9]+$" text='[Ww]eather: rain?' len=0-60 route=any prob=25 enabled=off)");
Fields compact = parse(R"(n="^(Alice|Bob) [0-9]+$" x='[Ww]eather: rain?' l=0-60 rt=a pr=25 e=0)");
assert(sameFields(normal, compact));
assert(normal.payload_set && normal.min_payload == 0 && normal.max_payload == 60);
assert(normal.route == AnyRoute && normal.probability == 25 && normal.disabled);
assert(contentRequested(normal) && hasFields(normal));
Fields empty{};
assert(validFields(empty) && !hasFields(empty) && !contentRequested(empty));
assert(sameFields(parse("prob=100"), empty));
Fields terminator = empty;
memset(terminator.sender, 'a', sizeof(terminator.sender));
assert(!validFields(terminator));
terminator = empty; memset(terminator.text, 'a', sizeof(terminator.text));
assert(!validFields(terminator));
for (const char* token : {"sender=", "text=", "sender=[", "text=a**", "len=", "len=-1", "len=3-2",
"len=185", "len=184-185", "len=1+2", "len=1+-2", "len=1-2+", "len=1--2", "len=99999999999999999999",
"route=bad", "prob=0", "prob=101", "prob=-1", "prob=50%", "enabled=yes"}) {
Fields row{}; uint8_t seen = 0; char reply[160] = {};
assert(parseToken(token, row, seen, reply) == -1 && strncmp(reply, "Err -", 5) == 0);
}
const std::string oversized = "sender=" + std::string(64, 'a');
uint8_t seen = 0; char reply[160] = {}; Fields row{};
assert(parseToken(oversized.c_str(), row, seen, reply) == -1);
seen = 0;
assert(parseToken("sender=Alice", row, seen, reply) == 1);
assert(parseToken("n=Bob", row, seen, reply) == -1 && !strcmp(row.sender, "Alice"));
assert(parseToken("mode=radio", row, seen, reply) == 0);
assert(parseToken("drop", row, seen, reply) == 0);
char malformed[] = "text=\"unterminated"; char* tokens[2];
assert(tokenize(malformed, tokens, 2) == -1);
char excess[] = "a b c"; assert(tokenize(excess, tokens, 2) == -1);
char whitespace[] = "\t "; assert(tokenize(whitespace, tokens, 2) == 0);
for (const char* syntax : {"len=0", "l=0-0"}) {
const auto zero = parse(syntax);
mesh::Packet packet; packet.header = ROUTE_TYPE_FLOOD;
assert(matchesEnvelope(zero, &packet, 0));
packet.payload_len = 1; assert(!matchesEnvelope(zero, &packet, 0));
}
for (bool abbreviated : {false, true}) {
for (const char* syntax : {R"(text='has "quote"')", R"(text='has \"quote\"')",
R"(text='\\server\ folder')", R"(text="it\'s both")"}) {
Fields source = parse(syntax);
StringWriter writer; appendFields(writer, source, abbreviated);
Fields restored = parse(writer.text.c_str());
for (const char* sample : {"has \"quote\"", "has quote", "\\server folder", "it's both", "unrelated"}) {
using mesh::flood::matchTextPattern;
const auto* bytes = reinterpret_cast<const uint8_t*>(sample);
assert(matchTextPattern(source.text, strlen(source.text), bytes, strlen(sample))
== matchTextPattern(restored.text, strlen(restored.text), bytes, strlen(sample)));
}
}
StringWriter writer; appendFields(writer, normal, abbreviated);
assert(sameFields(normal, parse(writer.text.c_str())));
}
}
static void envelopesAndProbability() {
Fields row{};
assert(!matchesEnvelope(row, nullptr, 0));
for (uint8_t route = 0; route < 4; ++route) {
mesh::Packet packet; packet.header = route; packet.payload_len = 44;
const bool flood = packet.isRouteFlood();
assert(matchesEnvelope(row, &packet, 0) == flood);
assert(matchesEnvelope(row, &packet, 1));
row.route = FloodRoute; assert(matchesEnvelope(row, &packet, 0) == flood);
row.route = DirectRoute; assert(matchesEnvelope(row, &packet, 0) == !flood);
row.route = AnyRoute; assert(matchesEnvelope(row, &packet, 0));
row.payload_set = 1; row.min_payload = 44; row.max_payload = 44;
assert(matchesEnvelope(row, &packet, 0));
packet.payload_len = 43; assert(!matchesEnvelope(row, &packet, 0));
packet.payload_len = 45; assert(!matchesEnvelope(row, &packet, 0));
packet.payload_len = 44; row.disabled = 1; assert(!matchesEnvelope(row, &packet, 0));
row = {};
}
for (unsigned probability = 0; probability <= 100; ++probability) {
row.probability = uint8_t(probability);
unsigned selected = 0;
for (uint32_t roll = 0; roll < 10000; ++roll) selected += matchesProbability(row, roll);
assert(selected == (probability ? probability * 100 : 10000));
}
}
static void paths() {
mesh::Packet packet; packet.header = ROUTE_TYPE_FLOOD;
packet.setPathHashSizeAndCount(1, 5);
const uint8_t bytes[] = {0x11, 0x22, 0x33, 0x44, 0x55}; memcpy(packet.path, bytes, sizeof(bytes));
for (const char* syntax : {"22>33", "22,33"}) {
Fields row{}; uint8_t width, hops, first[9];
assert(parsePath(syntax, width, hops, first, row, false));
assert(width == 1 && hops == 2 && row.path_position == Anywhere);
assert(pathMatches(row, &packet, width, hops, first));
row.path_position = Prefix; assert(!pathMatches(row, &packet, width, hops, first));
row.path_position = Suffix; assert(!pathMatches(row, &packet, width, hops, first));
}
for (const char* syntax : {"^11>22", "44>55$", "^11>22>33>44$"}) {
Fields row{}; uint8_t width, hops, first[9];
assert(parsePath(syntax, width, hops, first, row, false));
assert(pathMatches(row, &packet, width, hops, first) == (row.path_position != Exact));
}
for (unsigned width = 1; width <= 3; ++width) {
Fields row{}; uint8_t parsedWidth, hops, first[9];
StringWriter wanted;
for (unsigned hop = 0; hop < 4; ++hop) {
if (hop) wanted.append(">");
for (unsigned byte = 0; byte < width; ++byte) wanted.append("%02X", 0x11 + hop * width + byte);
}
assert(parsePath(wanted.text.c_str(), parsedWidth, hops, first, row, false));
assert(parsedWidth == width && hops == 4);
packet.setPathHashSizeAndCount(uint8_t(width), 6);
memset(packet.path, 0x99, sizeof(packet.path));
memcpy(packet.path + width, first, std::min(9U, 4 * width));
if (width == 3) memcpy(packet.path + width + 9, row.path_tail, 3);
assert(pathMatches(row, &packet, parsedWidth, hops, first));
for (bool compact : {false, true}) {
StringWriter writer; appendPath(writer, row, parsedWidth, hops, first, compact);
const char* syntax = strchr(writer.text.c_str(), '=') + 1;
Fields copy{}; uint8_t copiedWidth, copiedHops, copiedFirst[9];
assert(parsePath(syntax, copiedWidth, copiedHops, copiedFirst, copy, false));
assert(copiedWidth == parsedWidth && copiedHops == hops && sameFields(row, copy));
assert(!memcmp(first, copiedFirst, 9));
}
packet.header = ROUTE_TYPE_DIRECT; assert(!pathMatches(row, &packet, parsedWidth, hops, first));
packet.header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_TRACE << PH_TYPE_SHIFT);
assert(!pathMatches(row, &packet, parsedWidth, hops, first));
packet.header = ROUTE_TYPE_FLOOD;
packet.setPathHashSizeAndCount(uint8_t(width), 63);
if (width > 1) assert(!pathMatches(row, &packet, parsedWidth, hops, first));
}
for (const char* invalid : {"", "^", "$", "^$", "AA>", ">AA", "AA>>BB", "GG", "A", "AAAAA", "AA>BBBB", "AA>BB>CC>DD>EE"}) {
Fields row{}; uint8_t width, hops, first[9];
assert(!parsePath(invalid, width, hops, first, row, false));
}
Fields row{}; uint8_t width, hops, first[9];
assert(parsePath("*", width, hops, first, row, false) && !width && !hops);
assert(pathMatches(row, &packet, width, hops, first));
}
static void content() {
uint8_t key[32] = {0x42};
mesh::Packet packet; packet.header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_GRP_TXT << PH_TYPE_SHIFT); packet.payload_len = 35;
Fields row = parse(R"(sender=^Alice$ text='weather (sun|rain)')");
const auto setPlain = [](const std::string& text) {
plaintext.assign(5, 0); plaintext.insert(plaintext.end(), text.begin(), text.end()); plaintext.push_back(0);
};
setPlain("Alice: weather rain");
assert(matchesContent(row, &packet, key, 16));
assert(matchesContent(row, &packet, key, 32));
uint32_t work = 1000;
assert(matchesContent(row, &packet, key, 16, &work));
assert(work > 0 && work < 1000);
const uint32_t afterFirst = work;
assert(matchesContent(row, &packet, key, 16, &work) && work < afterFirst);
work = 0;
assert(!matchesContent(row, &packet, key, 16, &work, false));
assert(matchesContent(row, &packet, key, 16, &work, true));
bool exhausted = false;
assert(!matchesContent(row, &packet, key, 16, &work, false, &exhausted) && exhausted);
// An allow rule cannot become an unconditional exemption when work runs
// out. A deny rule may conservatively suppress uncertain authenticated data.
Fields textOnly = row; textOnly.sender[0] = 0;
assert(!matchesContent(textOnly, &packet, key, 16, &work, false));
assert(matchesContent(textOnly, &packet, key, 16, &work, true));
exhausted = false;
assert(!matchesContent(textOnly, &packet, key, 16, &work, false, &exhausted) && exhausted);
work = 1000;
assert(matchesContent(textOnly, &packet, key, 16, &work, false, &exhausted) && !exhausted);
assert(!matchesContent(row, &packet, key, 1));
assert(!matchesContent(row, &packet, nullptr, 16));
assert(!matchesContent(row, static_cast<const mesh::Packet*>(nullptr), key, 16));
decrypt_ok = false;
assert(!matchesContent(row, &packet, key, 16));
assert(!matchesContent(row, &packet, key, 16, &work, true));
exhausted = true;
assert(!matchesContent(row, &packet, key, 16, &work, false, &exhausted) && !exhausted);
decrypt_ok = true;
setPlain("alice: weather rain"); assert(!matchesContent(row, &packet, key, 16));
setPlain("Alice: dry"); assert(!matchesContent(row, &packet, key, 16));
setPlain("weather rain"); assert(!matchesContent(row, &packet, key, 16));
row.sender[0] = 0; assert(matchesContent(row, &packet, key, 16));
setPlain(std::string(140, 'x') + "Alice: weather sun");
strcpy(row.sender, "Alice$"); assert(matchesContent(row, &packet, key, 16));
plaintext[4] = TXT_TYPE_CLI_COMMAND << 2; assert(!matchesContent(row, &packet, key, 16));
packet.header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT);
assert(!matchesContent(row, &packet, key, 16));
row = {}; decrypts = 0;
assert(matchesContent(row, static_cast<const mesh::Packet*>(nullptr), nullptr, 0) && decrypts == 0);
}
struct ByteReader {
std::vector<uint8_t> bytes; size_t position = 0;
size_t read(uint8_t* out, size_t size) {
size = std::min(size, bytes.size() - position);
if (size) memcpy(out, bytes.data() + position, size);
position += size; return size;
}
};
static void storage() {
Fields source = parse(R"(sender=Alice text='rain storm' len=0-80 route=direct prob=33 enabled=off)");
source.path_position = Suffix; source.path_tail[2] = 0x99;
std::vector<uint8_t> bytes;
auto writer = [&bytes](const void* data, size_t size) {
const auto* first = static_cast<const uint8_t*>(data); bytes.insert(bytes.end(), first, first + size); return true;
};
assert(writeFields(writer, source));
const std::vector<uint8_t> expected = {0, 0, 80, 0, DirectRoute, 33, Suffix, 1, 1, 0, 0, 0x99,
5, 'A', 'l', 'i', 'c', 'e', 10, 'r', 'a', 'i', 'n', ' ', 's', 't', 'o', 'r', 'm'};
assert(bytes == expected);
ByteReader full{bytes}; Fields loaded{};
assert(readFields(full, loaded) && full.position == bytes.size() && sameFields(source, loaded));
for (size_t length = 0; length < bytes.size(); ++length) {
ByteReader shortRead{{bytes.begin(), bytes.begin() + length}};
assert(!readFields(shortRead, loaded));
}
const size_t senderLength = 12; // Four uint16 bytes, five control bytes, three tail bytes.
assert(bytes[senderLength] == strlen(source.sender));
for (const auto& change : std::vector<std::pair<size_t, uint8_t>>{{4, 4}, {5, 101}, {6, 4}, {7, 2}, {8, 2}, {senderLength, 64}, {senderLength + 1, '*'}}) {
ByteReader corrupt{bytes}; corrupt.bytes[change.first] = change.second;
assert(!readFields(corrupt, loaded));
}
unsigned successfulCalls = 0;
auto counter = [&successfulCalls](const void*, size_t) { ++successfulCalls; return true; };
assert(writeFields(counter, source));
for (unsigned failure = 0; failure < successfulCalls; ++failure) {
unsigned calls = 0;
auto broken = [&calls, failure](const void*, size_t) { return calls++ != failure; };
assert(!writeFields(broken, source));
}
}
int main() { parsing(); envelopesAndProbability(); paths(); content(); storage(); }
'''
class FloodRuleExtensionsTests(unittest.TestCase):
def test_real_parser_matching_and_storage(self):
with tempfile.TemporaryDirectory(prefix="flood-rule-extensions-") as folder:
folder = Path(folder)
(folder / "Utils.h").write_text(UTILS)
source = folder / "extensions.cpp"
source.write_text(PROGRAM)
binary = folder / "extensions"
flags = []
if os.name != "nt":
flags = ["-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-fno-pie", "-no-pie"]
subprocess.run(["g++", "-std=c++17", "-O1", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", *flags,
"-I" + str(folder), "-I" + str(ROOT / "src"), str(source), "-o", str(binary)], check=True)
subprocess.run([str(binary)], check=True)
if __name__ == "__main__":
unittest.main()
+21 -3
View File
@@ -24,8 +24,8 @@ static constexpr uint8_t NAME_LEN = 32, PATH_PREFIX_BYTES_MAX = 9;
'''
SCENARIOS = r'''
static_assert(sizeof(RepeaterEntry) == 192 && sizeof(RoomEntry) == 192,
"63 repeater rows / 31 room rows must retain the 192-byte bound");
static_assert(sizeof(RepeaterEntry) == 332 && sizeof(RoomEntry) == 332,
"extended rows retain the reviewed 332-byte RAM bound");
static_assert(sizeof(LegacyEntry) == 40, "retain legacy STM32 rule layout");
static_assert(alignof(RepeaterEntry) >= alignof(uint32_t));
static_assert(alignof(RoomEntry) >= alignof(uint32_t));
@@ -95,7 +95,7 @@ template<class Entry> void exerciseConfigurationEquality() {
assert(FloodFilterPolicy::sameRuleConfiguration(original, live));
// Bytewise struct comparisons are forbidden: neither alignment padding nor
// live counters identify a rule. Poison trailing padding to enforce this.
for (size_t offset = offsetof(Entry, transport_modes) + 1; offset < sizeof(Entry); ++offset)
for (size_t offset = offsetof(Entry, extra) + sizeof(live.extra); offset < sizeof(Entry); ++offset)
reinterpret_cast<unsigned char*>(&live)[offset] = 0xa5;
assert(FloodFilterPolicy::sameRuleConfiguration(original, live));
// Every configured field must still distinguish two rows. Arrays include
@@ -133,6 +133,24 @@ template<class Entry> void exerciseConfigurationEquality() {
assert(!FloodFilterPolicy::sameRuleConfiguration(original, changed));
assert(!FloodFilterPolicy::sameRuleConfiguration(changed, original));
}
for (unsigned field = 0; field < 11; ++field) {
Entry changed = original;
switch (field) {
case 0: changed.extra.min_payload = 1; break;
case 1: changed.extra.max_payload = 1; break;
case 2: changed.extra.payload_set = 1; break;
case 3: changed.extra.route = FloodRuleExtensions::DirectRoute; break;
case 4: changed.extra.probability = 30; break;
case 5: changed.extra.path_position = FloodRuleExtensions::Anywhere; break;
case 6: changed.extra.disabled = 1; break;
case 7: changed.extra.path_tail[2] = 1; break;
case 8: std::strcpy(changed.extra.sender, "Alice"); break;
case 9: std::strcpy(changed.extra.text, "weather"); break;
case 10: changed.extra.path_tail[0] = 1; break;
}
assert(!FloodFilterPolicy::sameRuleConfiguration(original, changed));
assert(!FloodFilterPolicy::sameRuleConfiguration(changed, original));
}
}
template<class Rules, class Entry> void exerciseProductionSlotSelection() {
Rules rules;
+221
View File
@@ -0,0 +1,221 @@
"""Exercise the production bounded matcher, including hostile regex-like input."""
from pathlib import Path
import os
import random
import re
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
PROGRAM = r'''
#include <cassert>
#include <cstdlib>
#include <cstring>
#include <iostream>
#include <string>
#include <vector>
#include "FloodTextPattern.h"
using namespace mesh::flood;
static bool forbidAllocation = false;
__attribute__((noinline)) void* operator new(size_t size) {
assert(!forbidAllocation);
void* result = malloc(size ? size : 1);
if (!result) std::abort();
return result;
}
__attribute__((noinline)) void* operator new[](size_t size) { return ::operator new(size); }
__attribute__((noinline)) void operator delete(void* pointer) noexcept { free(pointer); }
__attribute__((noinline)) void operator delete[](void* pointer) noexcept { free(pointer); }
static_assert(sizeof(pattern_detail::State) == 4, "small NFA instructions");
static_assert(sizeof(pattern_detail::State) * pattern_detail::MAX_STATES
+ 2 * sizeof(pattern_detail::StateList) <= 640, "bounded matching scratch");
static PatternMatchResult match(const char* pattern, const char* text, bool fold = false) {
forbidAllocation = true;
const auto result = matchTextPattern(pattern, strlen(pattern), reinterpret_cast<const uint8_t*>(text), strlen(text), fold);
forbidAllocation = false;
return result;
}
static void checks() {
using R = PatternMatchResult;
assert(match("", "") == R::Match);
assert(match("Alice", "a message from Alice today") == R::Match);
assert(match("^Alice$", "Alice") == R::Match);
assert(match("^Alice$", "alice") == R::NoMatch);
assert(match("^Alice$", "alice", true) == R::Match);
assert(match("^Alice$", "Alice2") == R::NoMatch);
assert(match("^Alice$", "2Alice") == R::NoMatch);
assert(match("Alice$", "Alice\n") == R::NoMatch);
assert(match("A.*e", "A\ne") == R::Match);
assert(match("^a\\.b\\*\\[\\]\\(\\)\\|\\+\\?\\^\\$\\\\$", "a.b*[]()|+?^$\\") == R::Match);
assert(match("^[A-F0-9]+$", "aBcF09", true) == R::Match);
assert(match("^[A-F0-9]+$", "aBcF09") == R::NoMatch);
assert(match("^[^A-F]+$", "xyz", true) == R::Match);
assert(match("^[^A-F]+$", "xyzA", true) == R::NoMatch);
assert(match("^[A-z]+$", "[_Zaz", true) == R::Match);
assert(match("^[\\]\\-\\[]+$", "]-[") == R::Match);
assert(match("^[a-]+$", "a--") == R::Match);
assert(match("^[-a]+$", "a--") == R::Match);
assert(match("^(Alice|Bob)[0-9]?$", "Bob3") == R::Match);
assert(match("^(Alice|Bob)[0-9]?$", "Bob33") == R::NoMatch);
assert(match("^(ab)*c+$", "ababc") == R::Match);
assert(match("^(ab)+c?$", "") == R::NoMatch);
assert(match("^(ab)*c?$", "") == R::Match);
assert(match("^(a|ab)*b$", "aabab") == R::Match);
assert(match("^(()*)*$", "") == R::Match); // epsilon cycles terminate
assert(match("^(a|)*$", "aaaa") == R::Match);
for (const char* invalid : {"[", "[]", "[^]", "[z-a]", "[a-\\]]", "\\", "(", ")", "a)",
"a**", "a+?", "a??", "*a", "^*", "$+", "a]"}) {
assert(!textPatternValid(invalid, strlen(invalid)));
assert(match(invalid, "anything") == R::Invalid);
}
assert(!textPatternValid(nullptr, 0));
assert(matchTextPattern("a", 1, nullptr, 1) == R::Invalid);
assert(matchTextPattern("", 0, nullptr, 0) == R::Match);
const char nulPattern[] = {'a', 0, 'b'};
assert(!textPatternValid(nulPattern, sizeof(nulPattern)));
const uint8_t nulText[] = {'a', 0, 'b'};
assert(matchTextPattern("^a.b$", 5, nulText, 3) == R::Match);
assert(matchTextPattern("^a$", 3, nulText, 3) == R::NoMatch);
const std::string maximum(MAX_TEXT_PATTERN_LENGTH, 'a');
assert(textPatternValid(maximum.data(), maximum.size()));
assert(!textPatternValid(maximum.data(), maximum.size() + 1));
assert(!textPatternValid("(((((((((a)))))))))", 19));
assert(textPatternValid("((((((((a))))))))", 17));
const std::string tooManyStates(MAX_TEXT_PATTERN_LENGTH, '|');
assert(!textPatternValid(tooManyStates.data(), tooManyStates.size()));
const std::string longSender = std::string(200, 'x') + "Alice";
assert(match("Alice$", longSender.c_str()) == R::Match);
assert(match("^x+$", longSender.c_str()) == R::NoMatch);
uint32_t budget = 0;
assert(matchTextPattern("a", 1, reinterpret_cast<const uint8_t*>("a"), 1, false, &budget) == R::BudgetExceeded);
assert(budget == 0);
budget = 100;
assert(matchTextPattern("a", 1, reinterpret_cast<const uint8_t*>("a"), 1, false, &budget) == R::Match);
assert(budget < 100 && budget > 0);
const uint32_t afterFirst = budget;
assert(matchTextPattern("b", 1, reinterpret_cast<const uint8_t*>("b"), 1, false, &budget) == R::Match);
assert(budget < afterFirst);
const std::string hostile(100000, 'a');
budget = 500;
assert(matchTextPattern("(a|aa)*b", 8, reinterpret_cast<const uint8_t*>(hostile.data()), hostile.size(), false, &budget) == R::BudgetExceeded);
assert(budget == 0);
// This pattern is exponential with a backtracking engine. The NFA finishes
// within the default fixed budget for the largest packet-sized payload.
const std::string packet(255, 'a');
assert(match("^(a|aa)*b$", packet.c_str()) == R::NoMatch);
}
static std::vector<uint8_t> unhex(const std::string& text) {
std::vector<uint8_t> result;
if (text == "-") return result;
for (size_t index = 0; index < text.size(); index += 2) {
result.push_back(uint8_t(std::stoul(text.substr(index, 2), nullptr, 16)));
}
return result;
}
int main() {
checks();
std::string patternHex, textHex;
bool ignoreCase;
while (std::cin >> patternHex >> textHex >> ignoreCase) {
const auto pattern = unhex(patternHex);
const auto text = unhex(textHex);
// Exact-sized allocations let ASan detect an accidental C-string read.
char* rawPattern = new char[pattern.size() ? pattern.size() : 1];
uint8_t* rawText = new uint8_t[text.size() ? text.size() : 1];
if (!pattern.empty()) memcpy(rawPattern, pattern.data(), pattern.size());
if (!text.empty()) memcpy(rawText, text.data(), text.size());
const auto result = matchTextPattern(rawPattern, pattern.size(), rawText, text.size(), ignoreCase);
std::cout << int(result) << '\n';
delete[] rawPattern;
delete[] rawText;
}
}
'''
ARM_PROGRAM = r'''
#include "FloodTextPattern.h"
extern "C" __attribute__((noinline)) mesh::flood::PatternMatchResult packet_match(
const char* pattern, size_t patternLength, const uint8_t* text, size_t textLength, uint32_t* budget) {
return mesh::flood::matchTextPattern(pattern, patternLength, text, textLength, false, budget);
}
'''
class FloodTextPatternTests(unittest.TestCase):
def test_packet_patterns_bounds_and_reference_agreement(self):
rng = random.Random(436)
patterns = ["", "a", "^a$", "a|b", "^(ab|a)*b$", "a?b+c*", "[ab]+", "[^a]*b$", "[A-z]+"]
atoms = ["a", "b", "c", ".", "[ab]", "[^c]", "[a-c]", "[0-2]", r"\.", "(ab|c)", "(a|)"]
for _ in range(300):
pattern = "^" if rng.randrange(2) else ""
for _ in range(rng.randrange(1, 5)):
pattern += rng.choice(atoms) + rng.choice(["", "", "?", "*", "+"])
if rng.randrange(2):
pattern += "$"
patterns.append(pattern)
cases = []
for pattern in patterns:
for _ in range(15):
text = "".join(rng.choice("abcABC012.") for _ in range(rng.randrange(21)))
fold = bool(rng.randrange(2))
expected = bool(re.search(pattern, text, re.DOTALL | (re.IGNORECASE | re.ASCII if fold else 0)))
cases.append((pattern, text, fold, expected))
encoded = "".join(f"{pattern.encode().hex() or '-'} {text.encode().hex() or '-'} {int(fold)}\n"
for pattern, text, fold, _ in cases)
# Deterministic malformed-input fuzzing exercises validation and patch
# list bounds without assuming Python supports exactly our byte syntax.
fuzz = ["".join(rng.choice("abc^$.*+?()[\\]|-") for _ in range(rng.randrange(1, 64)))
for _ in range(4000)]
encoded += "".join(f"{pattern.encode().hex()} 616263 0\n" for pattern in fuzz)
with tempfile.TemporaryDirectory() as folder:
folder = Path(folder)
source = folder / "patterns.cpp"
source.write_text(PROGRAM)
binary = folder / "patterns"
flags = []
if os.name != "nt":
flags = ["-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-fno-pie", "-no-pie"]
subprocess.run(["g++", "-std=c++11", "-O1", "-Wall", "-Wextra", "-Werror", *flags,
"-I" + str(ROOT / "src/helpers"), str(source), "-o", str(binary)], check=True)
result = subprocess.run([str(binary)], input=encoded, text=True, capture_output=True, check=True, timeout=30)
results = result.stdout.splitlines()
self.assertEqual(len(results), len(cases) + len(fuzz))
for value, case in zip(results, cases):
self.assertEqual(int(value), int(case[3]), case)
self.assertTrue(all(value in ("0", "1", "2") for value in results[len(cases):]))
def test_arm_stack_is_bounded(self):
compiler = shutil.which("arm-none-eabi-g++")
if not compiler:
packages = Path.home() / ".platformio/packages"
candidates = sorted(packages.glob("toolchain-gccarmnoneeabi*/bin/arm-none-eabi-g++*"))
candidates = [path for path in candidates if path.suffix in ("", ".exe")]
if candidates:
compiler = str(candidates[0])
if not compiler:
self.skipTest("ARM compiler unavailable")
with tempfile.TemporaryDirectory() as folder:
folder = Path(folder)
source = folder / "patterns.cpp"
source.write_text(ARM_PROGRAM)
subprocess.run([compiler, "-std=c++11", "-mcpu=cortex-m4", "-mthumb", "-Os", "-fstack-usage",
"-I" + str(ROOT / "src/helpers"), "-c", str(source), "-o", str(folder / "patterns.o")], check=True)
frames = {}
for line in (folder / "patterns.su").read_text().splitlines():
name, size, kind = line.rsplit("\t", 2)
self.assertEqual(kind, "static", line)
frames[name] = int(size)
self.assertTrue(frames)
# Compiler recursion is limited to eight groups. Conservatively
# count each parser frame at every level, plus runtime scratch.
recursive = sum(size for name, size in frames.items()
if any(token in name for token in ("::atom(", "::sequence(", "::expression(")))
other = max(size for name, size in frames.items()
if not any(token in name for token in ("::atom(", "::sequence(", "::expression(")))
self.assertLessEqual(recursive * 9 + other + 64, 2048, frames)
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -133,6 +133,7 @@ class MorseAuthorizationTests(unittest.TestCase):
"static bool commandFamilyMatches(", "static bool isCommonManagerReadOnlyAllowed(",
"static bool isRegionMgrAllowed(", "static bool isFilterMgrAllowed(",
))
helpers = f'#include "{(ROOT / "src/helpers/FloodCommandAliases.h").as_posix()}"\n' + helpers
code = HARNESS.replace("@MANAGER_HELPERS@", helpers).replace("@PORTABLE_HANDOFF@", handoff)
for extended_filters in (0, 1):
with self.subTest(extended_filters=extended_filters):
+266
View File
@@ -0,0 +1,266 @@
"""Run the complete production FULL-room rule engine over host I/O boundaries."""
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
UTILS = r'''#pragma once
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <vector>
namespace mesh {
inline std::vector<uint8_t> test_plaintext;
inline bool test_decrypt_ok=false;
struct RNG { virtual ~RNG() = default; virtual void random(uint8_t*, size_t) = 0; };
struct Utils {
static void sha256(uint8_t* out, size_t size, const uint8_t*, size_t) { memset(out, 0, size); }
static bool isHexChar(char c) { return (c>='0'&&c<='9')||(c>='a'&&c<='f')||(c>='A'&&c<='F'); }
static unsigned nibble(char c) { return c<='9' ? c-'0' : (c|32)-'a'+10; }
static bool fromHex(uint8_t* out, int size, const char* text) {
if (strlen(text)!=size_t(size)*2) return false;
for (int i=0;i<size;++i) {
if (!isHexChar(text[i*2])||!isHexChar(text[i*2+1])) return false;
out[i]=uint8_t(nibble(text[i*2])*16+nibble(text[i*2+1]));
}
return true;
}
static void toHex(char* out,const uint8_t* bytes,size_t size) {
const char* digits="0123456789ABCDEF";
for(size_t i=0;i<size;++i) {out[i*2]=digits[bytes[i]>>4];out[i*2+1]=digits[bytes[i]&15];}
out[size*2]=0;
}
// Explicit crypto boundary: only the budget test supplies authenticated
// plaintext. These tests do not claim to verify ciphertext authentication.
static int MACThenDecrypt(const uint8_t*,uint8_t* out,const uint8_t*,int) {
if(!test_decrypt_ok)return 0;
memcpy(out,test_plaintext.data(),test_plaintext.size());return int(test_plaintext.size());
}
};
}
'''
BOUNDARIES = r'''#pragma once
#include <algorithm>
#include <cassert>
#include <cctype>
#include <map>
#include <memory>
#include <set>
#include <string>
#include <vector>
#include <Packet.h>
#include <Utils.h>
#define TXT_TYPE_PLAIN 0
class MemoryFS;
class File {
MemoryFS* owner=nullptr;
std::shared_ptr<std::vector<uint8_t>> bytes;
size_t cursor=0;
public:
File()=default;
File(MemoryFS* fs,std::shared_ptr<std::vector<uint8_t>> data):owner(fs),bytes(data){}
operator bool() const {return bool(bytes);}
bool isDirectory() const {return false;}
size_t size() const {return bytes ? bytes->size() : 0;}
size_t available() const {return size()-cursor;}
size_t read(uint8_t* out,size_t count) {
count=std::min(count,available());
if(count) memcpy(out,bytes->data()+cursor,count);
cursor+=count;return count;
}
size_t write(const uint8_t*,size_t);
void close() {bytes.reset();}
};
class MemoryFS {
public:
std::map<std::string,std::shared_ptr<std::vector<uint8_t>>> files;
std::set<std::string> faults;
bool exists(const char* path) const {return files.count(path)!=0;}
bool remove(const char* path) {return files.erase(path)!=0;}
bool rename(const char* from,const char* to) {
if(faults.count(std::string("rename:")+from+":"+to)||!exists(from)||exists(to))return false;
files[to]=files[from];files.erase(from);return true;
}
File open(const char* path,const char* mode="r",bool=false) {
if(faults.count(std::string("open:")+mode+":"+path))return {};
if(mode[0]=='w')files[path]=std::make_shared<std::vector<uint8_t>>();
return exists(path)?File(this,files[path]):File();
}
};
inline size_t File::write(const uint8_t* data,size_t count) {
if(!bytes)return 0;
bytes->insert(bytes->end(),data,data+count);return count;
}
using FILESYSTEM=MemoryFS;
struct TransportKey {
uint8_t key[16]{};
bool isNull() const {for(auto byte:key)if(byte)return false;return true;}
uint16_t calcTransportCode(const mesh::Packet*) const {return 0;}
};
#define REGION_DENY_FLOOD 1
struct RegionEntry {uint8_t flags=0;char name[32]{};bool isWildcard()const{return false;}};
struct RegionMap {
static bool is_name_char(uint8_t c) {return std::isalnum(c)||c=='_'||c=='-';}
RegionEntry* findByName(const char*) {return nullptr;}
RegionEntry* findByNamePrefix(const char*) {return nullptr;}
int getTransportKeysFor(const RegionEntry&,TransportKey*,int) {return 0;}
};
'''
PROGRAM = r'''
#include "FloodRuleEngine.h"
#include <cstdio>
mesh::Packet::Packet():header(ROUTE_TYPE_FLOOD),payload_len(0),path_len(0),
transport_codes{},path{},payload{},_rssi(0),_snr(0),tx_cr(0),flood_retry_policy(0){}
struct TestRng:mesh::RNG {
uint32_t value=0;unsigned calls=0;
void random(uint8_t* out,size_t size) override {assert(size==4);memcpy(out,&value,4);++calls;}
};
static MemoryFS disk;
static RegionMap regions;
static TestRng rng;
static FloodRuleEngine rules;
static char reply[160];
void command(const char* text,bool success=true) {
assert(rules.handleCommand(text,reply));
assert((strncmp(reply,"Err",3)!=0)==success);
}
bool blocked(mesh::Packet& packet,uint32_t now=0,bool commit=false) {
auto matches=rules.evaluate(&packet,false,true,nullptr);
bool result=rules.shouldBlock(&packet,matches,now);
if(!result&&commit)rules.commitRates(&packet,matches,now);
return result;
}
void reboot() {rules.begin(&disk,&regions,&rng);}
uint32_t seedFor(const mesh::Packet& packet,unsigned slot,unsigned wanted) {
for(uint32_t seed=0;;++seed)
if(FloodRuleExtensions::probabilityRoll(&packet,seed,slot,0)%100==wanted)return seed;
}
int main() {
reboot();command("del fr all");
assert(!memcmp(disk.files.at("/flood_filter")->data(),"FPF7",4));
mesh::Packet packet;packet.header=uint8_t(PAYLOAD_TYPE_ADVERT<<2)|ROUTE_TYPE_FLOOD;
packet.payload_len=40;
const uint32_t stable=FloodRuleExtensions::probabilityRoll(&packet,12345,3,0);
packet.path[0]=0xAB;packet.setPathHashSizeAndCount(3,1);packet.transport_codes[0]=0x1234;
packet.header=uint8_t(PAYLOAD_TYPE_ADVERT<<2)|ROUTE_TYPE_TRANSPORT_FLOOD;
assert(stable==FloodRuleExtensions::probabilityRoll(&packet,12345,3,0));
packet.header=uint8_t(PAYLOAD_TYPE_ADVERT<<2)|ROUTE_TYPE_FLOOD;
assert(stable!=FloodRuleExtensions::probabilityRoll(&packet,12346,3,0));
assert(stable!=FloodRuleExtensions::probabilityRoll(&packet,12345,4,0));
assert(stable!=FloodRuleExtensions::probabilityRoll(&packet,12345,3,1));
packet.payload[0]=1;assert(stable!=FloodRuleExtensions::probabilityRoll(&packet,12345,3,0));
packet.payload[0]=0;
command("set flood.rule.4 type=advert len=30-50 prob=25 drop");
assert(!memcmp(disk.files.at("/flood_filter")->data(),"FPF8",4));
rng.value=seedFor(packet,3,24);reboot();assert(blocked(packet));
const unsigned draws=rng.calls;
for(unsigned i=0;i<20;++i)assert(blocked(packet));
assert(rng.calls==draws);
rng.value=seedFor(packet,3,25);reboot();assert(!blocked(packet));
packet.payload_len=29;assert(!blocked(packet));packet.payload_len=51;assert(!blocked(packet));
packet.payload_len=40;rng.value=seedFor(packet,3,0);reboot();assert(blocked(packet));
command("set fr.4 off");assert(!blocked(packet));reboot();assert(!blocked(packet));
command("set fr.4 e=on");assert(blocked(packet));
command("set fr.4 enabled=off");assert(!blocked(packet));
command("get fr.4");assert(strstr(reply,"e=off"));
command("set fr.4 enabled=on");assert(blocked(packet));
command("set fr.5 any rt=d d");packet.header=uint8_t(PAYLOAD_TYPE_ACK<<2)|ROUTE_TYPE_DIRECT;
assert(blocked(packet));packet.header=uint8_t(PAYLOAD_TYPE_ACK<<2)|ROUTE_TYPE_FLOOD;
assert(!blocked(packet));
command("set fr.5 any rt=d h=2+ d");packet.setPathHashSizeAndCount(1,3);
packet.header=uint8_t(PAYLOAD_TYPE_ACK<<2)|ROUTE_TYPE_DIRECT;assert(!blocked(packet));
command("set fr.5 any rt=d s=test",false);
command("del fr all");
packet.header=uint8_t(PAYLOAD_TYPE_ACK<<2)|ROUTE_TYPE_FLOOD;
command("set fr.4 any pa=AABB>CCDD$ d");
packet.setPathHashSizeAndCount(2,3);
const uint8_t path[]={0,1,0xAA,0xBB,0xCC,0xDD};memcpy(packet.path,path,sizeof(path));
assert(blocked(packet));packet.path[0]=0xAA;packet.path[1]=0xBB;
packet.path[2]=0xCC;packet.path[3]=0xDD;packet.path[4]=0;packet.path[5]=1;
assert(!blocked(packet));
command("set fr.4 any pa=^AA>BB>CC>DD d");packet.setPathHashSizeAndCount(1,4);
const uint8_t four[]={0xAA,0xBB,0xCC,0xDD};memcpy(packet.path,four,4);
assert(blocked(packet));reboot();assert(blocked(packet));
assert(!memcmp(disk.files.at("/flood_filter")->data(),"FPF8",4));
command("get fr.4");assert(strstr(reply,"pa=^AA>BB>CC>DD"));
command("set fr.4 any c=public n=\"^(Alice|Bob) Smith$\" x=\"^RX in place\" a");
command("get fr.4 n");assert(!strcmp(reply,"> ^(Alice|Bob) Smith$"));
command("get flood.rule.4 text");assert(!strcmp(reply,"> ^RX in place"));
reboot();command("get fr.4 x");assert(!strcmp(reply,"> ^RX in place"));
command("set fr.4 any c=hash:11 n=Alice d",false);
command("set fr.4 any n=Alice d",false);
command("set fr.4 any c=public n=\"unterminated d",false);
command("get fr.4 n");assert(!strcmp(reply,"> ^(Alice|Bob) Smith$"));
command("set fr.31 advert d");
disk.faults.insert("open:w:/flood_filter.tmp");command("del fr all",false);
command("get flood.rule.31");assert(strstr(reply,"advert"));
command("set fr.31 off",false);command("get fr.31");assert(!strstr(reply,"e=off"));
disk.faults.clear();command("del fr all");
command("set fr.4 advert q=1");
packet.header=uint8_t(PAYLOAD_TYPE_ADVERT<<2)|ROUTE_TYPE_FLOOD;
assert(!blocked(packet,0,true));assert(blocked(packet,1));
command("set fr.4 off");assert(!blocked(packet,2,true));
command("set fr.4 on");assert(blocked(packet,3));
assert(!blocked(packet,60000));
command("del fr all");command("set fr.4 any l=0 d");
packet.payload_len=0;assert(blocked(packet));packet.payload_len=1;assert(!blocked(packet));
command("del fr all");
for(unsigned slot=1;slot<=31;++slot) {
char text[256];
snprintf(text,sizeof(text),"set fr.%u any c=public x=(a|a|a|a|a|a|a|a|a|a|a|a|a|a|a|a|a|a|a|a|a|a)*z q=1 s=test stop",slot);
command(text);
}
packet.header=uint8_t(PAYLOAD_TYPE_GRP_TXT<<2)|ROUTE_TYPE_FLOOD;
packet.payload_len=179;packet.payload[0]=0;
mesh::test_plaintext.assign(176,'a');
memset(mesh::test_plaintext.data(),0,5);
memcpy(mesh::test_plaintext.data()+5,"U: ",3);
mesh::test_decrypt_ok=true;
const uint32_t exhausted=rules.evaluate(&packet,false,true,nullptr);
assert(exhausted==(uint32_t(1)<<31));
assert(rules.shouldBlock(&packet,exhausted,0));
bool scope_set=false,fast_track=false;
assert(!rules.applyScope(&packet,exhausted,scope_set,fast_track));
assert(!scope_set&&!fast_track);
rules.commitRates(&packet,exhausted,0);
mesh::test_decrypt_ok=false;
assert(!rules.handleCommand("get fridge",reply));
command("get fb",false);
puts("FULL-room filter parser, persistence, route, path, probability and rollback passed");
}
'''
class RoomFilterExtensionTests(unittest.TestCase):
def test_complete_production_engine(self):
with tempfile.TemporaryDirectory(prefix="room-filter-extensions-") as directory:
work = Path(directory)
(work / "Utils.h").write_text(UTILS)
(work / "boundaries.h").write_text(BOUNDARIES)
(work / "RoomServerFeatures.h").write_text("#define MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE 1\n")
header = (ROOT / "examples/simple_room_server/FloodRuleEngine.h").read_text()
for include in ("Arduino.h", "Mesh.h", "helpers/IdentityStore.h", "helpers/RegionMap.h"):
header = header.replace(f"#include <{include}>", "")
(work / "FloodRuleEngine.h").write_text('#include "boundaries.h"\n' + header)
(work / "FloodRuleEngine.cpp").write_text(
(ROOT / "examples/simple_room_server/FloodRuleEngine.cpp").read_text())
(work / "test.cpp").write_text(PROGRAM)
executable = work / ("test.exe" if os.name == "nt" else "test")
sanitizer = [] if os.name == "nt" else [
"-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
built = subprocess.run(["g++", "-std=c++17", "-O1", "-g", "-Wall", "-Wextra",
"-Wno-unused-variable", *sanitizer, "-I", str(work), "-I", str(ROOT / "src"),
str(work / "test.cpp"), str(work / "FloodRuleEngine.cpp"), "-o", str(executable)],
capture_output=True, text=True, timeout=90)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
run = subprocess.run([str(executable)], capture_output=True, text=True, timeout=20)
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)
if __name__ == "__main__":
unittest.main()
+93 -3
View File
@@ -206,6 +206,76 @@ int main() {
'''
class TransportFilterWiring(unittest.TestCase):
def test_aliases_dispatch_the_original_suffix_without_expanding_a_command_buffer(self):
source = (ROOT/'examples/simple_repeater/MyMesh.cpp').read_text()
start = source.index(' if (commandFamilyMatches(command, "get flood.channel.scope.require"))')
end = source.index('#if MESH_ENABLE_CLOCK_SYNC', start)
dispatch = source[start:end]
helper = method(source, 'static bool commandFamilyMatches(')
code = (f'#include "{(ROOT/"src/helpers/FloodCommandAliases.h").as_posix()}"\n'
'#include <cassert>\n#include <cstring>\n#include <initializer_list>\n') + helper + r'''
static const char* actual_args;
static const char* actual_action;
#define STUB(name) static void name(const char* args,char* reply) {actual_args=args;actual_action=#name;strcpy(reply,"OK");}
STUB(formatFloodChannelScopeRequirements)
STUB(setFloodChannelScopeRequirement)
STUB(deleteFloodChannelScopeRequirement)
STUB(formatFloodChannelScopes)
STUB(setFloodChannelScope)
STUB(deleteFloodChannelScope)
STUB(formatFloodPacketFilterBlacklist)
STUB(setFloodPacketFilterBlacklist)
STUB(deleteFloodPacketFilterBlacklist)
STUB(deleteFloodPacketFilter)
STUB(formatFloodGroupModeration)
STUB(setFloodGroupModeration)
STUB(deleteFloodGroupModeration)
static void formatFloodPacketFilters(const char* args,char* reply,bool=false) {actual_args=args;actual_action="formatFloodPacketFilters";strcpy(reply,"OK");}
static void setFloodPacketFilter(const char* args,char* reply,bool=false) {actual_args=args;actual_action="setFloodPacketFilter";strcpy(reply,"OK");}
void run(const char* command,char* reply) {
@DISPATCH@
else strcpy(reply,"unhandled");
}
int main() {
struct Test {const char* command;const char* suffix;const char* action;};
for(const auto& row : {
Test{"get fm.2",".2","formatFloodGroupModeration"},
Test{"set fm.2 public Alice drop",".2 public Alice drop","setFloodGroupModeration"},
Test{"del fm.2",".2","deleteFloodGroupModeration"},
Test{"get fs.2",".2","formatFloodChannelScopes"},
Test{"set fs.2 public scope=local",".2 public scope=local","setFloodChannelScope"},
Test{"del fs.2",".2","deleteFloodChannelScope"},
Test{"get fsr.2",".2","formatFloodChannelScopeRequirements"},
Test{"set fsr.2 public",".2 public","setFloodChannelScopeRequirement"},
Test{"del fsr.2",".2","deleteFloodChannelScopeRequirement"},
Test{"get fb.2",".2","formatFloodPacketFilterBlacklist"},
Test{"set fb.2 AABBCC",".2 AABBCC","setFloodPacketFilterBlacklist"},
Test{"del fb.2",".2","deleteFloodPacketFilterBlacklist"},
Test{"get fr.2 n",".2 n","formatFloodPacketFilters"},
Test{"set fr.2 any n=Alice d",".2 any n=Alice d","setFloodPacketFilter"},
Test{"del fr.2",".2","deleteFloodPacketFilter"},
Test{"set flood.rule.2 any n=Alice d",".2 any n=Alice d","setFloodPacketFilter"}}) {
char reply[160]{};
actual_args=nullptr;actual_action=nullptr;
run(row.command,reply);
assert(!strcmp(reply,"OK") && actual_args && actual_action);
assert(!strcmp(actual_args,row.suffix) && !strcmp(actual_action,row.action));
assert(actual_args>=row.command && actual_args<=row.command+strlen(row.command));
}
for(const char* command : {"get fmore","get fsrs","get fbwifi.password","get fridge"}) {
char reply[160]{};run(command,reply);assert(!strcmp(reply,"unhandled"));
}
}
'''
code = code.replace('@DISPATCH@', dispatch)
with tempfile.TemporaryDirectory() as directory:
work = Path(directory)
(work/'test.cpp').write_text(code)
subprocess.run(['g++', '-std=c++17', '-Wall', '-Wextra', '-Werror',
'-DMESH_ENABLE_FLOOD_RULE_ENGINE=1', '-DMESH_ENABLE_FLOOD_GROUP_MODERATION=1',
str(work/'test.cpp'), '-o', str(work/'test')], check=True)
subprocess.run([str(work/'test')], check=True)
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)
@@ -219,14 +289,34 @@ class TransportFilterWiring(unittest.TestCase):
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'''
code = (f'#include "{(ROOT/"src/helpers/FloodCommandAliases.h").as_posix()}"\n'
'#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"}) {
for (const char* cmd : {"get fr", "get fr.3", "set fr.3 any m=bc d", "del fr.3", "del fr all",
"set fr.3 any c=public sender=^Alice$ text=BEACON len=1-80 prob=50 drop",
"set flood.rule.3 type=grp_txt channel=public sender=^Alice$ text=BEACON drop",
"set flood.rule.3 type=any route=direct path=^AABB>CCDD$ enabled=off stop"}) {
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"})
for (const char* cmd : {"get fridge", "set frwifi.password secret", "get wifi.password", "del frx",
"set frwifi.password sender=^Alice$", "set flood.rulebook type=any drop",
"get fleet.channel", "set fleet.controller off"})
assert(!isFilterMgrAllowed(cmd));
for (const char* cmd : {"get fm", "set fm.2 public Alice drop", "del fm.2",
"get fb", "set fb.2 AABBCC", "del fb.2", "get fa", "get fa 2", "clear fa all"}) {
assert(isFilterMgrAllowed(cmd));
assert(!isRegionMgrAllowed(cmd));
}
for (const char* cmd : {"get fs", "set fs.2 public scope=local", "del fs.2",
"get fsr", "set fsr.2 public", "del fsr.2"}) {
assert(isRegionMgrAllowed(cmd));
assert(!isFilterMgrAllowed(cmd));
}
for (const char* cmd : {"get fmore", "set fss.password secret", "get fsrs", "clear failure all"}) {
assert(!isFilterMgrAllowed(cmd));
assert(!isRegionMgrAllowed(cmd));
}
}
'''
with tempfile.TemporaryDirectory() as directory: