From 6f61365d886692a1b11efb12b5aea6c1fce1e062 Mon Sep 17 00:00:00 2001 From: Rightup Date: Mon, 13 Jul 2026 21:28:21 +0100 Subject: [PATCH] feat: enhance flood loop detection with hash size validation and update tests --- repeater/engine.py | 22 ++++++++++++++-------- tests/test_engine.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/repeater/engine.py b/repeater/engine.py index 5870870..80280af 100644 --- a/repeater/engine.py +++ b/repeater/engine.py @@ -34,13 +34,12 @@ LOOP_DETECT_MINIMAL = "minimal" LOOP_DETECT_MODERATE = "moderate" LOOP_DETECT_STRICT = "strict" -# Thresholds for flood loop detection (hash-size-aware: 1, 2, or 3 bytes per hop). -# Count how many times our own hash already exists in the incoming FLOOD path. -# If occurrences >= threshold, treat as loop and drop. +# Thresholds for flood loop detection, keyed by loop mode and path-hash width. +# MeshCore treats 4-byte hashes as reserved, so only 1-3 byte widths are valid here. LOOP_DETECT_MAX_COUNTERS = { - LOOP_DETECT_MINIMAL: 4, - LOOP_DETECT_MODERATE: 2, - LOOP_DETECT_STRICT: 1, + LOOP_DETECT_MINIMAL: {1: 4, 2: 2, 3: 1}, + LOOP_DETECT_MODERATE: {1: 2, 2: 1, 3: 1}, + LOOP_DETECT_STRICT: {1: 1, 2: 1, 3: 1}, } @@ -778,6 +777,9 @@ class RepeaterHandler(BaseHandler): if not packet or not packet.payload: return False, "Empty payload" + if packet.get_path_hash_size() > 3: + return False, "Reserved path hash size is invalid" + if len(packet.path or []) >= MAX_PATH_SIZE: return ( False, @@ -806,11 +808,15 @@ class RepeaterHandler(BaseHandler): if mode == LOOP_DETECT_OFF: return False - max_counter = LOOP_DETECT_MAX_COUNTERS.get(mode) - if max_counter is None: + max_counters = LOOP_DETECT_MAX_COUNTERS.get(mode) + if max_counters is None: return False hash_size = packet.get_path_hash_size() + if hash_size not in max_counters: + return False + + max_counter = max_counters[hash_size] hop_count = packet.get_path_hash_count() path = packet.path or bytearray() local_hash = self.local_hash_bytes[:hash_size] diff --git a/tests/test_engine.py b/tests/test_engine.py index d45651c..713f707 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -22,6 +22,7 @@ from openhop_core.protocol.constants import ( ROUTE_TYPE_TRANSPORT_DIRECT, ROUTE_TYPE_TRANSPORT_FLOOD, ) +from openhop_core.protocol.packet_utils import PathUtils # --------------------------------------------------------------------------- # Helpers — build minimal config / mocks needed by RepeaterHandler.__init__ @@ -500,6 +501,13 @@ class TestValidatePacket: valid, reason = handler.validate_packet(pkt) assert valid is True + def test_reserved_four_byte_path_width_fails(self, handler): + pkt = _make_flood_packet(path=bytes([LOCAL_HASH] * 4)) + pkt.path_len = (3 << 6) | 1 + valid, reason = handler.validate_packet(pkt) + assert valid is False + assert "reserved" in reason.lower() + def test_none_packet(self, handler): valid, reason = handler.validate_packet(None) assert valid is False @@ -750,6 +758,42 @@ class TestFloodLoopDetection: result = handler.flood_forward(pkt) assert result is None + @pytest.mark.parametrize( + "loop_detect, hash_size, threshold", + [ + ("minimal", 1, 4), + ("minimal", 2, 2), + ("minimal", 3, 1), + ("moderate", 1, 2), + ("moderate", 2, 1), + ("moderate", 3, 1), + ("strict", 1, 1), + ("strict", 2, 1), + ("strict", 3, 1), + ], + ) + def test_loop_threshold_depends_on_hash_width(self, handler, loop_detect, hash_size, threshold): + handler.config["mesh"]["loop_detect"] = loop_detect + handler.reload_runtime_config() + handler.local_hash_bytes = bytes([LOCAL_HASH, 0xCD, 0xEF]) + + local_hash = handler.local_hash_bytes[:hash_size] + + below_payload = bytes([hash_size, threshold, 0x01]) + below_path = local_hash * max(threshold - 1, 0) + below_pkt = _make_flood_packet(path=below_path, payload=below_payload) + below_pkt.path_len = PathUtils.encode_path_len(hash_size, max(threshold - 1, 0)) + below_result = handler.flood_forward(below_pkt) + assert below_result is not None + + at_payload = bytes([hash_size, threshold, 0x02]) + at_path = local_hash * threshold + at_pkt = _make_flood_packet(path=at_path, payload=at_payload) + at_pkt.path_len = PathUtils.encode_path_len(hash_size, threshold) + at_result = handler.flood_forward(at_pkt) + assert at_result is None + assert "loop" in (at_pkt.drop_reason or "").lower() + # =================================================================== # 10. Airtime / duty-cycle integration