Extend forwarding filters and save moderation rules atomically
This commit is contained in:
1 parent
585393e36c
commit
953ad6defb
31 files changed
+3453
-342
No files matched your search
@@ -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("):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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,®ions,&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()
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user