Files
MeshCore/test/test_flood_policy_capacity.py
T

392 lines
20 KiB
Python

"""Run production flood-policy load/save code across firmware capacity changes."""
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
from test_common_prefs_commit import HARNESS
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
PRELUDE = r'''
#include <algorithm>
#include <cstring>
#include <cctype>
#include <helpers/FloodFilterPolicy.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_PACKET_FILTER_PATH_PREFIX_BYTES_MAX 9
#define FLOOD_PACKET_FILTER_ANY_TYPE 0xFF
#define FLOOD_PACKET_FILTER_MAX_HOPS 63
#define FLOOD_CHANNEL_HOPS_ALL 0xFF
#define FLOOD_GROUP_MODERATION_RATE_UNLIMITED 0xFFFF
#define FLOOD_CHANNEL_SCOPE_OTHER_ANY 2
#define FLOOD_CHANNEL_SCOPE_SLOTS 15
#define FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS 15
#define FLOOD_PACKET_FILTER_BLACKLIST_MAX 18
#define FLOOD_PACKET_FILTER_PATH_ID_SIZE 3
#define FLOOD_PACKET_FILTER_FILE "/flood_filter"
#define FLOOD_PACKET_FILTER_TEMP_FILE "/flood_filter.tmp"
#define FLOOD_PACKET_FILTER_BACKUP_FILE "/flood_filter.bak"
#define FLOOD_POLICY_SECTION_MAGIC "FPS1"
#define FILE_O_WRITE "w"
namespace mesh {
File openFileRead(FILESYSTEM* fs, const char* path) { return fs->open(path); }
struct Utils {
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=='-'; }
};
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 {
public:
@ENTRY@;
@SCOPE@;
FILESYSTEM* _fs;
std::vector<FloodPacketFilterEntry> storage;
FloodPacketFilterEntry* flood_packet_filters;
uint8_t flood_packet_filter_slots;
bool flood_policy_has_embedded_sections = false;
bool flood_policy_capacity_limited = false;
uint8_t flood_channel_data_rule_slot = 0xff;
uint8_t flood_channel_data_rule_max_hops = FLOOD_CHANNEL_HOPS_ALL;
FloodChannelScopeEntry flood_channel_scopes[FLOOD_CHANNEL_SCOPE_SLOTS]{};
char flood_channel_direct_scopes[FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS][32]{};
uint8_t flood_packet_filter_blacklist_count = 0;
uint8_t flood_packet_filter_blacklist[18][3]{};
unsigned seeded = 0;
MyMesh(MemoryFS& fs, uint8_t slots): _fs(&fs),storage(slots),
flood_packet_filters(storage.data()),flood_packet_filter_slots(slots) {}
void seedDefaultFloodPacketFilters() { ++seeded; }
bool loadFloodPacketFilters();
bool saveFloodPacketFilters(bool empty_scope_phase=false,bool empty_forward_phase=false);
bool isFloodChannelDataRule(const FloodPacketFilterEntry&) const;
};
'''
ROOM_CLASS = r'''
class FloodRuleEngine {
public:
static constexpr uint8_t RULE_SLOTS = 31, NAME_LEN = 32, PATH_PREFIX_BYTES_MAX = 9;
@ENTRY@;
FILESYSTEM* _fs;
Entry storage[RULE_SLOTS]{};
Entry* _entries = storage;
explicit FloodRuleEngine(MemoryFS& fs): _fs(&fs) {}
bool save();
};
static const char RULE_FILE[] = "/flood_filter";
static const char RULE_TEMP_FILE[] = "/flood_filter.tmp";
static const char RULE_BACKUP_FILE[] = "/flood_filter.bak";
'''
SCENARIOS = r'''
template<class Entry>
auto setScope(Entry& row, const char* name, int) -> decltype(row.target_name, void()) {
row.target_is_region = false;
std::strcpy(row.target_name, name);
}
template<class Entry>
void setScope(Entry& row, const char* name, long) { std::strcpy(row.scope_name,name); }
template<class Entry> void canonicalRule(Entry& row) {
row.active=true;row.payload_type=PAYLOAD_TYPE_ADVERT;
row.min_hops=2;row.max_hops=63;row.suspend_on_temp_radio=true;
setScope(row,"#Town",0);row.match_blacklisted_path=true;
row.scope_uses_slow_timing=true;row.incoming_scope_kind=FloodFilterPolicy::RULE_IN_ALLOWED;
row.path_hash_size=3;row.rate_limit_enabled=true;row.rate_per_minute=1234;
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}) {
auto& row=writer.storage[index];row.active=true;row.payload_type=PAYLOAD_TYPE_ADVERT;
row.min_hops=2;row.max_hops=63;row.drop_on_match=true;
row.incoming_scope_kind=FloodFilterPolicy::RULE_IN_ANY;
row.transport_modes=FloodFilterPolicy::RULE_MODE_RADIO;
}
assert(writer.saveFloodPacketFilters());
const auto bytes=fs.get(FLOOD_PACKET_FILTER_FILE);
MyMesh same(fs,slots);assert(same.loadFloodPacketFilters());
assert(same.storage[highest].active && same.storage[highest].drop_on_match);
return bytes;
}
std::map<std::string,std::vector<uint8_t>> snapshot(const MemoryFS& fs) {
std::map<std::string,std::vector<uint8_t>> result;
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.
{
MemoryFS fs;MyMesh writer(fs,63);auto& row=writer.storage[0];
canonicalRule(row);
std::vector<uint8_t> expected={'F','P','F','7',1,1,PAYLOAD_TYPE_ADVERT,2,63,1};
const auto zeroes=[&expected](size_t count) {expected.insert(expected.end(),count,0);};
expected.insert(expected.end(),{'#','T','o','w','n'});zeroes(27);
expected.insert(expected.end(),{1,1,1,3});zeroes(32);
expected.push_back(0x80);zeroes(32+32);
expected.insert(expected.end(),{3,0});zeroes(9);
expected.insert(expected.end(),{0,1,0xd2,0x04});zeroes(32);
expected.insert(expected.end(),{7,1,'F','P','S','1',0xff,0xff,0,0,0});
assert(writer.saveFloodPacketFilters());assert(fs.get(FLOOD_PACKET_FILTER_FILE)==expected);
MyMesh reader(fs,63);assert(reader.loadFloodPacketFilters());const auto& loaded=reader.storage[0];
assert(loaded.active && loaded.suspend_on_temp_radio && loaded.match_blacklisted_path);
assert(loaded.scope_uses_slow_timing && !loaded.drop_on_match && loaded.rate_limit_enabled);
assert(loaded.stop_on_match && loaded.retry_on_match && loaded.rate_per_minute==1234);
assert(!loaded.rate_window_active && loaded.rate_window_started==0 && loaded.rate_window_count==0);
// Execute the real room serializer too. It retains its 31 padded slots,
// and the same canonical row must load through the repeater path.
MemoryFS room_fs;FloodRuleEngine room(room_fs);canonicalRule(room.storage[0]);
auto room_expected=expected;room_expected[4]=31;
const size_t row_stride=expected.size()-5-9;
room_expected.insert(room_expected.end()-9,30*row_stride,0);
assert(room.save());assert(room_fs.get(FLOOD_PACKET_FILTER_FILE)==room_expected);
MyMesh from_room(room_fs,63);assert(from_room.loadFloodPacketFilters());
assert(from_room.storage[0].scope_uses_slow_timing && from_room.storage[0].stop_on_match);
assert(from_room.storage[0].retry_on_match && from_room.storage[0].rate_per_minute==1234);
// A drop rule exercises the remaining persisted packed flag without an
// incompatible scope/rate/retry action, while preserving the same format.
row={};row.active=true;row.payload_type=PAYLOAD_TYPE_ADVERT;row.max_hops=63;row.drop_on_match=true;
assert(writer.saveFloodPacketFilters());MyMesh drop(fs,63);assert(drop.loadFloodPacketFilters());
assert(drop.storage[0].active && drop.storage[0].drop_on_match && !drop.storage[0].retry_on_match);
}
{
MemoryFS fs;MyMesh writer(fs,63);auto& row=writer.storage[62];
row.active=true;row.payload_type=PAYLOAD_TYPE_ADVERT;row.max_hops=63;
row.target_is_region=true;
std::strcpy(row.target_name,"1234567890123456789012345678901");
assert(writer.saveFloodPacketFilters());
const auto image=fs.get(FLOOD_PACKET_FILTER_FILE);
MyMesh reader(fs,63);assert(reader.loadFloodPacketFilters());
assert(reader.storage[62].active && reader.storage[62].target_is_region);
assert(!std::strcmp(reader.storage[62].regionName(), row.target_name));
assert(reader.storage[62].scopeName()[0]==0);
assert(reader.saveFloodPacketFilters() && fs.get(FLOOD_PACKET_FILTER_FILE)==image);
// The stored region field starts at byte 153 of a 187-byte FPF7 row.
// A direct scope and region target in one old-format row is invalid;
// compaction must never silently choose one.
const size_t stride=(image.size()-5-9)/63;
assert(stride==187);
for(bool terminate : {true,false}) {
auto malformed=image;
const size_t scope=5+62*stride+5;
malformed[scope]='#';malformed[scope+1]='X';
if(!terminate)std::fill_n(malformed.begin()+scope,32,'X');
MemoryFS bad;bad.put(FLOOD_PACKET_FILTER_FILE,malformed);
MyMesh boot(bad,63);assert(boot.loadFloodPacketFilters());
assert(boot.seeded==1 && !boot.storage[62].active);
}
}
const auto large=fixture(63,62),small=fixture(4,2);
// Older writers stored every slot, including inactive trailing entries.
// Build the exact production wire format and replace the final active row
// with its serialized empty neighbor; the highest retained rule is slot 1.
MemoryFS empty_fs;MyMesh empty(empty_fs,63);assert(empty.saveFloodPacketFilters());
const auto overhead=empty_fs.get(FLOOD_PACKET_FILTER_FILE).size();
assert((large.size()-overhead)%63==0);
const size_t stride=(large.size()-overhead)/63;
auto padded=large;
std::copy_n(large.begin()+5+stride,stride,padded.begin()+5+62*stride);
for (uint8_t capacity : {uint8_t(4),uint8_t(47)}) {
MemoryFS fs;fs.put(FLOOD_PACKET_FILTER_FILE,padded);MyMesh reader(fs,capacity);
assert(reader.loadFloodPacketFilters());assert(reader.storage[0].active);
assert(!reader.flood_policy_capacity_limited && reader.seeded==0);
assert(fs.get(FLOOD_PACKET_FILTER_FILE)==padded);
assert(reader.saveFloodPacketFilters());
assert(fs.get(FLOOD_PACKET_FILTER_FILE)[4]==1);
}
// FPF6 used a fixed 40-byte legacy row and also stored inactive tails.
for (bool active_overflow : {false,true}) {
std::vector<uint8_t> legacy={'F','P','F','6',63};
for (unsigned index=0;index<63;++index) {
std::vector<uint8_t> row(40,0);
if(index==0 || (active_overflow && index==62)) {
row[0]=1;row[1]=PAYLOAD_TYPE_ADVERT;row[2]=2;row[3]=63;
}
legacy.insert(legacy.end(),row.begin(),row.end());
}
MemoryFS fs;fs.put(FLOOD_PACKET_FILTER_FILE,legacy);MyMesh reader(fs,4);
assert(reader.loadFloodPacketFilters()==!active_overflow);
assert(reader.flood_policy_capacity_limited==active_overflow);
assert(reader.storage[0].active==!active_overflow);
assert(fs.get(FLOOD_PACKET_FILTER_FILE)==legacy);
}
// An extension may not reference an inactive row beyond the new table.
// This malformed padded image must not perform an out-of-bounds read.
{
auto malformed=padded;
assert(std::memcmp(malformed.data()+5+63*stride,"FPS1",4)==0);
malformed[5+63*stride+4]=62;
MemoryFS fs;fs.put(FLOOD_PACKET_FILTER_FILE,malformed);MyMesh reader(fs,4);
assert(reader.loadFloodPacketFilters());assert(reader.seeded==1);
assert(!fs.exists(FLOOD_PACKET_FILTER_FILE));
}
// Future counts outside the 64-bit rule mask remain untouched as unsupported.
{
MemoryFS fs;const std::vector<uint8_t> future={'F','P','F','7',65};
fs.put(FLOOD_PACKET_FILTER_FILE,future);MyMesh reader(fs,4);
assert(!reader.loadFloodPacketFilters() && reader.flood_policy_capacity_limited);
assert(!reader.saveFloodPacketFilters());assert(fs.get(FLOOD_PACKET_FILTER_FILE)==future);
}
// Active overflow must never be mistaken for corrupt storage. Preserve all
// transaction images and refuse saves, including the empty-table path.
for (uint8_t capacity : {uint8_t(4),uint8_t(47)}) {
for (unsigned source=0;source<4;++source) {
MemoryFS fs;
if (source==0) {
fs.put(FLOOD_PACKET_FILTER_FILE,large);
fs.put(FLOOD_PACKET_FILTER_TEMP_FILE,small);
fs.put(FLOOD_PACKET_FILTER_BACKUP_FILE,small);
} else if(source==1) {
fs.put(FLOOD_PACKET_FILTER_TEMP_FILE,large);
fs.put(FLOOD_PACKET_FILTER_BACKUP_FILE,small);
} else if(source==2) {
fs.put(FLOOD_PACKET_FILTER_BACKUP_FILE,large);
} else {
fs.put(FLOOD_PACKET_FILTER_FILE,{0,1,2});
fs.put(FLOOD_PACKET_FILTER_TEMP_FILE,large);
fs.put(FLOOD_PACKET_FILTER_BACKUP_FILE,small);
}
const auto before=snapshot(fs);MyMesh reader(fs,capacity);
assert(!reader.loadFloodPacketFilters());
assert(reader.flood_policy_capacity_limited && reader.seeded==0);
assert(std::none_of(reader.storage.begin(),reader.storage.end(),[](const auto& e){return e.active;}));
assert(!reader.saveFloodPacketFilters());
assert(!reader.saveFloodPacketFilters(true,true));
assert(snapshot(fs)==before);
MyMesh compatible(fs,63);assert(compatible.loadFloodPacketFilters());
assert(compatible.storage[62].active);
}
}
// Normal committed and recoverable tables still use the production path.
for (const char* path : {FLOOD_PACKET_FILTER_FILE,FLOOD_PACKET_FILTER_TEMP_FILE,FLOOD_PACKET_FILTER_BACKUP_FILE}) {
MemoryFS fs;fs.put(path,small);MyMesh reader(fs,4);
assert(reader.loadFloodPacketFilters());assert(reader.storage[2].active);
assert(!reader.flood_policy_capacity_limited);
assert(fs.get(FLOOD_PACKET_FILTER_FILE)==small);
}
// Malformed recognized images remain corrupt, unlike a capacity mismatch.
MemoryFS bad;bad.put(FLOOD_PACKET_FILTER_FILE,{0,1,2});MyMesh reader(bad,4);
assert(reader.loadFloodPacketFilters());assert(reader.seeded==1);
assert(!reader.flood_policy_capacity_limited);
assert(!bad.exists(FLOOD_PACKET_FILTER_FILE));
}
'''
class FloodPolicyCapacityTests(unittest.TestCase):
def test_saved_rules_survive_smaller_firmware_capacity(self):
source=(ROOT/'examples/simple_repeater/MyMesh.cpp').read_text()
header=(ROOT/'examples/simple_repeater/MyMesh.h').read_text()
room_source=(ROOT/'examples/simple_room_server/FloodRuleEngine.cpp').read_text()
room_header=(ROOT/'examples/simple_room_server/FloodRuleEngine.h').read_text()
fs=HARNESS[:HARNESS.index('@STORE@')]
fs=fs.replace('bool valid = false;', 'bool valid = false;\n size_t cursor=0;')
fs=fs.replace('size_t write(const uint8_t*, size_t);', '''size_t write(const uint8_t*, size_t);
size_t read(uint8_t* out,size_t count) {
if (!valid) return 0;
count=std::min(count,bytes->size()-cursor);
std::copy_n(bytes->data()+cursor,count,out);cursor+=count;return count;
}
size_t available() const { return valid ? bytes->size()-cursor : 0; }
''')
fs='#include <algorithm>\n'+fs
parts=[fs,PRELUDE,CLASS.replace('@ENTRY@',extract_braced(header,'struct FloodPacketFilterEntry')).replace('@SCOPE@',extract_braced(header,'struct FloodChannelScopeEntry'))]
for marker in ('static File openFloodSettingsRead(', 'static File openFloodSettingsWrite(',
'static uint32_t updateFloodSettingsHash(', 'static bool verifyFloodSettingsWrite(',
'static bool isValidStoredFloodFilterScopeName(', 'static bool isValidStoredFloodRuleRegionName(',
'bool MyMesh::isFloodChannelDataRule(', 'bool MyMesh::loadFloodPacketFilters(',
'bool MyMesh::saveFloodPacketFilters('):
parts.append(extract_braced(source,marker))
parts.append(ROOM_CLASS.replace('@ENTRY@',extract_braced(room_header,'struct Entry')))
for marker in ('static File openRead(', 'static File openWrite(',
'static uint32_t updateFileHash(', 'static bool verifyWrittenFile(',
'bool FloodRuleEngine::save('):
parts.append(extract_braced(room_source,marker))
parts.append(SCENARIOS)
with tempfile.TemporaryDirectory(prefix='flood-policy-capacity-') as temp:
p=Path(temp);cpp=p/'test.cpp';cpp.write_text('\n'.join(parts))
for platform in ('NRF52_PLATFORM','ESP32'):
with self.subTest(platform=platform):
exe=p/platform
subprocess.run(['g++','-std=c++17','-O1','-g','-fsanitize=address,undefined',
'-fno-omit-frame-pointer','-no-pie','-D'+platform,
'-I'+str(ROOT/'src'),str(cpp),'-o',str(exe)],check=True)
subprocess.run([str(exe)],check=True,env={**os.environ,'ASAN_OPTIONS':'detect_leaks=1'})
if __name__=='__main__':unittest.main()