#!/usr/bin/env python3 """Execute role reply helpers to check full-admin CLI and GPIO policy scope.""" from pathlib import Path import os import re import subprocess import sys import tempfile import unittest from test_replay_reset_integration import extract_braced ROOT = Path(__file__).resolve().parents[1] HARNESS = r''' #include #include #include #include #include #include #include #include #include #include #include #include #include @CONSTANTS@ namespace mesh { struct Identity { uint8_t pub_key[PUB_KEY_SIZE]{}; }; struct Utils { static void sha256(uint8_t* out, size_t size, const uint8_t*, size_t, const uint8_t* = nullptr, size_t = 0) { memset(out, 0x11, size); } }; @PACKET_METHODS@ } using namespace mesh; @CLIENT_INFO@; @GPIO_STATE@ struct TransportKey {}; struct Clock { uint32_t getCurrentTimeUnique() { return 1000; } uint32_t getCurrentTime() { return 1000; } }; struct RNG { void random(uint8_t* out, size_t size) { memset(out, 0x42, size); } }; struct Radio { mesh::RadioProfiles config; const mesh::RadioProfiles* profiles() const { return &config; } }; struct ACL { ClientInfo clients[3]{}; int count=2; int getNumClients() const { return count; } ClientInfo* getClientByIdx(int i) { assert(i>=0 && i data; }; std::vector sent; MyMesh() { radio.config.secondary.mode=mesh::RadioProfileMode::RxTx; radio.config.cross=mesh::RadioCrossMode::Off; radio.config.reply_tx=mesh::RADIO_TX_BOTH; for (int i=0;iradio_reply,getTransmitProfileMask(p), std::vector(p->payload,p->payload+p->payload_len)}); return true; } bool sendClientReplyWithFallbackScope(ClientInfo*, mesh::Packet* p, unsigned long, uint8_t, const TransportKey*, bool copies) { redundant=copies; return queue(p); } bool sendClientReply(ClientInfo*, mesh::Packet* p, unsigned long, uint8_t) { return queue(p); } bool sendFlood(mesh::Packet* p, unsigned long, uint8_t) { return queue(p); } bool sendFloodScoped(uint8_t,mesh::Packet* p,unsigned long,uint8_t) { return queue(p); } bool sendDirect(mesh::Packet* p,const uint8_t*,uint8_t,unsigned long) { return queue(p); } void replaceActiveMessageRetries(mesh::Packet*,const uint8_t*,uint32_t) {} bool sendRemoteCliReply(ClientInfo*,const uint8_t*,uint8_t,uint32_t, const char*,const TransportKey*,mesh::Packet** = nullptr); void onUserGpioTimerCompleted(uint8_t,uint8_t,uint32_t); bool pushRoomTextToClient(ClientInfo*,uint32_t,const mesh::Identity&, const char*,uint32_t = 0); #if SENSOR_ROLE int matching_peer_indexes[MAX_SEARCH_RESULTS]{}; void onPeerDataRecv(mesh::Packet*,uint8_t,int,const uint8_t*,uint8_t*,size_t); uint8_t handleRequest(ClientInfo*,uint32_t,uint8_t,const uint8_t*,size_t,size_t) { reply_data[0]=0x5a; return 1; } bool handleIncomingMsg(ClientInfo&,uint32_t,uint8_t*,uint8_t,size_t) { return true; } void handleCommand(uint32_t,char*,char* reply,int,uint8_t) { strcpy(reply,"OK"); } bool sendFloodReply(mesh::Packet* p,unsigned long,uint8_t) { return queue(p); } void sendAckTo(ClientInfo& client,uint32_t,uint8_t) { const uint8_t data=0; queue(createDatagram(PAYLOAD_TYPE_ACK,client.id,client.shared_secret,&data,1)); } mesh::Packet* createPathReturn(const mesh::Identity& id,const uint8_t* key, const uint8_t*,uint8_t,uint8_t type,const uint8_t* data,size_t length) { return createDatagram(type,id,key,data,length); } #endif }; @METHODS@ int main() { #if SENSOR_ROLE for (uint8_t role=0;role<=PERM_ACL_ROLE_MASK;++role) { for (uint8_t flags : {TXT_TYPE_PLAIN,TXT_TYPE_CLI_DATA,TXT_TYPE_CLI_COMMAND}) { for (bool request : {false,true}) { MyMesh node; auto* client=&node.acl.clients[0]; client->permissions=role; uint8_t data[16]{}; const uint32_t timestamp=900; memcpy(data,×tamp,4); data[4]=flags<<2; strcpy(reinterpret_cast(data+5),"get tx"); mesh::Packet incoming; incoming.header=ROUTE_TYPE_DIRECT; node.onPeerDataRecv(&incoming,request ? PAYLOAD_TYPE_REQ : PAYLOAD_TYPE_TXT_MSG, 0,client->shared_secret,data,11); const bool admin=role==PERM_ACL_ADMIN; const bool admin_cli=admin && !request && flags!=TXT_TYPE_PLAIN; assert(node.sent.size()==(request || admin ? 1U : 0U)); if (!node.sent.empty()) { assert(node.sent[0].reply==admin_cli); assert(node.sent[0].mask==(admin_cli ? 3 : 2)); } } } } #endif #if REPEATER_ROLE for (unsigned permissions=0;permissions<256;++permissions) { MyMesh node; auto* client=&node.acl.clients[0]; client->permissions=permissions; assert(node.sendRemoteCliReply(client,client->shared_secret,1,1000,"OK",nullptr)); assert(node.sent.size()==1); const bool admin=(permissions & PERM_ACL_ROLE_MASK)==PERM_ACL_ADMIN; assert(node.sent[0].reply==admin && node.sent[0].mask==(admin ? 3 : 2)); // The same helper emits cached, deferred, and immediate CLI results. node.radio.config.secondary.mode=mesh::RadioProfileMode::Rx; node.radio.config.reply_force=true; assert(node.sendRemoteCliReply(client,client->shared_secret,1,1000,"cached OK",nullptr)); assert(node.sent.back().mask==(admin ? 3 : 0)); } MyMesh failed; auto* client=&failed.acl.clients[0]; mesh::Packet* queued=&failed.packet; failed.allocation_ok=false; assert(!failed.sendRemoteCliReply(client,client->shared_secret,1,0,"OK",nullptr,&queued)); assert(queued==nullptr && failed.sent.empty()); #endif for (uint8_t role=0;role<=PERM_ACL_ROLE_MASK;++role) { for (bool move : {false,true}) { MyMesh node; const uint8_t original_key=node.acl.clients[0].id.pub_key[0]; node._gpio_reply_tracker.beginCommand(0,2,node.acl.clients[0].id.pub_key); node._gpio_reply_tracker.timerScheduled(7,123); if (move) { node.acl.clients[1]=node.acl.clients[0]; memset(node.acl.clients[0].id.pub_key,0xAA,PUB_KEY_SIZE); } node.acl.clients[move ? 1 : 0].permissions=role; node.onUserGpioTimerCompleted(7,UserGpio::STATE_OFF,123); const bool admin=role==PERM_ACL_ADMIN; assert(node.sent.size()==(admin ? 1U : 0U)); if (admin) { assert(node.sent[0].reply && node.sent[0].mask==3); assert(node.acl.clients[move ? 1 : 0].id.pub_key[0]==original_key); } node.onUserGpioTimerCompleted(7,UserGpio::STATE_OFF,123); assert(node.sent.size()==(admin ? 1U : 0U)); // route is consumed once } } MyMesh removed; removed._gpio_reply_tracker.beginCommand(0,1,removed.acl.clients[0].id.pub_key); removed._gpio_reply_tracker.timerScheduled(3,456); memset(removed.acl.clients[0].id.pub_key,0x55,PUB_KEY_SIZE); removed.onUserGpioTimerCompleted(3,UserGpio::STATE_ON,456); assert(removed.sent.empty()); // a replacement ACL slot cannot inherit the reply #if ROOM_ROLE for (bool flood : {false,true}) { for (uint8_t role : {PERM_ACL_READ_WRITE,PERM_ACL_ADMIN}) { MyMesh node; node.receiving=false; node.radio.config.secondary.mode=mesh::RadioProfileMode::Rx; node.radio.config.reply_force=true; auto* client=&node.acl.clients[0]; client->permissions=role; if (flood) client->out_path_len=OUT_PATH_UNKNOWN; assert(node.pushRoomTextToClient(client,100,client->id,"room post")); assert(node.sent.size()==1 && !node.sent[0].reply && node.sent[0].mask==1); } } #endif puts("full-admin CLI/GPIO reply scope and revoked identity checks passed"); } ''' class AdminCliReplyScopeTests(unittest.TestCase): def test_role_helpers_gate_reply_override_and_reauthorize_gpio(self): acl = (ROOT / "src/helpers/ClientACL.h").read_text() packet = (ROOT / "src/Packet.cpp").read_text() dispatcher = (ROOT / "src/Dispatcher.cpp").read_text() constants = "\n".join(line for line in acl.splitlines() if line.startswith(("#define PERM_ACL_", "#define OUT_PATH_"))) for role, path, class_name in ( ("repeater", "examples/simple_repeater", "MyMesh"), ("room", "examples/simple_room_server", "MyMesh"), ("sensor", "examples/simple_sensor", "SensorMesh")): with self.subTest(role=role): source = (ROOT / path / (class_name + ".cpp")).read_text() header = (ROOT / path / (class_name + ".h")).read_text() protocol = (ROOT / "src/Mesh.h").read_text() labels = constants names = ["SERVER_RESPONSE_DELAY"] if role == "sensor": names.append("TXT_ACK_DELAY") if role != "room": names.append("CLI_REPLY_DELAY_MILLIS") else: names += ["PUSH_ACK_TIMEOUT_FLOOD", "PUSH_TIMEOUT_BASE", "PUSH_ACK_TIMEOUT_FACTOR"] for name in names: match = re.search(r"^\s*#define\s+" + name + r"\s+.*$", source + "\n" + header + "\n" + protocol, re.MULTILINE) self.assertIsNotNone(match, name) labels += "\n" + match.group() methods = extract_braced(source, "void " + class_name + "::onUserGpioTimerCompleted(") methods = methods.replace(class_name + "::", "MyMesh::") if role == "repeater": methods += "\n" + extract_braced(source, "bool MyMesh::sendRemoteCliReply(") elif role == "room": methods += "\n" + extract_braced(source, "bool MyMesh::pushRoomTextToClient(") else: labels += "\n#define MAX_SEARCH_RESULTS 24" methods += "\n" + extract_braced(source, "void SensorMesh::onPeerDataRecv(").replace( "SensorMesh::", "MyMesh::") mask = extract_braced(dispatcher, "uint8_t Dispatcher::getTransmitProfileMask(") methods += "\n" + mask.replace("Dispatcher::", "MyMesh::") generated = HARNESS.replace("@CONSTANTS@", labels) generated = generated.replace("@CLIENT_INFO@", extract_braced(acl, "struct ClientInfo {")) generated = generated.replace("@PACKET_METHODS@", "\n".join( extract_braced(packet, signature) for signature in ("Packet::Packet()", "bool Packet::isValidPathLen("))) gpio = (ROOT / "src/helpers/UserGpio.cpp").read_text() generated = generated.replace("@GPIO_STATE@", extract_braced(gpio, "const char* UserGpio::stateName(")) generated = generated.replace("@METHODS@", methods) with tempfile.TemporaryDirectory(prefix="admin-cli-reply-scope-") as directory: work = Path(directory) cpp = work / "test.cpp"; cpp.write_text(generated, encoding="ascii") binary = work / "reply-scope" sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-omit-frame-pointer", "-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else []) command = [os.environ.get("CXX", "g++"), "-std=c++17", "-O1", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", *sanitizers, "-DREPEATER_ROLE=" + str(int(role == "repeater")), "-DROOM_ROLE=" + str(int(role == "room")), "-DSENSOR_ROLE=" + str(int(role == "sensor")), "-I" + str(ROOT / "src"), "-I" + str(ROOT / "test/mocks"), str(cpp), "-o", str(binary)] compiled = subprocess.run(command, capture_output=True, text=True, timeout=60) self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr) checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=15) self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) if __name__ == "__main__": unittest.main()