Files
MeshCore/test/test_flood_rule_layout.py
T

286 lines
13 KiB
Python

"""Exercise production flood-entry layouts and independent packed flag states."""
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
PRELUDE = r'''
#include <cassert>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <type_traits>
#include <helpers/FloodFilterPolicy.h>
#include <helpers/FloodRuleCLI.h>
#define FLOOD_PACKET_FILTER_SCOPE_NAME_LEN 32
#define FLOOD_GROUP_MODERATION_NAME_LEN 32
#define FLOOD_PACKET_FILTER_PATH_PREFIX_BYTES_MAX 9
static constexpr uint8_t NAME_LEN = 32, PATH_PREFIX_BYTES_MAX = 9;
'''
SCENARIOS = r'''
static_assert(sizeof(RepeaterEntry) == 300 && sizeof(RoomEntry) == 332,
"compact repeater rows save 32 bytes; room layout stays unchanged");
static_assert(sizeof(LegacyEntry) == 40, "retain legacy STM32 rule layout");
static_assert(alignof(RepeaterEntry) >= alignof(uint32_t));
static_assert(alignof(RoomEntry) >= alignof(uint32_t));
#define CHECK_ALIGNED(T) \
static_assert(offsetof(T, rate_window_started) % alignof(uint32_t) == 0); \
static_assert(offsetof(T, rate_window_count) % alignof(uint16_t) == 0); \
static_assert(offsetof(T, rate_per_minute) % alignof(uint16_t) == 0); \
static_assert(std::is_trivially_copyable<T>::value)
CHECK_ALIGNED(RepeaterEntry);
CHECK_ALIGNED(RoomEntry);
template<class Entry>
auto setTargetByte(Entry& e, bool region, size_t offset, char value, int)
-> decltype(e.target_name, void()) {
e.target_is_region = region;
e.target_name[offset] = value;
}
template<class Entry>
void setTargetByte(Entry& e, bool region, size_t offset, char value, long) {
(region ? e.target_region_name : e.scope_name)[offset] = value;
}
template<class Entry> void setFlags(Entry& e, unsigned flags) {
e.active = flags & 1;
e.suspend_on_temp_radio = flags & 2;
e.match_blacklisted_path = flags & 4;
e.scope_uses_slow_timing = flags & 8;
e.drop_on_match = flags & 16;
e.rate_limit_enabled = flags & 32;
e.stop_on_match = flags & 64;
e.retry_on_match = flags & 128;
e.rate_window_active = flags & 256;
}
template<class Entry> unsigned flagsOf(const Entry& e) {
return unsigned(e.active) | unsigned(e.suspend_on_temp_radio) * 2
| unsigned(e.match_blacklisted_path) * 4
| unsigned(e.scope_uses_slow_timing) * 8 | unsigned(e.drop_on_match) * 16
| unsigned(e.rate_limit_enabled) * 32 | unsigned(e.stop_on_match) * 64
| unsigned(e.retry_on_match) * 128 | unsigned(e.rate_window_active) * 256;
}
template<class Entry> void exerciseFlags() {
// Exhaustive combinations detect adjacent flag corruption; copy/clear are
// the same operations used by production rule load, delete, and replace.
for (unsigned flags = 0; flags < 512; ++flags) {
Entry e{};
e.rate_window_started = 0xfedcba98;
e.rate_window_count = 0x7654;
e.rate_per_minute = 0x3210;
setFlags(e, flags);
assert(flagsOf(e) == flags);
assert(e.rate_window_started == 0xfedcba98);
assert(e.rate_window_count == 0x7654 && e.rate_per_minute == 0x3210);
Entry copied = e;
assert(flagsOf(copied) == flags);
std::memcpy(&copied, &e, sizeof(e));
assert(flagsOf(copied) == flags);
// These actual decoders require ordinary bool references. Packing must
// preserve this API and must not alter any unrelated flag.
assert(FloodFilterPolicy::decodeStoredRuleActive(5, copied.active,
copied.transport_modes));
assert(FloodFilterPolicy::decodeStoredRuleChannel(16, copied.channel_key_len,
copied.retry_on_match));
assert(flagsOf(copied) == ((flags | 1) & ~128U));
assert(copied.transport_modes == 4 && copied.channel_key_len == 16);
std::memset(&copied, 0, sizeof(copied));
assert(flagsOf(copied) == 0 && copied.rate_window_started == 0);
}
}
template<class Entry> void exerciseConfigurationEquality() {
Entry original{};
original.active = true;
original.rate_limit_enabled = true;
original.rate_per_minute = 5;
Entry live = original;
live.rate_window_started = 0xfedcba98;
live.rate_window_count = 3;
live.rate_window_active = true;
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, 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
// their final byte so accidental prefix-only comparisons cannot pass.
for (unsigned field = 0; field < 26; ++field) {
Entry changed = original;
switch (field) {
case 0: changed.active = false; break;
case 1: changed.payload_type = 1; break;
case 2: changed.min_hops = 1; break;
case 3: changed.max_hops = 1; break;
case 4: changed.suspend_on_temp_radio = true; break;
case 5: setTargetByte(changed, false, 31, 1, 0); break;
case 6: changed.match_blacklisted_path = true; break;
case 7: changed.scope_uses_slow_timing = true; break;
case 8: changed.incoming_scope_kind = 1; break;
case 9: changed.incoming_scope_name[sizeof(changed.incoming_scope_name)-1] = 1; break;
case 10: changed.channel_key_len = 1; break;
case 11: changed.channel_hash = 1; break;
case 12: changed.channel_secret[sizeof(changed.channel_secret)-1] = 1; break;
case 13: changed.channel_name[sizeof(changed.channel_name)-1] = 1; break;
case 14: changed.path_hash_size = 1; break;
case 15: changed.path_hops = 1; break;
case 16: changed.path[sizeof(changed.path)-1] = 1; break;
case 17: setTargetByte(changed, true, 31, 1, 0); break;
case 18: changed.drop_on_match = true; break;
case 19: changed.rate_limit_enabled = false; break;
case 20: changed.rate_per_minute = 6; break;
case 21: changed.priority = 1; break;
case 22: changed.stop_on_match = true; break;
case 23: changed.retry_on_match = true; break;
case 24: changed.transport_modes = 2; break;
case 25: setTargetByte(changed, false, 0, '#', 0); break;
}
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;
Entry candidate{};
candidate.active = true; candidate.payload_type = PAYLOAD_TYPE_ADVERT;
candidate.max_hops = 63; candidate.rate_limit_enabled = true;
candidate.rate_per_minute = 5;
assert(rules.select(candidate) == 0);
rules.storage[0] = candidate;
rules.storage[0].rate_window_started = 100;
rules.storage[0].rate_window_count = 3;
rules.storage[0].rate_window_active = true;
assert(rules.select(candidate) == 0); // Reuse despite a live rate window.
candidate.path_hash_size = 2;
assert(rules.select(candidate) == 1); // Distinct configuration needs a slot.
rules.storage[1] = candidate;
assert(rules.select(candidate) == 1);
assert(rules.select(candidate, 3) == 3); // Explicit index remains authoritative.
}
int main() {
// Every old flag combination can coexist with either target kind. Neither
// the tag nor name views may corrupt independent flags or leak a stale name.
for (unsigned flags = 0; flags < 512; ++flags) {
for (unsigned region = 0; region < 2; ++region) {
RepeaterEntry e{};
setFlags(e, flags);
e.target_is_region = region;
std::strcpy(e.target_name, "1234567890123456789012345678901");
assert(flagsOf(e) == flags);
const auto& selected = region ? e.regionName() : e.scopeName();
const auto& inactive = region ? e.scopeName() : e.regionName();
assert(!std::strcmp(selected, e.target_name));
for (char byte : inactive) assert(byte == 0);
RepeaterEntry copied = e;
assert(copied.target_is_region == region);
assert(!std::memcmp(copied.target_name, e.target_name, 32));
assert(flagsOf(copied) == flags);
}
}
exerciseFlags<RepeaterEntry>();
exerciseFlags<RoomEntry>();
exerciseConfigurationEquality<RepeaterEntry>();
exerciseConfigurationEquality<RoomEntry>();
exerciseProductionSlotSelection<RepeaterRules, RepeaterEntry>();
exerciseProductionSlotSelection<RoomRules, RoomEntry>();
}
'''
def production_layouts():
repeater = extract_braced(
(ROOT / 'examples/simple_repeater/MyMesh.h').read_text(),
'struct FloodPacketFilterEntry')
room = extract_braced(
(ROOT / 'examples/simple_room_server/FloodRuleEngine.h').read_text(),
'struct Entry')
return ('#define MESH_ENABLE_FLOOD_RULE_ENGINE 1\n'
+ repeater.replace('struct FloodPacketFilterEntry', 'struct RepeaterEntry')
+ ';\n' + room.replace('struct Entry', 'struct RoomEntry') + ';\n'
+ '#undef MESH_ENABLE_FLOOD_RULE_ENGINE\n'
+ '#define MESH_ENABLE_FLOOD_RULE_ENGINE 0\n'
+ repeater.replace('struct FloodPacketFilterEntry', 'struct LegacyEntry') + ';\n')
def production_slot_selection():
bodies = []
for role, marker, end, members in (
('simple_repeater', 'void MyMesh::setFloodPacketFilter(',
'\n if (slot < 0) {\n strcpy(reply, "Err - filter table full");',
'using FloodPacketFilterEntry = RepeaterEntry;\n'
'RepeaterEntry storage[63]{};\n'
'RepeaterEntry* flood_packet_filters = storage;\n'
'uint8_t flood_packet_filter_slots = 63, flood_channel_data_rule_slot = 0xff;'),
('simple_room_server', 'void FloodRuleEngine::set(',
'\n if (slot < 0) {\n copyString(reply, "Err - filter table full", 160);',
'using Entry = RoomEntry;\n'
'static constexpr uint8_t RULE_SLOTS = 31;\n'
'RoomEntry storage[RULE_SLOTS]{};\nEntry* _entries = storage;'),
):
file = 'MyMesh.cpp' if role == 'simple_repeater' else 'FloodRuleEngine.cpp'
method = extract_braced((ROOT / 'examples' / role / file).read_text(), marker)
# Compile both real setter slot-selection paths, not a test rewrite.
body = method[method.index(' int slot = requested_slot;'):method.index(end)]
name = 'RepeaterRules' if role == 'simple_repeater' else 'RoomRules'
entry = 'RepeaterEntry' if role == 'simple_repeater' else 'RoomEntry'
bodies.append('struct ' + name + ' {\n' + members
+ '\nint select(const ' + entry + '& candidate, int requested_slot=-1) {\n'
+ body + '\nreturn slot;\n}\n};\n')
return ''.join(bodies)
class FloodRuleLayoutTests(unittest.TestCase):
def test_packed_flags_keep_all_states_and_aligned_counters(self):
with tempfile.TemporaryDirectory(prefix='flood-rule-layout-') as temp:
path = Path(temp)
source = path / 'test.cpp'
source.write_text(PRELUDE + production_layouts() + production_slot_selection() + SCENARIOS)
exe = path / 'test'
subprocess.run(['g++', '-std=c++17', '-O1', '-g',
'-fsanitize=address,undefined', '-fno-omit-frame-pointer',
'-no-pie', '-I' + str(ROOT / 'src'), str(source),
'-o', str(exe)], check=True)
subprocess.run([str(exe)], check=True,
env={**os.environ, 'ASAN_OPTIONS': 'detect_leaks=1'})
def test_arm_layouts_keep_the_same_bound_and_natural_alignment(self):
compilers = sorted((Path.home() / '.platformio/packages').glob(
'toolchain-gccarmnoneeabi*/bin/arm-none-eabi-g++'))
if not compilers:
self.skipTest('ARM compiler is not installed')
# Syntax/type checks invoke the compiler directly, never PlatformIO.
for compiler in compilers:
with self.subTest(compiler=compiler.parent.parent.name):
subprocess.run([str(compiler), '-std=c++17', '-mcpu=cortex-m4',
'-mthumb', '-fsyntax-only', '-x', 'c++',
'-I' + str(ROOT / 'src'), '-'], check=True,
input=PRELUDE + production_layouts() + production_slot_selection() + SCENARIOS,
text=True)
if __name__ == '__main__':
unittest.main()