"""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 #include #include #include #include #include #include #include #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> 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 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;iopen(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 plain(const std::string& text) { std::vector 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 golden(MyMesh& mesh, unsigned count = 16) { std::vector bytes = {'F','G','M','1',uint8_t(count)}; auto append = [&bytes](const void* ptr, size_t size) { const auto* in = static_cast(ptr); bytes.insert(bytes.end(),in,in+size); }; for (unsigned i=0;i> 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{"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{ {" 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]); } // The prefix consumes five bytes. Preserve the existing154-byte detail // bound, NUL termination, and caller memory for either formatter style. struct GuardedReply { char text[160]; uint32_t canary=0xA5B6C7D8; } bounded; for (bool prefixed : {false,true}) { moderation_detail_reply=(prefixed ? "> " : "")+std::string(159,'X'); longform.setFloodGroupModeration(" public Alice q=9",bounded.text); assert(strlen(bounded.text)==159 && !strncmp(bounded.text,"OK - ",5)); assert(std::string(bounded.text+5)==std::string(154,'X')); assert(bounded.canary==0xA5B6C7D8); } moderation_detail_reply="> saved"; 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{ {" 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()