diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index a02f1eab5..03f81ff81 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -96,6 +96,11 @@ jobs: - name: Verify bridge filter wiring and shorthand permissions run: python3 -B test/test_transport_filter_wiring.py -v + - name: Verify saved repeat-off trace exception and relay guards + run: | + python3 -B test/test_repeater_trace_repeat.py -v + python3 -B test/test_repeater_trace_forwarding.py -v + - name: Verify MQTT start results and connection-attempt accounting run: python3 -B test/test_mqtt_transport_results.py -v diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index 800694fac..a50eba4c6 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -165,6 +165,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto | System | [`get/set reboot.interval`](#reboot-interval) | Full parser | Yes | Yes | Yes | | Clock sync | [`get/set clock.sync.*`; `clock.sync.mesh now`](cli_commands.md#estimate-and-correct-infrastructure-node-time-after-startup) | Repeater, sensor, and room server; `clock.sync.internet` needs MQTT repeater | Yes | Yes | Yes | | Routing | [`get/set repeat`](cli_commands.md#view-or-change-this-nodes-repeat-flag) | Forwarding-capable role | Yes | Yes | Yes | +| Routing | [`get/set repeat.trace`](cli_commands.md#allow-routed-traces-while-repeating-is-disabled-repeater-only) | Repeater only; saved repeat-off trace exception, default off | Yes | Yes | Yes | | Routing | [`get/set path.hash.mode`](cli_commands.md#view-or-change-this-nodes-advert-path-hash-size) | Role that supports path-hash selection | Yes | Yes | Yes | | Routing | [`get/set loop.detect`](cli_commands.md#view-or-change-this-nodes-loop-detection) | Repeater | Yes | Yes | Yes | | Routing | [`get/set txdelay`](cli_commands.md#view-or-change-the-retransmit-delay-factor-for-flood-traffic) | All text CLI roles, including bridges | Yes | Yes | Yes | @@ -338,6 +339,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto | System | [`get/set reboot.interval`](#reboot-interval) | Full parser | Yes | Yes | Yes | Yes | Yes | | Clock sync | [`get/set clock.sync.*`; `clock.sync.mesh now`](cli_commands.md#estimate-and-correct-infrastructure-node-time-after-startup) | Repeater, sensor, and room server; `clock.sync.internet` needs MQTT repeater | Yes | Yes | Yes | Yes | Yes | | Routing | [`get/set repeat`](cli_commands.md#view-or-change-this-nodes-repeat-flag) | Forwarding-capable role | Yes | Yes | Yes | Yes | Yes | +| Routing | [`get/set repeat.trace`](cli_commands.md#allow-routed-traces-while-repeating-is-disabled-repeater-only) | Repeater only; saved repeat-off trace exception, default off | Yes | Yes | Yes | Yes | Yes | | Routing | [`get/set path.hash.mode`](cli_commands.md#view-or-change-this-nodes-advert-path-hash-size) | Full parser | Yes | Yes | Yes | Yes | Yes | | Routing | [`get/set loop.detect`](cli_commands.md#view-or-change-this-nodes-loop-detection) | Repeater, full common parser | Yes | Yes | Yes | Yes | Yes | | Routing | [`get/set txdelay`](cli_commands.md#view-or-change-the-retransmit-delay-factor-for-flood-traffic) | All text CLI roles, including bridges | Yes | Yes | Yes | Yes | Yes | diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 62a6f0673..f1f459661 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -2382,7 +2382,47 @@ The pin number is the Arduino pin number used by that target (the normal GPIO nu **Parameters:** - `state`: `on`|`off` -**Default:** `flood.channel.data on`; `flood.channel.data.hops h=all` +**Default:** `repeat on`. + +`repeat off` disables relay forwarding, not locally generated traffic or +configuration replies. Repeaters can separately opt in to the routed-trace +exception below. + +#### Allow routed traces while repeating is disabled (repeater only) + +```text +get repeat.trace +set repeat.trace on +set repeat.trace off +``` + +Default: `off`, including upgrades from older preference files. The setting is +saved and takes effect without reboot. Failed saves leave the active choice +unchanged. Room Server, Sensor, and Companion do not gain this exception. + +To keep ordinary forwarding disabled while allowing routed trace diagnostics: + +```text +set repeat off +set repeat.trace on +``` + +Only direct TRACE packets with a matching next hop may be relayed. Ordinary +messages, adverts, group data, ACKs, and control packets remain subject to +`repeat off`; flood traces remain invalid. Existing trace length, path-capacity, +duplicate, radio-profile, and channel/duty-cycle checks still apply. This is +a diagnostics opt-in, not authenticated-admin-only forwarding: trace packets +retain the protocol's existing authentication behavior. + +`get repeat` and the disabled status bit continue to report repeating off. +With `repeat on`, traces use the ordinary forwarding policy regardless of +`repeat.trace`. Changing the exception to off while repeating is off blocks +subsequent relayed trace transmissions, including queued copies and retries; +a trace already on air is allowed to finish. Locally initiated traffic and +management replies are not cancelled. + +The dynamic configuration field is `repeat.trace_off` (`0` or `1`). It is +appended to `/com_prefs`; all previously saved field offsets stay unchanged. --- diff --git a/docs/role_feature_switches.md b/docs/role_feature_switches.md index 2af39931e..8a1436259 100644 --- a/docs/role_feature_switches.md +++ b/docs/role_feature_switches.md @@ -260,6 +260,9 @@ settings override these defaults. To toggle only MQTT while keeping USB logging unchanged, use `set mqtt.enabled off` / `on`. Neither setting turns LoRa repeating off. Repeater forwarding uses `set repeat off` / `on` and `get repeat` separately. +Repeaters can opt in to routed trace diagnostics with `set repeat.trace on` +while `repeat` stays off; the saved exception defaults off and does not relay +other payload types. See [repeat-off traces](cli_commands.md#allow-routed-traces-while-repeating-is-disabled-repeater-only). The selector changes USB master output, not saved `usb.debug` intent. Direct WiFi MQTT is independent of that USB verbosity preference. diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index dd33b54d2..6734a0241 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1022,7 +1022,8 @@ bool MyMesh::floodChannelDataHopApplies(const mesh::Packet* packet) const { } bool MyMesh::allowPacketForward(const mesh::Packet *packet) { - if (_prefs.disable_fwd) return false; + if (_prefs.disable_fwd && !(_prefs.trace_when_repeat_off == 1 && packet + && packet->isRouteDirect() && packet->getPayloadType() == PAYLOAD_TYPE_TRACE)) return false; if (packet->isRouteFlood()) { if (mesh::isFloodHopLimitExceeded(packet, _prefs.flood_max, _prefs.flood_max_unscoped, @@ -1086,6 +1087,16 @@ bool MyMesh::allowPacketForward(const mesh::Packet *packet) { return true; } +bool MyMesh::allowPacketTransmit(const mesh::Packet* packet) const { + // Forwarded traces have already appended this node's SNR. A locally + // originated trace starts with an empty SNR path and is not a relay. + // Recheck at egress so a queued relay/retry cannot outlive this opt-in. + if (_prefs.disable_fwd && _prefs.trace_when_repeat_off != 1 && packet + && packet->isRouteDirect() && packet->getPayloadType() == PAYLOAD_TYPE_TRACE + && packet->path_len > 0) return false; + return mesh::Mesh::allowPacketTransmit(packet); +} + const char *MyMesh::getLogDateTime() { static char tmp[32]; uint32_t now = getRTCClock()->getCurrentTime(); @@ -1462,7 +1473,9 @@ uint32_t MyMesh::getDirectRetryAttemptStepMillis() const { } bool MyMesh::allowDirectRetry(const mesh::Packet* packet, const uint8_t* next_hop_hash, uint8_t next_hop_hash_len) const { - (void)packet; + if (_prefs.disable_fwd && _prefs.trace_when_repeat_off != 1 && packet + && packet->isRouteDirect() && packet->getPayloadType() == PAYLOAD_TYPE_TRACE + && packet->path_len > 0) return false; if (!_prefs.direct_retry_enabled) { return false; } diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index c498d483e..c1b0a7425 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -917,6 +917,7 @@ protected: } bool allowPacketForward(const mesh::Packet* packet) override; + bool allowPacketTransmit(const mesh::Packet* packet) const override; const char* getLogDateTime() override; void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 8c8bddee0..e9098c173 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -764,6 +764,7 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) { memset(_prefs->extra_sf, 0, sizeof(_prefs->extra_sf)); _prefs->usb_logging_enabled = 1; _prefs->usb_debug_enabled = 0; + _prefs->trace_when_repeat_off = 0; #ifdef WITH_RS232_BRIDGE _prefs->bridge_uart = WITH_RS232_BRIDGE_UART; #else @@ -935,9 +936,10 @@ void CommonCLI::syncOtaConfigFromPrefs() { #endif void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { - // Older or truncated preference images must never inherit debug opt-in + // Older or truncated preference images must never inherit these opt-ins // from a previous load of a newer image. _prefs->usb_debug_enabled = 0; + _prefs->trace_when_repeat_off = 0; #if defined(RP2040_PLATFORM) File file = fs->open(filename, "r"); #else @@ -1330,6 +1332,10 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { if (file.available() >= (int)sizeof(_prefs->usb_debug_enabled)) { file.read((uint8_t *)&_prefs->usb_debug_enabled, sizeof(_prefs->usb_debug_enabled)); + if (file.available() >= (int)sizeof(_prefs->trace_when_repeat_off)) { + file.read((uint8_t *)&_prefs->trace_when_repeat_off, + sizeof(_prefs->trace_when_repeat_off)); + } } } } @@ -1445,6 +1451,8 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _prefs->usb_logging_enabled = constrain(_prefs->usb_logging_enabled, 0, 1); // boolean // Only a deliberately saved 1 enables diagnostics; corrupted bytes are OFF. _prefs->usb_debug_enabled = _prefs->usb_debug_enabled == 1 ? 1 : 0; + // Corrupt or unknown values must not enable a repeat-off exception. + _prefs->trace_when_repeat_off = _prefs->trace_when_repeat_off == 1 ? 1 : 0; _prefs->cad_enabled = constrain(_prefs->cad_enabled, 0, 1); // boolean if (!directRetryPrefsValid(_prefs)) { setDefaultDirectRetryPrefs(_prefs); @@ -1658,6 +1666,7 @@ static bool writeCommonPrefsImage(Writer& writer, NodePrefs* prefs) { WRITE_COMMON_PREFS(&prefs->primary_radio_preamble); // appended primary tuple field WRITE_COMMON_PREFS(&prefs->espnow_bridge_enabled); // appended Full ESP-NOW intent WRITE_COMMON_PREFS(&prefs->usb_debug_enabled); // appended USB debug intent + WRITE_COMMON_PREFS(&prefs->trace_when_repeat_off); // appended repeater trace exception #undef WRITE_COMMON_PREFS_BYTES #undef WRITE_COMMON_PREFS @@ -1671,6 +1680,7 @@ void CommonCLI::savePrefs(FILESYSTEM* fs, PrefsSaveRouting::Scope scope) { _common_save_result_known = true; _common_save_succeeded = false; _prefs->usb_debug_enabled = _prefs->usb_debug_enabled == 1 ? 1 : 0; + _prefs->trace_when_repeat_off = _prefs->trace_when_repeat_off == 1 ? 1 : 0; } #ifdef WITH_MQTT_BRIDGE // Observer builds use a verified temp/backup transaction for common prefs. @@ -1817,6 +1827,7 @@ void CommonCLI::savePrefs(FILESYSTEM* fs, PrefsSaveRouting::Scope scope) { file.write((uint8_t *)&_prefs->primary_radio_preamble, sizeof(_prefs->primary_radio_preamble)); // appended file.write((uint8_t *)&_prefs->espnow_bridge_enabled, sizeof(_prefs->espnow_bridge_enabled)); // appended file.write((uint8_t *)&_prefs->usb_debug_enabled, sizeof(_prefs->usb_debug_enabled)); // appended + file.write((uint8_t *)&_prefs->trace_when_repeat_off, sizeof(_prefs->trace_when_repeat_off)); // appended _common_save_succeeded = file.commit(); if (!_common_save_succeeded) { @@ -3349,6 +3360,29 @@ bool CommonCLI::handleSdCardGetCmd(const char* config, char* reply) { void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* reply) { const char* config = &command[4]; + if (strncmp(config, "repeat.trace", 12) == 0 + && (config[12] == 0 || config[12] == ' ' || config[12] == '\t')) { + if (strcmp(_callbacks->getRole(), "repeater") != 0) { + strcpy(reply, "Error: repeat.trace is only supported by repeaters"); + return; + } + const char* value = config + 12; + while (*value == ' ' || *value == '\t') ++value; + if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { + strcpy(reply, "Error: usage set repeat.trace on|off"); + return; + } + const uint8_t previous = _prefs->trace_when_repeat_off; + _prefs->trace_when_repeat_off = strcmp(value, "on") == 0 ? 1 : 0; + if (!trySavePrefs()) { + _prefs->trace_when_repeat_off = previous; + strcpy(reply, "Error: Repeat trace not saved; unchanged"); + return; + } + _callbacks->onRetryConfigChanged(); + snprintf(reply, 160, "OK - repeat.trace %s (saved)", value); + return; + } #if defined(NRF52_POWER_MANAGEMENT) if (mesh::power::handleVoltagePolicyCommand(command, reply, 160)) return; #endif @@ -4695,6 +4729,17 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* reply) { const char* config = &command[4]; + if (strncmp(config, "repeat.trace", 12) == 0 + && (config[12] == 0 || config[12] == ' ' || config[12] == '\t')) { + if (strcmp(_callbacks->getRole(), "repeater") != 0) { + strcpy(reply, "Error: repeat.trace is only supported by repeaters"); + } else if (strcmp(config, "repeat.trace") != 0) { + strcpy(reply, "Error: usage get repeat.trace"); + } else { + snprintf(reply, 160, "> %s", _prefs->trace_when_repeat_off == 1 ? "on" : "off"); + } + return; + } #if defined(NRF52_POWER_MANAGEMENT) if (mesh::power::handleVoltagePolicyCommand(command, reply, 160)) return; #endif diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 2293bcccc..900937279 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -183,8 +183,11 @@ public: // logging artifact's enabled-at-first-boot behavior. uint8_t usb_logging_enabled = 1; // USB diagnostics are opt-in independently of the packet output gate. - // Persisted at the very end of /com_prefs, not beside usb_log. + // Appended after ESP-NOW intent in /com_prefs, not beside usb_log. uint8_t usb_debug_enabled = 0; + // Repeater-only opt-in for routed trace diagnostics while repeat is off. + // Appended after usb_debug in /com_prefs; existing images stay disabled. + uint8_t trace_when_repeat_off = 0; // Runtime UART choice for merged RS-232 repeater artifacts. Appended at // /com_prefs offset 862; single-UART builds keep their compiled port here. uint8_t bridge_uart = 0; @@ -377,6 +380,7 @@ private: protected: void structure() override { def("disable", _parent->disable_fwd); + def("trace_off", _parent->trace_when_repeat_off); def("f_max", _parent->flood_max); def("f_max_uns", _parent->flood_max_unscoped); def("f_max_adv", _parent->flood_max_advert); diff --git a/src/helpers/UsbLoggingWatchdog.cpp b/src/helpers/UsbLoggingWatchdog.cpp index 9827b1625..9138af892 100644 --- a/src/helpers/UsbLoggingWatchdog.cpp +++ b/src/helpers/UsbLoggingWatchdog.cpp @@ -573,15 +573,19 @@ bool isUsbLoggingWatchdogUpdateActive() { return false; } bool handleUsbLoggingWatchdogCommand(const char* command, char* reply, size_t capacity) { if (!command || !reply || !capacity) return false; - static const char* const keys[] = {"get usb.watchdog.last", "get usb.watchdog", "set usb.watchdog"}; - for (const char* key : keys) { - const size_t length = strlen(key); - if (strncmp(command, key, length) - || (command[length] && command[length] != ' ' && command[length] != '\t')) continue; - snprintf(reply, capacity, "Error: unsupported"); - return true; + const char* suffix; + constexpr size_t prefix_size = sizeof("get usb.watchdog") - 1; + if (strncmp(command, "get usb.watchdog", prefix_size) == 0) { + suffix = command + prefix_size; + if (strncmp(suffix, ".last", 5) == 0) suffix += 5; + } else if (strncmp(command, "set usb.watchdog", prefix_size) == 0) { + suffix = command + prefix_size; + } else { + return false; } - return false; + if (*suffix && *suffix != ' ' && *suffix != '\t') return false; + snprintf(reply, capacity, "Error: unsupported"); + return true; } } // namespace mesh #endif // MESH_USB_CONSOLE_COOPERATIVE diff --git a/test/test_common_radio_persistence.py b/test/test_common_radio_persistence.py index 3c690c81e..55e2ccb61 100644 --- a/test/test_common_radio_persistence.py +++ b/test/test_common_radio_persistence.py @@ -82,8 +82,9 @@ int main() { CommonCLI cli; assert(cli.saveCommonPrefs()); auto original=cli.fs.files["/com_prefs"]; - const size_t preamble_offset=original.size()-4; - assert(preamble_offset>=864); + // Appending preferences must not move the established preamble bytes. + const size_t preamble_offset=866; + assert(original.size() >= preamble_offset+2); assert(original[preamble_offset]==48 && original[preamble_offset+1]==0); assert(original[preamble_offset+2]==1); Capture capture; assert(writeCommonPrefsImage(capture,&cli.prefs)); diff --git a/test/test_common_usb_debug.py b/test/test_common_usb_debug.py index 869a0d91a..97777d702 100644 --- a/test/test_common_usb_debug.py +++ b/test/test_common_usb_debug.py @@ -157,11 +157,11 @@ int main() { cli.savePrefs(&cli.fs, PrefsSaveRouting::Scope::Common); assert(cli._common_save_succeeded); const auto baseline = cli.fs.files["/com_prefs"]; - assert(baseline.size() == 870); - const size_t debug_offset = baseline.size() - 1; - const size_t logging_offset = baseline.size() - 7; - const size_t preamble_offset = baseline.size() - 4; - const size_t espnow_offset = baseline.size() - 2; + assert(baseline.size() == 871); + const size_t debug_offset = baseline.size() - 2; + const size_t logging_offset = baseline.size() - 8; + const size_t preamble_offset = baseline.size() - 5; + const size_t espnow_offset = baseline.size() - 3; assert(baseline[debug_offset] == 0 && baseline[logging_offset] == 1); assert(baseline[preamble_offset] == 48 && baseline[espnow_offset] == 1); Capture capture; @@ -249,10 +249,11 @@ int main() { assert(cli.prefs.usb_debug_enabled == 0 && cli.fs.files["/com_prefs"][debug_offset] == 0); } - // Every older/torn length inherits debug OFF, even after loading debug ON. + // Every image without the complete debug byte inherits debug OFF, even + // after loading debug ON; a missing later trace byte cannot erase debug. auto image = baseline; image[logging_offset] = 0; image[debug_offset] = 1; - for (size_t size = 0; size < image.size(); ++size) { + for (size_t size = 0; size <= debug_offset; ++size) { CommonCLI reader; reader.prefs.usb_debug_enabled = 1; reader.fs.files["/com_prefs"] = {image.begin(), image.begin() + size}; diff --git a/test/test_repeater_trace_forwarding.py b/test/test_repeater_trace_forwarding.py new file mode 100644 index 000000000..cef5fa53c --- /dev/null +++ b/test/test_repeater_trace_forwarding.py @@ -0,0 +1,222 @@ +"""Execute actual repeater trace gates with the production Packet definition.""" +from pathlib import Path +import os +import shutil +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 +#define LOOP_DETECT_OFF 0 +#define LOOP_DETECT_MINIMAL 1 +#define LOOP_DETECT_MODERATE 2 +#define LOOP_DETECT_STRICT 3 +#define FLOOD_CHANNEL_HOPS_ALL 255 +const uint8_t max_loop_minimal[] = {1}, max_loop_moderate[] = {2}, max_loop_strict[] = {3}; +namespace mesh { +@CONSTRUCTOR@ +// The boundary records delegation to the existing OTA/profile transmit veto. +class Mesh { +public: + mutable unsigned base_calls = 0; + bool base_allowed = true; + virtual ~Mesh() = default; + virtual bool allowPacketTransmit(const Packet*) const { + ++base_calls; + return base_allowed; + } +}; +} +struct SimpleMeshTables { + struct RecentRepeaterInfo { int8_t snr_x4 = 12; } recent; + bool known = false; + const RecentRepeaterInfo* findRecentRepeaterByHash(const uint8_t*, uint8_t) const { + return known ? &recent : nullptr; + } +}; +struct MyMesh : mesh::Mesh { + struct Prefs { + uint8_t disable_fwd = 1, trace_when_repeat_off = 0; + uint8_t flood_max = 64, flood_max_unscoped = 64, flood_max_advert = 64; + uint8_t flood_channel_data_enabled = 1, loop_detect = LOOP_DETECT_OFF; + uint8_t direct_retry_enabled = 1, direct_retry_recent_enabled = 0; + int8_t direct_retry_snr_margin_x4 = 0; + } _prefs; + bool recv_pkt_channel_scope_rejected = false, recv_pkt_regionless_scope_set = false; + bool recv_pkt_channel_scope_bypass = false, looped = false; + const void* recv_pkt_region = this; + unsigned recv_pkt_filter_match_mask = 0; + bool filter_block = false, moderation_block = false, clock_sync_mesh_edge_enabled = false; + unsigned commits = 0, clock_samples = 0; + SimpleMeshTables tables; + bool floodChannelDataHopApplies(const mesh::Packet*) const { return true; } + bool isLooped(const mesh::Packet*, const uint8_t*) { return looped; } + bool shouldBlockFloodPacketForward(const mesh::Packet*, unsigned) { return filter_block; } + bool shouldBlockFloodGroupTextForward(const mesh::Packet*) { return moderation_block; } + void commitFloodPacketFilterRates(const mesh::Packet*, unsigned) { ++commits; } + void recordAcceptedFloodClockSample(const mesh::Packet*) { ++clock_samples; } + const SimpleMeshTables* getTables() const { return &tables; } + int8_t getDirectRetryMinSNRX4() const { return 12; } + bool allowPacketForward(const mesh::Packet*); + bool allowPacketTransmit(const mesh::Packet*) const override; + bool allowDirectRetry(const mesh::Packet*, const uint8_t*, uint8_t) const; +}; +@METHODS@ + +int main() { + MyMesh node; + mesh::Packet p; + // Admission is an exact opt-in, not a general forwarding bypass. Test every + // route/type and invalid stored values with the actual header accessors. + for (uint8_t pref : {uint8_t(0), uint8_t(1), uint8_t(2), uint8_t(255)}) { + node._prefs.trace_when_repeat_off = pref; + for (uint8_t route = 0; route < 4; ++route) { + for (uint8_t type = 0; type < 16; ++type) { + p.header = route | (type << PH_TYPE_SHIFT); + p.path_len = 0; + assert(node.allowPacketForward(&p) == + (pref == 1 && p.isRouteDirect() && type == PAYLOAD_TYPE_TRACE)); + for (uint16_t path : {uint16_t(0), uint16_t(1), uint16_t(MAX_PATH_SIZE)}) { + p.path_len = path; + const bool blocked = pref != 1 && p.isRouteDirect() + && type == PAYLOAD_TYPE_TRACE && path > 0; + const unsigned before = node.base_calls; + assert(node.allowPacketTransmit(&p) == !blocked); + assert(node.base_calls == before + (blocked ? 0 : 1)); + assert(node.allowDirectRetry(&p, nullptr, 0) == !blocked); + } + } + } + } + assert(!node.allowPacketForward(nullptr)); + assert(node.allowPacketTransmit(nullptr)); + assert(node.allowDirectRetry(nullptr, nullptr, 0)); + + // repeat on restores the original gate; switching it off retires a relay, + // but never a locally generated trace whose SNR path starts empty. + p.header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_TRACE << PH_TYPE_SHIFT); + p.path_len = 1; + node._prefs.trace_when_repeat_off = 0; + node._prefs.disable_fwd = 0; + assert(node.allowPacketForward(&p) && node.allowPacketTransmit(&p)); + node._prefs.disable_fwd = 1; + assert(!node.allowPacketForward(&p) && !node.allowPacketTransmit(&p)); + node._prefs.trace_when_repeat_off = 1; + assert(node.allowPacketForward(&p) && node.allowPacketTransmit(&p)); + node._prefs.trace_when_repeat_off = 0; + assert(!node.allowPacketTransmit(&p) && !node.allowDirectRetry(&p, nullptr, 0)); + p.path_len = 0; + assert(node.allowPacketTransmit(&p) && node.allowDirectRetry(&p, nullptr, 0)); + + // An enabled opt-in still delegates the base hard-drain/profile safety veto. + p.path_len = 1; + node._prefs.trace_when_repeat_off = 1; + node.base_allowed = false; + assert(!node.allowPacketTransmit(&p)); + node.base_allowed = true; + node._prefs.direct_retry_enabled = 0; + assert(!node.allowDirectRetry(&p, nullptr, 0)); + node._prefs.direct_retry_enabled = 1; + node._prefs.direct_retry_recent_enabled = 1; + const uint8_t hop[] = {0x42}; + assert(node.allowDirectRetry(&p, hop, 1)); // unknown remains eligible + node.tables.known = true; + node.tables.recent.snr_x4 = 11; + assert(!node.allowDirectRetry(&p, hop, 1)); + node.tables.recent.snr_x4 = 12; + assert(node.allowDirectRetry(&p, hop, 1)); + node._prefs.direct_retry_snr_margin_x4 = 1; + assert(!node.allowDirectRetry(&p, hop, 1)); + + // The opt-in never overrides existing flood-hop/region/scope/loop rules, + // or spends moderation quota for a packet rejected earlier in the chain. + node._prefs.disable_fwd = 0; + p.header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_GRP_TXT << PH_TYPE_SHIFT); + p.setPathHashSizeAndCount(1, 1); + assert(node.allowPacketForward(&p)); + const unsigned committed = node.commits; + node._prefs.flood_max = 0; + assert(!node.allowPacketForward(&p)); + node._prefs.flood_max = 64; + node.recv_pkt_channel_scope_rejected = true; + assert(!node.allowPacketForward(&p)); + node.recv_pkt_channel_scope_rejected = false; + node.recv_pkt_region = nullptr; + assert(!node.allowPacketForward(&p)); + node.recv_pkt_region = &node; + node._prefs.loop_detect = LOOP_DETECT_MINIMAL; + node.looped = true; + assert(!node.allowPacketForward(&p)); + node.looped = false; +#if !defined(PORTABLE_MQTT_OBSERVER) + node.filter_block = true; + assert(!node.allowPacketForward(&p)); + node.filter_block = false; + node.moderation_block = true; + assert(!node.allowPacketForward(&p)); + node.moderation_block = false; + assert(node.commits == committed); +#else + (void)committed; +#endif + assert(node.allowPacketForward(&p)); + node._prefs.disable_fwd = 1; + assert(!node.allowPacketForward(&p)); +} +''' + + +class RepeaterTraceForwardingTest(unittest.TestCase): + def test_actual_repeater_methods(self): + compiler = os.environ.get('CXX') or shutil.which('g++') or shutil.which('clang++') + if not compiler: + self.skipTest('a host C++17 compiler is required') + source = (ROOT / 'examples/simple_repeater/MyMesh.cpp').read_text(encoding='utf-8') + methods = '\n'.join(extract_braced(source, signature) for signature in ( + 'bool MyMesh::allowPacketForward(', + 'bool MyMesh::allowPacketTransmit(', + 'bool MyMesh::allowDirectRetry(')) + packet_source = (ROOT / 'src/Packet.cpp').read_text(encoding='utf-8') + code = HARNESS.replace('@METHODS@', methods).replace( + '@CONSTRUCTOR@', extract_braced(packet_source, 'Packet::Packet()')) + flags = ['-fsanitize=address,undefined', '-fno-sanitize-recover=all', + '-fno-pie', '-no-pie'] if sys.platform.startswith('linux') else [] + with tempfile.TemporaryDirectory(prefix='repeater-trace-forwarding-') as directory: + work = Path(directory) + cpp = work / 'test.cpp' + cpp.write_text(code, encoding='utf-8') + for portable in (False, True): + with self.subTest(portable=portable): + binary = work / 'test' + built = subprocess.run([ + compiler, '-std=c++17', '-Wall', '-Wextra', '-Werror', + '-Wno-unused-parameter', + '-DMESH_ENABLE_FLOOD_GROUP_MODERATION=1', '-DMESH_ENABLE_CLOCK_SYNC=1', + *(['-DPORTABLE_MQTT_OBSERVER=1'] if portable else []), *flags, + '-I' + str(ROOT / 'src'), str(cpp), '-o', str(binary)], + capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + tested = subprocess.run([str(binary)], capture_output=True, + text=True, timeout=10) + self.assertEqual(tested.returncode, 0, tested.stdout + tested.stderr) + + def test_repeater_only_override(self): + header = (ROOT / 'examples/simple_repeater/MyMesh.h').read_text(encoding='utf-8') + self.assertIn('bool allowPacketTransmit(const mesh::Packet* packet) const override;', header) + for role in ('simple_room_server', 'simple_sensor', 'companion_radio'): + for path in (ROOT / 'examples' / role).glob('MyMesh.*'): + self.assertNotIn('trace_when_repeat_off', path.read_text(encoding='utf-8')) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_repeater_trace_repeat.py b/test/test_repeater_trace_repeat.py new file mode 100644 index 000000000..90244c8d5 --- /dev/null +++ b/test/test_repeater_trace_repeat.py @@ -0,0 +1,244 @@ +"""Execute the real repeater trace CLI and appended preference persistence.""" +from pathlib import Path +import os +import re +import subprocess +import tempfile +import unittest + +from test_common_usb_debug import HARNESS as COMMON_HARNESS +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] + +TRACE_MAIN = r''' +int main() { + CommonCLI cli; + char reply[160] = {}; + assert(cli.prefs.trace_when_repeat_off == 0); + cli.prefs.usb_debug_enabled = 1; + cli.savePrefs(&cli.fs, PrefsSaveRouting::Scope::Common); + assert(cli._common_save_succeeded); + const auto baseline = cli.fs.files["/com_prefs"]; + assert(baseline.size() == 871); + const size_t trace_offset = baseline.size() - 1; + const size_t debug_offset = trace_offset - 1; + assert(baseline[trace_offset] == 0 && baseline[debug_offset] == 1); + Capture capture; + assert(writeCommonPrefsImage(capture, &cli.prefs)); + assert(capture.bytes == baseline); + cli.get("repeat.trace", reply); + assert(!strcmp(reply, "> off")); + cli.set("repeat.trace on", reply); + assert(!strcmp(reply, "OK - repeat.trace on (saved)")); + assert(cli.prefs.trace_when_repeat_off == 1); + const auto enabled = cli.fs.files["/com_prefs"]; + assert(enabled.size() == baseline.size() && enabled[trace_offset] == 1); + assert(std::equal(baseline.begin(), baseline.begin() + trace_offset, enabled.begin())); + Capture enabled_capture; + assert(writeCommonPrefsImage(enabled_capture, &cli.prefs)); + assert(enabled_capture.bytes == enabled); + cli.get("repeat.trace", reply); + assert(!strcmp(reply, "> on")); + + for (const char* command : {"repeat.trace", "repeat.trace ", "repeat.trace\t", + "repeat.trace true", "repeat.trace 1", "repeat.trace ON", "repeat.trace Off", + "repeat.trace off junk", "repeat.trace off ", "repeat.trace off\t", + "repeat.trace on reboot", "repeat.trace off reboot", "repeat.trace on\n"}) { + const auto before = cli.fs.files["/com_prefs"]; + cli.set(command, reply); + assert(!strcmp(reply, "Error: usage set repeat.trace on|off")); + assert(cli.prefs.trace_when_repeat_off == 1 && cli.fs.files["/com_prefs"] == before); + } + for (const char* query : {"repeat.trace ", "repeat.trace\t", "repeat.trace on"}) { + cli.get(query, reply); + assert(!strcmp(reply, "Error: usage get repeat.trace")); + } + for (const char* collision : {"repeat.traces", "repeat.trace_off", + "repeat.tracex on", "repeat.traceback", "repeat", "repeat.trace/on"}) { + cli.set(collision, reply); + assert(!strcmp(reply, "unknown")); + cli.get(collision, reply); + assert(!strcmp(reply, "unknown")); + assert(cli.prefs.trace_when_repeat_off == 1 && cli.fs.files["/com_prefs"] == enabled); + } + cli.set("repeat.trace\t \toff", reply); + assert(!strcmp(reply, "OK - repeat.trace off (saved)")); + assert(cli.prefs.trace_when_repeat_off == 0); + + // Both enable and disable must preserve the saved and requested state when + // staging, the new final-byte write, readback or publication fails. + for (uint8_t desired : {1, 0}) { + cli.set(desired ? "repeat.trace off" : "repeat.trace on", reply); + assert(cli.prefs.trace_when_repeat_off == uint8_t(!desired)); + const auto committed = cli.fs.files["/com_prefs"]; + for (int fault : {0, 1, 2, 3}) { + const unsigned callbacks_before = cli.callbacks.retry_updates; + if (fault == 0) cli.fs.fail_write = true; + if (fault == 1) cli.fs.fail_write_after = trace_offset; + if (fault == 2) cli.fs.fail_read_open = true; + if (fault == 3) cli.fs.fail_rename = 1; + cli.set(desired ? "repeat.trace on" : "repeat.trace off", reply); + assert(!strcmp(reply, "Error: Repeat trace not saved; unchanged")); + assert(cli.prefs.trace_when_repeat_off == uint8_t(!desired)); + assert(cli.fs.files["/com_prefs"] == committed); + assert(cli.callbacks.retry_updates == callbacks_before); + cli.fs.fail_write = false; cli.fs.fail_write_after = -1; + cli.fs.fail_read_open = false; cli.fs.fail_rename = 0; + } + cli.set(desired ? "repeat.trace on" : "repeat.trace off", reply); + assert(cli.prefs.trace_when_repeat_off == desired); + Capture saved; + assert(writeCommonPrefsImage(saved, &cli.prefs)); + assert(saved.bytes == cli.fs.files["/com_prefs"]); + } + + // Sharing CommonCLI must not expose the setting on other firmware roles. + for (const char* role : {"room-server", "sensor", "companion", "observer", "Repeater", ""}) { + cli.callbacks.role = role; + const auto before = cli.fs.files["/com_prefs"]; + const uint8_t previous = cli.prefs.trace_when_repeat_off; + cli.get("repeat.trace", reply); + assert(!strcmp(reply, "Error: repeat.trace is only supported by repeaters")); + for (const char* command : {"repeat.trace on", "repeat.trace off"}) { + cli.set(command, reply); + assert(!strcmp(reply, "Error: repeat.trace is only supported by repeaters")); + assert(cli.prefs.trace_when_repeat_off == previous && cli.fs.files["/com_prefs"] == before); + } + } + cli.callbacks.role = "repeater"; + + // Execute the production RepeatPrefs dynamic schema and committed writers. + RepeatPrefs dynamic(&cli.prefs); + for (uint8_t requested : {1, 0, 2, 255}) { + assert(dynamic.import("trace_off", requested)); + assert(cli.prefs.trace_when_repeat_off == requested); + cli.savePrefs(&cli.fs, PrefsSaveRouting::Scope::Common); + assert(cli._common_save_succeeded); + assert(cli.prefs.trace_when_repeat_off == (requested == 1 ? 1 : 0)); + assert(cli.fs.files["/com_prefs"][trace_offset] == (requested == 1 ? 1 : 0)); + Capture saved; + assert(writeCommonPrefsImage(saved, &cli.prefs)); + assert(saved.bytes == cli.fs.files["/com_prefs"]); + } + + // Every old or torn image lacks the complete appended preference and must + // reset prior RAM intent to OFF. The old debug byte retains its old offset. + auto image = enabled; + for (size_t size = 0; size < image.size(); ++size) { + CommonCLI reader; + reader.prefs.trace_when_repeat_off = 1; + reader.fs.files["/com_prefs"] = {image.begin(), image.begin() + size}; + reader.loadPrefsInt(&reader.fs, "/com_prefs"); + assert(reader.prefs.trace_when_repeat_off == 0); + if (size > debug_offset) assert(reader.prefs.usb_debug_enabled == 1); + } + for (uint8_t saved : {0, 1, 2, 255}) { + image[trace_offset] = saved; + CommonCLI reader; + reader.prefs.trace_when_repeat_off = 1; + reader.fs.files["/com_prefs"] = image; + reader.loadPrefsInt(&reader.fs, "/com_prefs"); + assert(reader.prefs.trace_when_repeat_off == (saved == 1 ? 1 : 0)); + assert(reader.prefs.usb_debug_enabled == 1); + assert(reader.prefs.freq == 910 && reader.prefs.primary_radio_preamble == 48); + } + cli.prefs.trace_when_repeat_off = 1; + cli.loadPrefsInt(&cli.fs, "/missing"); + assert(cli.prefs.trace_when_repeat_off == 0); +} +''' + + +def production_harness(source, header): + """Reuse the common USB fixture while executing real trace/store methods.""" + fields = header.split('class NodePrefs :', 1)[1].split('private:', 1)[0] + fields = '\n'.join(re.findall( + r'^\s*(?:float|double|char|u?int(?:8|16|32)_t)\s+[^;]+;', fields, re.M)) + constants = '\n'.join(re.findall( + r'^\s*#define\s+\w+\s+(?:0x[\dA-Fa-f]+|\d+)\s*$', header, re.M)) + constants += '\n' + '\n'.join(re.findall( + r'^static const size_t LEGACY_\w+\s*=[^;]+;', source, re.M)) + constants += '\n' + re.search( + r'^static constexpr uint8_t DEFAULT_ADVERT_LOC_POLICY\s*=[^;]+;', + header, re.M).group() + helpers = '\n'.join(extract_braced(source, signature) for signature in ( + 'static bool bwMatches(', 'static bool isValidLoRaBandwidth(', + 'static float defaultLoRaBandwidth(', 'static void markDirectRetryPrefsValid(', + 'static void applyFloodRetryPreset(', 'static void applyDirectRetryPreset(', + 'static void setDefaultDirectRetryPrefs(', 'static bool directRetryPrefsValid(')) + methods = '\n'.join(extract_braced(source, signature) for signature in ( + 'static const char* commonPrefsSaveResultName(', + 'bool CommonCLI::recoverCommonPrefsFiles(', + 'bool CommonCLI::saveCommonPrefsImageAtomically(', + 'void CommonCLI::savePrefs(FILESYSTEM*', + 'void CommonCLI::savePrefs(PrefsSaveRouting::Scope scope)', + 'bool CommonCLI::trySavePrefs(', 'void CommonCLI::loadPrefsInt(')) + setter = extract_braced(source, 'void CommonCLI::handleSetCmd(') + getter = extract_braced(source, 'void CommonCLI::handleGetCmd(') + branch = 'if (strncmp(config, "repeat.trace", 12)' + code = COMMON_HARNESS.split('int main() {', 1)[0].replace( + 'void updateAdvertTimer() {}', + 'void updateAdvertTimer() {}\n' + ' const char* role = "repeater";\n' + ' const char* getRole() const { return role; }\n' + ' unsigned retry_updates = 0;\n' + ' void onRetryConfigChanged() { ++retry_updates; }') + replacements = { + 'FIELDS': fields, 'CONSTANTS': constants, 'HELPERS': helpers, + 'LEGACY': extract_braced(header, 'struct LegacyObserverTail') + ';', + 'SERIALIZER': 'template\n' + extract_braced( + source, 'static bool writeCommonPrefsImage('), + 'STORE': extract_braced(source, 'class CommonPrefsFileStore'), + 'METHODS': methods, + 'DYNAMIC': extract_braced(header, 'class RepeatPrefs :'), + 'SET_DEBUG': extract_braced(setter, branch), 'SET_MASTER': '', + 'GET_DEBUG': extract_braced(getter, branch), 'GET_MASTER': '', + } + for key, value in replacements.items(): + code = code.replace('@' + key + '@', value) + return code + TRACE_MAIN + + +class RepeaterTraceRepeatTest(unittest.TestCase): + def test_actual_cli_schema_and_old_new_persistence(self): + source = (ROOT / 'src/helpers/CommonCLI.cpp').read_text() + header = (ROOT / 'src/helpers/CommonCLI.h').read_text() + code = production_harness(source, header) + sanitizer_flags = [] if os.name == 'nt' else [ + '-fsanitize=address,undefined', '-fno-sanitize-recover=all', + '-fno-pie', '-no-pie'] + variants = [('ENABLE_OTA', platform) for platform in ( + 'NRF52_PLATFORM', 'STM32_PLATFORM', 'ESP32_PLATFORM', 'RP2040_PLATFORM')] + variants += [('ENABLE_OTA', 'ESP32_PLATFORM', 'WITH_MQTT_BRIDGE'), + ('NRF52_PLATFORM',), ('ESP32_PLATFORM', 'WITH_MQTT_BRIDGE')] + with tempfile.TemporaryDirectory(prefix='repeater-trace-') as directory: + work = Path(directory) + transaction = (ROOT / 'src/helpers/ContactFileTransaction.h').read_text() + (work / 'ContactFileTransaction.h').write_text(transaction.replace( + '#include "IdentityStore.h"', '#include ')) + (work / 'test.cpp').write_text(code) + for macros in variants: + with self.subTest(macros=macros): + exe = work / ('test.exe' if os.name == 'nt' else 'test') + built = subprocess.run([ + os.environ.get('CXX', 'g++'), '-std=c++17', + *['-D' + macro + '=1' for macro in macros], *sanitizer_flags, + '-I' + str(work), '-I' + str(ROOT / 'test/fixtures/radio_profiles/mocks'), + '-I' + str(ROOT / 'src'), '-I' + str(ROOT / 'src/helpers'), + str(work / 'test.cpp'), '-o', str(exe)], + capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stderr) + tested = subprocess.run([str(exe)], capture_output=True, text=True, timeout=10) + self.assertEqual(tested.returncode, 0, tested.stderr) + + def test_outer_load_defaults_before_loading_saved_image(self): + source = (ROOT / 'src/helpers/CommonCLI.cpp').read_text() + load = extract_braced(source, 'void CommonCLI::loadPrefs(FILESYSTEM*') + self.assertIn('_prefs->trace_when_repeat_off = 0;', load) + self.assertLess(load.index('_prefs->trace_when_repeat_off = 0;'), + load.index('loadPrefsInt(')) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_trace_retry/test_trace_retry.cpp b/test/test_trace_retry/test_trace_retry.cpp index 92c70c4b9..c347ac352 100644 --- a/test/test_trace_retry/test_trace_retry.cpp +++ b/test/test_trace_retry/test_trace_retry.cpp @@ -836,6 +836,160 @@ TEST(MeshReceiveHooks, FloodTraceAndControlAreNeverForwarded) { } } +// Admission/egress boundary controls deliberately do not reproduce the role +// predicate. The Python extraction suite executes the actual repeater methods; +// these tests exercise the real Mesh/Dispatcher validation and queue ownership. +class TraceForwardingPolicyMesh : public TraceTestMesh { +public: + using TraceTestMesh::TraceTestMesh; + const mesh::Packet* denied = nullptr; + unsigned forwarding_checks = 0; + bool allowPacketForward(const mesh::Packet*) override { + ++forwarding_checks; + return forwardFloods; + } + bool allowPacketTransmit(const mesh::Packet* packet) const override { + return packet != denied && mesh::Mesh::allowPacketTransmit(packet); + } +}; + +static mesh::Packet makeIncomingTrace(const TraceTestMesh& node, uint8_t route, + uint8_t flags = 0, uint8_t hash_size = 1) { + mesh::Packet packet; + packet.header = route | (PAYLOAD_TYPE_TRACE << PH_TYPE_SHIFT); + packet.path_len = 0; + packet.payload_len = 9 + 2 * hash_size; + memset(packet.payload, 0, packet.payload_len); + packet.payload[8] = flags; + memcpy(packet.payload + 9, node.self_id.pub_key, hash_size); + memset(packet.payload + 9 + hash_size, 0x98, hash_size); + packet._snr = 17; + return packet; +} + +TEST(TraceForwardingSafety, MatchingDirectHashesAppendOneSnrAndDuplicateIsRejected) { + for (uint8_t route : {ROUTE_TYPE_DIRECT, ROUTE_TYPE_TRANSPORT_DIRECT}) { + for (uint8_t code = 0; code < 4; ++code) { + TraceTestClock clock; TraceTestRTC rtc; TraceTestRNG rng; TraceTestRadio radio; + ForwardingTestTables tables; StaticPoolPacketManager manager(12); + TraceForwardingPolicyMesh node(radio, clock, rng, rtc, manager, tables); + node.forwardFloods = true; + memset(node.self_id.pub_key, 0x42, PUB_KEY_SIZE); + mesh::Packet packet = makeIncomingTrace(node, route, code, 1U << code); + mesh::Packet duplicate = packet; + EXPECT_NE(ACTION_RELEASE, node.receivePacket(&packet)); + EXPECT_EQ(1, packet.path_len); + EXPECT_EQ(17, packet.path[0]); + EXPECT_EQ(1, tables.mark_seen_calls); + EXPECT_EQ(ACTION_RELEASE, node.receivePacket(&duplicate)); + EXPECT_EQ(0, duplicate.path_len); + EXPECT_EQ(1, tables.mark_seen_calls); + } + } +} + +TEST(TraceForwardingSafety, MalformedWrongHashAndFullSnrPathNeverReachForwardingGate) { + TraceTestClock clock; TraceTestRTC rtc; TraceTestRNG rng; TraceTestRadio radio; + ForwardingTestTables tables; StaticPoolPacketManager manager(12); + TraceForwardingPolicyMesh node(radio, clock, rng, rtc, manager, tables); + node.forwardFloods = true; + memset(node.self_id.pub_key, 0x42, PUB_KEY_SIZE); + const mesh::Packet valid = makeIncomingTrace(node, ROUTE_TYPE_DIRECT); + for (uint8_t length = 0; length < 9; ++length) { + mesh::Packet packet = valid; + packet.payload_len = length; + EXPECT_EQ(ACTION_RELEASE, node.receivePacket(&packet)); + } + mesh::Packet wrong = valid; + wrong.payload[9] ^= 0xff; + EXPECT_EQ(ACTION_RELEASE, node.receivePacket(&wrong)); + mesh::Packet truncated = valid; + truncated.payload[8] = 3; + truncated.payload_len = 10; // neither an eight-byte nor four-byte hash fits + EXPECT_EQ(ACTION_RELEASE, node.receivePacket(&truncated)); + mesh::Packet full = valid; + full.path_len = MAX_PATH_SIZE; + EXPECT_EQ(ACTION_RELEASE, node.receivePacket(&full)); + mesh::Packet long_offset = valid; + long_offset.path_len = 33; + long_offset.payload[8] = 3; + long_offset.payload_len = 9 + 160; + memset(long_offset.payload + 9, 0x42, 160); + EXPECT_EQ(ACTION_RELEASE, node.receivePacket(&long_offset)); // 33*8 must not wrap + EXPECT_EQ(0u, node.forwarding_checks); + EXPECT_EQ(0, tables.mark_seen_calls); +} + +TEST(TraceForwardingSafety, FinalSnrSlotIsSafeAndDeniedAdmissionDoesNotMarkSeen) { + TraceTestClock clock; TraceTestRTC rtc; TraceTestRNG rng; TraceTestRadio radio; + ForwardingTestTables tables; StaticPoolPacketManager manager(12); + TraceForwardingPolicyMesh node(radio, clock, rng, rtc, manager, tables); + memset(node.self_id.pub_key, 0x42, PUB_KEY_SIZE); + mesh::Packet blocked = makeIncomingTrace(node, ROUTE_TYPE_DIRECT); + EXPECT_EQ(ACTION_RELEASE, node.receivePacket(&blocked)); + EXPECT_EQ(0, blocked.path_len); + EXPECT_EQ(0, tables.mark_seen_calls); + node.forwardFloods = true; + mesh::Packet last = blocked; + last.path_len = MAX_PATH_SIZE - 1; + last.payload_len = 9 + MAX_PATH_SIZE; + memset(last.payload + 9, 0x42, MAX_PATH_SIZE); + EXPECT_NE(ACTION_RELEASE, node.receivePacket(&last)); + EXPECT_EQ(MAX_PATH_SIZE, last.path_len); + EXPECT_EQ(17, last.path[MAX_PATH_SIZE - 1]); + EXPECT_EQ(1, tables.mark_seen_calls); +} + +TEST(TraceForwardingSafety, EgressVetoRetiresQueuedRelayAndItsRetryOwner) { + TraceTestClock clock; TraceTestRTC rtc; TraceTestRNG rng; TraceTestRadio radio; + ForwardingTestTables tables; StaticPoolPacketManager manager(12); + TraceForwardingPolicyMesh node(radio, clock, rng, rtc, manager, tables); + node.begin(); + node.forwardFloods = true; + memset(node.self_id.pub_key, 0x42, PUB_KEY_SIZE); + auto* relay = manager.allocNew(); ASSERT_NE(nullptr, relay); + *relay = makeIncomingTrace(node, ROUTE_TYPE_DIRECT); + ASSERT_NE(ACTION_RELEASE, node.receivePacket(relay)); + ASSERT_TRUE(node.sendPacket(relay, 1)); + node.denied = relay; + clock.now = 1000; node.loop(); + EXPECT_FALSE(radio.sending); + EXPECT_EQ(0, manager.getOutboundTotal()); + node.denied = nullptr; + clock.now = 10000; node.loop(); + EXPECT_FALSE(radio.sending); + EXPECT_EQ(0, manager.getOutboundTotal()); + EXPECT_EQ(12, manager.getFreeCount()); +} + +TEST(TraceForwardingSafety, AirtimeAlreadyStartedFinishesButQueuedRetryIsRetired) { + TraceTestClock clock; TraceTestRTC rtc; TraceTestRNG rng; TraceTestRadio radio; + ForwardingTestTables tables; StaticPoolPacketManager manager(12); + TraceForwardingPolicyMesh node(radio, clock, rng, rtc, manager, tables); + node.begin(); + node.forwardFloods = true; + memset(node.self_id.pub_key, 0x42, PUB_KEY_SIZE); + auto* relay = manager.allocNew(); ASSERT_NE(nullptr, relay); + *relay = makeIncomingTrace(node, ROUTE_TYPE_DIRECT); + ASSERT_NE(ACTION_RELEASE, node.receivePacket(relay)); + ASSERT_TRUE(node.sendPacket(relay, 1)); + clock.now = 1; node.loop(); ASSERT_TRUE(radio.sending); + node.denied = relay; + clock.now = 2; node.loop(); EXPECT_TRUE(radio.sending); + radio.complete = true; + clock.now = 3; node.loop(); EXPECT_FALSE(radio.sending); + ASSERT_EQ(1, manager.getOutboundTotal()); + node.denied = manager.getOutboundByIdx(0); + clock.now = 1000; node.loop(); + EXPECT_FALSE(radio.sending); + EXPECT_EQ(0, manager.getOutboundTotal()); + node.denied = nullptr; + clock.now = 10000; node.loop(); + EXPECT_FALSE(radio.sending); + EXPECT_EQ(0, manager.getOutboundTotal()); + EXPECT_EQ(12, manager.getFreeCount()); +} + static mesh::Packet* makeTrace(TraceTestMesh& node, uint32_t tag, uint32_t auth, const uint8_t* route, uint8_t route_len) { mesh::Packet* packet = node.createTrace(tag, auth, 0);