From 83fd62b756b94d178ee6ce4bad0b2001b38de9de Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 18 Aug 2026 12:41:08 +0000 Subject: [PATCH] test(native): add 14 suites for routing, persistence, parsing and identity gaps (#11515) * test(native): add 14 suites for routing, persistence, parsing and identity gaps Coverage audit of the native test tree; adds the highest-value untested logic as 11 new suites and extends 3 existing ones (200 test functions). New: test_stream_framing, test_nodedb_boot_recovery, test_nodedb_legacy_migration, test_nodedb_v25_roundtrip, test_nodedb_identity_hygiene, test_channel_keys, test_reliable_ack_matrix, test_hop_start_policy, test_routing_response_hops, test_phone_api_config_dump, test_observer. Extended: test_rtc, test_mqtt, test_xmodem. Two source changes the audit produced: - StreamAPI::handleRecStream copied stream->read()'s `cInt < 0` EOF check into the buffer-fed path, where there is no EOF sentinel; with signed char any byte >= 0x80 (START1 is 0x94) aborted the parse. Read the byte as uint8_t directly. Latent on develop (no callers), pinned by test_stream_framing. - Extract the post-decode pre-hop predicate from Router::handleReceived into shouldSkipHandleForPostDecodeHop() (NodeDB.h) so test_hop_start_policy drives the exact expression the router calls. No behavior change. test/state-manifest.tsv declares the suites that construct a NodeDB. Full 68-suite Docker coverage run matches the pre-change baseline. * test(native): address review - harden observer dispatch, trim comments Review follow-ups on the coverage-audit suites: - Observable::notifyObservers() erased list nodes while holding an iterator into them, so an observer that unobserves itself from onNotify corrupted the dispatch. Today the only self-detacher (PhoneAPI::onNotify -> checkConnectionTimeout -> close -> unobserve) survives solely because it returns -1 and aborts the chain before the increment; that unwritten contract is now gone. Removal during a dispatch nulls the entry and the outermost notify sweeps afterwards, which keeps self-detach, next-detach and destruction-during-notify all safe without an allocation. Hoisting the next iterator instead would have inverted the hazard and broken the existing next-detach case. Two regression tests added. - Correct the documented caller of shouldSkipHandleForPostDecodeHop: the call is in Router::dispatchReceived, not handleReceived. - Cast hop fields to unsigned at the %u call site in test_hop_start_policy. - Trim the new suites' file headers to the one-or-two-line rule in AGENTS.md. - Rename eight test functions whose names were exactly `test_` + 35 chars: that is the shape of a Lob API key, so trufflehog flagged them as secrets and failed the Trunk CI check. Full 68-suite Docker coverage run matches the pre-change baseline. * test(native): revert the observer dispatch change, keep the contract test Backs out the notifyObservers() deferred-removal hardening from the previous commit. It was reviewer-driven scope creep: nothing in the coverage audit needed it, no test required it, and it changes dispatch semantics in a header with ~76 observe() call sites on native verification alone. The hazard it addressed is not reachable today. The only observer that unobserves itself from onNotify is PhoneAPI (onNotify -> checkConnectionTimeout -> close -> unobserve), and it returns -1, which aborts the chain before the iterator is advanced past the erased node. test_self_detach_with_abort_during_notify stays: it passes against the unmodified dispatch and pins that the -1 is load-bearing, so a later cleanup that "simplifies" it away goes red. The unsafe variant (self-detach returning 0) is documented in a comment rather than tested, since asserting it would be asserting UB. * fix(serial): recover the frame behind a stray framing marker A byte that failed the START2 check was discarded rather than re-tested as a possible START1, so 0x94 0x94 0xc3 ... lost the real frame: one corrupted byte on a noisy UART silently dropped the frame behind it. Re-test the byte in place instead. Applied to both copies of the receive state machine. readStream() is the one that matters in the field - it is the serial path every phone client uses - while handleRecStream() still has no callers on develop. Strictly widens what the parser accepts; no frame that parsed before parses differently. test_stream_framing covers it on both receive paths, plus a run of stray markers and a START1-then-unrelated-byte resync. This was originally documented as a known gap in the framing suite. Fixing it instead was NomDeTom's call on review: a passing test asserting the bad behavior is what makes it hard to change later, and it is the same defect shape as the signedness fix three functions away. Also: use Throttle::deadlinePassed() in test_reliable_ack_matrix rather than a bare millis() compare, matching the house deadline rule. * test(native): cover the stray-marker resync on the buffer path too The stray-marker fix went into both copies of the receive state machine, but only test_stray_start1_before_frame_still_delivers drove both. The repeated- marker and unrelated-byte cases drove readStream() alone, so a regression in handleRecStream() would have gone unnoticed by two of the three. Verified load-bearing: reverting only the handleRecStream() half of the fix turns test_repeated_stray_start1_before_frame_still_delivers red on the new assertion. test_start1_then_unrelated_byte_resyncs stays green under that mutation by design - its failing byte is 0x00, where both branches reset to 0 - and covers the other half of the ternary. Also drops the stale header on test_stray_start1_before_frame_still_delivers, which still described the gap as pinned-as-is after the fix landed. Co-Authored-By: Claude Opus 5 * test(native): make the hop-start truth table assert the rows it prints test_truth_table_summary was six TEST_MESSAGE lines and no assertion, so it reported as a case that could not fail - the anti-pattern #11517 names in its unfinished assertion-presence lint, and the one exception to NomDeTom's "no RUN_TEST without an assertion" pass over this PR. The printed row and the checked expectation now come from one struct, so the summary cannot narrate a table the predicates no longer implement. It also covers the consequence columns the per-row tests do not assert together: classifyHopStart, shouldDropPacketForPreHop and shouldSkipHandleForPostDecodeHop for the same packet, with the expectations gated on MESHTASTIC_PREHOP_DROP. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- src/mesh/NodeDB.h | 12 + src/mesh/Router.cpp | 9 +- src/mesh/StreamAPI.cpp | 17 +- test/state-manifest.tsv | 7 + test/test_channel_keys/test_main.cpp | 586 ++++++++++++ test/test_hop_start_policy/test_main.cpp | 352 ++++++++ test/test_mqtt/MQTT.cpp | 263 +++++- test/test_nodedb_boot_recovery/test_main.cpp | 396 ++++++++ .../test_main.cpp | 512 +++++++++++ .../test_main.cpp | 570 ++++++++++++ test/test_nodedb_v25_roundtrip/test_main.cpp | 691 ++++++++++++++ test/test_observer/test_main.cpp | 378 ++++++++ test/test_phone_api_config_dump/test_main.cpp | 574 ++++++++++++ test/test_reliable_ack_matrix/test_main.cpp | 853 ++++++++++++++++++ test/test_routing_response_hops/test_main.cpp | 273 ++++++ test/test_rtc/test_main.cpp | 374 ++++++++ test/test_stream_framing/test_main.cpp | 397 ++++++++ test/test_xmodem/test_main.cpp | 476 +++++++++- 18 files changed, 6710 insertions(+), 30 deletions(-) create mode 100644 test/test_channel_keys/test_main.cpp create mode 100644 test/test_hop_start_policy/test_main.cpp create mode 100644 test/test_nodedb_boot_recovery/test_main.cpp create mode 100644 test/test_nodedb_identity_hygiene/test_main.cpp create mode 100644 test/test_nodedb_legacy_migration/test_main.cpp create mode 100644 test/test_nodedb_v25_roundtrip/test_main.cpp create mode 100644 test/test_observer/test_main.cpp create mode 100644 test/test_phone_api_config_dump/test_main.cpp create mode 100644 test/test_reliable_ack_matrix/test_main.cpp create mode 100644 test/test_routing_response_hops/test_main.cpp create mode 100644 test/test_stream_framing/test_main.cpp diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index e5b5a67ac..ca0acf171 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -223,6 +223,18 @@ inline bool shouldDropPacketForPreHop(const meshtastic_MeshPacket &p) #endif } +/// Post-decode, the encrypted bitfield makes MISSING_OR_UNKNOWN decidable. +/// Local packets are exempt; Router::dispatchReceived uses this predicate to set skipHandle. +inline bool shouldSkipHandleForPostDecodeHop(const meshtastic_MeshPacket &p) +{ +#if !MESHTASTIC_PREHOP_DROP + (void)p; + return false; +#else + return !isFromUs(&p) && classifyHopStart(p) != HopStartStatus::VALID; +#endif +} + /// Rate-limited debug log when hop_start is invalid/missing and packet is dropped. void logHopStartDrop(const meshtastic_MeshPacket &p, const char *context); diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index c382e8576..99d448ac6 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -1456,11 +1456,10 @@ void Router::dispatchReceived(meshtastic_MeshPacket *p, RxSource src) printPacket("handleReceived(REMOTE)", p); #if MESHTASTIC_PREHOP_DROP - // Pre-hop firmware drop, post-decode half: the bitfield that proves the origin populated hop_start is - // encrypted under the channel key, so it can only be evaluated now that the packet is decoded. A packet - // whose hop_start is still missing/unknown comes from pre-hop firmware - keep it out of module - // processing, admin handling, phone delivery, MQTT and rebroadcast. Local-origin packets are exempt. - if (!isFromUs(p) && classifyHopStart(*p) != HopStartStatus::VALID) { + // Pre-hop firmware drop, post-decode half: a packet whose hop_start is still missing/unknown comes + // from pre-hop firmware - keep it out of module processing, admin handling, phone delivery, MQTT + // and rebroadcast. + if (shouldSkipHandleForPostDecodeHop(*p)) { logHopStartDrop(*p, "post-decode pre-hop drop"); cancelSending(p->from, p->id); skipHandle = true; diff --git a/src/mesh/StreamAPI.cpp b/src/mesh/StreamAPI.cpp index 412a9786a..7d3ca3953 100644 --- a/src/mesh/StreamAPI.cpp +++ b/src/mesh/StreamAPI.cpp @@ -86,12 +86,9 @@ int32_t StreamAPI::handleRecStream(const char *buf, uint16_t bufLen) { uint16_t index = 0; while (bufLen > index) { // Currently we never want to block - int cInt = buf[index++]; - if (cInt < 0) - break; // We ran out of characters (even though available said otherwise) - this can happen on rf52 adafruit - // arduino - - uint8_t c = (uint8_t)cInt; + // Unlike stream->read(), a buffer byte has no EOF sentinel: bufLen already bounds the loop, + // and a signed-char comparison would treat any byte >= 0x80 (START1 included) as EOF. + uint8_t c = (uint8_t)buf[index++]; // Use the read pointer for a little state machine, first look for framing, then length bytes, then payload size_t ptr = rxPtr; @@ -105,8 +102,10 @@ int32_t StreamAPI::handleRecStream(const char *buf, uint16_t bufLen) if (c != START1) rxPtr = 0; // failed to find framing } else if (ptr == 1) { // looking for START2 + // A byte that fails START2 can itself be the START1 of the real frame (0x94 0x94 0xc3 + // ...), so re-test it here: discarding it drops the frame behind a single stray marker. if (c != START2) - rxPtr = 0; // failed to find framing + rxPtr = (c == START1) ? 1 : 0; } else if (ptr >= HEADER_LEN - 1) { // we have at least read our 4 byte framing uint32_t len = (rxBuf[2] << 8) + rxBuf[3]; // big endian 16 bit length follows framing @@ -161,8 +160,10 @@ int32_t StreamAPI::readStream() if (c != START1) rxPtr = 0; // failed to find framing } else if (ptr == 1) { // looking for START2 + // A byte that fails START2 can itself be the START1 of the real frame (0x94 0x94 + // 0xc3 ...): discarding it drops the frame behind a single stray marker. if (c != START2) - rxPtr = 0; // failed to find framing + rxPtr = (c == START1) ? 1 : 0; } else if (ptr >= HEADER_LEN - 1) { // we have at least read our 4 byte framing uint32_t len = (rxBuf[2] << 8) + rxBuf[3]; // big endian 16 bit length follows framing diff --git a/test/state-manifest.tsv b/test/state-manifest.tsv index 02870c573..4c8f56bbb 100644 --- a/test/state-manifest.tsv +++ b/test/state-manifest.tsv @@ -54,13 +54,20 @@ test_firmware_edition writes=config.proto,module.proto,device.proto,channels.pro test_fuzz_decode errors=20000..250000 fuzzes protobuf decode; every rejection logs. A collapse to near zero means the corpus stopped reaching the decoder test_fuzz_packets writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat,Messages_default.msgs errors=5000..60000 drives decode of fuzzed packets through the real NodeDB and message store test_hop_scaling writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto constructs a NodeDB to hold the hop-distance fixtures +test_hop_start_policy writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto constructs a NodeDB (isFromUs needs nodeDB->getNodeNum()), whose constructor persists a default set when the prefs directory is empty test_mesh_beacon writes=module.proto exercises the beacon's module-config save path test_mesh_module writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat module framework tests construct a NodeDB test_mqtt writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto errors=1000..12000 constructs a NodeDB for node lookups in the MQTT paths test_nexthop_routing writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto next-hop selection reads and updates the node DB test_nodedb_blocked state=per-suite writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat saturates the DB with MAX_NUM_NODES-2 favourited nodes to test the protected cap; a later test's removeNodeByNum() persists that state, and the cap test depends on the fill from the test before it +test_nodedb_boot_recovery state=per-suite writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto deliberate boot-recovery ladder: corrupts/deletes/restores the pref files and reboots a NodeDB per test to pin the DECODE_FAILED identity freeze, so each test observes the previous test's on-disk state +test_nodedb_identity_hygiene writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat,Messages_default.msgs constructs a NodeDB; addFromContact persists the node DB after every merge, the reboot test proves the key-erasure guard survives a reload, and the should_ignore path rewrites the message store +test_nodedb_legacy_migration writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat each test hand-writes a v24-format nodes.proto fixture and cold-boots a NodeDB, whose constructor persists the migrated v25 database (warm.dat via the over-cap eviction absorb) +test_nodedb_v25_roundtrip writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat v25 persistence round-trips: every test saves nodes.proto and cold-boots a NodeDB whose constructor persists the default segments; warm.dat on the node-DB save cadence test_packet_signing writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat errors=300 needs a NodeDB holding both peers' keys for the PKI encode/decode paths +test_phone_api_config_dump writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto per-test NodeDB fixture backing full PhoneAPI want_config dumps; the constructor persists a default config/channel/node set in a fresh sandbox test_pki_admin_fallback writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto needs a NodeDB holding admin keys for the fallback paths +test_reliable_ack_matrix writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto constructs a NodeDB (whose constructor persists a default set when the prefs directory is empty) for the sender-key lookups in the ACK/NAK matrix test_stream_api writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto drives real PhoneAPI handshakes, which read and persist config and the node DB test_traceroute_nexthop writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto traceroute route selection reads the node DB test_traffic_management writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat errors=3000..12000 constructs a NodeDB for the per-node rate-limit and dedup state; test_tm_fuzz_nodenum_blitz feeds malformed payloads, and each rejection logs (measured 7985) diff --git a/test/test_channel_keys/test_main.cpp b/test/test_channel_keys/test_main.cpp new file mode 100644 index 000000000..34c42b332 --- /dev/null +++ b/test/test_channel_keys/test_main.cpp @@ -0,0 +1,586 @@ +// Channel key derivation and hash layer: getKey() PSK expansion, generateHash() golden values, +// onConfigChanged() primary restore, setChannel() demotion, and perhapsDecode()'s hash fall-through. + +#include "Channels.h" +#include "CryptoEngine.h" +#include "MeshTypes.h" // Include BEFORE TestUtil.h (provides NodeNum, isBroadcast, etc.) +#include "NodeDB.h" +#include "Router.h" +#include "TestUtil.h" +#include "mesh-pb-constants.h" +#include // printf() group separators +#include +#include + +#if defined(ARCH_PORTDUINO) +#define CK_TEST_ENTRY extern "C" +#else +#define CK_TEST_ENTRY +#endif + +// --- Test output helpers --- +#define MSG_BUF_LEN 200 +#define TEST_MSG_FMT(fmt, ...) \ + do { \ + char _buf[MSG_BUF_LEN]; \ + snprintf(_buf, sizeof(_buf), fmt, __VA_ARGS__); \ + TEST_MESSAGE(_buf); \ + } while (0) + +// --- Reference hash implementation --- +// Independent re-statement of the algorithm in Channels.cpp (xorHash of the channel name, +// XORed with xorHash of the *expanded* key bytes), used to derive expected values from +// first principles. The golden constants below were computed by hand from this same rule. +static uint8_t refXorHash(const uint8_t *p, size_t len) +{ + uint8_t code = 0; + for (size_t i = 0; i < len; i++) + code ^= p[i]; + return code; +} + +static uint8_t refHash(const char *name, const uint8_t *keyBytes, size_t keyLen) +{ + return refXorHash((const uint8_t *)name, strlen(name)) ^ refXorHash(keyBytes, keyLen); +} + +// Golden values, derived by hand from the algorithm above (pinned so a helper bug cannot +// silently re-derive a wrong expectation): +// xorHash("LongFast") = 'L'^'o'^'n'^'g'^'F'^'a'^'s'^'t' = 0x0A +// xorHash(defaultpsk) = d4^f1^bb^3a^20^29^07^59^f0^bc^ff^ab^cf^4e^69^01 = 0x02 +// hash(default LongFast channel) = 0x0A ^ 0x02 = 0x08 +static const int16_t GOLDEN_LONGFAST_HASH = 0x08; +static const uint8_t GOLDEN_LONGFAST_NAME_XOR = 0x0A; +static const uint8_t GOLDEN_DEFAULTPSK_XOR = 0x02; + +// --- Fixture helpers --- + +// A 16-byte-of-0xEE sentinel armed before each test so "crypto key unchanged" is a real +// assertion instead of an accident of whatever the previous test left behind. +static const uint8_t kSentinelByte = 0xEE; + +static void armCryptoSentinel() +{ + CryptoKey s; + memset(s.bytes, kSentinelByte, sizeof(s.bytes)); + s.length = 16; + crypto->setKey(s); +} + +static bool cryptoKeyIsSentinel() +{ + if (crypto->key.length != 16) + return false; + for (int i = 0; i < 16; i++) + if (crypto->key.bytes[i] != kSentinelByte) + return false; + return true; +} + +static void expectCryptoKey(const uint8_t *expected, int len) +{ + TEST_ASSERT_EQUAL_INT(len, crypto->key.length); + if (len > 0) + TEST_ASSERT_EQUAL_UINT8_ARRAY(expected, crypto->key.bytes, (uint32_t)len); +} + +// Write a slot directly and re-run fixupChannel() so the hash cache tracks the edit, +// mirroring how the admin/config paths mutate channelFile. +static meshtastic_Channel &setSlot(uint8_t idx, meshtastic_Channel_Role role, const char *name, const uint8_t *psk, size_t pskLen) +{ + meshtastic_Channel &ch = channels.getByIndex(idx); + ch.index = idx; + ch.has_settings = true; + ch.role = role; + memset(&ch.settings, 0, sizeof(ch.settings)); + if (name) + strncpy(ch.settings.name, name, sizeof(ch.settings.name) - 1); + if (psk && pskLen) + memcpy(ch.settings.psk.bytes, psk, pskLen); + ch.settings.psk.size = (pb_size_t)pskLen; + channels.fixupChannel(idx); + return ch; +} + +// Slot 0 as the canonical stock channel (1-byte PSK index 1, empty name -> preset name), +// independent of any USERPREFS_CHANNEL_0_* a build variant may bake into initDefaults(). +static void forceCanonicalDefaultSlot0() +{ + static const uint8_t defaultIndexPsk[1] = {0x01}; + setSlot(0, meshtastic_Channel_Role_PRIMARY, "", defaultIndexPsk, 1); +} + +// ===================================================================================== +// Group 1: generateHash golden values and sensitivity +// ===================================================================================== + +void test_default_longfast_hash_is_golden() +{ + TEST_ASSERT_EQUAL_INT16(GOLDEN_LONGFAST_HASH, channels.getHash(0)); + // Cross-check the hand-derived constant against the reference algorithm on the + // expanded key (a 1-byte index-1 PSK expands to exactly defaultpsk). + TEST_ASSERT_EQUAL_UINT8((uint8_t)GOLDEN_LONGFAST_HASH, refHash("LongFast", defaultpsk, sizeof(defaultpsk))); + TEST_ASSERT_EQUAL_UINT8(GOLDEN_LONGFAST_NAME_XOR ^ GOLDEN_DEFAULTPSK_XOR, (uint8_t)GOLDEN_LONGFAST_HASH); +} + +void test_explicit_longfast_name_hashes_like_empty_name() +{ + // getName() substitutes the modem-preset display name for "" - so an explicit + // "LongFast" and the stock empty name MUST be wire-identical or the two devices + // silently stop decoding each other. + static const uint8_t defaultIndexPsk[1] = {0x01}; + setSlot(0, meshtastic_Channel_Role_PRIMARY, "LongFast", defaultIndexPsk, 1); + TEST_ASSERT_EQUAL_INT16(GOLDEN_LONGFAST_HASH, channels.getHash(0)); +} + +void test_default_string_name_is_normalized() +{ + // fixupChannel() converts the legacy "Default" name to the "" short form. + static const uint8_t defaultIndexPsk[1] = {0x01}; + meshtastic_Channel &ch = setSlot(0, meshtastic_Channel_Role_PRIMARY, "Default", defaultIndexPsk, 1); + TEST_ASSERT_EQUAL_STRING("", ch.settings.name); + TEST_ASSERT_EQUAL_INT16(GOLDEN_LONGFAST_HASH, channels.getHash(0)); +} + +void test_hash_differs_on_psk_only() +{ + // Same name, PSKs that differ in bytes AND xor -> different hashes. + static const uint8_t pskA[16] = {0x01}; + static const uint8_t pskB[16] = {0x02}; + setSlot(1, meshtastic_Channel_Role_SECONDARY, "alpha", pskA, sizeof(pskA)); + setSlot(2, meshtastic_Channel_Role_SECONDARY, "alpha", pskB, sizeof(pskB)); + TEST_ASSERT_TRUE(channels.getHash(1) >= 0); + TEST_ASSERT_TRUE(channels.getHash(2) >= 0); + TEST_ASSERT_NOT_EQUAL(channels.getHash(1), channels.getHash(2)); + TEST_ASSERT_EQUAL_UINT8(refHash("alpha", pskA, sizeof(pskA)), (uint8_t)channels.getHash(1)); + TEST_ASSERT_EQUAL_UINT8(refHash("alpha", pskB, sizeof(pskB)), (uint8_t)channels.getHash(2)); +} + +void test_hash_differs_on_name_only() +{ + static const uint8_t psk[16] = {0x01}; + setSlot(1, meshtastic_Channel_Role_SECONDARY, "alpha", psk, sizeof(psk)); + setSlot(2, meshtastic_Channel_Role_SECONDARY, "beta", psk, sizeof(psk)); + TEST_ASSERT_TRUE(channels.getHash(1) >= 0); + TEST_ASSERT_TRUE(channels.getHash(2) >= 0); + TEST_ASSERT_NOT_EQUAL(channels.getHash(1), channels.getHash(2)); +} + +void test_disabled_channel_has_invalid_hash() +{ + // Slot 3 was never configured: fixupChannel() in setUp left it DISABLED. + TEST_ASSERT_EQUAL(meshtastic_Channel_Role_DISABLED, channels.getByIndex(3).role); + TEST_ASSERT_EQUAL_INT16(-1, channels.getHash(3)); + // setActiveByIndex on it must refuse and must not touch the crypto key. + TEST_ASSERT_EQUAL_INT16(-1, channels.setActiveByIndex(3)); + TEST_ASSERT_TRUE(cryptoKeyIsSentinel()); +} + +// ===================================================================================== +// Group 2: getKey() PSK expansion and padding (observed via setActiveByIndex -> crypto->key, +// which is public under PIO_UNIT_TESTING) +// ===================================================================================== + +void test_psk_index_1_expands_to_defaultpsk() +{ + TEST_ASSERT_EQUAL_INT16(GOLDEN_LONGFAST_HASH, channels.setActiveByIndex(0)); + expectCryptoKey(defaultpsk, sizeof(defaultpsk)); +} + +void test_psk_index_2_bumps_last_byte() +{ + static const uint8_t psk[1] = {0x02}; + setSlot(0, meshtastic_Channel_Role_PRIMARY, "", psk, 1); + uint8_t expected[sizeof(defaultpsk)]; + memcpy(expected, defaultpsk, sizeof(defaultpsk)); + expected[sizeof(defaultpsk) - 1] = (uint8_t)(expected[sizeof(defaultpsk) - 1] + 1); // index 2 -> last byte +1 + TEST_ASSERT_TRUE(channels.setActiveByIndex(0) >= 0); + expectCryptoKey(expected, sizeof(expected)); + TEST_ASSERT_EQUAL_UINT8(refHash("LongFast", expected, sizeof(expected)), (uint8_t)channels.getHash(0)); +} + +void test_psk_index_0_disables_encryption() +{ + static const uint8_t psk[1] = {0x00}; + setSlot(0, meshtastic_Channel_Role_PRIMARY, "", psk, 1); + // Key length 0 = plaintext; the hash then covers the name alone. + TEST_ASSERT_EQUAL_INT16(GOLDEN_LONGFAST_NAME_XOR, channels.setActiveByIndex(0)); + TEST_ASSERT_EQUAL_INT8(0, crypto->key.length); +} + +void test_psk_index_255_boundary() +{ + static const uint8_t psk[1] = {0xFF}; + setSlot(0, meshtastic_Channel_Role_PRIMARY, "", psk, 1); + uint8_t expected[sizeof(defaultpsk)]; + memcpy(expected, defaultpsk, sizeof(defaultpsk)); + // last byte 0x01 + 0xFF - 1 = 0xFF: the full index range stays inside one uint8_t + expected[sizeof(defaultpsk) - 1] = 0xFF; + TEST_ASSERT_TRUE(channels.setActiveByIndex(0) >= 0); + expectCryptoKey(expected, sizeof(expected)); +} + +void test_short_key_pads_to_aes128() +{ + static const uint8_t psk[5] = {0xA1, 0xB2, 0xC3, 0xD4, 0xE5}; + setSlot(0, meshtastic_Channel_Role_PRIMARY, "", psk, sizeof(psk)); + uint8_t expected[16] = {0xA1, 0xB2, 0xC3, 0xD4, 0xE5}; // bytes 5..15 zero-padded + TEST_ASSERT_TRUE(channels.setActiveByIndex(0) >= 0); + expectCryptoKey(expected, sizeof(expected)); +} + +void test_midsize_key_pads_to_aes256() +{ + uint8_t psk[24]; + for (size_t i = 0; i < sizeof(psk); i++) + psk[i] = (uint8_t)(0x40 + i); + setSlot(0, meshtastic_Channel_Role_PRIMARY, "", psk, sizeof(psk)); + uint8_t expected[32] = {}; + memcpy(expected, psk, sizeof(psk)); // bytes 24..31 zero-padded + TEST_ASSERT_TRUE(channels.setActiveByIndex(0) >= 0); + expectCryptoKey(expected, sizeof(expected)); +} + +void test_exact_16_and_32_byte_keys_pass_through() +{ + uint8_t psk16[16]; + for (size_t i = 0; i < sizeof(psk16); i++) + psk16[i] = (uint8_t)(0x10 + i); + setSlot(0, meshtastic_Channel_Role_PRIMARY, "", psk16, sizeof(psk16)); + TEST_ASSERT_TRUE(channels.setActiveByIndex(0) >= 0); + expectCryptoKey(psk16, sizeof(psk16)); + + uint8_t psk32[32]; + for (size_t i = 0; i < sizeof(psk32); i++) + psk32[i] = (uint8_t)(0x20 + i); + setSlot(0, meshtastic_Channel_Role_PRIMARY, "", psk32, sizeof(psk32)); + TEST_ASSERT_TRUE(channels.setActiveByIndex(0) >= 0); + expectCryptoKey(psk32, sizeof(psk32)); +} + +// ===================================================================================== +// Group 3: secondary key inheritance and the recursion guard +// ===================================================================================== + +void test_secondary_empty_psk_inherits_primary_key() +{ + setSlot(1, meshtastic_Channel_Role_SECONDARY, "second", nullptr, 0); + // Effective key is the primary's expanded key (defaultpsk); the hash mixes the + // secondary's OWN name with that inherited key: + // xorHash("second") = 's'^'e'^'c'^'o'^'n'^'d' = 0x10; 0x10 ^ 0x02 = 0x12 + TEST_ASSERT_EQUAL_INT16(0x12, channels.getHash(1)); + TEST_ASSERT_EQUAL_UINT8(refHash("second", defaultpsk, sizeof(defaultpsk)), (uint8_t)channels.getHash(1)); + TEST_ASSERT_TRUE(channels.setActiveByIndex(1) >= 0); + expectCryptoKey(defaultpsk, sizeof(defaultpsk)); +} + +void test_recursion_guard_primary_slot_marked_secondary() +{ + // Malformed config: the slot primaryIndex points at (0) is itself SECONDARY with no + // PSK. Without the chIndex != primaryIndex guard, getKey(0) would recurse into + // getKey(0) forever; the guarded path treats it as encryption-off instead. + setSlot(0, meshtastic_Channel_Role_SECONDARY, "", nullptr, 0); + TEST_ASSERT_EQUAL_UINT8(0, channels.getPrimaryIndex()); + TEST_ASSERT_EQUAL_INT16(GOLDEN_LONGFAST_NAME_XOR, channels.getHash(0)); // name-only hash + TEST_ASSERT_EQUAL_INT16(GOLDEN_LONGFAST_NAME_XOR, channels.setActiveByIndex(0)); + TEST_ASSERT_EQUAL_INT8(0, crypto->key.length); +} + +// ===================================================================================== +// Group 4: onConfigChanged() no-primary restore and setChannel() demotion +// ===================================================================================== + +void test_onconfigchanged_promotes_demoted_primary_slot_keeping_key() +{ + // Phone demotes every slot: the slot primaryIndex references is SECONDARY with real + // key material -> it must be promoted in place, NOT replaced with a default key. + static const uint8_t privatePsk[16] = {0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, + 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB}; + setSlot(0, meshtastic_Channel_Role_SECONDARY, "keep", privatePsk, sizeof(privatePsk)); + channels.onConfigChanged(); + + meshtastic_Channel &ch = channels.getByIndex(0); + TEST_ASSERT_EQUAL(meshtastic_Channel_Role_PRIMARY, ch.role); + TEST_ASSERT_EQUAL_UINT8(0, channels.getPrimaryIndex()); + TEST_ASSERT_EQUAL_UINT16(sizeof(privatePsk), ch.settings.psk.size); + TEST_ASSERT_EQUAL_UINT8_ARRAY(privatePsk, ch.settings.psk.bytes, sizeof(privatePsk)); + TEST_ASSERT_TRUE(channels.setActiveByIndex(0) >= 0); + expectCryptoKey(privatePsk, sizeof(privatePsk)); +} + +void test_onconfigchanged_restores_default_when_all_disabled() +{ + // Every slot DISABLED (zeroed): promoting a zeroed slot would create a plaintext + // primary, so the restore must install the stock default channel instead. + memset(&channelFile, 0, sizeof(channelFile)); + channelFile.channels_count = MAX_NUM_CHANNELS; + channels.onConfigChanged(); + + meshtastic_Channel &ch = channels.getByIndex(channels.getPrimaryIndex()); + TEST_ASSERT_EQUAL(meshtastic_Channel_Role_PRIMARY, ch.role); + TEST_ASSERT_TRUE(ch.settings.psk.size >= 1); + TEST_ASSERT_TRUE(channels.setActiveByIndex(channels.getPrimaryIndex()) >= 0); + // The restored primary must never come up plaintext. + TEST_ASSERT_TRUE(crypto->key.length > 0); +#if !defined(USERPREFS_CHANNEL_0_PSK) && !defined(USERPREFS_CHANNEL_0_NAME) + // Stock build: the restored channel is exactly the default LongFast channel. + TEST_ASSERT_EQUAL_UINT8(0, channels.getPrimaryIndex()); + TEST_ASSERT_EQUAL_UINT16(1, ch.settings.psk.size); + TEST_ASSERT_EQUAL_UINT8(0x01, ch.settings.psk.bytes[0]); + TEST_ASSERT_EQUAL_INT16(GOLDEN_LONGFAST_HASH, channels.getHash(0)); + expectCryptoKey(defaultpsk, sizeof(defaultpsk)); +#endif +} + +void test_setchannel_demotes_old_primary() +{ + static const uint8_t psk[1] = {0x02}; + meshtastic_Channel c = meshtastic_Channel_init_zero; + c.index = 1; + c.role = meshtastic_Channel_Role_PRIMARY; + c.has_settings = true; + strncpy(c.settings.name, "boss", sizeof(c.settings.name) - 1); + memcpy(c.settings.psk.bytes, psk, sizeof(psk)); + c.settings.psk.size = sizeof(psk); + + channels.setChannel(c); + TEST_ASSERT_EQUAL(meshtastic_Channel_Role_SECONDARY, channels.getByIndex(0).role); + TEST_ASSERT_EQUAL(meshtastic_Channel_Role_PRIMARY, channels.getByIndex(1).role); + + // primaryIndex tracks the change only once onConfigChanged() re-scans. + channels.onConfigChanged(); + TEST_ASSERT_EQUAL_UINT8(1, channels.getPrimaryIndex()); +} + +// ===================================================================================== +// Group 5: decryptForHash() bounds - regression pin for #11046 (cfecef537). Pre-fix the +// bound was `>`, so chIndex == getNumChannels() read one past hashes[] on the hot decode +// path for every received packet. +// ===================================================================================== + +void test_decryptforhash_rejects_out_of_range_index() +{ + const ChannelIndex n = channels.getNumChannels(); + TEST_ASSERT_EQUAL_UINT8(MAX_NUM_CHANNELS, n); + TEST_ASSERT_FALSE(channels.decryptForHash(n, (ChannelHash)channels.getHash(0))); + TEST_ASSERT_FALSE(channels.decryptForHash((ChannelIndex)(n + 1), (ChannelHash)channels.getHash(0))); + TEST_ASSERT_FALSE(channels.decryptForHash((ChannelIndex)MAX_NUM_CHANNELS, 0x08)); + TEST_ASSERT_FALSE(channels.decryptForHash((ChannelIndex)255, 0x08)); + // A rejected index must not have touched the crypto key. + TEST_ASSERT_TRUE(cryptoKeyIsSentinel()); +} + +void test_decryptforhash_accepts_valid_index_and_hash() +{ + TEST_ASSERT_TRUE(channels.decryptForHash(0, (ChannelHash)GOLDEN_LONGFAST_HASH)); + expectCryptoKey(defaultpsk, sizeof(defaultpsk)); +} + +void test_decryptforhash_rejects_wrong_hash() +{ + TEST_ASSERT_FALSE(channels.decryptForHash(0, (ChannelHash)(GOLDEN_LONGFAST_HASH + 1))); + TEST_ASSERT_TRUE(cryptoKeyIsSentinel()); +} + +void test_decryptforhash_disabled_slot_matches_no_hash() +{ + // A DISABLED slot's cached hash is -1 (int16), which no 0-255 wire hash can equal. + TEST_ASSERT_EQUAL(meshtastic_Channel_Role_DISABLED, channels.getByIndex(3).role); + for (int h = 0; h <= 255; h++) + TEST_ASSERT_FALSE(channels.decryptForHash(3, (ChannelHash)h)); + TEST_ASSERT_TRUE(cryptoKeyIsSentinel()); +} + +// ===================================================================================== +// Group 6: Router perhapsDecode() same-hash fall-through. Two enabled channels can share +// a hash (it is one xor byte); the decoder must try each candidate and commit the one +// whose key authenticates a well-formed Data, rewriting p->channel from hash to INDEX - +// the value admin-channel authorization consumes downstream. +// +// Skipped on event builds: their decode path runs isBlockedEventCoordinatePacket() -> +// willUsePki(), which dereferences the nodeDB this suite deliberately never constructs +// (keeping it free of disk writes). +// ===================================================================================== + +#if !USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL + +// Same name + PSKs with equal xor but different bytes -> identical hash, different keys. +static const uint8_t kClashPskA[16] = {0x01}; +static const uint8_t kClashPskB[16] = {0x00, 0x01}; + +static uint8_t configureCollisionChannels() +{ + setSlot(1, meshtastic_Channel_Role_SECONDARY, "clash", kClashPskA, sizeof(kClashPskA)); + setSlot(2, meshtastic_Channel_Role_SECONDARY, "clash", kClashPskB, sizeof(kClashPskB)); + TEST_ASSERT_TRUE(channels.getHash(1) >= 0); + TEST_ASSERT_EQUAL_INT16(channels.getHash(1), channels.getHash(2)); + // Nonzero hash keeps perhapsDecode() off the PKI-candidate branch (p->channel == 0), + // which would dereference the nodeDB this suite deliberately never constructs. + TEST_ASSERT_TRUE(channels.getHash(1) != 0); + return (uint8_t)channels.getHash(1); +} + +static meshtastic_Data makeProbeData() +{ + meshtastic_Data d = meshtastic_Data_init_zero; + d.portnum = meshtastic_PortNum_POSITION_APP; + static const char probe[] = "collision-probe"; + memcpy(d.payload.bytes, probe, sizeof(probe)); + d.payload.size = sizeof(probe); + return d; +} + +// Encrypts with whatever key is currently loaded into the crypto engine. +static meshtastic_MeshPacket makeEncryptedPacket(uint8_t channelHash, const meshtastic_Data &d) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = 0x11223344; + p.to = NODENUM_BROADCAST; // broadcast: no unicast-only branches + p.id = 0xA5A5A5A5; + p.channel = channelHash; + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + p.encrypted.size = (pb_size_t)pb_encode_to_bytes(p.encrypted.bytes, sizeof(p.encrypted.bytes), &meshtastic_Data_msg, &d); + TEST_ASSERT_TRUE(p.encrypted.size > 0); + crypto->encryptPacket(p.from, p.id, p.encrypted.size, p.encrypted.bytes); + return p; +} + +void test_perhapsdecode_collision_selects_matching_psk() +{ + // is_licensed short-circuits the legacy-DM isToUs() check inside perhapsDecode(), + // which would otherwise dereference the absent nodeDB (restored in tearDown). + owner.is_licensed = true; + const uint8_t h = configureCollisionChannels(); + const meshtastic_Data d = makeProbeData(); + + TEST_ASSERT_TRUE(channels.setActiveByIndex(2) >= 0); // encrypt with slot 2's key + meshtastic_MeshPacket p = makeEncryptedPacket(h, d); + + TEST_ASSERT_EQUAL_INT(DecodeState::DECODE_SUCCESS, perhapsDecode(&p)); + // Hash slot 1 was tried first and rejected; the committed channel is the INDEX 2. + TEST_ASSERT_EQUAL_UINT8(2, p.channel); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_decoded_tag, p.which_payload_variant); + TEST_ASSERT_EQUAL_INT(meshtastic_PortNum_POSITION_APP, p.decoded.portnum); + TEST_ASSERT_EQUAL_UINT16(d.payload.size, p.decoded.payload.size); + TEST_ASSERT_EQUAL_UINT8_ARRAY(d.payload.bytes, p.decoded.payload.bytes, d.payload.size); +} + +void test_perhapsdecode_wrong_key_is_decode_failure() +{ + owner.is_licensed = true; + const uint8_t h = configureCollisionChannels(); + + // Encrypt with a key belonging to NO configured channel; the hash still matches + // slots 1 and 2, so a channel was tried -> DECODE_FAILURE, not DECODE_OPAQUE. + CryptoKey stranger; + memset(stranger.bytes, 0x5A, sizeof(stranger.bytes)); + stranger.length = 16; + crypto->setKey(stranger); + meshtastic_MeshPacket p = makeEncryptedPacket(h, makeProbeData()); + + TEST_ASSERT_EQUAL_INT(DecodeState::DECODE_FAILURE, perhapsDecode(&p)); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_encrypted_tag, p.which_payload_variant); +} + +void test_perhapsdecode_unknown_hash_is_opaque() +{ + owner.is_licensed = true; + configureCollisionChannels(); + + // Find a nonzero wire hash no enabled channel produces. + int candidate = -1; + for (int c = 1; c < 256 && candidate < 0; c++) { + bool used = false; + for (ChannelIndex i = 0; i < channels.getNumChannels(); i++) + if (channels.getHash(i) == c) + used = true; + if (!used) + candidate = c; + } + TEST_ASSERT_TRUE(candidate > 0); + TEST_MSG_FMT("unknown-hash probe uses 0x%02x", (unsigned)candidate); + + TEST_ASSERT_TRUE(channels.setActiveByIndex(2) >= 0); + meshtastic_MeshPacket p = makeEncryptedPacket((uint8_t)candidate, makeProbeData()); + + // No channel matched at all: the packet stays opaque (relayable ciphertext). + TEST_ASSERT_EQUAL_INT(DecodeState::DECODE_OPAQUE, perhapsDecode(&p)); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_encrypted_tag, p.which_payload_variant); +} + +#endif // !USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL + +// --- Unity lifecycle --- + +void setUp(void) +{ + memset(&channelFile, 0, sizeof(channelFile)); + memset(&config, 0, sizeof(config)); + owner.is_licensed = false; + channels.initDefaults(); // 8 slots + default lora config; only slot 0 populated + // Pin the preset the golden hashes assume ("" -> "LongFast"), in case a variant + // build's USERPREFS_LORACONFIG_MODEM_PRESET overrode it inside initDefaults(). + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + channels.onConfigChanged(); // computes the hash cache and primaryIndex + forceCanonicalDefaultSlot0(); + armCryptoSentinel(); +} + +void tearDown(void) +{ + owner.is_licensed = false; +} + +CK_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); + // perhapsDecode() takes cryptLock; normally Router's ctor allocates it, but this + // suite never constructs a Router (nor a NodeDB - it must stay disk-write free). + if (!cryptLock) + cryptLock = new concurrency::Lock(); + UNITY_BEGIN(); + + printf("\n=== generateHash golden values ===\n"); + RUN_TEST(test_default_longfast_hash_is_golden); + RUN_TEST(test_explicit_longfast_name_hashes_like_empty_name); + RUN_TEST(test_default_string_name_is_normalized); + RUN_TEST(test_hash_differs_on_psk_only); + RUN_TEST(test_hash_differs_on_name_only); + RUN_TEST(test_disabled_channel_has_invalid_hash); + + printf("\n=== getKey expansion and padding ===\n"); + RUN_TEST(test_psk_index_1_expands_to_defaultpsk); + RUN_TEST(test_psk_index_2_bumps_last_byte); + RUN_TEST(test_psk_index_0_disables_encryption); + RUN_TEST(test_psk_index_255_boundary); + RUN_TEST(test_short_key_pads_to_aes128); + RUN_TEST(test_midsize_key_pads_to_aes256); + RUN_TEST(test_exact_16_and_32_byte_keys_pass_through); + + printf("\n=== secondary inheritance and recursion guard ===\n"); + RUN_TEST(test_secondary_empty_psk_inherits_primary_key); + RUN_TEST(test_recursion_guard_primary_slot_marked_secondary); + + printf("\n=== onConfigChanged restore and setChannel ===\n"); + RUN_TEST(test_onconfigchanged_promotes_demoted_primary_slot_keeping_key); + RUN_TEST(test_onconfigchanged_restores_default_when_all_disabled); + RUN_TEST(test_setchannel_demotes_old_primary); + + printf("\n=== decryptForHash bounds (#11046) ===\n"); + RUN_TEST(test_decryptforhash_rejects_out_of_range_index); + RUN_TEST(test_decryptforhash_accepts_valid_index_and_hash); + RUN_TEST(test_decryptforhash_rejects_wrong_hash); + RUN_TEST(test_decryptforhash_disabled_slot_matches_no_hash); + +#if !USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL + printf("\n=== perhapsDecode same-hash fall-through ===\n"); + RUN_TEST(test_perhapsdecode_collision_selects_matching_psk); + RUN_TEST(test_perhapsdecode_wrong_key_is_decode_failure); + RUN_TEST(test_perhapsdecode_unknown_hash_is_opaque); +#endif + + exit(UNITY_END()); +} + +CK_TEST_ENTRY void loop() {} diff --git a/test/test_hop_start_policy/test_main.cpp b/test/test_hop_start_policy/test_main.cpp new file mode 100644 index 000000000..84cc7a9c8 --- /dev/null +++ b/test/test_hop_start_policy/test_main.cpp @@ -0,0 +1,352 @@ +#include "MeshTypes.h" // Include BEFORE TestUtil.h (provides NodeNum, isFromUs) +#include "TestUtil.h" +#include + +#include "configuration.h" // MESHTASTIC_PREHOP_DROP +#include "mesh/NodeDB.h" // classifyHopStart, shouldDropPacketForPreHop, HopStartStatus +#include +#include + +// TEST_MESSAGE emits file:line:INFO lines visible at -vv; printf lines appear un-prefixed. +// TEST_MSG_FMT wraps TEST_MESSAGE for formatted per-case diagnostics. +#define MSG_BUF_LEN 200 +#define TEST_MSG_FMT(fmt, ...) \ + do { \ + char _buf[MSG_BUF_LEN]; \ + snprintf(_buf, sizeof(_buf), fmt, __VA_ARGS__); \ + TEST_MESSAGE(_buf); \ + } while (0) + +static constexpr NodeNum kLocalNode = 0x11111111; +static constexpr NodeNum kRemoteNode = 0x22222222; + +// shouldDropPacketForPreHop -> isFromUs -> nodeDB->getNodeNum(), so a real NodeDB must be live. +static NodeDB *testNodeDB = nullptr; + +// --------------------------------------------------------------------------- +// Packet builders +// --------------------------------------------------------------------------- + +// A still-encrypted packet as Router::perhapsHandleReceived sees it (Router.cpp:1598): the +// channel-encrypted bitfield is unreadable, so the union's decoded half is untouched garbage. +static meshtastic_MeshPacket makeEncrypted(NodeNum from, uint8_t hopStart, uint8_t hopLimit) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = from; + p.to = NODENUM_BROADCAST; + p.id = 0x1000u + (uint32_t)hopStart * 16u + hopLimit; + p.hop_start = hopStart; + p.hop_limit = hopLimit; + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + p.encrypted.size = 16; // opaque ciphertext; contents irrelevant to hop classification + return p; +} + +// A decoded packet as Router::handleReceived sees it post-decrypt (Router.cpp:1450). +static meshtastic_MeshPacket makeDecoded(NodeNum from, uint8_t hopStart, uint8_t hopLimit, bool hasBitfield) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = from; + p.to = NODENUM_BROADCAST; + p.id = 0x2000u + (uint32_t)hopStart * 16u + hopLimit; + p.hop_start = hopStart; + p.hop_limit = hopLimit; + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.has_bitfield = hasBitfield; + p.decoded.bitfield = hasBitfield ? 1 : 0; + return p; +} + +static void assertClassify(const meshtastic_MeshPacket &p, HopStartStatus expected, const char *label) +{ + HopStartStatus got = classifyHopStart(p); + TEST_MSG_FMT("%-44s hop_start=%u hop_limit=%u -> %d (expect %d)", label, (unsigned)p.hop_start, (unsigned)p.hop_limit, + (int)got, (int)expected); + TEST_ASSERT_EQUAL_INT_MESSAGE((int)expected, (int)got, label); +} + +// The shared predicate Router::dispatchReceived uses to set skipHandle, so gate drift fails here. +// (The cancelSending side effect stays uncovered.) +static bool routerPostDecodeWouldSkip(const meshtastic_MeshPacket &p) +{ + return shouldSkipHandleForPostDecodeHop(p); +} + +// --------------------------------------------------------------------------- +// classifyHopStart truth table +// --------------------------------------------------------------------------- + +void test_classify_invalid_when_hop_start_below_hop_limit() +{ + TEST_MESSAGE("=== hop_start < hop_limit is provably corrupt on any payload variant ==="); + + assertClassify(makeEncrypted(kRemoteNode, 2, 5), HopStartStatus::INVALID, "encrypted 2/5"); + assertClassify(makeEncrypted(kRemoteNode, 0, 1), HopStartStatus::INVALID, "encrypted 0/1"); + assertClassify(makeEncrypted(kRemoteNode, 0, 3), HopStartStatus::INVALID, "encrypted 0/3 (not UNKNOWN: limit > 0)"); + // The bitfield cannot rescue an inconsistent pair - the guard runs before the zero-hop probe. + assertClassify(makeDecoded(kRemoteNode, 2, 5, true), HopStartStatus::INVALID, "decoded+bitfield 2/5"); + assertClassify(makeDecoded(kRemoteNode, 0, 3, true), HopStartStatus::INVALID, "decoded+bitfield 0/3"); + assertClassify(makeDecoded(kRemoteNode, 0, 3, false), HopStartStatus::INVALID, "decoded no-bitfield 0/3"); +} + +void test_classify_valid_when_hop_start_covers_hop_limit() +{ + TEST_MESSAGE("=== hop_start > 0 and >= hop_limit is VALID regardless of variant or bitfield ==="); + + assertClassify(makeEncrypted(kRemoteNode, 3, 3), HopStartStatus::VALID, "encrypted 3/3 (fresh broadcast)"); + assertClassify(makeEncrypted(kRemoteNode, 3, 0), HopStartStatus::VALID, "encrypted 3/0 (fully relayed)"); + assertClassify(makeEncrypted(kRemoteNode, 5, 2), HopStartStatus::VALID, "encrypted 5/2 (mid-relay)"); + assertClassify(makeDecoded(kRemoteNode, 3, 3, false), HopStartStatus::VALID, "decoded no-bitfield 3/3"); + assertClassify(makeDecoded(kRemoteNode, 1, 0, false), HopStartStatus::VALID, "decoded no-bitfield 1/0"); +} + +void test_classify_zero_hop_modern_beacon_valid() +{ + TEST_MESSAGE("=== 0/0 decoded with bitfield = modern zero-hop broadcast, VALID ==="); + + assertClassify(makeDecoded(kRemoteNode, 0, 0, true), HopStartStatus::VALID, "decoded+bitfield 0/0 (beacon)"); +} + +void test_classify_zero_hop_decoded_without_bitfield_unknown() +{ + TEST_MESSAGE("=== 0/0 decoded without bitfield = pre-2.3.0 origin, MISSING_OR_UNKNOWN ==="); + + assertClassify(makeDecoded(kRemoteNode, 0, 0, false), HopStartStatus::MISSING_OR_UNKNOWN, "decoded no-bitfield 0/0"); +} + +void test_classify_zero_hop_encrypted_is_unknown() +{ + TEST_MESSAGE("=== 0/0 encrypted: bitfield unreadable pre-decode, MISSING_OR_UNKNOWN ==="); + + assertClassify(makeEncrypted(kRemoteNode, 0, 0), HopStartStatus::MISSING_OR_UNKNOWN, "encrypted 0/0"); +} + +void test_classify_encrypted_variant_ignores_stale_union_bitfield() +{ + TEST_MESSAGE("=== stale decoded-union bytes must not leak through the variant check ==="); + + // Adversarial struct state: payload variant says encrypted, but the union's decoded half + // still claims has_bitfield (e.g. a reused pool packet). The variant tag must gate the read. + meshtastic_MeshPacket p = makeEncrypted(kRemoteNode, 0, 0); + p.decoded.has_bitfield = true; + p.decoded.bitfield = 1; + assertClassify(p, HopStartStatus::MISSING_OR_UNKNOWN, "encrypted 0/0 w/ stale union bitfield"); +} + +void test_classify_hop_cap_boundaries() +{ + TEST_MESSAGE("=== boundaries at the 3-bit wire cap (HOP_MAX=7) and uint8 extremes ==="); + + assertClassify(makeEncrypted(kRemoteNode, 7, 7), HopStartStatus::VALID, "encrypted 7/7 (max fresh)"); + assertClassify(makeEncrypted(kRemoteNode, 7, 0), HopStartStatus::VALID, "encrypted 7/0 (max relayed out)"); + assertClassify(makeEncrypted(kRemoteNode, 6, 7), HopStartStatus::INVALID, "encrypted 6/7 (one below limit)"); + // Above the wire cap: unreachable from radio (3-bit fields) but reachable via phone input, + // where hop fields are plain uint8 in the struct. + assertClassify(makeEncrypted(kRemoteNode, 7, 8), HopStartStatus::INVALID, "encrypted 7/8 (limit past cap)"); + assertClassify(makeEncrypted(kRemoteNode, 255, 255), HopStartStatus::VALID, "encrypted 255/255"); + assertClassify(makeEncrypted(kRemoteNode, 254, 255), HopStartStatus::INVALID, "encrypted 254/255"); +} + +// --------------------------------------------------------------------------- +// Pre-decode drop policy (Router.cpp:1598 gate) and post-decode re-check +// --------------------------------------------------------------------------- + +#if MESHTASTIC_PREHOP_DROP + +void test_predecode_drops_provably_corrupt_only() +{ + TEST_MESSAGE("=== pre-decode gate drops only INVALID; VALID passes ==="); + + TEST_ASSERT_TRUE_MESSAGE(shouldDropPacketForPreHop(makeEncrypted(kRemoteNode, 2, 5)), "corrupt 2/5 must drop"); + TEST_ASSERT_TRUE_MESSAGE(shouldDropPacketForPreHop(makeEncrypted(kRemoteNode, 0, 3)), "corrupt 0/3 must drop"); + TEST_ASSERT_FALSE_MESSAGE(shouldDropPacketForPreHop(makeEncrypted(kRemoteNode, 3, 3)), "valid 3/3 must pass"); + TEST_ASSERT_FALSE_MESSAGE(shouldDropPacketForPreHop(makeEncrypted(kRemoteNode, 5, 2)), "valid 5/2 must pass"); +} + +void test_predecode_keeps_unknown_encrypted() +{ + TEST_MESSAGE("=== REGRESSION (#10758): MISSING_OR_UNKNOWN must survive the pre-decode gate ==="); + TEST_MESSAGE("Pre-fix, every non-VALID verdict dropped here - silently discarding all encrypted"); + TEST_MESSAGE("traffic whose proving bitfield was still under the channel key."); + + meshtastic_MeshPacket p = makeEncrypted(kRemoteNode, 0, 0); + TEST_ASSERT_EQUAL_INT((int)HopStartStatus::MISSING_OR_UNKNOWN, (int)classifyHopStart(p)); + TEST_ASSERT_FALSE_MESSAGE(shouldDropPacketForPreHop(p), "unknown-yet packet dropped before decryption"); +} + +void test_predecode_from_us_exempt() +{ + TEST_MESSAGE("=== local-origin packets are never pre-hop dropped, even when corrupt ==="); + + TEST_ASSERT_FALSE_MESSAGE(shouldDropPacketForPreHop(makeEncrypted(kLocalNode, 2, 5)), "own node num exempt"); + // from == 0 also counts as us (isFromUs), e.g. phone-injected packets pre-numbering. + TEST_ASSERT_FALSE_MESSAGE(shouldDropPacketForPreHop(makeEncrypted(0, 2, 5)), "from==0 exempt"); +} + +void test_postdecode_recheck_catches_unknown() +{ + TEST_MESSAGE("=== the pre/post-decode asymmetry: UNKNOWN passes the gate, then skipHandle ==="); + + // Pre-decode the packet is opaque 0/0 -> kept; post-decode the absent bitfield proves a + // pre-hop-firmware origin -> Router.cpp:1450 sets skipHandle. This split IS the fix; a + // cleanup that collapses the two checks into one re-creates the mesh-wide drop. + meshtastic_MeshPacket preHopOrigin = makeDecoded(kRemoteNode, 0, 0, false); + TEST_ASSERT_FALSE(shouldDropPacketForPreHop(makeEncrypted(kRemoteNode, 0, 0))); + TEST_ASSERT_TRUE_MESSAGE(routerPostDecodeWouldSkip(preHopOrigin), "post-decode must exclude pre-hop origin"); + + meshtastic_MeshPacket modernBeacon = makeDecoded(kRemoteNode, 0, 0, true); + TEST_ASSERT_FALSE_MESSAGE(routerPostDecodeWouldSkip(modernBeacon), "modern zero-hop beacon must be handled"); + + meshtastic_MeshPacket ourOwn = makeDecoded(kLocalNode, 0, 0, false); + TEST_ASSERT_FALSE_MESSAGE(routerPostDecodeWouldSkip(ourOwn), "local-origin exempt post-decode too"); + + meshtastic_MeshPacket corrupt = makeDecoded(kRemoteNode, 2, 5, true); + TEST_ASSERT_TRUE_MESSAGE(routerPostDecodeWouldSkip(corrupt), "corrupt still excluded post-decode"); +} + +#else // !MESHTASTIC_PREHOP_DROP + +void test_prehop_disabled_never_drops() +{ + TEST_MESSAGE("=== MESHTASTIC_PREHOP_DROP=0: the gate is compiled out entirely ==="); + + TEST_ASSERT_FALSE(shouldDropPacketForPreHop(makeEncrypted(kRemoteNode, 2, 5))); + TEST_ASSERT_FALSE(shouldDropPacketForPreHop(makeEncrypted(kRemoteNode, 0, 0))); +} + +#endif // MESHTASTIC_PREHOP_DROP + +// --------------------------------------------------------------------------- +// Cross-check against getHopsAway +// --------------------------------------------------------------------------- + +void test_gethopsaway_agrees_with_classification() +{ + TEST_MESSAGE("=== getHopsAway yields a hop count iff classifyHopStart says VALID ==="); + + struct Case { + meshtastic_MeshPacket p; + const char *label; + }; + const Case cases[] = { + {makeEncrypted(kRemoteNode, 2, 5), "encrypted 2/5"}, + {makeEncrypted(kRemoteNode, 0, 3), "encrypted 0/3"}, + {makeEncrypted(kRemoteNode, 0, 0), "encrypted 0/0"}, + {makeEncrypted(kRemoteNode, 3, 3), "encrypted 3/3"}, + {makeEncrypted(kRemoteNode, 5, 2), "encrypted 5/2"}, + {makeEncrypted(kRemoteNode, 7, 0), "encrypted 7/0"}, + {makeDecoded(kRemoteNode, 0, 0, true), "decoded+bitfield 0/0"}, + {makeDecoded(kRemoteNode, 0, 0, false), "decoded no-bitfield 0/0"}, + {makeDecoded(kRemoteNode, 0, 3, true), "decoded+bitfield 0/3"}, + {makeDecoded(kRemoteNode, 4, 1, false), "decoded no-bitfield 4/1"}, + }; + + for (const Case &c : cases) { + const bool valid = classifyHopStart(c.p) == HopStartStatus::VALID; + const int8_t hops = getHopsAway(c.p, -1); + TEST_MSG_FMT("%-28s valid=%d hopsAway=%d", c.label, (int)valid, (int)hops); + if (valid) { + TEST_ASSERT_EQUAL_INT8_MESSAGE((int8_t)(c.p.hop_start - c.p.hop_limit), hops, c.label); + } else { + TEST_ASSERT_EQUAL_INT8_MESSAGE(-1, hops, c.label); + } + } +} + +// --------------------------------------------------------------------------- +// Summary +// --------------------------------------------------------------------------- + +// Printed row and checked expectation come from one struct, so the summary cannot narrate a table +// the predicates no longer implement. Was TEST_MESSAGE-only, i.e. a case that could not fail. +void test_truth_table_summary() +{ +#if MESHTASTIC_PREHOP_DROP + constexpr bool kGate = true; +#else + constexpr bool kGate = false; +#endif + + struct Row { + meshtastic_MeshPacket p; + HopStartStatus expected; + bool preDrop; // shouldDropPacketForPreHop, gate compiled in + bool postSkip; // shouldSkipHandleForPostDecodeHop, ditto + const char *label; + }; + const Row rows[] = { + {makeDecoded(kRemoteNode, 2, 5, true), HopStartStatus::INVALID, true, true, + "hop_start0, >=limit | any variant | VALID | handled normally"}, + {makeDecoded(kRemoteNode, 0, 0, true), HopStartStatus::VALID, false, false, + "0/0 | decoded + bitfield | VALID | modern zero-hop beacon"}, + {makeDecoded(kRemoteNode, 0, 0, false), HopStartStatus::MISSING_OR_UNKNOWN, false, true, + "0/0 | decoded, no bitfield | UNKNOWN | kept pre-decode, skipHandle post-decode"}, + {makeEncrypted(kRemoteNode, 0, 0), HopStartStatus::MISSING_OR_UNKNOWN, false, true, + "0/0 | encrypted | UNKNOWN | kept pre-decode (bitfield unreadable)"}, + {makeDecoded(kLocalNode, 2, 5, true), HopStartStatus::INVALID, false, false, + "isFromUs | any | any | never dropped by pre-hop policy"}, + }; + + TEST_MESSAGE("=== classifyHopStart truth table ==="); + for (const Row &r : rows) { + TEST_MESSAGE(r.label); + TEST_ASSERT_EQUAL_INT_MESSAGE((int)r.expected, (int)classifyHopStart(r.p), r.label); + TEST_ASSERT_EQUAL_INT_MESSAGE((int)(kGate && r.preDrop), (int)shouldDropPacketForPreHop(r.p), r.label); + TEST_ASSERT_EQUAL_INT_MESSAGE((int)(kGate && r.postSkip), (int)routerPostDecodeWouldSkip(r.p), r.label); + } +} + +// --------------------------------------------------------------------------- +// Unity lifecycle +// --------------------------------------------------------------------------- + +void setUp(void) +{ + if (!testNodeDB) + testNodeDB = new NodeDB(); + + config = meshtastic_LocalConfig_init_zero; + moduleConfig = meshtastic_LocalModuleConfig_init_zero; + myNodeInfo.my_node_num = kLocalNode; + nodeDB = testNodeDB; +} + +void tearDown(void) {} + +void setup() +{ + initializeTestEnvironment(); + + UNITY_BEGIN(); + + printf("\n=== classifyHopStart truth table ===\n"); + RUN_TEST(test_classify_invalid_when_hop_start_below_hop_limit); + RUN_TEST(test_classify_valid_when_hop_start_covers_hop_limit); + RUN_TEST(test_classify_zero_hop_modern_beacon_valid); + RUN_TEST(test_classify_zero_hop_decoded_without_bitfield_unknown); + RUN_TEST(test_classify_zero_hop_encrypted_is_unknown); + RUN_TEST(test_classify_encrypted_variant_ignores_stale_union_bitfield); + RUN_TEST(test_classify_hop_cap_boundaries); + + printf("\n=== Pre-hop drop policy ===\n"); +#if MESHTASTIC_PREHOP_DROP + RUN_TEST(test_predecode_drops_provably_corrupt_only); + RUN_TEST(test_predecode_keeps_unknown_encrypted); + RUN_TEST(test_predecode_from_us_exempt); + RUN_TEST(test_postdecode_recheck_catches_unknown); +#else + RUN_TEST(test_prehop_disabled_never_drops); +#endif + + printf("\n=== Cross-checks ===\n"); + RUN_TEST(test_gethopsaway_agrees_with_classification); + + printf("\n=== Summary ===\n"); + RUN_TEST(test_truth_table_summary); + + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_mqtt/MQTT.cpp b/test/test_mqtt/MQTT.cpp index b67cf31ab..64ea0cd4d 100644 --- a/test/test_mqtt/MQTT.cpp +++ b/test/test_mqtt/MQTT.cpp @@ -27,6 +27,7 @@ #include #include +#include #include #include #include @@ -86,8 +87,15 @@ class MockMeshService : public MeshService class MockNodeDB : public NodeDB { public: - meshtastic_NodeInfoLite *getMeshNode(NodeNum n) override { return &emptyNode; } + // Per-NodeNum overlay on top of the shared node, so a test can make one endpoint known + // while another stays unknown; everything else keeps the shared-node semantics. + meshtastic_NodeInfoLite *getMeshNode(NodeNum n) override + { + auto it = nodes_.find(n); + return it != nodes_.end() ? &it->second : &emptyNode; + } meshtastic_NodeInfoLite emptyNode = {}; + std::map nodes_; }; // Minimal RoutingModule needed to return values from sendAckNak. @@ -417,8 +425,10 @@ void setUp(void) // The shared MockNodeDB node is mutated by the XEdDSA policy tests (signer bit, public // key); reset it so state can't leak between tests. - if (mockNodeDB) + if (mockNodeDB) { mockNodeDB->emptyNode = meshtastic_NodeInfoLite(); + mockNodeDB->nodes_.clear(); + } router = mockRouter = new MockRouter(); service = mockMeshService = new MockMeshService(); @@ -758,7 +768,9 @@ void test_receiveIgnoresOwnPublishedMessages(void) TEST_ASSERT_TRUE(mockRoutingModule->ackNacks_.empty()); } -// Considers receiving one of our packets an acknowledgement of it being sent. +// Considers receiving one of our packets an acknowledgement of it being sent: hearing our own +// packet back on our own gateway topic synthesizes an implicit ACK, delivered locally through +// sendLocal() -> handleReceived() -> the phone queue, marked as arriving via MQTT transport. void test_receiveAcksOwnSentMessages(void) { meshtastic_MeshPacket p = decoded; @@ -766,13 +778,26 @@ void test_receiveAcksOwnSentMessages(void) unitTest->publish(&p, nodeDB->getNodeId().c_str()); - // FIXME: Better assertion for this test - // TEST_ASSERT_TRUE(mockRouter->packets_.empty()); - // TEST_ASSERT_EQUAL(1, mockRoutingModule->ackNacks_.size()); - // const auto &[err, to, idFrom, chIndex, hopLimit] = mockRoutingModule->ackNacks_.front(); - // TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, err); - // TEST_ASSERT_EQUAL(myNodeInfo.my_node_num, to); - // TEST_ASSERT_EQUAL(p.id, idFrom); + // The implicit ACK is delivered locally, never enqueued as MQTT downlink ingress. + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); + + meshtastic_MeshPacket *ack = mockMeshService->getForPhone(); + TEST_ASSERT_NOT_NULL(ack); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_decoded_tag, ack->which_payload_variant); + TEST_ASSERT_EQUAL(meshtastic_PortNum_ROUTING_APP, ack->decoded.portnum); + TEST_ASSERT_EQUAL(myNodeInfo.my_node_num, ack->to); + TEST_ASSERT_EQUAL(myNodeInfo.my_node_num, ack->from); + TEST_ASSERT_EQUAL(p.id, ack->decoded.request_id); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT, ack->transport_mechanism); + + meshtastic_Routing routing = meshtastic_Routing_init_default; + TEST_ASSERT_TRUE( + pb_decode_from_bytes(ack->decoded.payload.bytes, ack->decoded.payload.size, &meshtastic_Routing_msg, &routing)); + TEST_ASSERT_EQUAL(meshtastic_Routing_error_reason_tag, routing.which_variant); + TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, routing.error_reason); + + mockMeshService->releaseToPool(ack); + TEST_ASSERT_NULL(mockMeshService->getForPhone()); // exactly one ACK } // Should ignore our own messages from MQTT that were heard by other nodes. @@ -967,6 +992,208 @@ void test_receiveIgnoresInvalidHopLimit(void) TEST_ASSERT_TRUE(mockRouter->packets_.empty()); } +// =========================================================================== +// Downlink acceptance gates - shouldDropMqttDownlink + onReceiveProto policy +// =========================================================================== + +// hop_start above HOP_MAX is rejected even when hop_limit is valid. +void test_receiveIgnoresInvalidHopStart(void) +{ + meshtastic_MeshPacket p = decoded; + p.hop_start = 10; + p.hop_limit = 3; + + unitTest->publish(&p); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +// The ignore_mqtt kill-switch drops every MQTT downlink. +void test_receiveDropsWhenIgnoreMqttSet(void) +{ + config.lora.ignore_mqtt = true; + + unitTest->publish(&decoded); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +// A sender listed in config.lora.ignore_incoming is dropped. +void test_receiveDropsSenderInIgnoreIncomingList(void) +{ + config.lora.ignore_incoming_count = 1; + config.lora.ignore_incoming[0] = decoded.from; + + unitTest->publish(&decoded); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +// A non-empty ignore list only drops matching senders - presence of the list alone must not drop. +void test_receiveAcceptsSenderNotInIgnoreIncomingList(void) +{ + config.lora.ignore_incoming_count = 2; + config.lora.ignore_incoming[0] = 99; + config.lora.ignore_incoming[1] = 100; + + unitTest->publish(&decoded); + + TEST_ASSERT_EQUAL(1, mockRouter->packets_.size()); +} + +// A sender whose NodeDB entry carries the is_ignored bit is dropped (resurrect-ignored-node guard). +void test_receiveDropsNodeDbIgnoredSender(void) +{ + mockNodeDB->emptyNode.bitfield |= NODEINFO_BITFIELD_IS_IGNORED_MASK; + + unitTest->publish(&decoded); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +// A packet claiming the broadcast address as its source is dropped. +void test_receiveDropsBroadcastSource(void) +{ + meshtastic_MeshPacket p = decoded; + p.from = NODENUM_BROADCAST; + + unitTest->publish(&p); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); + TEST_ASSERT_TRUE(mockRoutingModule->ackNacks_.empty()); +} + +// A broker cannot assert PKI authentication or a transport: every accepted downlink is laundered +// to pki_encrypted=false + TRANSPORT_MQTT + via_mqtt=true. pki_encrypted grants admin-level trust +// downstream, so a regression here is remote privilege escalation. +void test_receiveLaundersPkiAndTransportFields(void) +{ + meshtastic_MeshPacket p = decoded; + p.pki_encrypted = true; + p.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + + unitTest->publish(&p); + + TEST_ASSERT_EQUAL(1, mockRouter->packets_.size()); + const meshtastic_MeshPacket &r = mockRouter->packets_.front(); + TEST_ASSERT_FALSE(r.pki_encrypted); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT, r.transport_mechanism); + TEST_ASSERT_TRUE(r.via_mqtt); +} + +// PKI-topic envelopes are dropped when no channel has downlink enabled, even when addressed to us. +void test_receiveDropsPkiTopicWhenNoChannelHasDownlink(void) +{ + channelFile.channels[0].settings.downlink_enabled = false; + meshtastic_MeshPacket e = encrypted; + e.to = myNodeInfo.my_node_num; + + unitTest->publish(&e, "!87654321", "PKI"); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +// Any single downlink-enabled channel (here only a secondary) is enough to admit PKI envelopes. +void test_receiveAcceptsPkiTopicWithOnlySecondaryDownlink(void) +{ + channelFile.channels[0].settings.downlink_enabled = false; + channelFile.channels[1] = meshtastic_Channel{ + .index = 1, + .has_settings = true, + .settings = {.name = "second", .downlink_enabled = true}, + .role = meshtastic_Channel_Role_SECONDARY, + }; + channelFile.channels_count = 2; + channels.onConfigChanged(); + meshtastic_MeshPacket e = encrypted; + e.to = myNodeInfo.my_node_num; + + unitTest->publish(&e, "!87654321", "PKI"); + + TEST_ASSERT_EQUAL(1, mockRouter->packets_.size()); +} + +// An encrypted PKI envelope not addressed to us needs both endpoints known with user info. +void test_receiveDropsPkiNotToUsWithUnknownEndpoints(void) +{ + unitTest->publish(&encrypted, "!87654321", "PKI"); // to=2; neither endpoint has user info + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +void test_receiveAcceptsPkiNotToUsWithKnownEndpoints(void) +{ + // MockNodeDB serves the same node for every NodeNum, so this marks both endpoints known. + mockNodeDB->emptyNode.bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK; + + unitTest->publish(&encrypted, "!87654321", "PKI"); + + TEST_ASSERT_EQUAL(1, mockRouter->packets_.size()); + const meshtastic_MeshPacket &r = mockRouter->packets_.front(); + TEST_ASSERT_TRUE(r.via_mqtt); + TEST_ASSERT_FALSE(r.pki_encrypted); // laundered even on the PKI topic + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT, r.transport_mechanism); +} + +// The endpoint gate is an AND: knowing only the sender (from=1) while the receiver (to=2) is +// unknown must still drop. Distinguishes && from || in the MQTT.cpp acceptance rule. +void test_receiveDropsPkiNotToUsWithOnlySenderKnown(void) +{ + mockNodeDB->nodes_[1].bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK; // only from=1 known; to=2 stays unknown + + unitTest->publish(&encrypted, "!87654321", "PKI"); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +// An envelope naming a channel we do not have is dropped, even though getByName falls back to +// the primary channel - the case-sensitive global-id recheck must refuse the substitution. +void test_receiveDropsUnknownChannelName(void) +{ + unitTest->publish(&decoded, "!87654321", "nope"); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +// getByName matches case-insensitively, but the downlink gate compares case-sensitively; a +// mixed-case channel_id must not ride the primary channel's downlink permission. +void test_receiveDropsCaseMismatchedChannelName(void) +{ + unitTest->publish(&decoded, "!87654321", "TEST"); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +// A validly-decoding envelope missing channel_id is rejected before any gate runs. +void test_receiveRejectsEnvelopeWithoutChannelId(void) +{ + const meshtastic_ServiceEnvelope env = {.packet = const_cast(&decoded), + .channel_id = NULL, + .gateway_id = const_cast("!87654321")}; + uint8_t bytes[256]; + const size_t numBytes = pb_encode_to_bytes(bytes, sizeof(bytes), &meshtastic_ServiceEnvelope_msg, &env); + unitTest->deliverRaw("msh/2/e/test/!87654321", bytes, numBytes); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +// Every strict prefix of a valid envelope must be rejected: either the truncated decode fails, or +// it succeeds with gateway_id (the last-encoded field) missing and the NULL check refuses it. +void test_receiveRejectsTruncatedEnvelope(void) +{ + const meshtastic_ServiceEnvelope env = {.packet = const_cast(&decoded), + .channel_id = const_cast("test"), + .gateway_id = const_cast("!87654321")}; + uint8_t bytes[256]; + const size_t numBytes = pb_encode_to_bytes(bytes, sizeof(bytes), &meshtastic_ServiceEnvelope_msg, &env); + TEST_ASSERT_TRUE(numBytes > 0); + + for (size_t n = 1; n < numBytes; n++) + unitTest->deliverRaw("msh/2/e/test/!87654321", bytes, n); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + // Publishing to a text channel. void test_publishTextMessageDirect(void) { @@ -1295,6 +1522,22 @@ void setup() #endif RUN_TEST(test_receiveIgnoresUnexpectedFields); RUN_TEST(test_receiveIgnoresInvalidHopLimit); + RUN_TEST(test_receiveIgnoresInvalidHopStart); + RUN_TEST(test_receiveDropsWhenIgnoreMqttSet); + RUN_TEST(test_receiveDropsSenderInIgnoreIncomingList); + RUN_TEST(test_receiveAcceptsSenderNotInIgnoreIncomingList); + RUN_TEST(test_receiveDropsNodeDbIgnoredSender); + RUN_TEST(test_receiveDropsBroadcastSource); + RUN_TEST(test_receiveLaundersPkiAndTransportFields); + RUN_TEST(test_receiveDropsPkiTopicWhenNoChannelHasDownlink); + RUN_TEST(test_receiveAcceptsPkiTopicWithOnlySecondaryDownlink); + RUN_TEST(test_receiveDropsPkiNotToUsWithUnknownEndpoints); + RUN_TEST(test_receiveAcceptsPkiNotToUsWithKnownEndpoints); + RUN_TEST(test_receiveDropsPkiNotToUsWithOnlySenderKnown); + RUN_TEST(test_receiveDropsUnknownChannelName); + RUN_TEST(test_receiveDropsCaseMismatchedChannelName); + RUN_TEST(test_receiveRejectsEnvelopeWithoutChannelId); + RUN_TEST(test_receiveRejectsTruncatedEnvelope); RUN_TEST(test_receiveFuzzServiceEnvelope); RUN_TEST(test_publishTextMessageDirect); RUN_TEST(test_publishTextMessageWithProxy); diff --git a/test/test_nodedb_boot_recovery/test_main.cpp b/test/test_nodedb_boot_recovery/test_main.cpp new file mode 100644 index 000000000..0c2f537df --- /dev/null +++ b/test/test_nodedb_boot_recovery/test_main.cpp @@ -0,0 +1,396 @@ +// NodeDB boot-recovery contract: an undecodable config.proto must freeze identity (no keygen, no +// overwrite), an absent one takes the fresh-install path, and a corrupt nodes.proto does neither. +// The tests are a ladder (state=per-suite): arrange /prefs, then "reboot" a fresh NodeDB. +#include "MeshTypes.h" // Include BEFORE TestUtil.h +#include "TestUtil.h" +#include + +#if defined(ARCH_PORTDUINO) +#define NBR_TEST_ENTRY extern "C" +#else +#define NBR_TEST_ENTRY +#endif + +#include "FSCommon.h" // defines FSCom; must precede the feature guard below + +// The identity-freeze contract only exists where there is a filesystem and boot keygen. +#if defined(FSCom) && !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) + +#include "mesh/NodeDB.h" +#include "mesh/TypeConversions.h" +#include +#include +#include +#include + +// Friend seam declared in NodeDB.h (PIO_UNIT_TESTING): read the private degraded-boot flag. +// Never instantiated - constructing one would run the real boot sequence. +class NodeDBTestShim : public NodeDB +{ + public: + static bool decodeFailed(const NodeDB *db) { return db->configDecodeFailed; } +}; + +namespace +{ + +// --- Identity baseline captured after a healthy keyed boot --- +uint32_t baseNodeNum = 0; +uint8_t basePublicKey[32]; +uint8_t basePrivateKey[32]; +char baseLongName[sizeof(meshtastic_User::long_name)]; +std::vector goodConfigBytes; // byte-exact healthy config.proto for restore tests + +// --- File helpers (through FSCom so the tests stay agnostic about the mountpoint) --- + +bool readFileBytes(const char *path, std::vector &out) +{ + out.clear(); + File f = FSCom.open(path, FILE_O_READ); + if (!f) + return false; + uint8_t buf[512]; + size_t n; + while ((n = f.read(buf, sizeof(buf))) > 0) + out.insert(out.end(), buf, buf + n); + f.close(); + return true; +} + +void writeFileBytes(const char *path, const uint8_t *data, size_t len) +{ + FSCom.remove(path); // FILE_O_WRITE is append on some backends; start clean + File f = FSCom.open(path, FILE_O_WRITE); + TEST_ASSERT_TRUE_MESSAGE(f, path); + TEST_ASSERT_EQUAL_size_t(len, f.write(data, len)); + f.close(); +} + +// FNV-1a content fingerprint; answers only "did this file change?". 0 == missing file. +uint64_t fileFingerprint(const char *path) +{ + std::vector bytes; + if (!readFileBytes(path, bytes)) + return 0; + uint64_t h = 1469598103934665603ULL; + for (uint8_t b : bytes) { + h ^= b; + h *= 1099511628211ULL; + } + return h; +} + +// A varint tag of five 0xFF bytes overflows 32 bits, so nanopb fails deterministically. +const uint8_t kGarbage[32] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + +// --- Reboot helper --- + +// A real boot starts with a zeroed nodeDatabase; in-process the global retains the previous +// boot's vector (the decode callback appends, it does not clear), so reset it first. +void rebootNodeDB() +{ + nodeDatabase.version = 0; + nodeDatabase.nodes.clear(); + NodeDB *rebooted = new NodeDB(); + delete nodeDB; + nodeDB = rebooted; +} + +void captureIdentityBaseline() +{ + TEST_ASSERT_EQUAL(32, config.security.public_key.size); + TEST_ASSERT_EQUAL(32, config.security.private_key.size); + TEST_ASSERT_EQUAL(32, owner.public_key.size); + baseNodeNum = myNodeInfo.my_node_num; + memcpy(basePublicKey, config.security.public_key.bytes, 32); + memcpy(basePrivateKey, config.security.private_key.bytes, 32); + strncpy(baseLongName, owner.long_name, sizeof(baseLongName)); + baseLongName[sizeof(baseLongName) - 1] = '\0'; + TEST_ASSERT_TRUE(readFileBytes(configFileName, goodConfigBytes)); + TEST_ASSERT_GREATER_THAN(1, goodConfigBytes.size()); +} + +// Persist a set region so boot keygen is unconditionally armed (generateCryptoKeyPair skips +// while region == UNSET unless the portduino sim-radio bypass applies), then reboot into the +// healthy keyed state every later test measures against. +void establishHealthyBaseline() +{ + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + TEST_ASSERT_TRUE(nodeDB->saveToDisk(SEGMENT_CONFIG)); + rebootNodeDB(); + // Reboot once more so any boot-time coercion of the freshly saved config (preset clamp) + // has reached its fixpoint on disk before we fingerprint it as the "good" file. + rebootNodeDB(); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region); + captureIdentityBaseline(); +} + +void assertIdentityMatchesBaseline() +{ + TEST_ASSERT_EQUAL_UINT32(baseNodeNum, myNodeInfo.my_node_num); + TEST_ASSERT_EQUAL(32, config.security.public_key.size); + TEST_ASSERT_EQUAL_MEMORY(basePublicKey, config.security.public_key.bytes, 32); + TEST_ASSERT_EQUAL(32, config.security.private_key.size); + TEST_ASSERT_EQUAL_MEMORY(basePrivateKey, config.security.private_key.bytes, 32); + TEST_ASSERT_EQUAL(32, owner.public_key.size); + TEST_ASSERT_EQUAL_MEMORY(basePublicKey, owner.public_key.bytes, 32); +} + +} // namespace + +void setUp(void) {} +void tearDown(void) {} + +// --- Healthy-boot identity --- + +// The #11001 renumber family: a keyed boot must mint NodeNum == crc32(public_key) once, and +// every subsequent reboot must reproduce the same NodeNum, keypair and owner identity. +static void test_firstBoot_establishesKeyedIdentity(void) +{ + TEST_MESSAGE("=== First keyed boot mints crc32(pubkey) identity ==="); + establishHealthyBaseline(); + + TEST_ASSERT_EQUAL_UINT32(crc32Buffer(config.security.public_key.bytes, 32), myNodeInfo.my_node_num); + // The minted identity is in the store of record: self entry present, carrying our key. + const meshtastic_NodeInfoLite *self = nodeDB->getMeshNode(nodeDB->getNodeNum()); + TEST_ASSERT_NOT_NULL(self); + TEST_ASSERT_TRUE(nodeInfoLiteHasUser(self)); + TEST_ASSERT_EQUAL(32, self->public_key.size); + TEST_ASSERT_EQUAL_MEMORY(basePublicKey, self->public_key.bytes, 32); + TEST_ASSERT_FALSE(NodeDBTestShim::decodeFailed(nodeDB)); +} + +static void test_healthyReboot_preservesIdentity(void) +{ + TEST_MESSAGE("=== Plain reboot: identity byte-identical, config.proto not rewritten ==="); + const uint64_t fpBefore = fileFingerprint(configFileName); + TEST_ASSERT_NOT_EQUAL(0, fpBefore); + + rebootNodeDB(); + + assertIdentityMatchesBaseline(); + TEST_ASSERT_EQUAL_STRING(baseLongName, owner.long_name); + // A healthy boot has nothing to persist for config: the on-disk file is already the fixpoint. + TEST_ASSERT_EQUAL_UINT64(fpBefore, fileFingerprint(configFileName)); +} + +// --- Degraded boot: present-but-undecodable config --- + +static void test_corruptConfig_freezesIdentity_leavesFileUntouched(void) +{ + TEST_MESSAGE("=== Corrupt config.proto: frozen identity, radio silent, file untouched ==="); + writeFileBytes(configFileName, kGarbage, sizeof(kGarbage)); + const uint64_t fpGarbage = fileFingerprint(configFileName); + TEST_ASSERT_NOT_EQUAL(0, fpGarbage); + + rebootNodeDB(); + + TEST_ASSERT_TRUE(NodeDBTestShim::decodeFailed(nodeDB)); + // Radio silent until the operator restores a config. + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, config.lora.region); + TEST_ASSERT_FALSE(config.lora.tx_enabled); + // Keygen skipped: no replacement keypair minted into RAM... + TEST_ASSERT_EQUAL(0, config.security.private_key.size); + // ...and the identity carried by devicestate is untouched, so the NodeNum cannot move. + TEST_ASSERT_EQUAL_UINT32(baseNodeNum, myNodeInfo.my_node_num); + TEST_ASSERT_EQUAL(32, owner.public_key.size); + TEST_ASSERT_EQUAL_MEMORY(basePublicKey, owner.public_key.bytes, 32); + // The boot must not have overwritten the (maybe transiently) corrupt file with defaults. + TEST_ASSERT_EQUAL_UINT64(fpGarbage, fileFingerprint(configFileName)); +} + +// Runs against the still-degraded NodeDB from the previous test: runtime reconfiguration +// (admin set_config -> saveToDisk) must not be permanently blocked by the boot freeze. +static void test_degradedBoot_runtimeConfigSaveStillPersists(void) +{ + TEST_MESSAGE("=== Degraded boot: an explicit runtime config save still lands ==="); + TEST_ASSERT_TRUE(NodeDBTestShim::decodeFailed(nodeDB)); + const uint64_t fpGarbage = fileFingerprint(configFileName); + + TEST_ASSERT_TRUE(nodeDB->saveToDisk(SEGMENT_CONFIG)); + + TEST_ASSERT_NOT_EQUAL(fpGarbage, fileFingerprint(configFileName)); + // What landed is a decodable config again (the degraded-boot defaults). + static meshtastic_LocalConfig scratch; + TEST_ASSERT_EQUAL(LoadFileResult::LOAD_SUCCESS, nodeDB->loadProto(configFileName, meshtastic_LocalConfig_size, + sizeof(scratch), &meshtastic_LocalConfig_msg, &scratch)); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, scratch.lora.region); +} + +static void test_restoredConfig_recoversOriginalIdentity(void) +{ + TEST_MESSAGE("=== Good config bytes restored: next boot is normal with the ORIGINAL identity ==="); + writeFileBytes(configFileName, goodConfigBytes.data(), goodConfigBytes.size()); + + rebootNodeDB(); + + TEST_ASSERT_FALSE(NodeDBTestShim::decodeFailed(nodeDB)); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region); + TEST_ASSERT_TRUE(config.lora.tx_enabled); + assertIdentityMatchesBaseline(); +} + +// --- Absent config: fresh install, not a freeze --- + +static void test_absentConfig_takesFreshInstallPath(void) +{ + TEST_MESSAGE("=== Absent config.proto: OTHER_FAILURE -> defaults + fresh keypair ==="); + uint8_t previousPublicKey[32]; + memcpy(previousPublicKey, basePublicKey, 32); + TEST_ASSERT_TRUE(FSCom.remove(configFileName)); + + rebootNodeDB(); + + // No usable contents to protect, so this is NOT the frozen path. + TEST_ASSERT_FALSE(NodeDBTestShim::decodeFailed(nodeDB)); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, config.lora.region); + + // Re-arm keygen (region gate) and reboot into the replacement identity. + establishHealthyBaseline(); // re-captures the baseline for the remaining tests + + // A fresh install mints a new keypair - and with it a new NodeNum, still crc32-derived. + // (This is the flip side of the DECODE_FAILED freeze: with the file genuinely gone there + // is no identity left to preserve.) + TEST_ASSERT_EQUAL(32, config.security.public_key.size); + TEST_ASSERT_TRUE(memcmp(previousPublicKey, config.security.public_key.bytes, 32) != 0); + TEST_ASSERT_EQUAL_UINT32(crc32Buffer(config.security.public_key.bytes, 32), myNodeInfo.my_node_num); + TEST_ASSERT_TRUE(FSCom.exists(configFileName)); +} + +// --- Freeze is config-scoped --- + +static void test_corruptNodesDb_doesNotFreezeIdentity(void) +{ + TEST_MESSAGE("=== Corrupt nodes.proto alone: config loads, keygen runs, NodeNum kept ==="); + const uint64_t fpConfig = fileFingerprint(configFileName); + writeFileBytes(nodeDatabaseFileName, kGarbage, sizeof(kGarbage)); + + rebootNodeDB(); + + TEST_ASSERT_FALSE(NodeDBTestShim::decodeFailed(nodeDB)); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region); + assertIdentityMatchesBaseline(); + // The store rebuilt from defaults still contains us. + const meshtastic_NodeInfoLite *self = nodeDB->getMeshNode(nodeDB->getNodeNum()); + TEST_ASSERT_NOT_NULL(self); + TEST_ASSERT_TRUE(nodeInfoLiteHasUser(self)); + TEST_ASSERT_EQUAL_UINT64(fpConfig, fileFingerprint(configFileName)); +} + +// --- Devicestate-loss owner recovery --- + +// The recovery block in loadFromDisk() fires when device.proto decodes but is below +// DEVICESTATE_MIN_VER: identity fields survive (my_node_num is in the decoded struct), the +// defaults overwrite the owner names, and the own-node entry in nodes.proto restores them. +static void test_oldDevicestate_recoversOwnerFromNodeDb(void) +{ + TEST_MESSAGE("=== Old-version devicestate: owner names recovered from own NodeDB entry ==="); + // Put the recoverable names into the store of record... + strncpy(owner.long_name, "Recovered Owner", sizeof(owner.long_name)); + strncpy(owner.short_name, "RCVR", sizeof(owner.short_name)); + meshtastic_NodeInfoLite *self = nodeDB->getMeshNode(nodeDB->getNodeNum()); + TEST_ASSERT_NOT_NULL(self); + TypeConversions::CopyUserToNodeInfoLite(self, owner); + TEST_ASSERT_TRUE(nodeDB->saveToDisk(SEGMENT_NODEDATABASE)); + + // ...then persist a devicestate that is valid but too old, carrying DIFFERENT names, so a + // recovered name can only have come from the nodes.proto entry. + strncpy(owner.long_name, "Stale Devicestate", sizeof(owner.long_name)); + strncpy(owner.short_name, "STAL", sizeof(owner.short_name)); + devicestate.version = DEVICESTATE_MIN_VER - 1; + TEST_ASSERT_TRUE(nodeDB->saveToDisk(SEGMENT_DEVICESTATE)); + + rebootNodeDB(); + + TEST_ASSERT_EQUAL_STRING("Recovered Owner", owner.long_name); + TEST_ASSERT_EQUAL_STRING("RCVR", owner.short_name); + // Identity survives the devicestate discard: the NodeNum in the old file is carried over + // and keygen re-derives the same crc32(public_key) value. + assertIdentityMatchesBaseline(); + + // The recovery is re-persisted: the on-disk devicestate is current-version with the + // recovered names, not the stale ones. + static meshtastic_DeviceState saved; + TEST_ASSERT_EQUAL(LoadFileResult::LOAD_SUCCESS, nodeDB->loadProto(deviceStateFileName, meshtastic_DeviceState_size, + sizeof(saved), &meshtastic_DeviceState_msg, &saved)); + TEST_ASSERT_EQUAL(DEVICESTATE_CUR_VER, saved.version); + TEST_ASSERT_EQUAL_STRING("Recovered Owner", saved.owner.long_name); +} + +// --- loadProto classification --- + +// The wipe cascade lived in the difference between these verdicts: DECODE_FAILED is the only +// protected path, and loadProto never returns NOT_FOUND (an unopenable file is OTHER_FAILURE). +static void test_loadProto_classifiesFailuresDistinctly(void) +{ + TEST_MESSAGE("=== loadProto: absent=OTHER_FAILURE, garbage/truncated=DECODE_FAILED ==="); + const char *scratchPath = "/prefs/nbr_scratch.proto"; + static meshtastic_LocalConfig scratch; + + FSCom.remove(scratchPath); + TEST_ASSERT_EQUAL(LoadFileResult::OTHER_FAILURE, nodeDB->loadProto(scratchPath, meshtastic_LocalConfig_size, sizeof(scratch), + &meshtastic_LocalConfig_msg, &scratch)); + + writeFileBytes(scratchPath, kGarbage, sizeof(kGarbage)); + TEST_ASSERT_EQUAL(LoadFileResult::DECODE_FAILED, nodeDB->loadProto(scratchPath, meshtastic_LocalConfig_size, sizeof(scratch), + &meshtastic_LocalConfig_msg, &scratch)); + + // A torn write: a valid encoding minus its final byte always cuts the last field short. + TEST_ASSERT_GREATER_THAN(1, goodConfigBytes.size()); + writeFileBytes(scratchPath, goodConfigBytes.data(), goodConfigBytes.size() - 1); + TEST_ASSERT_EQUAL(LoadFileResult::DECODE_FAILED, nodeDB->loadProto(scratchPath, meshtastic_LocalConfig_size, sizeof(scratch), + &meshtastic_LocalConfig_msg, &scratch)); + + // The unmodified bytes still decode - the failure above was the truncation, nothing else. + writeFileBytes(scratchPath, goodConfigBytes.data(), goodConfigBytes.size()); + TEST_ASSERT_EQUAL(LoadFileResult::LOAD_SUCCESS, nodeDB->loadProto(scratchPath, meshtastic_LocalConfig_size, sizeof(scratch), + &meshtastic_LocalConfig_msg, &scratch)); + + FSCom.remove(scratchPath); // leave nothing behind +} + +NBR_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); + nodeDB = new NodeDB(); // first boot on the pristine per-suite sandbox + + UNITY_BEGIN(); + + printf("\n=== Healthy-boot identity ===\n"); + RUN_TEST(test_firstBoot_establishesKeyedIdentity); + RUN_TEST(test_healthyReboot_preservesIdentity); + + printf("\n=== Degraded boot (corrupt config) ===\n"); + RUN_TEST(test_corruptConfig_freezesIdentity_leavesFileUntouched); + RUN_TEST(test_degradedBoot_runtimeConfigSaveStillPersists); + RUN_TEST(test_restoredConfig_recoversOriginalIdentity); + + printf("\n=== Fresh install vs freeze scoping ===\n"); + RUN_TEST(test_absentConfig_takesFreshInstallPath); + RUN_TEST(test_corruptNodesDb_doesNotFreezeIdentity); + + printf("\n=== Devicestate recovery + loadProto classification ===\n"); + RUN_TEST(test_oldDevicestate_recoversOwnerFromNodeDb); + RUN_TEST(test_loadProto_classifiesFailuresDistinctly); + + exit(UNITY_END()); +} + +NBR_TEST_ENTRY void loop() {} + +#else // !FSCom || PKI excluded + +void setUp(void) {} +void tearDown(void) {} + +NBR_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + exit(UNITY_END()); +} + +NBR_TEST_ENTRY void loop() {} + +#endif diff --git a/test/test_nodedb_identity_hygiene/test_main.cpp b/test/test_nodedb_identity_hygiene/test_main.cpp new file mode 100644 index 000000000..6a2f7eaf9 --- /dev/null +++ b/test/test_nodedb_identity_hygiene/test_main.cpp @@ -0,0 +1,512 @@ +// Identity hygiene for the remote-identity commit paths in NodeDB: updateUser() key pinning and +// addFromContact() guards (a keyless contact must never erase a stored key - the #11432 regression). +#include "MeshTypes.h" // Include BEFORE TestUtil.h +#include "TestUtil.h" +#include + +#if defined(ARCH_PORTDUINO) +#define IH_TEST_ENTRY extern "C" +#else +#define IH_TEST_ENTRY +#endif + +#include "FSCommon.h" +#include "SPILock.h" +#include "mesh/NodeDB.h" +#include "support/MockMeshService.h" +#include +#include + +// Subclass shim: the friend declaration in NodeDB.h grants access to the +// private state these tests must seed/reset (duplicateWarned latch, warm-tier +// demotion). Declared at global scope so it matches `friend class NodeDBTestShim`. +class NodeDBTestShim : public NodeDB +{ + public: + void clearHot() + { + meshNodes->clear(); + numMeshNodes = 0; + } + + // keySeed == 0 means "no stored key"; otherwise a deterministic 32-byte pattern. + void push(NodeNum num, uint32_t lastHeard, uint8_t keySeed = 0, bool xeddsaSigned = false) + { + meshtastic_NodeInfoLite n = meshtastic_NodeInfoLite_init_zero; + n.num = num; + n.last_heard = lastHeard; + nodeInfoLiteSetBit(&n, NODEINFO_BITFIELD_HAS_USER_MASK, true); + if (keySeed) { + n.public_key.size = 32; + memset(n.public_key.bytes, keySeed, 32); + n.public_key.bytes[0] = 0x01; // never all-zero (all-zero == "no key") + } + if (xeddsaSigned) + nodeInfoLiteSetBit(&n, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true); + meshNodes->push_back(n); + numMeshNodes = meshNodes->size(); + } + + // Index 0 is our own node; eviction scans treat it as self. + void seedSelf() { push(0x0BADF00D, 0xFFFFFFFFu); } + + void resetDuplicateWarned() { duplicateWarned = false; } + +#if WARM_NODE_COUNT > 0 + void runDemote() { demoteOldestHotNodesToWarm(); } +#endif +}; + +namespace +{ + +NodeDBTestShim *db = nullptr; +MockMeshService *mockService = nullptr; + +meshtastic_User savedOwner; +meshtastic_LocalConfig savedConfig; + +constexpr NodeNum kPeer = 0xE1000001; + +// Same pattern as NodeDBTestShim::push so a "matching" user key really matches. +template void fillKey(KeyT &k, uint8_t seed) +{ + k.size = 32; + memset(k.bytes, seed, 32); + k.bytes[0] = 0x01; +} + +meshtastic_User makeUser(const char *longName, const char *shortName, uint8_t keySeed = 0) +{ + meshtastic_User u = meshtastic_User_init_zero; + strncpy(u.long_name, longName, sizeof(u.long_name) - 1); + strncpy(u.short_name, shortName, sizeof(u.short_name) - 1); + if (keySeed) + fillKey(u.public_key, keySeed); + return u; +} + +meshtastic_SharedContact makeContact(NodeNum num, const char *longName, const char *shortName, uint8_t keySeed = 0) +{ + meshtastic_SharedContact c = meshtastic_SharedContact_init_zero; + c.node_num = num; + c.has_user = true; + c.user = makeUser(longName, shortName, keySeed); + return c; +} + +void assertStoredKeyEquals(NodeNum num, uint8_t seed) +{ + const meshtastic_NodeInfoLite *info = db->getMeshNode(num); + TEST_ASSERT_NOT_NULL(info); + TEST_ASSERT_EQUAL(32, info->public_key.size); + uint8_t expected[32]; + memset(expected, seed, 32); + expected[0] = 0x01; + TEST_ASSERT_EQUAL_MEMORY(expected, info->public_key.bytes, 32); +} + +} // namespace + +// --- addFromContact --- + +// The #11432 regression: a stored 32-byte key plus a contact with has_user=true +// but no key must keep the stored key bit-for-bit while still merging the user +// fields (clients send add_contact before every DM, usually keyless). +static void test_contact_keyless_preserves_stored_key(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42); + + db->addFromContact(makeContact(kPeer, "Alice", "AL")); + + assertStoredKeyEquals(kPeer, 0x42); + const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer); + TEST_ASSERT_EQUAL_STRING("Alice", info->long_name); // merge still applied + TEST_ASSERT_TRUE(nodeInfoLiteIsFavorite(info)); // anti-eviction stamp for normal roles +} + +// The guard blocks erasure, not update: a contact carrying a different valid +// 32-byte key replaces the stored one (the QR contact-sharing flow). +static void test_contact_new_key_updates_stored_key(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42); + + db->addFromContact(makeContact(kPeer, "Alice", "AL", /*keySeed=*/0x77)); + + assertStoredKeyEquals(kPeer, 0x77); +} + +// A manually-verified pin refuses the ENTIRE update from a non-verified contact +// whose key mismatches - name and key both stay untouched. +static void test_contact_verified_pin_blocks_mismatched_key(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42); + nodeInfoLiteSetBit(db->getMeshNode(kPeer), NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK, true); + + db->addFromContact(makeContact(kPeer, "Mallory", "MA", /*keySeed=*/0x77)); + + assertStoredKeyEquals(kPeer, 0x42); + const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer); + TEST_ASSERT_EQUAL_STRING("", info->long_name); // refused wholesale, not just the key + TEST_ASSERT_FALSE(nodeInfoLiteIsFavorite(info)); // returned before the favorite stamp + TEST_ASSERT_TRUE(nodeInfoLiteIsKeyManuallyVerified(info)); +} + +// The verified pin also refuses a KEYLESS non-verified contact wholesale (a +// size mismatch is a key mismatch) - unlike the plain erasure guard below, +// which merges the user fields and only restores the key. +static void test_contact_verified_pin_blocks_keyless_unverified(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42); + nodeInfoLiteSetBit(db->getMeshNode(kPeer), NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK, true); + + db->addFromContact(makeContact(kPeer, "Alice", "AL")); // keyless, not verified + + assertStoredKeyEquals(kPeer, 0x42); + TEST_ASSERT_EQUAL_STRING("", db->getMeshNode(kPeer)->long_name); +} + +// A non-verified contact whose key MATCHES the verified pin may still update +// the user fields; the verified bit survives the merge. +static void test_contact_verified_pin_allows_matching_key(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42); + nodeInfoLiteSetBit(db->getMeshNode(kPeer), NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK, true); + + db->addFromContact(makeContact(kPeer, "Alice", "AL", /*keySeed=*/0x42)); + + assertStoredKeyEquals(kPeer, 0x42); + const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer); + TEST_ASSERT_EQUAL_STRING("Alice", info->long_name); + TEST_ASSERT_TRUE(nodeInfoLiteIsKeyManuallyVerified(info)); +} + +// contact.manually_verified sets the bit, and a later plain update (here via +// updateUser with the pinned key) must not clear it - CopyUserToNodeInfoLite +// only touches the user-derived bits. +static void test_contact_manually_verified_bit_survives_updates(void) +{ + meshtastic_SharedContact c = makeContact(kPeer, "Alice", "AL", /*keySeed=*/0x42); + c.manually_verified = true; + db->addFromContact(c); + TEST_ASSERT_TRUE(nodeInfoLiteIsKeyManuallyVerified(db->getMeshNode(kPeer))); + + meshtastic_User u = makeUser("Alice2", "A2", /*keySeed=*/0x42); + TEST_ASSERT_TRUE(db->updateUser(kPeer, u)); + + const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer); + TEST_ASSERT_EQUAL_STRING("Alice2", info->long_name); + TEST_ASSERT_TRUE(nodeInfoLiteIsKeyManuallyVerified(info)); + assertStoredKeyEquals(kPeer, 0x42); +} + +// should_ignore blocks the contact and drops its satellite data but keeps the +// stored public key: an ignored peer stays a verifiable identity. +static void test_contact_should_ignore_blocks_but_keeps_key(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42); + nodeInfoLiteSetBit(db->getMeshNode(kPeer), NODEINFO_BITFIELD_IS_FAVORITE_MASK, true); +#if !MESHTASTIC_EXCLUDE_POSITIONDB + meshtastic_PositionLite pos = meshtastic_PositionLite_init_zero; + pos.latitude_i = 123456789; + db->nodePositions[kPeer] = pos; + TEST_ASSERT_TRUE(db->hasNodePosition(kPeer)); +#endif + + meshtastic_SharedContact c = makeContact(kPeer, "Blocked", "BL"); // keyless on purpose + c.should_ignore = true; + db->addFromContact(c); + + const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer); + TEST_ASSERT_NOT_NULL(info); + TEST_ASSERT_TRUE(nodeInfoLiteIsIgnored(info)); + TEST_ASSERT_FALSE(nodeInfoLiteIsFavorite(info)); +#if !MESHTASTIC_EXCLUDE_POSITIONDB + TEST_ASSERT_FALSE(db->hasNodePosition(kPeer)); +#endif + assertStoredKeyEquals(kPeer, 0x42); // key retained through the keyless ignore contact +} + +// CLIENT_BASE must not auto-favorite (is_favorite has special meaning there); +// the anti-eviction protection is a heard-now stamp instead. +static void test_contact_client_base_stamps_heard_not_favorite(void) +{ + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT_BASE; + + db->addFromContact(makeContact(kPeer, "Alice", "AL")); + + const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer); + TEST_ASSERT_NOT_NULL(info); + TEST_ASSERT_FALSE(nodeInfoLiteIsFavorite(info)); + // initializeTestEnvironment() set an NTP-quality RTC, so the stamp lands in last_heard. + TEST_ASSERT_NOT_EQUAL(0, info->last_heard); +} + +// A contact without a user payload must not merge fields or apply should_ignore to an +// existing node. (getOrCreateMeshNode still runs first, so an unknown num would be +// admitted as a blank row - that path is not covered here.) +static void test_contact_without_user_is_noop(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42); + const size_t countBefore = db->getNumMeshNodes(); + + meshtastic_SharedContact c = meshtastic_SharedContact_init_zero; + c.node_num = kPeer; + c.has_user = false; + c.should_ignore = true; + db->addFromContact(c); + + const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer); + TEST_ASSERT_FALSE(nodeInfoLiteIsIgnored(info)); + assertStoredKeyEquals(kPeer, 0x42); + TEST_ASSERT_EQUAL_UINT(countBefore, db->getNumMeshNodes()); // existing node: no new row admitted +} + +// --- updateUser --- + +#if !(MESHTASTIC_EXCLUDE_PKI) + +// A pinned 32-byte key is immutable against a NodeInfo carrying a different key. +static void test_updateuser_pinned_key_blocks_mismatch(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42); + + meshtastic_User u = makeUser("Mallory", "MA", /*keySeed=*/0x77); + TEST_ASSERT_FALSE(db->updateUser(kPeer, u)); + + assertStoredKeyEquals(kPeer, 0x42); + TEST_ASSERT_EQUAL_STRING("", db->getMeshNode(kPeer)->long_name); // dropped wholesale +} + +// ...and against a NodeInfo carrying NO key: unlike addFromContact, updateUser +// drops a keyless update for a pinned node entirely. +static void test_updateuser_keyless_nodeinfo_dropped_wholesale(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42); + + meshtastic_User u = makeUser("Alice", "AL"); + TEST_ASSERT_FALSE(db->updateUser(kPeer, u)); + + assertStoredKeyEquals(kPeer, 0x42); + TEST_ASSERT_EQUAL_STRING("", db->getMeshNode(kPeer)->long_name); +} + +// First key for a node is accepted (TOFU) and the reach-channel is stamped. +static void test_updateuser_first_key_accepted(void) +{ + db->push(kPeer, 1000); + + meshtastic_User u = makeUser("Alice", "AL", /*keySeed=*/0x42); + TEST_ASSERT_TRUE(db->updateUser(kPeer, u, /*channelIndex=*/3)); + + assertStoredKeyEquals(kPeer, 0x42); + const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer); + TEST_ASSERT_EQUAL_STRING("Alice", info->long_name); + TEST_ASSERT_EQUAL(3, info->channel); +} + +// A remote node advertising OUR public key is refused with exactly one +// ClientNotification; the duplicateWarned latch silences the second attempt. +static void test_updateuser_own_key_advert_notifies_once(void) +{ + fillKey(owner.public_key, 0x5A); + meshtastic_User u = makeUser("Evil twin", "ET", /*keySeed=*/0x5A); + + TEST_ASSERT_FALSE(db->updateUser(kPeer, u)); + TEST_ASSERT_EQUAL(1, mockService->notificationCount); + + TEST_ASSERT_FALSE(db->updateUser(kPeer, u)); + TEST_ASSERT_EQUAL(1, mockService->notificationCount); // latched +} + +// user.id is always re-derived from the node number, whatever the payload claims. +static void test_updateuser_id_derived_from_nodenum(void) +{ + meshtastic_User u = makeUser("Alice", "AL", /*keySeed=*/0x42); + strncpy(u.id, "!deadbeef", sizeof(u.id) - 1); + + TEST_ASSERT_TRUE(db->updateUser(kPeer, u)); + + char expected[16]; + snprintf(expected, sizeof(expected), "!%08x", (unsigned)kPeer); + TEST_ASSERT_EQUAL_STRING(expected, u.id); +} + +// A known XEdDSA signer's identity only changes via a signed update - even a +// same-key name change arriving unsigned is refused. +static void test_updateuser_unsigned_update_refused_for_hot_signer(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42, /*xeddsaSigned=*/true); + meshtastic_User u = makeUser("New name", "NN", /*keySeed=*/0x42); + + TEST_ASSERT_FALSE(db->updateUser(kPeer, u, 0, /*xeddsaSigned=*/false)); + TEST_ASSERT_EQUAL_STRING("", db->getMeshNode(kPeer)->long_name); + + TEST_ASSERT_TRUE(db->updateUser(kPeer, u, 0, /*xeddsaSigned=*/true)); // signed control + TEST_ASSERT_EQUAL_STRING("New name", db->getMeshNode(kPeer)->long_name); +} + +// The key pin outranks the signature: a signed update still cannot rotate a +// pinned key (rotation goes through commitRemoteKey's proven paths instead). +static void test_updateuser_signed_update_cannot_rotate_pinned_key(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42, /*xeddsaSigned=*/true); + + meshtastic_User u = makeUser("Rotated", "RO", /*keySeed=*/0x77); + TEST_ASSERT_FALSE(db->updateUser(kPeer, u, 0, /*xeddsaSigned=*/true)); + + assertStoredKeyEquals(kPeer, 0x42); +} + +#if WARM_NODE_COUNT > 0 +// The signer gate runs BEFORE getOrCreateMeshNode, so refusing an unsigned +// update for a warm-tier signer must not evict a hot node, must not re-admit +// the signer, and must not consume its warm record. +static void test_updateuser_warm_signer_refusal_does_not_evict(void) +{ + const NodeNum signerNum = 0xE2000000 + 3; + const int extra = MAX_NUM_NODES + 30; // overflow so the oldest non-protected demote to warm + for (int i = 1; i <= extra; i++) + db->push(0xE2000000 + i, /*lastHeard=*/i, /*keySeed=*/0x42); + nodeInfoLiteSetBit(db->getMeshNode(signerNum), NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true); + + db->runDemote(); + + TEST_ASSERT_NULL(db->getMeshNode(signerNum)); // demoted out of hot + TEST_ASSERT_TRUE(db->isKnownXeddsaSigner(signerNum)); + TEST_ASSERT_TRUE(db->isFull()); + const int hotBefore = (int)db->getNumMeshNodes(); + + meshtastic_User u = makeUser("New name", "NN", /*keySeed=*/0x42); + TEST_ASSERT_FALSE(db->updateUser(signerNum, u, 0, /*xeddsaSigned=*/false)); + + TEST_ASSERT_EQUAL_INT(hotBefore, (int)db->getNumMeshNodes()); + TEST_ASSERT_NULL(db->getMeshNode(signerNum)); // not re-admitted + TEST_ASSERT_TRUE(db->isKnownXeddsaSigner(signerNum)); // warm record intact (take() never ran) + + // Signed control: the same update signed is accepted and re-admits the + // signer from warm with its key and signer bit restored. + TEST_ASSERT_TRUE(db->updateUser(signerNum, u, 0, /*xeddsaSigned=*/true)); + const meshtastic_NodeInfoLite *back = db->getMeshNode(signerNum); + TEST_ASSERT_NOT_NULL(back); + TEST_ASSERT_TRUE(nodeInfoLiteHasXeddsaSigned(back)); + TEST_ASSERT_EQUAL_STRING("New name", back->long_name); + assertStoredKeyEquals(signerNum, 0x42); +} +#endif // WARM_NODE_COUNT > 0 + +#endif // !(MESHTASTIC_EXCLUDE_PKI) + +// --- persistence --- + +// The erasure guard's outcome must survive the disk round trip: after a keyless +// add_contact against a pinned key, a rebooted NodeDB still holds the full key +// (pre-#11432 the zeroed key was persisted, breaking DMs until re-exchange). +static void test_contact_key_guard_survives_reboot(void) +{ + // saveNodeDatabaseToDisk() skips keyless devices, so give ourselves a key. + fillKey(owner.public_key, 0x5A); + + meshtastic_SharedContact keyed = makeContact(kPeer, "Alice", "AL", /*keySeed=*/0x42); + keyed.manually_verified = true; + db->addFromContact(keyed); // persists + // The keyless pre-DM contact for a verified node also carries manually_verified + // (a non-verified keyless contact would be refused by the verified pin instead). + meshtastic_SharedContact keyless = makeContact(kPeer, "Al2", "A2"); + keyless.manually_verified = true; + db->addFromContact(keyless); // keyless merge; persists the guard result + assertStoredKeyEquals(kPeer, 0x42); + + // A real cold boot starts with a zeroed nodeDatabase global; in-process the decode + // callback appends on top of the previous boot's rows, so without this reset the + // lookups below would find the pre-reboot RAM row and the persistence claim is vacuous. + delete db; + db = nullptr; + nodeDB = nullptr; + nodeDatabase.version = 0; + nodeDatabase.nodes.clear(); + nodeDatabase.positions.clear(); + nodeDatabase.telemetry.clear(); + nodeDatabase.environment.clear(); + nodeDatabase.status.clear(); + db = new NodeDBTestShim(); + nodeDB = db; + + const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer); + TEST_ASSERT_NOT_NULL_MESSAGE(info, "contact must survive the reload"); + assertStoredKeyEquals(kPeer, 0x42); + TEST_ASSERT_EQUAL_STRING("Al2", info->long_name); + TEST_ASSERT_TRUE(nodeInfoLiteIsKeyManuallyVerified(info)); // pin survives the reboot too +} + +// --- Unity lifecycle --- + +void setUp(void) +{ + savedOwner = owner; + savedConfig = config; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + owner.public_key.size = 0; + + mockService = new MockMeshService(); + service = mockService; + + db->clearHot(); + db->seedSelf(); + db->resetDuplicateWarned(); +} + +void tearDown(void) +{ + owner = savedOwner; + config = savedConfig; + service = nullptr; + delete mockService; + mockService = nullptr; +} + +IH_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); +#ifdef FSCom + // NodeDB and MessageStore bracket their FS writes with spiLock; nothing in the + // test environment creates it, so do it here (initSPI asserts it only runs once). + if (!spiLock) + initSPI(); +#endif + db = new NodeDBTestShim(); + nodeDB = db; + + UNITY_BEGIN(); + + printf("\n=== addFromContact guards ===\n"); + RUN_TEST(test_contact_keyless_preserves_stored_key); + RUN_TEST(test_contact_new_key_updates_stored_key); + RUN_TEST(test_contact_verified_pin_blocks_mismatched_key); + RUN_TEST(test_contact_verified_pin_blocks_keyless_unverified); + RUN_TEST(test_contact_verified_pin_allows_matching_key); + RUN_TEST(test_contact_manually_verified_bit_survives_updates); + RUN_TEST(test_contact_should_ignore_blocks_but_keeps_key); + RUN_TEST(test_contact_client_base_stamps_heard_not_favorite); + RUN_TEST(test_contact_without_user_is_noop); + +#if !(MESHTASTIC_EXCLUDE_PKI) + printf("\n=== updateUser key pinning ===\n"); + RUN_TEST(test_updateuser_pinned_key_blocks_mismatch); + RUN_TEST(test_updateuser_keyless_nodeinfo_dropped_wholesale); + RUN_TEST(test_updateuser_first_key_accepted); + RUN_TEST(test_updateuser_own_key_advert_notifies_once); + RUN_TEST(test_updateuser_id_derived_from_nodenum); + RUN_TEST(test_updateuser_unsigned_update_refused_for_hot_signer); + RUN_TEST(test_updateuser_signed_update_cannot_rotate_pinned_key); +#if WARM_NODE_COUNT > 0 + RUN_TEST(test_updateuser_warm_signer_refusal_does_not_evict); +#endif +#endif + + printf("\n=== persistence ===\n"); + RUN_TEST(test_contact_key_guard_survives_reboot); + + exit(UNITY_END()); +} +IH_TEST_ENTRY void loop() {} diff --git a/test/test_nodedb_legacy_migration/test_main.cpp b/test/test_nodedb_legacy_migration/test_main.cpp new file mode 100644 index 000000000..3f6a645f3 --- /dev/null +++ b/test/test_nodedb_legacy_migration/test_main.cpp @@ -0,0 +1,570 @@ +// The one-shot v24 -> v25 NodeDatabase migration every 2.7 -> 2.8 upgrader runs: each test +// hand-encodes a legacy /prefs/nodes.proto, cold-boots a real NodeDB, and asserts the migrated +// state (including sanitizeUtf8 of legacy names, which the later encode depends on). +#include "MeshTypes.h" // BEFORE TestUtil.h - provides MAX_NUM_NODES via mesh-pb-constants.h +#include "TestUtil.h" +#include + +#if defined(ARCH_PORTDUINO) +#define NDBM_TEST_ENTRY extern "C" +#else +#define NDBM_TEST_ENTRY +#endif + +#include "FSCommon.h" + +// The migration is a file-load path; without a filesystem there is nothing to drive. +#if defined(FSCom) + +#include "mesh/NodeDB.h" +#include "mesh/generated/meshtastic/deviceonly_legacy.pb.h" +#include "meshUtils.h" +#include +#include +#include +#include +#include +#include +#include + +// Exposes the private save path via the friend declaration in NodeDB.h, so the +// hostile-name test can prove the migrated store re-encodes cleanly. +class NodeDBTestShim : public NodeDB +{ + public: + bool saveDatabase() { return saveNodeDatabaseToDisk(); } +}; + +namespace +{ + +NodeDBTestShim *db = nullptr; + +void fillKey(meshtastic_UserLite_public_key_t &key, uint8_t seed) +{ + key.size = 32; + for (int i = 0; i < 32; i++) + key.bytes[i] = (uint8_t)(i ^ seed); + key.bytes[0] = seed; // distinctive, never all-zero +} + +meshtastic_NodeInfoLite_Legacy makeLegacyNode(uint32_t num, uint32_t lastHeard) +{ + meshtastic_NodeInfoLite_Legacy n = meshtastic_NodeInfoLite_Legacy_init_zero; + n.num = num; + n.last_heard = lastHeard; + return n; +} + +void giveLegacyUser(meshtastic_NodeInfoLite_Legacy &n, const char *longName, const char *shortName) +{ + n.has_user = true; + strncpy(n.user.long_name, longName, sizeof(n.user.long_name)); + n.user.long_name[sizeof(n.user.long_name) - 1] = '\0'; + strncpy(n.user.short_name, shortName, sizeof(n.user.short_name)); + n.user.short_name[sizeof(n.user.short_name) - 1] = '\0'; +} + +/// Encode a legacy-shape NodeDatabase - exactly what a 2.7 device leaves +/// behind for the 2.8 boot to find. +std::vector encodeLegacyNodes(uint32_t version, const std::vector &nodes) +{ + // _init_zero brace-inits the embedded std::vector via its explicit + // (size_type, allocator) ctor, so default-construct instead (see + // NodeDBLegacyMigration.cpp). + meshtastic_NodeDatabase_Legacy legacyDb{}; + legacyDb.version = version; + legacyDb.nodes = nodes; + + size_t encodedSize = 0; + TEST_ASSERT_TRUE_MESSAGE(pb_get_encoded_size(&encodedSize, meshtastic_NodeDatabase_Legacy_fields, &legacyDb), + "sizing the legacy fixture must succeed"); + std::vector buf(encodedSize); + pb_ostream_t stream = pb_ostream_from_buffer(buf.data(), buf.size()); + TEST_ASSERT_TRUE_MESSAGE(pb_encode(&stream, meshtastic_NodeDatabase_Legacy_fields, &legacyDb), + "encoding the legacy fixture must succeed"); + buf.resize(stream.bytes_written); + return buf; +} + +void writeNodesBytes(const uint8_t *bytes, size_t len) +{ + FSCom.mkdir("/prefs"); + FSCom.remove(nodeDatabaseFileName); + auto f = FSCom.open(nodeDatabaseFileName, FILE_O_WRITE); + TEST_ASSERT_TRUE((bool)f); + const size_t wrote = f.write(bytes, len); + f.close(); + TEST_ASSERT_EQUAL_MESSAGE(len, wrote, "short write laying down the nodes.proto fixture"); +} + +void writeLegacyNodesFile(uint32_t version, const std::vector &nodes) +{ + const std::vector buf = encodeLegacyNodes(version, nodes); + writeNodesBytes(buf.data(), buf.size()); +} + +/// Overwrite a unique same-length placeholder inside an encoded fixture with +/// raw bytes. PB_VALIDATE_UTF8 makes pb_encode refuse invalid UTF-8, so a +/// hostile v24 name (written by pre-validation firmware) can only be produced +/// by patching the encoded bytes - the protobuf framing stays intact because +/// the length does not change. +void patchBytes(std::vector &buf, const char *placeholder, const char *raw, size_t n) +{ + TEST_ASSERT_EQUAL(strlen(placeholder), n); + auto it = std::search(buf.begin(), buf.end(), reinterpret_cast(placeholder), + reinterpret_cast(placeholder) + n); + TEST_ASSERT_TRUE_MESSAGE(it != buf.end(), "placeholder not found in encoded fixture"); + memcpy(&*it, raw, n); +} + +/// Simulate a process restart. A real cold boot starts with a zeroed +/// nodeDatabase global; in-process it still holds the previous boot's version +/// stamp and nodes, which would short-circuit the version-gate ladder. +void coldBoot() +{ + if (db) { + delete db; + db = nullptr; + nodeDB = nullptr; + } + nodeDatabase.version = 0; + nodeDatabase.nodes.clear(); + nodeDatabase.positions.clear(); + nodeDatabase.telemetry.clear(); + nodeDatabase.environment.clear(); + nodeDatabase.status.clear(); + + db = new NodeDBTestShim(); + nodeDB = db; +} + +/// The migrated-store re-save (migrationSavePending) is skipped for keyless +/// devices, so every persistence assertion depends on boot keygen having run. +void assertBootKeygenRan() +{ + TEST_ASSERT_EQUAL_MESSAGE(32, owner.public_key.size, + "boot keygen did not run - persistence legs of this suite need an owner key"); +} + +/// True UTF-8 cleanliness check via the production validator: a second +/// sanitize pass over already-sanitized bytes must find nothing to replace. +void assertValidUtf8(const char *s, size_t width) +{ + char copy[64]; + TEST_ASSERT_TRUE(width < sizeof(copy)); + memcpy(copy, s, width); + TEST_ASSERT_FALSE_MESSAGE(sanitizeUtf8(copy, width), "migrated name still contains invalid UTF-8"); +} + +} // namespace + +void setUp(void) {} +void tearDown(void) {} + +// --- Version-gate ladder (NodeDB.cpp loadFromDisk) --- + +// v24 with no nodes is still a migration: the version stamp must advance and +// the boot must complete with just ourself in the store. +static void test_emptyV24File_migratesToEmptyV25(void) +{ + writeLegacyNodesFile(24, {}); + coldBoot(); + + TEST_ASSERT_EQUAL_UINT32(DEVICESTATE_CUR_VER, nodeDatabase.version); + TEST_ASSERT_EQUAL_INT(1, (int)db->getNumMeshNodes()); // self only, added by nodeDBSelfCare +} + +// version < DEVICESTATE_MIN_VER: discarded, never migrated. +static void test_versionBelowMin_discardsToDefaults(void) +{ + auto old = makeLegacyNode(0xF6000001, 1000); + giveLegacyUser(old, "Ancient", "OLD"); + writeLegacyNodesFile(DEVICESTATE_MIN_VER - 1, {old}); + coldBoot(); + + TEST_ASSERT_NULL(db->getMeshNode(0xF6000001)); + TEST_ASSERT_EQUAL_UINT32(DEVICESTATE_CUR_VER, nodeDatabase.version); + TEST_ASSERT_EQUAL_INT(1, (int)db->getNumMeshNodes()); +} + +// Garbage bytes: the v25 decode fails, the version stays below MIN, and the +// boot lands on installDefaultNodeDatabase instead of crashing or migrating. +static void test_garbageNodesProto_installsDefaults(void) +{ + static const uint8_t garbage[] = {0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x13, 0x37, 0xC0, 0xFF, 0xEE}; + writeNodesBytes(garbage, sizeof(garbage)); + coldBoot(); + + TEST_ASSERT_EQUAL_UINT32(DEVICESTATE_CUR_VER, nodeDatabase.version); + TEST_ASSERT_EQUAL_INT(1, (int)db->getNumMeshNodes()); +} + +// --- Field-by-field migration fidelity --- + +static void test_v24RoundTrip_migratesFieldsBitfieldAndSatellites(void) +{ + std::vector nodes; + + // Node A: every scalar populated, plus position + device_metrics. + auto a = makeLegacyNode(0xA1000001, 111111); + giveLegacyUser(a, "Alice Node", "AL"); + a.user.hw_model = meshtastic_HardwareModel_TBEAM; + a.user.role = meshtastic_Config_DeviceConfig_Role_TRACKER; + fillKey(a.user.public_key, 0x42); + a.snr = 7.25f; + a.channel = 2; + a.has_hops_away = true; + a.hops_away = 3; + a.next_hop = 0xAB; + a.has_position = true; + a.position.latitude_i = 375000000; + a.position.longitude_i = -1219876543; + a.position.altitude = 123; + a.position.time = 1700000000; + a.position.location_source = meshtastic_Position_LocSource_LOC_INTERNAL; + a.position.precision_bits = 32; + a.has_device_metrics = true; + a.device_metrics.has_battery_level = true; + a.device_metrics.battery_level = 87; + a.device_metrics.has_voltage = true; + a.device_metrics.voltage = 3.7f; + nodes.push_back(a); + + // Node B: the legacy compatibility bools that must pack into the bitfield. + auto b = makeLegacyNode(0xA1000002, 222222); + giveLegacyUser(b, "Bob", "BB"); + b.via_mqtt = true; + b.is_favorite = true; + nodes.push_back(b); + + // Node C: blocked + licensed. + auto c = makeLegacyNode(0xA1000003, 333333); + giveLegacyUser(c, "Carol", "CC"); + c.is_ignored = true; + c.user.is_licensed = true; + nodes.push_back(c); + + // Node D: tri-state unmessagable present-and-set. + auto d = makeLegacyNode(0xA1000004, 444444); + giveLegacyUser(d, "Dave", "DD"); + d.user.has_is_unmessagable = true; + d.user.is_unmessagable = true; + nodes.push_back(d); + + // Node E: control - no key, no bools, no unmessagable tri-state. + auto e = makeLegacyNode(0xA1000005, 555555); + giveLegacyUser(e, "Erin", "EE"); + nodes.push_back(e); + + writeLegacyNodesFile(24, nodes); + coldBoot(); + + TEST_ASSERT_EQUAL_UINT32(DEVICESTATE_CUR_VER, nodeDatabase.version); + TEST_ASSERT_EQUAL_INT(6, (int)db->getNumMeshNodes()); // 5 migrated + self + + const meshtastic_NodeInfoLite *na = db->getMeshNode(0xA1000001); + TEST_ASSERT_NOT_NULL(na); + TEST_ASSERT_EQUAL_STRING("Alice Node", na->long_name); + TEST_ASSERT_EQUAL_STRING("AL", na->short_name); + TEST_ASSERT_EQUAL(meshtastic_HardwareModel_TBEAM, na->hw_model); + TEST_ASSERT_EQUAL(meshtastic_Config_DeviceConfig_Role_TRACKER, na->role); + TEST_ASSERT_EQUAL_FLOAT(7.25f, na->snr); + TEST_ASSERT_EQUAL_UINT32(111111, na->last_heard); + TEST_ASSERT_EQUAL_UINT8(2, na->channel); + TEST_ASSERT_TRUE(na->has_hops_away); + TEST_ASSERT_EQUAL_UINT8(3, na->hops_away); + TEST_ASSERT_EQUAL_UINT8(0xAB, na->next_hop); + TEST_ASSERT_TRUE(nodeInfoLiteHasUser(na)); + TEST_ASSERT_FALSE(nodeInfoLiteViaMqtt(na)); + TEST_ASSERT_FALSE(nodeInfoLiteIsFavorite(na)); + TEST_ASSERT_FALSE(nodeInfoLiteIsIgnored(na)); + TEST_ASSERT_FALSE(nodeInfoLiteIsLicensed(na)); + + // Satellite routing: position and device_metrics land in the maps, not the header. +#if !MESHTASTIC_EXCLUDE_POSITIONDB + meshtastic_PositionLite pos; + TEST_ASSERT_TRUE(db->copyNodePosition(0xA1000001, pos)); + TEST_ASSERT_EQUAL_INT32(375000000, pos.latitude_i); + TEST_ASSERT_EQUAL_INT32(-1219876543, pos.longitude_i); + TEST_ASSERT_EQUAL_INT32(123, pos.altitude); + TEST_ASSERT_EQUAL_UINT32(1700000000, pos.time); + TEST_ASSERT_EQUAL(meshtastic_Position_LocSource_LOC_INTERNAL, pos.location_source); + TEST_ASSERT_EQUAL_UINT32(32, pos.precision_bits); +#endif +#if !MESHTASTIC_EXCLUDE_TELEMETRYDB + meshtastic_DeviceMetrics dm; + TEST_ASSERT_TRUE(db->copyNodeTelemetry(0xA1000001, dm)); + TEST_ASSERT_TRUE(dm.has_battery_level); + TEST_ASSERT_EQUAL_UINT32(87, dm.battery_level); + TEST_ASSERT_TRUE(dm.has_voltage); + TEST_ASSERT_EQUAL_FLOAT(3.7f, dm.voltage); +#endif + + const meshtastic_NodeInfoLite *nb = db->getMeshNode(0xA1000002); + TEST_ASSERT_NOT_NULL(nb); + TEST_ASSERT_TRUE(nodeInfoLiteViaMqtt(nb)); + TEST_ASSERT_TRUE(nodeInfoLiteIsFavorite(nb)); + TEST_ASSERT_FALSE(nodeInfoLiteIsIgnored(nb)); + + const meshtastic_NodeInfoLite *nc = db->getMeshNode(0xA1000003); + TEST_ASSERT_NOT_NULL(nc); + TEST_ASSERT_TRUE(nodeInfoLiteIsIgnored(nc)); + TEST_ASSERT_TRUE(nodeInfoLiteIsLicensed(nc)); + TEST_ASSERT_FALSE(nodeInfoLiteViaMqtt(nc)); + + const meshtastic_NodeInfoLite *nd = db->getMeshNode(0xA1000004); + TEST_ASSERT_NOT_NULL(nd); + TEST_ASSERT_TRUE(nodeInfoLiteHasIsUnmessagable(nd)); + TEST_ASSERT_TRUE(nodeInfoLiteIsUnmessagable(nd)); + + const meshtastic_NodeInfoLite *ne = db->getMeshNode(0xA1000005); + TEST_ASSERT_NOT_NULL(ne); + TEST_ASSERT_FALSE(nodeInfoLiteHasIsUnmessagable(ne)); + TEST_ASSERT_FALSE(nodeInfoLiteIsUnmessagable(ne)); + TEST_ASSERT_EQUAL(0, ne->public_key.size); + + // public_key survives byte-identical, and the public lookup API finds it. + TEST_ASSERT_EQUAL(32, na->public_key.size); + meshtastic_UserLite_public_key_t expected; + fillKey(expected, 0x42); + TEST_ASSERT_EQUAL_MEMORY(expected.bytes, na->public_key.bytes, 32); + meshtastic_NodeInfoLite_public_key_t got = {0, {0}}; + TEST_ASSERT_TRUE(db->copyPublicKey(0xA1000001, got)); + TEST_ASSERT_EQUAL(32, got.size); + TEST_ASSERT_EQUAL_MEMORY(expected.bytes, got.bytes, 32); +} + +// has_position=false / has_device_metrics=false entries must not seed +// zero-position ghosts in the satellite maps. +static void test_absentSubmessages_noSatelliteGhostRows(void) +{ + auto a = makeLegacyNode(0xC3000001, 1000); + giveLegacyUser(a, "NoPos", "NP"); + auto b = makeLegacyNode(0xC3000002, 2000); + giveLegacyUser(b, "NoTel", "NT"); + writeLegacyNodesFile(24, {a, b}); + coldBoot(); + + TEST_ASSERT_NOT_NULL(db->getMeshNode(0xC3000001)); + TEST_ASSERT_NOT_NULL(db->getMeshNode(0xC3000002)); +#if !MESHTASTIC_EXCLUDE_POSITIONDB + TEST_ASSERT_FALSE(db->hasNodePosition(0xC3000001)); + TEST_ASSERT_FALSE(db->hasNodePosition(0xC3000002)); + TEST_ASSERT_TRUE(db->snapshotPositionNodeNums(0).empty()); +#endif +#if !MESHTASTIC_EXCLUDE_TELEMETRYDB + TEST_ASSERT_FALSE(db->hasNodeTelemetry(0xC3000001)); + TEST_ASSERT_TRUE(db->snapshotTelemetryNodeNums(0).empty()); +#endif +} + +// --- sanitizeUtf8 firewall (hostile v24 names) --- + +// The truncation firewall: a wide-but-VALID v24 long_name (UserLite allows 40 +// bytes) whose 25-byte slim copy cuts a multi-byte sequence in half. Without +// migration's sanitizeUtf8, the orphaned lead byte makes the next +// saveNodeDatabaseToDisk() fail its PB_VALIDATE_UTF8 encode - and a failed +// save is what triggers saveToDisk()'s fsFormat() wipe on device. +static void test_truncatedWideName_sanitizedAndReencodable(void) +{ + // 23 ASCII bytes then Euro signs straddling the 24-byte truncation boundary. + std::string straddle(23, 'a'); + straddle += "\xE2\x82\xAC\xE2\x82\xAC"; // two Euro signs, 29 bytes total - valid UTF-8 in v24 + auto s = makeLegacyNode(0xB2000002, 2000); + giveLegacyUser(s, straddle.c_str(), "OK"); + + writeLegacyNodesFile(24, {s}); + coldBoot(); + + const meshtastic_NodeInfoLite *ns = db->getMeshNode(0xB2000002); + TEST_ASSERT_NOT_NULL(ns); + std::string expected(23, 'a'); + expected += '?'; // orphaned 0xE2 lead byte after the cut, replaced by sanitizeUtf8 + TEST_ASSERT_EQUAL_STRING(expected.c_str(), ns->long_name); + assertValidUtf8(ns->long_name, sizeof(ns->long_name)); + + // The firewall itself: the migrated store must encode and re-decode. + assertBootKeygenRan(); + TEST_ASSERT_TRUE_MESSAGE(db->saveDatabase(), "sanitized store must re-encode without a nanopb failure"); + meshtastic_NodeDatabase reloaded{}; + TEST_ASSERT_EQUAL(LoadFileResult::LOAD_SUCCESS, + db->loadProto(nodeDatabaseFileName, db->getMaxNodesAllocatedSize(), sizeof(meshtastic_NodeDatabase), + &meshtastic_NodeDatabase_msg, &reloaded)); + TEST_ASSERT_EQUAL_UINT32(DEVICESTATE_CUR_VER, reloaded.version); +} + +// Raw invalid UTF-8 inside a v24 name (written by pre-PB_VALIDATE_UTF8 +// firmware): nanopb refuses to decode that node and the legacy callback drops +// it, but the rest of the file must still migrate and the boot must still +// complete and re-save. One poisoned node must never cost the whole database. +static void test_rawInvalidUtf8Node_droppedWithoutBreakingMigration(void) +{ + static const char kPlaceholderLong[] = "Bad0(nameXXzzYY"; // 15 ASCII bytes, patched below + static const char kHostileLong[] = "Bad\xC3" + "(name\xFF\xFE" + "zz\xE2\x82"; // invalid leads + truncated tail, same 15 bytes + + auto h = makeLegacyNode(0xB2000001, 1000); + giveLegacyUser(h, kPlaceholderLong, "HN"); + + auto good = makeLegacyNode(0xB2000003, 3000); + giveLegacyUser(good, "Good Node", "GN"); + + std::vector buf = encodeLegacyNodes(24, {h, good}); + patchBytes(buf, kPlaceholderLong, kHostileLong, 15); + writeNodesBytes(buf.data(), buf.size()); + coldBoot(); + + // The poisoned node is gone (its num was consumed before the failing name, + // so no partial-decode fragment can carry it either)... + TEST_ASSERT_NULL(db->getMeshNode(0xB2000001)); + // ...while its well-formed sibling in the same file migrated intact. + const meshtastic_NodeInfoLite *ng = db->getMeshNode(0xB2000003); + TEST_ASSERT_NOT_NULL(ng); + TEST_ASSERT_EQUAL_STRING("Good Node", ng->long_name); + TEST_ASSERT_EQUAL_UINT32(DEVICESTATE_CUR_VER, nodeDatabase.version); + + // And the migrated store still persists cleanly. + assertBootKeygenRan(); + TEST_ASSERT_TRUE(db->saveDatabase()); + meshtastic_NodeDatabase reloaded{}; + TEST_ASSERT_EQUAL(LoadFileResult::LOAD_SUCCESS, + db->loadProto(nodeDatabaseFileName, db->getMaxNodesAllocatedSize(), sizeof(meshtastic_NodeDatabase), + &meshtastic_NodeDatabase_msg, &reloaded)); + TEST_ASSERT_EQUAL_UINT32(DEVICESTATE_CUR_VER, reloaded.version); +} + +// --- Capacity --- + +// A legacy file from a larger-cap build migrates at most MAX_NUM_NODES entries +// in file order; no OOB under ASan (the getOrCreate boot-loop family guard). +static void test_overCapLegacyFile_truncatesToMaxNumNodes(void) +{ + const int maxNodes = MAX_NUM_NODES; + const int extra = 20; + std::vector nodes; + nodes.reserve(maxNodes + extra); + for (int i = 0; i < maxNodes + extra; i++) { + auto n = makeLegacyNode(0xE5000000u + i, (uint32_t)(i + 1)); // ascending: index 0 is oldest + char ln[16], sn[5]; + snprintf(ln, sizeof(ln), "n%d", i); + snprintf(sn, sizeof(sn), "%02d", i % 100); + giveLegacyUser(n, ln, sn); // users required: keyless/userless entries are purged by cleanupMeshDB + nodes.push_back(n); + } + writeLegacyNodesFile(24, nodes); + coldBoot(); + + // Exactly the hot cap: file entries 0..max-1 migrated, the tail dropped, + // then nodeDBSelfCare evicted one old migrated node to admit self. Which + // of the oldest is the victim is an eviction-policy detail; only the + // counts and the cap boundary are contract here. + TEST_ASSERT_EQUAL_INT(maxNodes, (int)db->getNumMeshNodes()); + TEST_ASSERT_NULL(db->getMeshNode(0xE5000000u + maxNodes)); // first beyond the cap: dropped + TEST_ASSERT_NULL(db->getMeshNode(0xE5000000u + maxNodes + extra - 1)); // last beyond the cap: dropped + TEST_ASSERT_NOT_NULL(db->getMeshNode(0xE5000000u + maxNodes - 1)); // last within the cap: kept + TEST_ASSERT_NOT_NULL(db->getMeshNode(db->getNodeNum())); // self admitted + int survivors = 0; + for (int i = 0; i < maxNodes; i++) { + if (db->getMeshNode(0xE5000000u + i)) + survivors++; + } + TEST_ASSERT_EQUAL_INT_MESSAGE(maxNodes - 1, survivors, "exactly one within-cap node should have been evicted for self"); +} + +// --- Full boot ladder persistence --- + +// The deferred migrationSavePending re-save must land: after the boot, +// the on-disk nodes.proto is v25 with the migrated node, key, and satellite. +static void test_fullBootLadder_persistsMigratedV25(void) +{ + auto a = makeLegacyNode(0xD4000001, 4000); + giveLegacyUser(a, "Persist Me", "PM"); + fillKey(a.user.public_key, 0x77); + a.has_position = true; + a.position.latitude_i = 101010101; + a.position.longitude_i = -202020202; + writeLegacyNodesFile(24, {a}); + coldBoot(); + + assertBootKeygenRan(); + + meshtastic_NodeDatabase reloaded{}; + TEST_ASSERT_EQUAL(LoadFileResult::LOAD_SUCCESS, + db->loadProto(nodeDatabaseFileName, db->getMaxNodesAllocatedSize(), sizeof(meshtastic_NodeDatabase), + &meshtastic_NodeDatabase_msg, &reloaded)); + TEST_ASSERT_EQUAL_UINT32(DEVICESTATE_CUR_VER, reloaded.version); + + const meshtastic_NodeInfoLite *persisted = nullptr; + for (const auto &n : reloaded.nodes) { + if (n.num == 0xD4000001) + persisted = &n; + } + TEST_ASSERT_NOT_NULL_MESSAGE(persisted, "migrated node must survive the v25 re-save"); + TEST_ASSERT_EQUAL_STRING("Persist Me", persisted->long_name); + TEST_ASSERT_TRUE(persisted->bitfield & NODEINFO_BITFIELD_HAS_USER_MASK); + TEST_ASSERT_EQUAL(32, persisted->public_key.size); + meshtastic_UserLite_public_key_t expected; + fillKey(expected, 0x77); + TEST_ASSERT_EQUAL_MEMORY(expected.bytes, persisted->public_key.bytes, 32); + +#if !MESHTASTIC_EXCLUDE_POSITIONDB + // With the decode targets disarmed (steady state), satellite entries land in + // the struct's own vectors - so this asserts the on-disk projection directly. + bool posFound = false; + for (const auto &e : reloaded.positions) { + if (e.num == 0xD4000001 && e.has_position) { + posFound = true; + TEST_ASSERT_EQUAL_INT32(101010101, e.position.latitude_i); + TEST_ASSERT_EQUAL_INT32(-202020202, e.position.longitude_i); + } + } + TEST_ASSERT_TRUE_MESSAGE(posFound, "satellite position must survive the v25 re-save"); +#endif +} + +NDBM_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); + // First boot on the empty sandbox: installs defaults, runs keygen, and + // persists the base config files every later cold boot reloads. + coldBoot(); + + UNITY_BEGIN(); + + printf("\n=== Version-gate ladder ===\n"); + RUN_TEST(test_emptyV24File_migratesToEmptyV25); + RUN_TEST(test_versionBelowMin_discardsToDefaults); + RUN_TEST(test_garbageNodesProto_installsDefaults); + + printf("\n=== Migration fidelity ===\n"); + RUN_TEST(test_v24RoundTrip_migratesFieldsBitfieldAndSatellites); + RUN_TEST(test_absentSubmessages_noSatelliteGhostRows); + + printf("\n=== sanitizeUtf8 firewall ===\n"); + RUN_TEST(test_truncatedWideName_sanitizedAndReencodable); + RUN_TEST(test_rawInvalidUtf8Node_droppedWithoutBreakingMigration); + + printf("\n=== Capacity and persistence ===\n"); + RUN_TEST(test_overCapLegacyFile_truncatesToMaxNumNodes); + RUN_TEST(test_fullBootLadder_persistsMigratedV25); + + exit(UNITY_END()); +} +NDBM_TEST_ENTRY void loop() {} + +#else // !FSCom - no filesystem, nothing to migrate + +void setUp(void) {} +void tearDown(void) {} + +NDBM_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + exit(UNITY_END()); +} +NDBM_TEST_ENTRY void loop() {} + +#endif diff --git a/test/test_nodedb_v25_roundtrip/test_main.cpp b/test/test_nodedb_v25_roundtrip/test_main.cpp new file mode 100644 index 000000000..93b15a955 --- /dev/null +++ b/test/test_nodedb_v25_roundtrip/test_main.cpp @@ -0,0 +1,691 @@ +// Round-trip fidelity of the v25 slim NodeDB persistence cycle: snr_q4 quantization and its +// HAS_SNR sentinel, satellite-map projection/rehydration and eviction, the keyless-device write +// skip, and resetNodes() compaction. Each test saves, cold-boots a real NodeDB, and reads back. +#include "MeshTypes.h" // BEFORE TestUtil.h - provides MAX_SATELLITE_NODES via mesh-pb-constants.h +#include "TestUtil.h" +#include + +#if defined(ARCH_PORTDUINO) +#define NDBR_TEST_ENTRY extern "C" +#else +#define NDBR_TEST_ENTRY +#endif + +#include "FSCommon.h" + +// This is a disk round-trip suite; without a filesystem there is nothing to pin. +#if defined(FSCom) + +#include "mesh/NodeDB.h" +#include +#include +#include +#include + +// Friend declared in NodeDB.h (PIO_UNIT_TESTING): exposes the private save path so +// the tests drive exactly the gate under test, without saveToDisk()'s format-retry. +class NodeDBTestShim : public NodeDB +{ + public: + bool saveDatabase() { return saveNodeDatabaseToDisk(); } +}; + +namespace +{ + +NodeDBTestShim *db = nullptr; + +/// Simulate a process restart. A real cold boot starts with a zeroed nodeDatabase +/// global; in-process the decode callback would append on top of the previous +/// boot's rows, duplicating every node. +void coldBoot() +{ + if (db) { + delete db; + db = nullptr; + nodeDB = nullptr; + } + nodeDatabase.version = 0; + nodeDatabase.nodes.clear(); + nodeDatabase.positions.clear(); + nodeDatabase.telemetry.clear(); + nodeDatabase.environment.clear(); + nodeDatabase.status.clear(); + + db = new NodeDBTestShim(); + nodeDB = db; +} + +meshtastic_User makeUser(uint32_t num, uint8_t seed) +{ + meshtastic_User u = meshtastic_User_init_zero; + snprintf(u.id, sizeof(u.id), "!%08x", num); + snprintf(u.long_name, sizeof(u.long_name), "Node %02X", seed); + snprintf(u.short_name, sizeof(u.short_name), "N%02X", seed); + u.hw_model = meshtastic_HardwareModel_TBEAM; + u.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + u.public_key.size = 32; + for (int i = 0; i < 32; i++) + u.public_key.bytes[i] = (uint8_t)(i ^ seed ^ 0x5A); + return u; +} + +/// Give the node a user so it survives the next boot's cleanupMeshDB() purge - +/// userless, non-ignored rows are dropped on load, which is itself part of the cycle. +meshtastic_NodeInfoLite *addUserNode(uint32_t num, uint8_t seed, uint8_t channelIndex = 0) +{ + meshtastic_User u = makeUser(num, seed); + nodeDB->updateUser(num, u, channelIndex); + meshtastic_NodeInfoLite *info = nodeDB->getMeshNode(num); + TEST_ASSERT_NOT_NULL_MESSAGE(info, "updateUser must admit the node"); + return info; +} + +/// A packet as the real over-the-air RX path shapes it: decoded, TRANSPORT_LORA, +/// modern-sender bitfield, rx_time and rx_rssi present. +meshtastic_MeshPacket makeRxPacket(uint32_t from) +{ + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + mp.from = from; + mp.to = nodeDB->getNodeNum(); + mp.id = 0x1000u + (from & 0xFFFu); + mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + mp.decoded.has_bitfield = true; // modern sender: hop_start is trustworthy + mp.has_rx_time = true; + mp.rx_time = 1700000000; + mp.hop_start = 3; + mp.hop_limit = 3; + mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + mp.has_rx_rssi = true; + mp.rx_rssi = -80; + return mp; +} + +void heardOverLoRa(uint32_t from, float snr) +{ + meshtastic_MeshPacket mp = makeRxPacket(from); + mp.rx_snr = snr; + nodeDB->updateFrom(mp); +} + +meshtastic_StatusMessage makeStatus(const char *text) +{ + meshtastic_StatusMessage st = meshtastic_StatusMessage_init_zero; + snprintf(st.status, sizeof(st.status), "%s", text); + return st; +} + +bool readFileBytes(const char *path, std::vector &out) +{ + auto f = FSCom.open(path, FILE_O_READ); + if (!f) + return false; + out.resize(f.size()); + if (!out.empty() && f.read(out.data(), out.size()) != out.size()) { + f.close(); + return false; + } + f.close(); + return true; +} + +void decodeNodesFile(meshtastic_NodeDatabase &out) +{ + // _init_zero brace-inits the embedded std::vector via its (size_type) ctor, + // so callers pass a default-constructed struct; decode targets are disarmed in + // steady state, so satellite entries land in the struct's own vectors - this + // reads the on-disk projection directly. + TEST_ASSERT_EQUAL_MESSAGE(LoadFileResult::LOAD_SUCCESS, + db->loadProto(nodeDatabaseFileName, db->getMaxNodesAllocatedSize(), sizeof(meshtastic_NodeDatabase), + &meshtastic_NodeDatabase_msg, &out), + "nodes.proto must decode"); +} + +void assertTempVectorsEmpty(const char *when) +{ + TEST_ASSERT_TRUE_MESSAGE(nodeDatabase.positions.empty(), when); + TEST_ASSERT_TRUE_MESSAGE(nodeDatabase.telemetry.empty(), when); + TEST_ASSERT_TRUE_MESSAGE(nodeDatabase.environment.empty(), when); + TEST_ASSERT_TRUE_MESSAGE(nodeDatabase.status.empty(), when); +} + +void clearAllSatellites() +{ + auto wipe = [](const std::vector &nums) { + for (NodeNum n : nums) + nodeDB->eraseNodeSatellites(n); + }; + wipe(nodeDB->snapshotPositionNodeNums(0)); + wipe(nodeDB->snapshotTelemetryNodeNums(0)); + wipe(nodeDB->snapshotEnvironmentNodeNums(0)); + wipe(nodeDB->snapshotStatusNodeNums(0)); +} + +} // namespace + +void setUp(void) {} +void tearDown(void) {} + +// --- Environment preconditions --- + +// Every persistence leg depends on boot keygen having produced an owner key +// (keyless devices deliberately skip the nodes.proto write - tested below). +static void test_identityReady_saveUnlocked(void) +{ + TEST_ASSERT_EQUAL_MESSAGE(32, owner.public_key.size, "boot keygen did not run - this suite needs an owner key"); + TEST_ASSERT_NOT_NULL(db->getMeshNode(db->getNodeNum())); +} + +// --- updateFrom SNR admission gates (in-RAM policy feeding the persisted bit) --- + +static void test_updateFrom_snrTransportGates(void) +{ + const uint32_t A = 0x52000001, B = 0x52000002, C = 0x52000003; + + // Genuine RF reception of a 0 dB packet: stored, and HAS_SNR says so. + heardOverLoRa(A, 0.0f); + const meshtastic_NodeInfoLite *na = db->getMeshNode(A); + TEST_ASSERT_NOT_NULL(na); + TEST_ASSERT_TRUE_MESSAGE(nodeInfoLiteHasSnr(na), "a measured 0 dB must be recorded as known"); + TEST_ASSERT_EQUAL_FLOAT(0.0f, na->snr); + + // Broker-delivered MQTT packet: rx_snr is not our measurement, never recorded. + meshtastic_MeshPacket mp = makeRxPacket(B); + mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT; + mp.via_mqtt = true; + mp.rx_snr = 7.5f; + nodeDB->updateFrom(mp); + const meshtastic_NodeInfoLite *nb = db->getMeshNode(B); + TEST_ASSERT_NOT_NULL(nb); + TEST_ASSERT_FALSE_MESSAGE(nodeInfoLiteHasSnr(nb), "MQTT-transport SNR must not be recorded"); + TEST_ASSERT_EQUAL_FLOAT(0.0f, nb->snr); + TEST_ASSERT_TRUE(nodeInfoLiteViaMqtt(nb)); + + // TRANSPORT_LORA without has_rx_rssi (the PhoneAPI-replay shape): not recorded. + mp = makeRxPacket(C); + mp.has_rx_rssi = false; + mp.rx_rssi = 0; + mp.rx_snr = 6.0f; + nodeDB->updateFrom(mp); + const meshtastic_NodeInfoLite *nc = db->getMeshNode(C); + TEST_ASSERT_NOT_NULL(nc); + TEST_ASSERT_FALSE_MESSAGE(nodeInfoLiteHasSnr(nc), "replay-shaped packets must not mint a measurement"); + TEST_ASSERT_EQUAL_FLOAT(0.0f, nc->snr); + + // An MQTT-origin packet a gateway rebroadcast onto LoRa: we measured that one. + mp = makeRxPacket(B); + mp.via_mqtt = true; + mp.rx_snr = -3.5f; + nodeDB->updateFrom(mp); + nb = db->getMeshNode(B); + TEST_ASSERT_TRUE(nodeInfoLiteHasSnr(nb)); + TEST_ASSERT_EQUAL_FLOAT(-3.5f, nb->snr); +} + +// --- snr_q4 quantization + HAS_SNR sentinel through a real save/boot cycle --- + +static void test_snrQuantization_roundTripsThroughDisk(void) +{ + const uint32_t N1 = 0x53000001; // |SNR| < 0.25 dB: rounds to -1, not truncated to the sentinel + const uint32_t N2 = 0x53000002; // measured 0.0 dB: the #11271 sentinel collision + const uint32_t N3 = 0x53000003; // rounds TO 0 yet stays a known measurement + const uint32_t N4 = 0x53000004; // legacy record: snr set, HAS_SNR clear (compat branch) + const uint32_t N5 = 0x53000005; // never measured + const uint32_t N6 = 0x53000006; // plain quantization: 7.9 -> 32/4 = 8.0 + + addUserNode(N1, 0x01); + heardOverLoRa(N1, -0.2f); + addUserNode(N2, 0x02); + heardOverLoRa(N2, 0.0f); + addUserNode(N3, 0x03); + heardOverLoRa(N3, 0.1f); + meshtastic_NodeInfoLite *legacy = addUserNode(N4, 0x04); + legacy->snr = 3.0f; // pre-HAS_SNR store shape: value present, bit clear + addUserNode(N5, 0x05); + addUserNode(N6, 0x06); + heardOverLoRa(N6, 7.9f); + + TEST_ASSERT_TRUE(db->saveDatabase()); + coldBoot(); + + const meshtastic_NodeInfoLite *n = db->getMeshNode(N1); + TEST_ASSERT_NOT_NULL(n); + TEST_ASSERT_TRUE(nodeInfoLiteHasSnr(n)); + TEST_ASSERT_EQUAL_FLOAT_MESSAGE(-0.25f, n->snr, "lroundf(-0.8) = -1 -> -0.25 dB (rounding, not truncation)"); + + n = db->getMeshNode(N2); + TEST_ASSERT_NOT_NULL(n); + TEST_ASSERT_TRUE_MESSAGE(nodeInfoLiteHasSnr(n), "a genuine 0 dB reading must come back as known, not unknown"); + TEST_ASSERT_EQUAL_FLOAT(0.0f, n->snr); + + n = db->getMeshNode(N3); + TEST_ASSERT_NOT_NULL(n); + TEST_ASSERT_TRUE_MESSAGE(nodeInfoLiteHasSnr(n), "a measurement that quantizes to 0 is still a measurement"); + TEST_ASSERT_EQUAL_FLOAT(0.0f, n->snr); + + n = db->getMeshNode(N4); + TEST_ASSERT_NOT_NULL(n); + TEST_ASSERT_FALSE(nodeInfoLiteHasSnr(n)); + TEST_ASSERT_EQUAL_FLOAT_MESSAGE(3.0f, n->snr, "legacy snr_q4 without the bit must decode via the compat branch"); + + n = db->getMeshNode(N5); + TEST_ASSERT_NOT_NULL(n); + TEST_ASSERT_FALSE_MESSAGE(nodeInfoLiteHasSnr(n), "snr_q4 = 0 with the bit clear is unambiguously unknown"); + TEST_ASSERT_EQUAL_FLOAT(0.0f, n->snr); + + n = db->getMeshNode(N6); + TEST_ASSERT_NOT_NULL(n); + TEST_ASSERT_TRUE(nodeInfoLiteHasSnr(n)); + TEST_ASSERT_EQUAL_FLOAT(8.0f, n->snr); +} + +// --- Full header + satellite-map projection/rehydration cycle --- + +static void test_fullRoundTrip_headerAndSatelliteFidelity(void) +{ + const uint32_t P = 0x54000001; // position + const uint32_t T = 0x54000002; // device telemetry + const uint32_t E = 0x54000003; // environment + status + const uint32_t M = 0x54000004; // bitfield bools + hops + + addUserNode(P, 0x11, /*channelIndex=*/2); + heardOverLoRa(P, 5.5f); +#if !MESHTASTIC_EXCLUDE_POSITIONDB + meshtastic_Position pos = meshtastic_Position_init_zero; + pos.latitude_i = 375000000; + pos.longitude_i = -1219876543; + pos.altitude = 123; + pos.time = 1700000200; + pos.location_source = meshtastic_Position_LocSource_LOC_INTERNAL; + pos.precision_bits = 32; + nodeDB->updatePosition(P, pos); +#endif + + addUserNode(T, 0x12); +#if !MESHTASTIC_EXCLUDE_TELEMETRYDB + meshtastic_Telemetry tel = meshtastic_Telemetry_init_zero; + tel.which_variant = meshtastic_Telemetry_device_metrics_tag; + tel.variant.device_metrics.has_battery_level = true; + tel.variant.device_metrics.battery_level = 87; + tel.variant.device_metrics.has_voltage = true; + tel.variant.device_metrics.voltage = 3.7f; + tel.variant.device_metrics.has_channel_utilization = true; + tel.variant.device_metrics.channel_utilization = 12.5f; + tel.variant.device_metrics.has_air_util_tx = true; + tel.variant.device_metrics.air_util_tx = 1.5f; + tel.variant.device_metrics.has_uptime_seconds = true; + tel.variant.device_metrics.uptime_seconds = 3600; + nodeDB->updateTelemetry(T, tel); +#endif + + addUserNode(E, 0x13); +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB + meshtastic_Telemetry env = meshtastic_Telemetry_init_zero; + env.which_variant = meshtastic_Telemetry_environment_metrics_tag; + env.variant.environment_metrics.has_temperature = true; + env.variant.environment_metrics.temperature = 21.5f; + env.variant.environment_metrics.has_relative_humidity = true; + env.variant.environment_metrics.relative_humidity = 40.5f; + env.variant.environment_metrics.has_barometric_pressure = true; + env.variant.environment_metrics.barometric_pressure = 1013.25f; + nodeDB->updateTelemetry(E, env); +#endif +#if !MESHTASTIC_EXCLUDE_STATUSDB + nodeDB->setNodeStatus(E, makeStatus("on the tower")); +#endif + + meshtastic_NodeInfoLite *m = addUserNode(M, 0x14); + meshtastic_MeshPacket mp = makeRxPacket(M); + mp.via_mqtt = true; // gateway rebroadcast: bit stored, SNR still ours + mp.hop_start = 5; + mp.hop_limit = 2; // hops_away = 3 + mp.rx_snr = 2.0f; + nodeDB->updateFrom(mp); + m = db->getMeshNode(M); + nodeInfoLiteSetBit(m, NODEINFO_BITFIELD_IS_MUTED_MASK, true); + + TEST_ASSERT_TRUE(db->saveDatabase()); + assertTempVectorsEmpty("temp vectors must be cleared after the save projection"); + + coldBoot(); + assertTempVectorsEmpty("armed decode must route entries into the maps, not the temp vectors"); + + // Header fidelity + const meshtastic_NodeInfoLite *np = db->getMeshNode(P); + TEST_ASSERT_NOT_NULL(np); + TEST_ASSERT_EQUAL_STRING("Node 11", np->long_name); + TEST_ASSERT_EQUAL_STRING("N11", np->short_name); + TEST_ASSERT_EQUAL(meshtastic_HardwareModel_TBEAM, np->hw_model); + TEST_ASSERT_EQUAL_UINT8(2, np->channel); + TEST_ASSERT_EQUAL_UINT32(1700000000, np->last_heard); + TEST_ASSERT_TRUE(nodeInfoLiteHasSnr(np)); + TEST_ASSERT_EQUAL_FLOAT(5.5f, np->snr); + meshtastic_User expected = makeUser(P, 0x11); + TEST_ASSERT_EQUAL(32, np->public_key.size); + TEST_ASSERT_EQUAL_MEMORY_MESSAGE(expected.public_key.bytes, np->public_key.bytes, 32, + "public key must survive byte-identical"); + + const meshtastic_NodeInfoLite *nm = db->getMeshNode(M); + TEST_ASSERT_NOT_NULL(nm); + TEST_ASSERT_TRUE(nodeInfoLiteViaMqtt(nm)); + TEST_ASSERT_TRUE(nodeInfoLiteIsMuted(nm)); + TEST_ASSERT_TRUE(nm->has_hops_away); + TEST_ASSERT_EQUAL_UINT8(3, nm->hops_away); + TEST_ASSERT_TRUE(nodeInfoLiteHasSnr(nm)); + TEST_ASSERT_EQUAL_FLOAT(2.0f, nm->snr); + + // Satellite rehydration - identical values, and only where they were written. +#if !MESHTASTIC_EXCLUDE_POSITIONDB + meshtastic_PositionLite gotPos; + TEST_ASSERT_TRUE(db->copyNodePosition(P, gotPos)); + TEST_ASSERT_EQUAL_INT32(375000000, gotPos.latitude_i); + TEST_ASSERT_EQUAL_INT32(-1219876543, gotPos.longitude_i); + TEST_ASSERT_EQUAL_INT32(123, gotPos.altitude); + TEST_ASSERT_EQUAL_UINT32(1700000200, gotPos.time); + TEST_ASSERT_EQUAL(meshtastic_Position_LocSource_LOC_INTERNAL, gotPos.location_source); + TEST_ASSERT_EQUAL_UINT32(32, gotPos.precision_bits); + TEST_ASSERT_FALSE_MESSAGE(db->hasNodePosition(T), "no position was ever written for T"); +#endif + +#if !MESHTASTIC_EXCLUDE_TELEMETRYDB + meshtastic_DeviceMetrics gotDm; + TEST_ASSERT_TRUE(db->copyNodeTelemetry(T, gotDm)); + TEST_ASSERT_TRUE(gotDm.has_battery_level); + TEST_ASSERT_EQUAL_UINT32(87, gotDm.battery_level); + TEST_ASSERT_TRUE(gotDm.has_voltage); + TEST_ASSERT_EQUAL_FLOAT(3.7f, gotDm.voltage); + TEST_ASSERT_TRUE(gotDm.has_channel_utilization); + TEST_ASSERT_EQUAL_FLOAT(12.5f, gotDm.channel_utilization); + TEST_ASSERT_TRUE(gotDm.has_air_util_tx); + TEST_ASSERT_EQUAL_FLOAT(1.5f, gotDm.air_util_tx); + TEST_ASSERT_TRUE(gotDm.has_uptime_seconds); + TEST_ASSERT_EQUAL_UINT32(3600, gotDm.uptime_seconds); + TEST_ASSERT_FALSE(db->hasNodeTelemetry(P)); +#endif + +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB + meshtastic_EnvironmentMetrics gotEnv; + TEST_ASSERT_TRUE(db->copyNodeEnvironment(E, gotEnv)); + TEST_ASSERT_TRUE(gotEnv.has_temperature); + TEST_ASSERT_EQUAL_FLOAT(21.5f, gotEnv.temperature); + TEST_ASSERT_TRUE(gotEnv.has_relative_humidity); + TEST_ASSERT_EQUAL_FLOAT(40.5f, gotEnv.relative_humidity); + TEST_ASSERT_TRUE(gotEnv.has_barometric_pressure); + TEST_ASSERT_EQUAL_FLOAT(1013.25f, gotEnv.barometric_pressure); +#endif + +#if !MESHTASTIC_EXCLUDE_STATUSDB + meshtastic_StatusMessage gotSt; + TEST_ASSERT_TRUE(db->copyNodeStatus(E, gotSt)); + TEST_ASSERT_EQUAL_STRING("on the tower", gotSt.status); + TEST_ASSERT_FALSE(db->hasNodeStatus(P)); +#endif +} + +// --- Keyless-save skip (part of the PKI-DM key-amnesia diagnosis) --- + +static void test_keylessDevice_skipsNodesProtoWrite(void) +{ +#if MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI + TEST_IGNORE_MESSAGE("keyless-save gate compiled out on this build"); +#else + std::vector before; + TEST_ASSERT_TRUE_MESSAGE(readFileBytes(nodeDatabaseFileName, before), "nodes.proto must exist before the gate check"); + + const meshtastic_User_public_key_t savedKey = owner.public_key; + const bool savedLicensed = owner.is_licensed; + owner.public_key.size = 0; + owner.is_licensed = false; + + // Returning success on the skip matters: a false here would propagate into + // saveToDisk()'s fsFormat() whole-FS wipe. + TEST_ASSERT_TRUE_MESSAGE(db->saveDatabase(), "keyless save must report success"); + + std::vector after; + TEST_ASSERT_TRUE(readFileBytes(nodeDatabaseFileName, after)); + TEST_ASSERT_TRUE_MESSAGE(before == after, "keyless save must leave nodes.proto byte-identical"); + + owner.public_key = savedKey; + owner.is_licensed = savedLicensed; + + // Control: with the key restored, the same call writes. + addUserNode(0x55000001, 0x55); + TEST_ASSERT_TRUE(db->saveDatabase()); + TEST_ASSERT_TRUE(readFileBytes(nodeDatabaseFileName, after)); + TEST_ASSERT_FALSE_MESSAGE(before == after, "keyed save must rewrite nodes.proto"); +#endif +} + +// --- Live satellite-cap eviction policy --- + +#if !MESHTASTIC_EXCLUDE_STATUSDB +static void test_satelliteCap_evictionPolicy(void) +{ + if ((size_t)MAX_NUM_NODES < (size_t)MAX_SATELLITE_NODES + 8) + TEST_IGNORE_MESSAGE("hot cap too small to own a full satellite map on this build"); + + clearAllSatellites(); + TEST_ASSERT_EQUAL_UINT(0, (unsigned)nodeDB->snapshotStatusNodeNums(0).size()); + + const NodeNum self = nodeDB->getNodeNum(); + meshtastic_NodeInfoLite *selfRow = nodeDB->getOrCreateMeshNode(self); + TEST_ASSERT_NOT_NULL(selfRow); + selfRow->last_heard = 0; // stalest possible: only the identity exemption can protect it + nodeDB->setNodeStatus(self, makeStatus("self")); + + // Fill to exactly the cap with hot-owned entries; owner i heard at 1000+i. + const size_t owners = (size_t)MAX_SATELLITE_NODES - 1; + const NodeNum ownerBase = 0x60000000u; + for (size_t i = 0; i < owners; i++) { + meshtastic_NodeInfoLite *info = nodeDB->getOrCreateMeshNode(ownerBase + i); + TEST_ASSERT_NOT_NULL(info); + info->last_heard = 1000 + (uint32_t)i; + nodeDB->setNodeStatus(ownerBase + i, makeStatus("owned")); + } + TEST_ASSERT_EQUAL_UINT((unsigned)MAX_SATELLITE_NODES, (unsigned)nodeDB->snapshotStatusNodeNums(0).size()); + + // (a) At cap, a new entry evicts the stalest-by-owner victim - never self, + // even though self ranks stalest of all. + const NodeNum orphan1 = 0x60FFFF01u; + nodeDB->setNodeStatus(orphan1, makeStatus("new")); + TEST_ASSERT_TRUE_MESSAGE(db->hasNodeStatus(self), "self must never be evicted"); + TEST_ASSERT_FALSE_MESSAGE(db->hasNodeStatus(ownerBase + 0), "stalest owner must be the victim"); + TEST_ASSERT_TRUE(db->hasNodeStatus(ownerBase + 1)); + TEST_ASSERT_TRUE(db->hasNodeStatus(orphan1)); + TEST_ASSERT_EQUAL_UINT((unsigned)MAX_SATELLITE_NODES, (unsigned)nodeDB->snapshotStatusNodeNums(0).size()); + + // (b) Orphans (owner absent from the hot store) are evicted before any owner, + // however stale the owner: orphan1 (recency 0) loses to owner1 (1001). + const NodeNum orphan2 = 0x60FFFF02u; + nodeDB->setNodeStatus(orphan2, makeStatus("new2")); + TEST_ASSERT_FALSE_MESSAGE(db->hasNodeStatus(orphan1), "orphan must be evicted before any owned entry"); + TEST_ASSERT_TRUE(db->hasNodeStatus(ownerBase + 1)); + TEST_ASSERT_TRUE(db->hasNodeStatus(orphan2)); + TEST_ASSERT_EQUAL_UINT((unsigned)MAX_SATELLITE_NODES, (unsigned)nodeDB->snapshotStatusNodeNums(0).size()); + + // (c) Updating an existing key at cap must not evict anything. + nodeDB->setNodeStatus(ownerBase + 1, makeStatus("updated")); + TEST_ASSERT_EQUAL_UINT((unsigned)MAX_SATELLITE_NODES, (unsigned)nodeDB->snapshotStatusNodeNums(0).size()); + TEST_ASSERT_TRUE_MESSAGE(db->hasNodeStatus(orphan2), "update-in-place must not trigger eviction"); + meshtastic_StatusMessage got; + TEST_ASSERT_TRUE(db->copyNodeStatus(ownerBase + 1, got)); + TEST_ASSERT_EQUAL_STRING("updated", got.status); +} +#endif // !MESHTASTIC_EXCLUDE_STATUSDB + +// --- Boot-time trim of an over-cap nodes.proto (capacity downgrade / foreign file) --- + +#if !MESHTASTIC_EXCLUDE_POSITIONDB +static void test_bootTrim_overCapSatellitesHealedOnDisk(void) +{ + const size_t overBy = 10; + const NodeNum base = 0x70000000u; + + // Craft a v25 nodes.proto whose position store exceeds this build's cap, as a + // larger-cap build (or a peer backup) would leave behind. + meshtastic_NodeDatabase crafted{}; + crafted.version = DEVICESTATE_CUR_VER; + for (size_t i = 0; i < (size_t)MAX_SATELLITE_NODES + overBy; i++) { + meshtastic_NodePositionEntry e = meshtastic_NodePositionEntry_init_zero; + e.num = base + (uint32_t)i; + e.has_position = true; + e.position.latitude_i = (int32_t)(1000 + i); + e.position.time = 1000 + (uint32_t)i; + crafted.positions.push_back(e); + } + size_t craftedSize = 0; + TEST_ASSERT_TRUE(pb_get_encoded_size(&craftedSize, meshtastic_NodeDatabase_fields, &crafted)); + TEST_ASSERT_TRUE(db->saveProto(nodeDatabaseFileName, craftedSize, &meshtastic_NodeDatabase_msg, &crafted, false)); + + coldBoot(); + + // Trimmed in RAM to exactly the cap; all entries were orphans, so the + // lowest-recency victims (here: the lowest-numbered) went first. + TEST_ASSERT_EQUAL_UINT((unsigned)MAX_SATELLITE_NODES, (unsigned)nodeDB->snapshotPositionNodeNums(0).size()); + TEST_ASSERT_TRUE(db->hasNodePosition(base + (uint32_t)MAX_SATELLITE_NODES + (uint32_t)overBy - 1)); + TEST_ASSERT_FALSE(db->hasNodePosition(base)); + + // And healed on disk: nodeDBSelfCare rewrote the store once during the boot. + meshtastic_NodeDatabase reloaded{}; + decodeNodesFile(reloaded); + size_t persisted = 0; + for (const auto &e : reloaded.positions) + if (e.has_position) + persisted++; + TEST_ASSERT_EQUAL_UINT_MESSAGE((unsigned)MAX_SATELLITE_NODES, (unsigned)persisted, + "boot must rewrite the over-cap store trimmed"); +} +#endif // !MESHTASTIC_EXCLUDE_POSITIONDB + +// --- resetNodes(keepFavorites): no ghost rows above numMeshNodes --- + +static void test_resetNodesKeepFavorites_compactsWithoutGhostRows(void) +{ + const uint32_t F1 = 0x71000001, F2 = 0x71000002, F3 = 0x71000003, F4 = 0x71000004; + addUserNode(F1, 0x21); + addUserNode(F2, 0x22); + addUserNode(F3, 0x23); + addUserNode(F4, 0x24); + TEST_ASSERT_TRUE(nodeDB->set_favorite(true, F2)); + TEST_ASSERT_TRUE(nodeDB->set_favorite(true, F4)); +#if !MESHTASTIC_EXCLUDE_POSITIONDB + meshtastic_Position pos = meshtastic_Position_init_zero; + pos.latitude_i = 111; + pos.longitude_i = 222; + nodeDB->updatePosition(F1, pos); + nodeDB->updatePosition(F2, pos); +#endif + + nodeDB->resetNodes(/*keepFavorites=*/true); + + // RAM: self + the two favorites, compacted into contiguous low slots. + TEST_ASSERT_EQUAL_INT(3, (int)nodeDB->getNumMeshNodes()); + TEST_ASSERT_NULL(db->getMeshNode(F1)); + TEST_ASSERT_NULL(db->getMeshNode(F3)); + const meshtastic_NodeInfoLite *f2 = db->getMeshNode(F2); + const meshtastic_NodeInfoLite *f4 = db->getMeshNode(F4); + TEST_ASSERT_NOT_NULL(f2); + TEST_ASSERT_NOT_NULL(f4); + TEST_ASSERT_TRUE(nodeInfoLiteIsFavorite(f2)); + TEST_ASSERT_TRUE(nodeInfoLiteIsFavorite(f4)); +#if !MESHTASTIC_EXCLUDE_POSITIONDB + TEST_ASSERT_FALSE_MESSAGE(db->hasNodePosition(F1), "non-favorite satellites must be dropped"); + TEST_ASSERT_TRUE_MESSAGE(db->hasNodePosition(F2), "favorite satellites must survive"); +#endif + + // Disk: resetNodes saved; the serialized store must carry the favorites in + // the low slots and NOTHING above numMeshNodes - a zeroed-in-place favorite + // would be invisible to every scan yet still serialized (the ghost bug). + meshtastic_NodeDatabase reloaded{}; + decodeNodesFile(reloaded); + TEST_ASSERT_TRUE(reloaded.nodes.size() >= 3); + size_t liveRows = 0; + bool sawF2 = false, sawF4 = false, sawSelf = false; + for (size_t i = 0; i < reloaded.nodes.size(); i++) { + const meshtastic_NodeInfoLite &row = reloaded.nodes[i]; + if (row.num == 0) + continue; + liveRows++; + TEST_ASSERT_TRUE_MESSAGE(i < 3, "live row serialized above numMeshNodes: a ghost entry"); + if (row.num == F2) + sawF2 = true; + if (row.num == F4) + sawF4 = true; + if (row.num == nodeDB->getNodeNum()) + sawSelf = true; + } + TEST_ASSERT_EQUAL_UINT(3, (unsigned)liveRows); + TEST_ASSERT_TRUE(sawSelf); + TEST_ASSERT_TRUE(sawF2); + TEST_ASSERT_TRUE(sawF4); +} + +NDBR_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); +#if defined(ARCH_PORTDUINO) + // The stalest-owner eviction case needs hot capacity above the satellite cap + // (the real large-flash topology). Set before the first NodeDB so every boot + // in this suite sees one consistent cap. + portduino_config.MaxNodes = (int)MAX_SATELLITE_NODES + 50; +#endif + // First boot on the empty sandbox: installs defaults, runs keygen, and + // persists the base config files every later cold boot reloads. + coldBoot(); + +#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) + // Boot keygen is region-gated on real radios (simradio bypasses the gate); + // if this environment blocked it, set a region and mint the identity now so + // the persistence legs run instead of cascading off a locked save. + if (owner.public_key.size != 32) { + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + nodeDB->generateCryptoKeyPair(nullptr); + } +#endif + + UNITY_BEGIN(); + + printf("\n=== Preconditions ===\n"); + RUN_TEST(test_identityReady_saveUnlocked); + + printf("\n=== updateFrom SNR gates ===\n"); + RUN_TEST(test_updateFrom_snrTransportGates); + + printf("\n=== snr_q4 + HAS_SNR round trip ===\n"); + RUN_TEST(test_snrQuantization_roundTripsThroughDisk); + + printf("\n=== Satellite projection/rehydration ===\n"); + RUN_TEST(test_fullRoundTrip_headerAndSatelliteFidelity); + + printf("\n=== Keyless-save gate ===\n"); + RUN_TEST(test_keylessDevice_skipsNodesProtoWrite); + + printf("\n=== Satellite caps ===\n"); +#if !MESHTASTIC_EXCLUDE_STATUSDB + RUN_TEST(test_satelliteCap_evictionPolicy); +#endif +#if !MESHTASTIC_EXCLUDE_POSITIONDB + RUN_TEST(test_bootTrim_overCapSatellitesHealedOnDisk); +#endif + + printf("\n=== resetNodes ghost rows ===\n"); + RUN_TEST(test_resetNodesKeepFavorites_compactsWithoutGhostRows); + + exit(UNITY_END()); +} +NDBR_TEST_ENTRY void loop() {} + +#else // !FSCom - no filesystem, nothing to round-trip + +void setUp(void) {} +void tearDown(void) {} + +NDBR_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + exit(UNITY_END()); +} +NDBR_TEST_ENTRY void loop() {} + +#endif diff --git a/test/test_observer/test_main.cpp b/test/test_observer/test_main.cpp new file mode 100644 index 000000000..88998575f --- /dev/null +++ b/test/test_observer/test_main.cpp @@ -0,0 +1,378 @@ +// Unit tests for src/Observer.h: notification order, the nonzero-return abort chain, +// CallbackObserver dispatch, ~Observer auto-detach, and list mutation from inside onNotify. +#include "Arduino.h" +#include "Observer.h" +#include "TestUtil.h" +#include +#include +#include + +// Tags of observers in the order their onNotify ran, e.g. "ABC". Cleared in setUp. +static std::string callOrder; + +// An observer that records its calls and can optionally mutate observer lists from inside +// onNotify - the mid-notify hazard the detach/attach-during-notify tests drive. +class RecordingObserver : public Observer +{ + public: + explicit RecordingObserver(char _tag) : tag(_tag) {} + + char tag; + int returnCode = 0; + int calls = 0; + int lastArg = 0; + + // When set, onNotify detaches detachWho from detachFrom before returning. + Observer *detachWho = nullptr; + Observable *detachFrom = nullptr; + + // When set, onNotify attaches attachWho to attachTo before returning. + Observer *attachWho = nullptr; + Observable *attachTo = nullptr; + + protected: + int onNotify(int arg) override + { + callOrder += tag; + calls++; + lastArg = arg; + if (detachWho && detachFrom) + detachWho->unobserve(detachFrom); + if (attachWho && attachTo) + attachWho->observe(attachTo); + return returnCode; + } +}; + +// Target class for the CallbackObserver member-pointer dispatch tests. +class CallbackTarget +{ + public: + int calls = 0; + int lastArg = 0; + + int handle(int arg) + { + calls++; + lastArg = arg; + return 0; + } + + int handleAbort(int arg) + { + calls++; + lastArg = arg; + return 42; + } +}; + +// --- basic delivery --- + +void test_notify_with_no_observers_returns_zero() +{ + Observable subject; + TEST_ASSERT_EQUAL(0, subject.notifyObservers(99)); +} + +void test_notify_order_and_arg() +{ + Observable subject; + RecordingObserver a('A'), b('B'), c('C'); + a.observe(&subject); + b.observe(&subject); + c.observe(&subject); + + TEST_ASSERT_EQUAL(0, subject.notifyObservers(42)); + TEST_ASSERT_EQUAL_STRING("ABC", callOrder.c_str()); // insertion order + TEST_ASSERT_EQUAL(42, a.lastArg); + TEST_ASSERT_EQUAL(42, b.lastArg); + TEST_ASSERT_EQUAL(42, c.lastArg); + + // Delivery is not one-shot: a second notify reaches everyone again. + TEST_ASSERT_EQUAL(0, subject.notifyObservers(43)); + TEST_ASSERT_EQUAL_STRING("ABCABC", callOrder.c_str()); + TEST_ASSERT_EQUAL(2, b.calls); + TEST_ASSERT_EQUAL(43, b.lastArg); +} + +// --- abort contract --- + +void test_nonzero_return_aborts_chain_and_propagates() +{ + Observable subject; + RecordingObserver a('A'), b('B'), c('C'); + a.observe(&subject); + b.observe(&subject); + c.observe(&subject); + + b.returnCode = 7; + TEST_ASSERT_EQUAL(7, subject.notifyObservers(1)); + TEST_ASSERT_EQUAL_STRING("AB", callOrder.c_str()); + TEST_ASSERT_EQUAL(0, c.calls); // chain stopped before C + + // Clearing the abort restores full delivery. + b.returnCode = 0; + callOrder.clear(); + TEST_ASSERT_EQUAL(0, subject.notifyObservers(2)); + TEST_ASSERT_EQUAL_STRING("ABC", callOrder.c_str()); +} + +// --- CallbackObserver --- + +void test_callback_observer_dispatches_member_function() +{ + Observable subject; + CallbackTarget target; + CallbackObserver cb(&target, &CallbackTarget::handle); + cb.observe(&subject); + + TEST_ASSERT_EQUAL(0, subject.notifyObservers(1234)); + TEST_ASSERT_EQUAL(1, target.calls); + TEST_ASSERT_EQUAL(1234, target.lastArg); +} + +void test_callback_observer_return_code_aborts_chain() +{ + Observable subject; + CallbackTarget target; + CallbackObserver cb(&target, &CallbackTarget::handleAbort); + RecordingObserver after('X'); + cb.observe(&subject); + after.observe(&subject); + + TEST_ASSERT_EQUAL(42, subject.notifyObservers(5)); + TEST_ASSERT_EQUAL(1, target.calls); + TEST_ASSERT_EQUAL(0, after.calls); // callback's abort code stopped the chain +} + +// --- lifecycle: destructor auto-detach --- + +void test_destroyed_observer_is_not_notified() +{ + Observable subject; + RecordingObserver a('A'), c('C'); + a.observe(&subject); + RecordingObserver *b = new RecordingObserver('B'); + b->observe(&subject); + c.observe(&subject); + + TEST_ASSERT_EQUAL(0, subject.notifyObservers(1)); + TEST_ASSERT_EQUAL_STRING("ABC", callOrder.c_str()); + + delete b; // ~Observer must remove it from the observable's list + + callOrder.clear(); + TEST_ASSERT_EQUAL(0, subject.notifyObservers(2)); // ASan-clean: no dangling pointer left behind + TEST_ASSERT_EQUAL_STRING("AC", callOrder.c_str()); +} + +void test_observer_watching_two_observables_detaches_from_both() +{ + Observable subject1; + Observable subject2; + { + RecordingObserver x('X'); + x.observe(&subject1); + x.observe(&subject2); // re-target onto a second observable: both now deliver + subject1.notifyObservers(1); + subject2.notifyObservers(2); + TEST_ASSERT_EQUAL(2, x.calls); + TEST_ASSERT_EQUAL(2, x.lastArg); + } // x destroyed here - must have detached from both observables + + callOrder.clear(); + TEST_ASSERT_EQUAL(0, subject1.notifyObservers(3)); + TEST_ASSERT_EQUAL(0, subject2.notifyObservers(4)); + TEST_ASSERT_EQUAL_STRING("", callOrder.c_str()); +} + +// --- duplicate observe / unobserve semantics --- + +void test_duplicate_observe_delivers_twice_and_unobserve_removes_all() +{ + Observable subject; + RecordingObserver a('A'); + a.observe(&subject); + a.observe(&subject); // current semantics: second observe means double delivery + + TEST_ASSERT_EQUAL(0, subject.notifyObservers(9)); + TEST_ASSERT_EQUAL_STRING("AA", callOrder.c_str()); + TEST_ASSERT_EQUAL(2, a.calls); + + // One unobserve removes every entry (std::list::remove semantics), not just one. + a.unobserve(&subject); + callOrder.clear(); + TEST_ASSERT_EQUAL(0, subject.notifyObservers(10)); + TEST_ASSERT_EQUAL_STRING("", callOrder.c_str()); + TEST_ASSERT_EQUAL(2, a.calls); +} + +void test_unobserve_of_never_observed_observable_is_noop() +{ + Observable subject; + RecordingObserver a('A'), stranger('S'); + a.observe(&subject); + + stranger.unobserve(&subject); // never attached: must be a safe no-op + + TEST_ASSERT_EQUAL(0, subject.notifyObservers(1)); + TEST_ASSERT_EQUAL_STRING("A", callOrder.c_str()); + TEST_ASSERT_EQUAL(0, stranger.calls); +} + +// --- list mutation from inside onNotify (the safe cases) --- + +void test_detach_of_earlier_observer_during_notify() +{ + Observable subject; + RecordingObserver a('A'), b('B'), c('C'); + a.observe(&subject); + b.observe(&subject); + c.observe(&subject); + b.detachWho = &a; // B removes already-visited A mid-notify + b.detachFrom = &subject; + + TEST_ASSERT_EQUAL(0, subject.notifyObservers(1)); + TEST_ASSERT_EQUAL_STRING("ABC", callOrder.c_str()); // A was visited before removal; C unaffected + + b.detachWho = nullptr; + callOrder.clear(); + TEST_ASSERT_EQUAL(0, subject.notifyObservers(2)); + TEST_ASSERT_EQUAL_STRING("BC", callOrder.c_str()); // A stays detached +} + +void test_detach_of_later_observer_during_notify() +{ + Observable subject; + RecordingObserver a('A'), b('B'), c('C'); + a.observe(&subject); + b.observe(&subject); + c.observe(&subject); + a.detachWho = &c; // A removes not-yet-visited C mid-notify + a.detachFrom = &subject; + + TEST_ASSERT_EQUAL(0, subject.notifyObservers(1)); + TEST_ASSERT_EQUAL_STRING("AB", callOrder.c_str()); // iteration stays valid, C never called + TEST_ASSERT_EQUAL(0, c.calls); + + a.detachWho = nullptr; + callOrder.clear(); + TEST_ASSERT_EQUAL(0, subject.notifyObservers(2)); + TEST_ASSERT_EQUAL_STRING("AB", callOrder.c_str()); +} + +// Tightest safe case: removing the node the iterator will step to next. std::list relinks A's +// next pointer when B's node is erased, so ++iterator lands on C. +void test_detach_of_immediately_next_observer_during_notify() +{ + Observable subject; + RecordingObserver a('A'), b('B'), c('C'); + a.observe(&subject); + b.observe(&subject); + c.observe(&subject); + a.detachWho = &b; + a.detachFrom = &subject; + + TEST_ASSERT_EQUAL(0, subject.notifyObservers(1)); + TEST_ASSERT_EQUAL_STRING("AC", callOrder.c_str()); + TEST_ASSERT_EQUAL(0, b.calls); + + a.detachWho = nullptr; + callOrder.clear(); + TEST_ASSERT_EQUAL(0, subject.notifyObservers(2)); + TEST_ASSERT_EQUAL_STRING("AC", callOrder.c_str()); +} + +// Self-detach is only safe when the observer also aborts the chain: returning nonzero exits +// before the iterator is advanced past the node unobserve() just erased. PhoneAPI is the one +// observer in the tree that does this (onNotify -> checkConnectionTimeout -> close() -> +// unobserve, returning -1), and its -1 is load-bearing, not incidental. A self-detaching +// observer that returned 0 would walk a freed node - not covered here, because asserting that +// would be asserting UB; notifyObservers() has to be hardened before it can be tested. +void test_self_detach_with_abort_during_notify() +{ + Observable subject; + RecordingObserver a('A'), b('B'), c('C'); + a.observe(&subject); + b.observe(&subject); + c.observe(&subject); + b.detachWho = &b; + b.detachFrom = &subject; + b.returnCode = -1; + + TEST_ASSERT_EQUAL(-1, subject.notifyObservers(1)); + TEST_ASSERT_EQUAL_STRING("AB", callOrder.c_str()); // C never runs: the chain aborted + TEST_ASSERT_EQUAL(0, c.calls); + + b.detachWho = nullptr; + b.returnCode = 0; + callOrder.clear(); + TEST_ASSERT_EQUAL(0, subject.notifyObservers(2)); + TEST_ASSERT_EQUAL_STRING("AC", callOrder.c_str()); +} + +void test_attach_during_notify_is_safe_and_delivers_next_time() +{ + Observable subject; + RecordingObserver a('A'), b('B'), c('C'), d('D'); + a.observe(&subject); + b.observe(&subject); + c.observe(&subject); + a.attachWho = &d; // A appends D mid-notify (push_back never invalidates list iterators) + a.attachTo = &subject; + + TEST_ASSERT_EQUAL(0, subject.notifyObservers(1)); + // The pre-existing observers all ran, in order. Whether the same pass also reaches the + // freshly appended D is deliberately not asserted - a hardened notifyObservers that + // snapshots the list would legitimately change that, and it should not go red for it. + TEST_ASSERT_EQUAL_STRING("ABC", callOrder.substr(0, 3).c_str()); + + a.attachWho = nullptr; + callOrder.clear(); + TEST_ASSERT_EQUAL(0, subject.notifyObservers(2)); + TEST_ASSERT_EQUAL_STRING("ABCD", callOrder.c_str()); // D is a full participant from now on +} + +// --- Unity lifecycle --- + +void setUp(void) +{ + callOrder.clear(); +} +void tearDown(void) {} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + + printf("\n=== Basic delivery ===\n"); + RUN_TEST(test_notify_with_no_observers_returns_zero); + RUN_TEST(test_notify_order_and_arg); + + printf("\n=== Abort contract ===\n"); + RUN_TEST(test_nonzero_return_aborts_chain_and_propagates); + + printf("\n=== CallbackObserver ===\n"); + RUN_TEST(test_callback_observer_dispatches_member_function); + RUN_TEST(test_callback_observer_return_code_aborts_chain); + + printf("\n=== Lifecycle ===\n"); + RUN_TEST(test_destroyed_observer_is_not_notified); + RUN_TEST(test_observer_watching_two_observables_detaches_from_both); + + printf("\n=== Duplicate observe / unobserve ===\n"); + RUN_TEST(test_duplicate_observe_delivers_twice_and_unobserve_removes_all); + RUN_TEST(test_unobserve_of_never_observed_observable_is_noop); + + printf("\n=== Mutation during notify (safe cases) ===\n"); + RUN_TEST(test_detach_of_earlier_observer_during_notify); + RUN_TEST(test_detach_of_later_observer_during_notify); + RUN_TEST(test_detach_of_immediately_next_observer_during_notify); + RUN_TEST(test_self_detach_with_abort_during_notify); + RUN_TEST(test_attach_during_notify_is_safe_and_delivers_next_time); + + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_phone_api_config_dump/test_main.cpp b/test/test_phone_api_config_dump/test_main.cpp new file mode 100644 index 000000000..2dff75d6e --- /dev/null +++ b/test/test_phone_api_config_dump/test_main.cpp @@ -0,0 +1,574 @@ +// PhoneAPI::getFromRadio() config-dump sequence, asserted on decoded FromRadio protobufs: the +// order client apps depend on, the heartbeat preempt, SPECIAL_NONCE_ONLY_* jumps, mid-dump +// restart, and the post-complete drain reaching idle. +#include "MeshTypes.h" +#include "TestUtil.h" +#include + +#include "Channels.h" +#include "CryptoEngine.h" +#include "MeshService.h" +#include "NodeDB.h" +#include "PhoneAPI.h" +#include "Router.h" +#include "mesh-pb-constants.h" +#include "meshtastic/admin.pb.h" +#include +#include +#include + +// File-scope flag in PhoneAPI.cpp: set by a client heartbeat, cleared by the queueStatus reply. +extern bool heartbeatReceived; + +namespace +{ +constexpr uint32_t FULL_DUMP_NONCE = 0x51C0FFEE; +constexpr uint32_t SECOND_NONCE = 0x0DDBA11; +constexpr NodeNum SEEDED_NODE_A = 0x00000A01; +constexpr NodeNum SEEDED_NODE_B = 0x00000A02; + +constexpr unsigned NUM_SINGLETON_PREFIX = 5; // my_info, deviceuiConfig, own node_info, metadata, region_presets +constexpr unsigned NUM_CONFIG_MESSAGES = _meshtastic_AdminMessage_ConfigType_MAX + 1; +constexpr unsigned NUM_MODULE_CONFIG_MESSAGES = _meshtastic_AdminMessage_ModuleConfigType_MAX + 1; + +// STATE_SEND_CONFIG iterates config_state over the AdminMessage ConfigType enum but emits +// Config oneof tags: a proto bump that grows one without the other makes a config message +// carry inner variant 0. The static_asserts turn that drift into a compile error here. +const pb_size_t kExpectedConfigVariants[] = { + meshtastic_Config_device_tag, meshtastic_Config_position_tag, meshtastic_Config_power_tag, + meshtastic_Config_network_tag, meshtastic_Config_display_tag, meshtastic_Config_lora_tag, + meshtastic_Config_bluetooth_tag, meshtastic_Config_security_tag, meshtastic_Config_sessionkey_tag, + meshtastic_Config_device_ui_tag, +}; +static_assert(sizeof(kExpectedConfigVariants) / sizeof(kExpectedConfigVariants[0]) == NUM_CONFIG_MESSAGES, + "AdminMessage ConfigType enum and Config oneof diverged - update PhoneAPI's STATE_SEND_CONFIG and this list"); + +const pb_size_t kExpectedModuleConfigVariants[] = { + meshtastic_ModuleConfig_mqtt_tag, + meshtastic_ModuleConfig_serial_tag, + meshtastic_ModuleConfig_external_notification_tag, + meshtastic_ModuleConfig_store_forward_tag, + meshtastic_ModuleConfig_range_test_tag, + meshtastic_ModuleConfig_telemetry_tag, + meshtastic_ModuleConfig_canned_message_tag, + meshtastic_ModuleConfig_audio_tag, + meshtastic_ModuleConfig_remote_hardware_tag, + meshtastic_ModuleConfig_neighbor_info_tag, + meshtastic_ModuleConfig_ambient_lighting_tag, + meshtastic_ModuleConfig_detection_sensor_tag, + meshtastic_ModuleConfig_paxcounter_tag, + meshtastic_ModuleConfig_statusmessage_tag, + meshtastic_ModuleConfig_traffic_management_tag, + meshtastic_ModuleConfig_tak_tag, +#if !MESHTASTIC_EXCLUDE_BEACON + meshtastic_ModuleConfig_mesh_beacon_tag, +#else + 0, // beacon compiled out: the slot still ships, as an empty ModuleConfig +#endif +}; +static_assert(sizeof(kExpectedModuleConfigVariants) / sizeof(kExpectedModuleConfigVariants[0]) == NUM_MODULE_CONFIG_MESSAGES, + "AdminMessage ModuleConfigType enum and ModuleConfig oneof diverged - update STATE_SEND_MODULECONFIG and this " + "list"); + +/// PhoneAPI over a permanently-connected fake transport. +class PhoneAPITestShim : public PhoneAPI +{ + protected: + bool checkIsConnected() override { return true; } +}; + +/// Concrete Router with no radio interface: getQueueStatus() reports an all-zero queue. +class TestRouter : public Router +{ + public: + // Router's ctor allocated the global cryptLock; nothing else frees it. + ~TestRouter() + { + delete cryptLock; + cryptLock = nullptr; + } +}; + +// Saved-global fixture, template test_event_channel_phone_api. Restored in tearDown() rather +// than by RAII because a failed TEST_ASSERT longjmps out of the test without running destructors. +struct GlobalState { + MeshService *service; + Router *router; + NodeDB *nodeDB; + concurrency::Lock *cryptLock; + meshtastic_MyNodeInfo myNodeInfo; + Channels channels; + meshtastic_ChannelFile channelFile; + meshtastic_LocalConfig config; + meshtastic_LocalModuleConfig moduleConfig; + meshtastic_DeviceState deviceState; +}; + +GlobalState *savedState = nullptr; +MeshService *mockService = nullptr; +TestRouter *testRouter = nullptr; +NodeDB *testNodeDB = nullptr; +PhoneAPITestShim *api = nullptr; + +/// Give every channel slot a distinct index so the dump's 0..7 ordering is observable. +void configureTestChannels() +{ + channelFile = meshtastic_ChannelFile_init_default; + channelFile.channels_count = MAX_NUM_CHANNELS; + for (pb_size_t i = 0; i < MAX_NUM_CHANNELS; i++) { + channelFile.channels[i].index = (int8_t)i; + channelFile.channels[i].has_settings = true; + channelFile.channels[i].role = i == 0 ? meshtastic_Channel_Role_PRIMARY : meshtastic_Channel_Role_SECONDARY; + } + channels.onConfigChanged(); +} + +/// Create a remote node in the scratch NodeDB the way received traffic would. +void seedRemoteNode(NodeNum num) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + p.from = num; + p.to = NODENUM_BROADCAST; + nodeDB->updateFrom(p); +} + +bool sendToRadio(const meshtastic_ToRadio &message) +{ + uint8_t encoded[meshtastic_ToRadio_size] = {}; + const size_t encodedSize = + pb_encode_to_bytes(encoded, sizeof(encoded), &meshtastic_ToRadio_msg, const_cast(&message)); + TEST_ASSERT_GREATER_THAN_UINT(0, encodedSize); + return api->handleToRadio(encoded, encodedSize); +} + +void startHandshake(uint32_t nonce) +{ + meshtastic_ToRadio request = meshtastic_ToRadio_init_zero; + request.which_payload_variant = meshtastic_ToRadio_want_config_id_tag; + request.want_config_id = nonce; + sendToRadio(request); +} + +void sendPlainHeartbeat() +{ + meshtastic_ToRadio hb = meshtastic_ToRadio_init_zero; + hb.which_payload_variant = meshtastic_ToRadio_heartbeat_tag; + hb.heartbeat = meshtastic_Heartbeat_init_zero; // nonce 0 = plain keepalive, expects a queueStatus reply + sendToRadio(hb); +} + +/// One decoded FromRadio pulled off the wire; zero-length reads return false. +bool readOneFromRadio(meshtastic_FromRadio &out) +{ + uint8_t buf[meshtastic_FromRadio_size]; + const size_t len = api->getFromRadio(buf); + if (len == 0) + return false; + out = meshtastic_FromRadio_init_zero; + TEST_ASSERT_TRUE_MESSAGE(pb_decode_from_bytes(buf, len, &meshtastic_FromRadio_msg, &out), + "device emitted an undecodable FromRadio"); + return true; +} + +/// Everything the dump emitted, in order, as decoded facts rather than internals. +struct DumpTranscript { + std::vector variants; // outer which_payload_variant per message + std::vector configVariants; // inner variant of each FromRadio.config + std::vector moduleConfigVariants; // inner variant of each FromRadio.moduleConfig + std::vector channelIndices; + std::vector nodeNums; + unsigned fileInfoCount = 0; + unsigned queueStatusCount = 0; + uint32_t completeId = 0; + bool sawComplete = false; +}; + +/// Pull messages until config_complete_id; false if the stream stalls or overruns the cap. +bool drainUntilComplete(DumpTranscript &t, unsigned maxMessages = 600) +{ + for (unsigned i = 0; i < maxMessages; i++) { + meshtastic_FromRadio msg; + if (!readOneFromRadio(msg)) + return false; + t.variants.push_back(msg.which_payload_variant); + switch (msg.which_payload_variant) { + case meshtastic_FromRadio_config_tag: + t.configVariants.push_back(msg.config.which_payload_variant); + break; + case meshtastic_FromRadio_moduleConfig_tag: + t.moduleConfigVariants.push_back(msg.moduleConfig.which_payload_variant); + break; + case meshtastic_FromRadio_channel_tag: + t.channelIndices.push_back(msg.channel.index); + break; + case meshtastic_FromRadio_node_info_tag: + t.nodeNums.push_back(msg.node_info.num); + break; + case meshtastic_FromRadio_fileInfo_tag: + t.fileInfoCount++; + break; + case meshtastic_FromRadio_queueStatus_tag: + t.queueStatusCount++; + break; + case meshtastic_FromRadio_config_complete_id_tag: + t.completeId = msg.config_complete_id; + t.sawComplete = true; + return true; + default: + break; + } + } + return false; +} + +unsigned countVariant(const DumpTranscript &t, pb_size_t tag) +{ + unsigned n = 0; + for (pb_size_t v : t.variants) + if (v == tag) + n++; + return n; +} + +/// Assert the two non-self node records are the seeded pair (DB iteration order not pinned). +void assertSeededPair(uint32_t first, uint32_t second) +{ + const bool inOrder = first == SEEDED_NODE_A && second == SEEDED_NODE_B; + const bool swapped = first == SEEDED_NODE_B && second == SEEDED_NODE_A; + TEST_ASSERT_TRUE_MESSAGE(inOrder || swapped, "other node_infos are not the seeded pair"); +} + +// --- Tests --- + +// The full documented sequence, section by section, ending in the nonce echo. Also pins the +// channel section: exactly MAX_NUM_CHANNELS messages, indices 0..7 in order, between +// region_presets and the first config. +void test_full_want_config_dump_emits_documented_sequence() +{ + seedRemoteNode(SEEDED_NODE_A); + seedRemoteNode(SEEDED_NODE_B); + startHandshake(FULL_DUMP_NONCE); + + DumpTranscript t; + TEST_ASSERT_TRUE_MESSAGE(drainUntilComplete(t), "dump stalled before config_complete_id"); + + const pb_size_t expectedPrefix[NUM_SINGLETON_PREFIX] = { + meshtastic_FromRadio_my_info_tag, meshtastic_FromRadio_deviceuiConfig_tag, meshtastic_FromRadio_node_info_tag, + meshtastic_FromRadio_metadata_tag, meshtastic_FromRadio_region_presets_tag}; + TEST_ASSERT_GREATER_OR_EQUAL_UINT(NUM_SINGLETON_PREFIX, t.variants.size()); + for (unsigned i = 0; i < NUM_SINGLETON_PREFIX; i++) + TEST_ASSERT_EQUAL_UINT_MESSAGE(expectedPrefix[i], t.variants[i], "header sequence changed"); + + // Bound the raw indexing below: header + channels + configs + moduleConfigs + 2 seeded + // node_infos + complete is the minimum a full dump can be. + TEST_ASSERT_GREATER_OR_EQUAL_UINT( + NUM_SINGLETON_PREFIX + MAX_NUM_CHANNELS + NUM_CONFIG_MESSAGES + NUM_MODULE_CONFIG_MESSAGES + 3, t.variants.size()); + + // Channel section: contiguous, complete, ordered. + const size_t channelStart = NUM_SINGLETON_PREFIX; + TEST_ASSERT_EQUAL_UINT((unsigned)MAX_NUM_CHANNELS, t.channelIndices.size()); + for (unsigned i = 0; i < MAX_NUM_CHANNELS; i++) { + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_channel_tag, t.variants[channelStart + i]); + TEST_ASSERT_EQUAL_INT_MESSAGE((int)i, t.channelIndices[i], "channels must arrive as indices 0..7 in order"); + } + + const size_t configStart = channelStart + MAX_NUM_CHANNELS; + for (unsigned i = 0; i < NUM_CONFIG_MESSAGES; i++) + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_config_tag, t.variants[configStart + i]); + + const size_t moduleStart = configStart + NUM_CONFIG_MESSAGES; + for (unsigned i = 0; i < NUM_MODULE_CONFIG_MESSAGES; i++) + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_moduleConfig_tag, t.variants[moduleStart + i]); + + // Other node_infos follow the module configs; the own record was already sent in the header. + const size_t nodesStart = moduleStart + NUM_MODULE_CONFIG_MESSAGES; + TEST_ASSERT_EQUAL_UINT(3, t.nodeNums.size()); + TEST_ASSERT_EQUAL_UINT32(nodeDB->getNodeNum(), t.nodeNums[0]); + assertSeededPair(t.nodeNums[1], t.nodeNums[2]); + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_node_info_tag, t.variants[nodesStart]); + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_node_info_tag, t.variants[nodesStart + 1]); + + // Everything between the node_infos and the completion id is file manifest (count is + // whatever the sandbox filesystem holds, so only the position is asserted). + for (size_t i = nodesStart + 2; i + 1 < t.variants.size(); i++) + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_fileInfo_tag, t.variants[i]); + + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_config_complete_id_tag, t.variants.back()); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(FULL_DUMP_NONCE, t.completeId, "config_complete_id must echo the request nonce"); + + // Singletons exactly once, and no stray preempts. + TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_my_info_tag)); + TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_deviceuiConfig_tag)); + TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_metadata_tag)); + TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_region_presets_tag)); + TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_config_complete_id_tag)); + TEST_ASSERT_EQUAL_UINT(0, t.queueStatusCount); + TEST_ASSERT_EQUAL_UINT(NUM_SINGLETON_PREFIX + MAX_NUM_CHANNELS + NUM_CONFIG_MESSAGES + NUM_MODULE_CONFIG_MESSAGES + 2 + + t.fileInfoCount + 1, + t.variants.size()); +} + +// Guards the ConfigType-enum-to-oneof-tag iteration: a desync emits a config message whose +// inner variant is 0, which every phone app decodes as an empty Config. +void test_config_section_inner_variants_match_config_type_enum() +{ + startHandshake(FULL_DUMP_NONCE); + DumpTranscript t; + TEST_ASSERT_TRUE(drainUntilComplete(t)); + + TEST_ASSERT_EQUAL_UINT(NUM_CONFIG_MESSAGES, t.configVariants.size()); + for (unsigned i = 0; i < NUM_CONFIG_MESSAGES; i++) { + TEST_ASSERT_NOT_EQUAL_MESSAGE(0, t.configVariants[i], + "config with inner variant 0: ConfigType enum drifted from the Config oneof"); + TEST_ASSERT_EQUAL_UINT(kExpectedConfigVariants[i], t.configVariants[i]); + } +} + +// Same closed-set guard for the module config section (the drift class already happened once, +// for statusmessage). +void test_module_config_section_inner_variants_match_module_config_type_enum() +{ + startHandshake(FULL_DUMP_NONCE); + DumpTranscript t; + TEST_ASSERT_TRUE(drainUntilComplete(t)); + + TEST_ASSERT_EQUAL_UINT(NUM_MODULE_CONFIG_MESSAGES, t.moduleConfigVariants.size()); + for (unsigned i = 0; i < NUM_MODULE_CONFIG_MESSAGES; i++) { + if (kExpectedModuleConfigVariants[i] != 0) + TEST_ASSERT_NOT_EQUAL_MESSAGE( + 0, t.moduleConfigVariants[i], + "moduleConfig with inner variant 0: ModuleConfigType enum drifted from the ModuleConfig oneof"); + TEST_ASSERT_EQUAL_UINT(kExpectedModuleConfigVariants[i], t.moduleConfigVariants[i]); + } +} + +// SPECIAL_NONCE_ONLY_NODES jumps straight to the node stream: own record, others, completion - +// no headers, channels, configs, or manifest. +void test_only_nodes_nonce_sends_nodes_then_complete() +{ + seedRemoteNode(SEEDED_NODE_A); + seedRemoteNode(SEEDED_NODE_B); + startHandshake(SPECIAL_NONCE_ONLY_NODES); + + DumpTranscript t; + TEST_ASSERT_TRUE(drainUntilComplete(t)); + + TEST_ASSERT_EQUAL_UINT(4, t.variants.size()); // own + 2 seeded + complete + TEST_ASSERT_EQUAL_UINT(3, t.nodeNums.size()); + TEST_ASSERT_EQUAL_UINT32(nodeDB->getNodeNum(), t.nodeNums[0]); + assertSeededPair(t.nodeNums[1], t.nodeNums[2]); + TEST_ASSERT_EQUAL_UINT32(SPECIAL_NONCE_ONLY_NODES, t.completeId); + + TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_my_info_tag)); + TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_deviceuiConfig_tag)); + TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_metadata_tag)); + TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_region_presets_tag)); + TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_channel_tag)); + TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_config_tag)); + TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_moduleConfig_tag)); + TEST_ASSERT_EQUAL_UINT(0, t.fileInfoCount); +} + +// SPECIAL_NONCE_ONLY_CONFIG delivers the full config but skips the non-self node DB, and must +// not arm the post-complete satellite replay. +void test_only_config_nonce_skips_other_nodeinfos() +{ + seedRemoteNode(SEEDED_NODE_A); + seedRemoteNode(SEEDED_NODE_B); + startHandshake(SPECIAL_NONCE_ONLY_CONFIG); + + DumpTranscript t; + TEST_ASSERT_TRUE(drainUntilComplete(t)); + + TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_node_info_tag)); // own record only + TEST_ASSERT_EQUAL_UINT(1, t.nodeNums.size()); + TEST_ASSERT_EQUAL_UINT32(nodeDB->getNodeNum(), t.nodeNums[0]); + TEST_ASSERT_EQUAL_UINT((unsigned)MAX_NUM_CHANNELS, countVariant(t, meshtastic_FromRadio_channel_tag)); + TEST_ASSERT_EQUAL_UINT(NUM_CONFIG_MESSAGES, t.configVariants.size()); + TEST_ASSERT_EQUAL_UINT(NUM_MODULE_CONFIG_MESSAGES, t.moduleConfigVariants.size()); + TEST_ASSERT_EQUAL_UINT32(SPECIAL_NONCE_ONLY_CONFIG, t.completeId); + + // ONLY_CONFIG skips node/satellite sync entirely: the stream must be idle immediately. + uint8_t buf[meshtastic_FromRadio_size]; + TEST_ASSERT_EQUAL_UINT(0, api->getFromRadio(buf)); + TEST_ASSERT_FALSE(api->available()); +} + +// A keepalive heartbeat mid-dump preempts exactly one read with a queueStatus, then the dump +// resumes where it left off; the flag self-clears so nothing repeats or restarts. +void test_heartbeat_mid_dump_preempts_once_then_resumes() +{ + startHandshake(FULL_DUMP_NONCE); + + // Pull the first three header messages, leaving the machine about to send metadata. + meshtastic_FromRadio msg; + TEST_ASSERT_TRUE(readOneFromRadio(msg)); + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_my_info_tag, msg.which_payload_variant); + TEST_ASSERT_TRUE(readOneFromRadio(msg)); + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_deviceuiConfig_tag, msg.which_payload_variant); + TEST_ASSERT_TRUE(readOneFromRadio(msg)); + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_node_info_tag, msg.which_payload_variant); + + sendPlainHeartbeat(); + + TEST_ASSERT_TRUE(readOneFromRadio(msg)); + TEST_ASSERT_EQUAL_UINT_MESSAGE(meshtastic_FromRadio_queueStatus_tag, msg.which_payload_variant, + "heartbeat must be answered with a queueStatus before the dump continues"); + TEST_ASSERT_TRUE(readOneFromRadio(msg)); + TEST_ASSERT_EQUAL_UINT_MESSAGE(meshtastic_FromRadio_metadata_tag, msg.which_payload_variant, + "dump must resume exactly where the heartbeat preempted it"); + + DumpTranscript rest; + TEST_ASSERT_TRUE(drainUntilComplete(rest)); + TEST_ASSERT_EQUAL_UINT_MESSAGE(0, rest.queueStatusCount, "heartbeat flag must self-clear after one reply"); + TEST_ASSERT_EQUAL_UINT_MESSAGE(0, countVariant(rest, meshtastic_FromRadio_my_info_tag), + "heartbeat must not restart the dump"); + TEST_ASSERT_EQUAL_UINT32(FULL_DUMP_NONCE, rest.completeId); +} + +// Disconnect mid-dump, then a fresh handshake: the machine restarts from my_info with the new +// nonce and every section is delivered exactly once. +void test_close_mid_dump_then_reconnect_restarts_clean() +{ + seedRemoteNode(SEEDED_NODE_A); + startHandshake(FULL_DUMP_NONCE); + + meshtastic_FromRadio msg; + for (unsigned i = 0; i < 5; i++) + TEST_ASSERT_TRUE(readOneFromRadio(msg)); + + api->close(); + TEST_ASSERT_FALSE(api->isConnected()); + uint8_t buf[meshtastic_FromRadio_size]; + TEST_ASSERT_EQUAL_UINT_MESSAGE(0, api->getFromRadio(buf), "a closed connection must emit nothing"); + + startHandshake(SECOND_NONCE); + DumpTranscript t; + TEST_ASSERT_TRUE(drainUntilComplete(t)); + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_my_info_tag, t.variants[0]); + TEST_ASSERT_EQUAL_UINT((unsigned)MAX_NUM_CHANNELS, t.channelIndices.size()); + TEST_ASSERT_EQUAL_UINT(NUM_CONFIG_MESSAGES, t.configVariants.size()); + TEST_ASSERT_EQUAL_UINT(NUM_MODULE_CONFIG_MESSAGES, t.moduleConfigVariants.size()); + TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_config_complete_id_tag)); + TEST_ASSERT_EQUAL_UINT32(SECOND_NONCE, t.completeId); +} + +// A new want_config while a dump is in flight (no disconnect) also restarts the machine, and +// stale mid-section progress must not leak into the new dump. +void test_rehandshake_mid_dump_restarts_from_my_info() +{ + startHandshake(FULL_DUMP_NONCE); + + // Read into the middle of the config section (5 headers + 8 channels + 7 configs). + meshtastic_FromRadio msg; + for (unsigned i = 0; i < NUM_SINGLETON_PREFIX + MAX_NUM_CHANNELS + 7; i++) + TEST_ASSERT_TRUE(readOneFromRadio(msg)); + + startHandshake(SECOND_NONCE); + DumpTranscript t; + TEST_ASSERT_TRUE(drainUntilComplete(t)); + TEST_ASSERT_EQUAL_UINT_MESSAGE(meshtastic_FromRadio_my_info_tag, t.variants[0], "re-handshake must restart from my_info"); + TEST_ASSERT_EQUAL_UINT((unsigned)MAX_NUM_CHANNELS, t.channelIndices.size()); + for (unsigned i = 0; i < MAX_NUM_CHANNELS; i++) + TEST_ASSERT_EQUAL_INT((int)i, t.channelIndices[i]); + TEST_ASSERT_EQUAL_UINT_MESSAGE(NUM_CONFIG_MESSAGES, t.configVariants.size(), + "stale config_state leaked into the restarted dump"); + TEST_ASSERT_EQUAL_UINT(NUM_MODULE_CONFIG_MESSAGES, t.moduleConfigVariants.size()); + TEST_ASSERT_EQUAL_UINT32(SECOND_NONCE, t.completeId); +} + +// After config_complete_id the trailing satellite replay must reach idle in bounded reads - a +// drain loop keyed on available() must terminate (the infinite-drain regression class). +void test_dump_reaches_idle_after_complete() +{ + seedRemoteNode(SEEDED_NODE_A); + seedRemoteNode(SEEDED_NODE_B); + startHandshake(FULL_DUMP_NONCE); + + DumpTranscript t; + TEST_ASSERT_TRUE(drainUntilComplete(t)); + + uint8_t buf[meshtastic_FromRadio_size]; + bool idle = false; + for (unsigned i = 0; i < 8 && !idle; i++) { + if (!api->available()) + idle = true; + else + api->getFromRadio(buf); // replay drain: empty phases must advance toward idle + } + TEST_ASSERT_TRUE_MESSAGE(idle, "post-complete drain never went idle: available() stuck true"); + TEST_ASSERT_EQUAL_UINT(0, api->getFromRadio(buf)); +} + +} // namespace + +void setUp(void) +{ + savedState = + new GlobalState{service, router, nodeDB, cryptLock, myNodeInfo, channels, channelFile, config, moduleConfig, devicestate}; + + service = mockService = new MeshService(); + // A real boot starts with a zeroed nodeDatabase; in-process the global retains the previous + // test's vector (the decode callback appends, it does not clear), so reset it first. + nodeDatabase.version = 0; + nodeDatabase.nodes.clear(); + nodeDB = testNodeDB = new NodeDB(); + configureTestChannels(); + cryptLock = nullptr; // Router's ctor asserts this is unset before allocating its own. + router = testRouter = new TestRouter(); + api = new PhoneAPITestShim(); + heartbeatReceived = false; +} + +void tearDown(void) +{ + delete api; // dtor runs close(), which still needs the mock service installed + api = nullptr; + delete testRouter; // ~TestRouter() deletes the cryptLock its ctor allocated + testRouter = nullptr; + delete testNodeDB; + testNodeDB = nullptr; + delete mockService; + mockService = nullptr; + heartbeatReceived = false; + + service = savedState->service; + router = savedState->router; + nodeDB = savedState->nodeDB; + cryptLock = savedState->cryptLock; // ~TestRouter() nulled it; hand the saved router its own back + myNodeInfo = savedState->myNodeInfo; + channels = savedState->channels; + channelFile = savedState->channelFile; + config = savedState->config; + moduleConfig = savedState->moduleConfig; + devicestate = savedState->deviceState; + delete savedState; + savedState = nullptr; +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + + printf("\n=== want_config dump sequence ===\n"); + RUN_TEST(test_full_want_config_dump_emits_documented_sequence); + RUN_TEST(test_config_section_inner_variants_match_config_type_enum); + RUN_TEST(test_module_config_section_inner_variants_match_module_config_type_enum); + + printf("\n=== special nonces ===\n"); + RUN_TEST(test_only_nodes_nonce_sends_nodes_then_complete); + RUN_TEST(test_only_config_nonce_skips_other_nodeinfos); + + printf("\n=== preemption and restart ===\n"); + RUN_TEST(test_heartbeat_mid_dump_preempts_once_then_resumes); + RUN_TEST(test_close_mid_dump_then_reconnect_restarts_clean); + RUN_TEST(test_rehandshake_mid_dump_restarts_from_my_info); + RUN_TEST(test_dump_reaches_idle_after_complete); + + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_reliable_ack_matrix/test_main.cpp b/test/test_reliable_ack_matrix/test_main.cpp new file mode 100644 index 000000000..635b97903 --- /dev/null +++ b/test/test_reliable_ack_matrix/test_main.cpp @@ -0,0 +1,853 @@ +// ReliableRouter ACK/NAK decision matrix: which ACK or NAK sniffReceived() emits per inbound +// shape, retransmission bookkeeping, the #11502 implicit ACK for our own overheard opaque DM +// (Group 5b drives the real OPAQUE_RELAY_ONLY ingress path), and the pending-timer extensions. +// Harness copied from test_nexthop_routing (ReliableRouterTestShim + MockRoutingModule). + +#include "MeshTypes.h" // before TestUtil.h: provides NodeNum etc. +#include "TestUtil.h" +#include + +#include "airtime.h" +#include "configuration.h" +#include "gps/RTC.h" +#include "mesh/Channels.h" +#include "mesh/NodeDB.h" +#include "mesh/RadioInterface.h" +#include "mesh/ReliableRouter.h" +#include "mesh/Throttle.h" +#include "modules/RoutingModule.h" +#include +#include +#include +#include +#include +#include + +static constexpr NodeNum kLocalNode = 0x11111111; // last byte 0x11 +static constexpr NodeNum kRemoteNode = 0x22222222; +static constexpr NodeNum kThirdNode = 0x33333333; + +// --------------------------------------------------------------------------- +// MockNodeDB - inject sender records with a controlled public-key size, so the PKI_UNKNOWN_PUBKEY +// vs NO_CHANNEL discrimination in sniffReceived() can be driven per test. +// --------------------------------------------------------------------------- +class MockNodeDB : public NodeDB +{ + public: + void clearTestNodes() + { + testNodes.clear(); + meshNodes = &testNodes; + numMeshNodes = 0; + } + + void addNode(NodeNum num, uint8_t publicKeySize = 0) + { + meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero; + node.num = num; + node.last_heard = getTime(); + node.public_key.size = publicKeySize; + if (publicKeySize) + memset(node.public_key.bytes, 0x5C, publicKeySize); + nodeInfoLiteSetBit(&node, NODEINFO_BITFIELD_HAS_USER_MASK, true); + testNodes.push_back(node); + meshNodes = &testNodes; + numMeshNodes = testNodes.size(); + } + + std::vector testNodes; +}; + +// --------------------------------------------------------------------------- +// Test shim - expose the protected sniff/filter entry points and the pending/route-health state. +// --------------------------------------------------------------------------- +class ReliableRouterTestShim : public ReliableRouter +{ + public: + ReliableRouterTestShim() : ReliableRouter() {} + + using NextHopRouter::findRouteHealth; + using NextHopRouter::noteRouteFailure; + using NextHopRouter::noteRouteLearned; + + size_t pendingCount() const { return pending.size(); } + + void seedRetry(const meshtastic_MeshPacket &p, uint8_t attempts) + { + auto *copy = packetPool.allocCopy(p); + TEST_ASSERT_NOT_NULL(copy); + startRetransmission(copy, attempts); + } + + void sniffForTest(const meshtastic_MeshPacket *p, const meshtastic_Routing *routing) + { + ReliableRouter::sniffReceived(p, routing); + } + + bool filterForTest(const meshtastic_MeshPacket *p) { return ReliableRouter::shouldFilterReceived(p); } + + bool hasPending(NodeNum from, PacketId id) { return findPendingPacket(from, id) != nullptr; } + + uint32_t pendingNextTx(NodeNum from, PacketId id) + { + PendingPacket *entry = findPendingPacket(from, id); + TEST_ASSERT_NOT_NULL(entry); + return entry->nextTxMsec; + } + + const meshtastic_MeshPacket *pendingPacket(NodeNum from, PacketId id) + { + PendingPacket *entry = findPendingPacket(from, id); + TEST_ASSERT_NOT_NULL(entry); + return entry->packet; + } + + void clearPendingForTest() + { + while (!pending.empty()) + stopRetransmission(pending.begin()->first); + } + + void resetRouteHealthForTest() + { + for (auto &h : routeHealth) + h = RouteHealth{}; + } +}; + +// Capture radio with a configurable per-packet airtime, so the pending-timer extension loops +// (which are no-ops with a 0-returning stub) become observable. +class TimedCaptureRadio : public RadioInterface +{ + public: + ErrorCode send(meshtastic_MeshPacket *p) override + { + sentPackets.push_back(*p); + packetPool.release(p); + return ERRNO_OK; + } + + bool cancelSending(NodeNum from, PacketId id) override + { + (void)from; + (void)id; + cancelCount++; + return false; + } + + bool findInTxQueue(NodeNum from, PacketId id) override + { + (void)from; + (void)id; + return false; + } + + uint32_t getPacketTime(uint32_t totalPacketLen, bool received = false) override + { + (void)totalPacketLen; + (void)received; + return packetTimeMsec; + } + + void reset() + { + sentPackets.clear(); + cancelCount = 0; + packetTimeMsec = 0; + } + + std::vector sentPackets; + uint32_t cancelCount = 0; + uint32_t packetTimeMsec = 0; +}; + +class MockRoutingModule : public RoutingModule +{ + public: + void sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, uint8_t hopLimit = 0, + bool ackWantsAck = false) override + { + ackNaks.emplace_back(err, to, idFrom, chIndex, hopLimit, ackWantsAck); + } + + std::list> ackNaks; +}; + +class ScopedAirTimeFixture +{ + public: + ScopedAirTimeFixture() : previous(airTime) { airTime = &instance; } + ~ScopedAirTimeFixture() { airTime = previous; } + + private: + AirTime instance; + AirTime *previous; +}; + +static MockNodeDB *mockNodeDB = nullptr; +static ReliableRouterTestShim *reliableShim = nullptr; +static TimedCaptureRadio *radio = nullptr; +static MockRoutingModule *mockRoutingModule = nullptr; +static std::unique_ptr airTimeFixture; +static PacketId nextTestPacketId = 0x7A000000; + +// --------------------------------------------------------------------------- +// Packet builders +// --------------------------------------------------------------------------- + +static meshtastic_MeshPacket makeDecodedPacket(meshtastic_PortNum portnum, NodeNum from, NodeNum to, uint8_t channel, + bool wantAck = false) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = from; + p.to = to; + p.id = nextTestPacketId++; + p.channel = channel; + p.hop_start = 3; + p.hop_limit = 3; // hop_start == hop_limit -> getHopsAway() == 0 ("heard directly") + p.relay_node = 0x22; + p.next_hop = NO_NEXT_HOP_PREFERENCE; + p.want_ack = wantAck; + p.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = portnum; + return p; +} + +static meshtastic_MeshPacket makeEncryptedToUs(uint8_t channel, bool wantAck) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = kRemoteNode; + p.to = kLocalNode; + p.id = nextTestPacketId++; + p.channel = channel; + p.hop_start = 3; + p.hop_limit = 3; + p.relay_node = 0x22; + p.next_hop = NO_NEXT_HOP_PREFERENCE; + p.want_ack = wantAck; + p.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + p.encrypted.size = 32; + return p; +} + +static void expectSingleAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId id, ChannelIndex chIndex, uint8_t hopLimit, + bool ackWantsAck) +{ + TEST_ASSERT_EQUAL_UINT32(1, mockRoutingModule->ackNaks.size()); + const auto &ack = mockRoutingModule->ackNaks.front(); + TEST_ASSERT_EQUAL_INT(err, std::get<0>(ack)); + TEST_ASSERT_EQUAL_HEX32(to, std::get<1>(ack)); + TEST_ASSERT_EQUAL_HEX32(id, std::get<2>(ack)); + TEST_ASSERT_EQUAL_UINT8(chIndex, std::get<3>(ack)); + TEST_ASSERT_EQUAL_UINT8(hopLimit, std::get<4>(ack)); + TEST_ASSERT_EQUAL(ackWantsAck, std::get<5>(ack)); +} + +static void configureChannels() +{ + memset(&channelFile, 0, sizeof(channelFile)); + channelFile.channels_count = 2; + + meshtastic_Channel primary = meshtastic_Channel_init_default; + primary.index = 0; + primary.has_settings = true; + primary.role = meshtastic_Channel_Role_PRIMARY; + strncpy(primary.settings.name, "primary", sizeof(primary.settings.name) - 1); + + meshtastic_Channel secondary = meshtastic_Channel_init_default; + secondary.index = 1; + secondary.has_settings = true; + secondary.role = meshtastic_Channel_Role_SECONDARY; + strncpy(secondary.settings.name, "second", sizeof(secondary.settings.name) - 1); + secondary.settings.psk.size = 32; + memset(secondary.settings.psk.bytes, 0xAB, secondary.settings.psk.size); + + channelFile.channels[0] = primary; + channelFile.channels[1] = secondary; + channels.onConfigChanged(); +} + +void setUp(void) +{ + myNodeInfo.my_node_num = kLocalNode; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_ALL; + config.lora.override_duty_cycle = true; + config.lora.hop_limit = 3; // keep getHopLimitForResponse() deterministic across tests + config.security.private_key.size = 0; + owner.is_licensed = false; + // Keep our own key unset: the PKI_UNKNOWN_PUBKEY NAK handler dereferences nodeInfoModule (a null + // global here) only when owner.public_key.size == 32. + owner.public_key.size = 0; + mockNodeDB->clearTestNodes(); + reliableShim->clearPendingForTest(); + reliableShim->resetRouteHealthForTest(); + radio->reset(); + mockRoutingModule->ackNaks.clear(); + configureChannels(); +} + +void tearDown(void) {} + +// =========================================================================== +// Group 1 - want_ack ACK variants (decoded packets to us) +// =========================================================================== + +void test_text_dm_want_ack_gets_want_ack_ack(void) +{ + auto p = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/true); + uint8_t expectedHop = mockRoutingModule->getHopLimitForResponse(p); + TEST_ASSERT_NOT_EQUAL(0, expectedHop); // must be distinguishable from the 0-hop ACK branch + + reliableShim->sniffForTest(&p, nullptr); + + expectSingleAckNak(meshtastic_Routing_Error_NONE, kRemoteNode, p.id, 1, expectedHop, /*ackWantsAck=*/true); +} + +void test_text_reply_still_gets_want_ack_ack(void) +{ + // shouldSuccessAckWithWantAck() runs before the response branch, so a text DM that is itself a + // reply still gets the reliable want-ack ACK (not the 0-hop response treatment). + auto p = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/true); + p.decoded.reply_id = 0x1234; + uint8_t expectedHop = mockRoutingModule->getHopLimitForResponse(p); + + reliableShim->sniffForTest(&p, nullptr); + + expectSingleAckNak(meshtastic_Routing_Error_NONE, kRemoteNode, p.id, 1, expectedHop, /*ackWantsAck=*/true); +} + +void test_nontext_dm_want_ack_gets_plain_ack(void) +{ + auto p = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/true); + uint8_t expectedHop = mockRoutingModule->getHopLimitForResponse(p); + TEST_ASSERT_NOT_EQUAL(0, expectedHop); + + reliableShim->sniffForTest(&p, nullptr); + + expectSingleAckNak(meshtastic_Routing_Error_NONE, kRemoteNode, p.id, 1, expectedHop, /*ackWantsAck=*/false); +} + +void test_response_heard_directly_gets_zero_hop_ack(void) +{ + // A response (request_id set) heard at 0 hops: the original sender cannot overhear an implicit + // ACK, so we ACK - but only with hop limit 0. + auto p = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/true); + p.decoded.request_id = 0x4242; + + reliableShim->sniffForTest(&p, nullptr); + + expectSingleAckNak(meshtastic_Routing_Error_NONE, kRemoteNode, p.id, 1, /*hopLimit=*/0, /*ackWantsAck=*/false); +} + +void test_response_relayed_gets_no_ack(void) +{ + // A relayed response with no next-hop addressing already got its implicit ACK from the + // rebroadcast; ACKing again would only burn airtime. + auto p = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/true); + p.decoded.request_id = 0x4242; + p.hop_limit = 2; // hop_start 3 -> 1 hop away + + reliableShim->sniffForTest(&p, nullptr); + + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); +} + +void test_response_relayed_via_next_hop_gets_zero_hop_ack(void) +{ + // Relayed, but directed at a next_hop: the immediate relayer retransmits until stopped, so a + // 0-hop ACK is still required. + auto p = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/true); + p.decoded.request_id = 0x4242; + p.hop_limit = 2; + p.next_hop = 0x77; + + reliableShim->sniffForTest(&p, nullptr); + + expectSingleAckNak(meshtastic_Routing_Error_NONE, kRemoteNode, p.id, 1, /*hopLimit=*/0, /*ackWantsAck=*/false); +} + +void test_broadcast_want_ack_gets_no_ack(void) +{ + // 0-hop reliability is unicast-only: a want_ack broadcast is never ACKed (isToUs() is false). + auto p = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, NODENUM_BROADCAST, 0, /*wantAck=*/true); + + reliableShim->sniffForTest(&p, nullptr); + + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); +} + +// =========================================================================== +// Group 2 - undecodable want_ack NAKs (encrypted packets to us) +// =========================================================================== + +void test_pki_unknown_sender_gets_pki_unknown_pubkey_nak(void) +{ + // channel==0 + sender absent from NodeDB -> the PKI key-amnesia NAK, on the primary channel. + auto p = makeEncryptedToUs(/*channel=*/0, /*wantAck=*/true); + uint8_t expectedHop = mockRoutingModule->getHopLimitForResponse(p); + + reliableShim->sniffForTest(&p, nullptr); + + expectSingleAckNak(meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY, kRemoteNode, p.id, channels.getPrimaryIndex(), expectedHop, + /*ackWantsAck=*/false); +} + +void test_pki_keyless_sender_record_gets_pki_unknown_pubkey_nak(void) +{ + // The sender is in the DB but we hold no key for it - same NAK as a fully unknown node. + mockNodeDB->addNode(kRemoteNode, /*publicKeySize=*/0); + auto p = makeEncryptedToUs(/*channel=*/0, /*wantAck=*/true); + uint8_t expectedHop = mockRoutingModule->getHopLimitForResponse(p); + + reliableShim->sniffForTest(&p, nullptr); + + expectSingleAckNak(meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY, kRemoteNode, p.id, channels.getPrimaryIndex(), expectedHop, + /*ackWantsAck=*/false); +} + +void test_pki_known_key_sender_gets_no_channel_nak(void) +{ + // Discriminator: with the sender's key on hand an undecodable channel-0 want_ack packet is NOT a + // key problem, so it falls through to the generic NO_CHANNEL NAK. + mockNodeDB->addNode(kRemoteNode, /*publicKeySize=*/32); + auto p = makeEncryptedToUs(/*channel=*/0, /*wantAck=*/true); + uint8_t expectedHop = mockRoutingModule->getHopLimitForResponse(p); + + reliableShim->sniffForTest(&p, nullptr); + + expectSingleAckNak(meshtastic_Routing_Error_NO_CHANNEL, kRemoteNode, p.id, channels.getPrimaryIndex(), expectedHop, + /*ackWantsAck=*/false); +} + +void test_unknown_channel_hash_gets_no_channel_nak(void) +{ + // Nonzero channel hash we cannot decode -> NO_CHANNEL on the primary channel (not the hash). + auto p = makeEncryptedToUs(/*channel=*/0x5A, /*wantAck=*/true); + uint8_t expectedHop = mockRoutingModule->getHopLimitForResponse(p); + + reliableShim->sniffForTest(&p, nullptr); + + expectSingleAckNak(meshtastic_Routing_Error_NO_CHANNEL, kRemoteNode, p.id, channels.getPrimaryIndex(), expectedHop, + /*ackWantsAck=*/false); +} + +// =========================================================================== +// Group 3 - no want_ack, but we are the addressed next hop +// =========================================================================== + +void test_next_hop_addressed_to_us_gets_zero_hop_ack(void) +{ + // We were the addressed next hop: a 0-hop ACK stops the relayer's retransmissions even though + // the packet itself did not ask for an ACK. + auto p = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/false); + p.next_hop = 0x11; // our last byte + p.hop_limit = 1; + + reliableShim->sniffForTest(&p, nullptr); + + expectSingleAckNak(meshtastic_Routing_Error_NONE, kRemoteNode, p.id, 1, /*hopLimit=*/0, /*ackWantsAck=*/false); +} + +void test_next_hop_with_hop_limit_zero_gets_no_ack(void) +{ + auto p = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/false); + p.next_hop = 0x11; + p.hop_limit = 0; + + reliableShim->sniffForTest(&p, nullptr); + + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); +} + +void test_next_hop_other_byte_gets_no_ack(void) +{ + auto p = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/false); + p.next_hop = 0x22; // someone else's byte + p.hop_limit = 1; + + reliableShim->sniffForTest(&p, nullptr); + + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); +} + +// =========================================================================== +// Group 4 - explicit ACK/NAK vs pending retransmissions, MQTT gate, route health +// =========================================================================== + +void test_explicit_ack_stops_retransmissions_and_clears_route_failures(void) +{ + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + reliableShim->noteRouteLearned(kRemoteNode, 0xAB, millis()); + reliableShim->noteRouteFailure(kRemoteNode); + reliableShim->noteRouteFailure(kRemoteNode); + TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount()); + + auto ack = makeDecodedPacket(meshtastic_PortNum_ROUTING_APP, kRemoteNode, kLocalNode, 1); + ack.decoded.request_id = original.id; + meshtastic_Routing routing = meshtastic_Routing_init_zero; + routing.error_reason = meshtastic_Routing_Error_NONE; + + reliableShim->sniffForTest(&ack, &routing); + + TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); + // The end-to-end ACK proves the route to its sender works -> noteRouteSuccess clears failures. + RouteHealth *h = reliableShim->findRouteHealth(kRemoteNode); + TEST_ASSERT_NOT_NULL(h); + TEST_ASSERT_EQUAL_UINT8(0, h->consecutiveFailures); +} + +void test_nak_stops_retransmissions_but_keeps_route_failures(void) +{ + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + reliableShim->noteRouteLearned(kRemoteNode, 0xAB, millis()); + reliableShim->noteRouteFailure(kRemoteNode); + reliableShim->noteRouteFailure(kRemoteNode); + + auto nak = makeDecodedPacket(meshtastic_PortNum_ROUTING_APP, kRemoteNode, kLocalNode, 1); + nak.decoded.request_id = original.id; + meshtastic_Routing routing = meshtastic_Routing_init_zero; + routing.error_reason = meshtastic_Routing_Error_MAX_RETRANSMIT; + + reliableShim->sniffForTest(&nak, &routing); + + TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); + // A NAK is not a delivery success: the failure count must survive. + RouteHealth *h = reliableShim->findRouteHealth(kRemoteNode); + TEST_ASSERT_NOT_NULL(h); + TEST_ASSERT_EQUAL_UINT8(2, h->consecutiveFailures); +} + +void test_pki_unknown_pubkey_nak_stops_retransmissions(void) +{ + // The remote lost our key: its PKI_UNKNOWN_PUBKEY NAK must still clear the pending record. + // owner.public_key.size == 0 (setUp) keeps the NodeInfo re-send branch (a nodeInfoModule + // dereference, null in this harness) out of the path. + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + + auto nak = makeDecodedPacket(meshtastic_PortNum_ROUTING_APP, kRemoteNode, kLocalNode, 1); + nak.decoded.request_id = original.id; + meshtastic_Routing routing = meshtastic_Routing_init_zero; + routing.error_reason = meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY; + + reliableShim->sniffForTest(&nak, &routing); + + TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); +} + +void test_own_ack_echo_via_mqtt_keeps_retransmissions(void) +{ + // An implicit ACK that is our own traffic echoed back via MQTT must not stop LoRa retries. + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + + auto echo = makeDecodedPacket(meshtastic_PortNum_ROUTING_APP, kLocalNode, kLocalNode, 1); + echo.decoded.request_id = original.id; + echo.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT; + + reliableShim->sniffForTest(&echo, nullptr); + + TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount()); + TEST_ASSERT_TRUE(reliableShim->hasPending(kLocalNode, original.id)); +} + +void test_own_ack_echo_via_lora_stops_retransmissions(void) +{ + // Control for the MQTT gate: the identical from-us echo via LoRa does stop the retries. + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + + auto echo = makeDecodedPacket(meshtastic_PortNum_ROUTING_APP, kLocalNode, kLocalNode, 1); + echo.decoded.request_id = original.id; + echo.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + + reliableShim->sniffForTest(&echo, nullptr); + + TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); +} + +void test_remote_ack_via_mqtt_still_stops_retransmissions(void) +{ + // The gate is scoped to from-us echoes: a genuine end-to-end ACK arriving over MQTT counts. + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + + auto ack = makeDecodedPacket(meshtastic_PortNum_ROUTING_APP, kRemoteNode, kLocalNode, 1); + ack.decoded.request_id = original.id; + ack.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT; + meshtastic_Routing routing = meshtastic_Routing_init_zero; + routing.error_reason = meshtastic_Routing_Error_NONE; + + reliableShim->sniffForTest(&ack, &routing); + + TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); +} + +// =========================================================================== +// Group 5 - implicit ACK for our own overheard DM through shouldFilterReceived. This is the +// pre-existing route (a decodable copy still in encrypted wire form reaches it); the #11502 +// opaque short-circuit is exercised separately in Group 5b. +// =========================================================================== + +void test_overheard_own_dm_rebroadcast_mints_implicit_ack(void) +{ + // The implicit ACK is minted from the header alone (from/id), so this route must work on a + // still-encrypted packet, and the LoRa copy stops the retransmissions. + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + + meshtastic_MeshPacket overheard = meshtastic_MeshPacket_init_zero; + overheard.from = kLocalNode; + overheard.to = kRemoteNode; + overheard.id = original.id; + overheard.hop_start = 3; + overheard.hop_limit = 2; + overheard.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + overheard.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + overheard.encrypted.size = 32; + + reliableShim->filterForTest(&overheard); + + // ACK is addressed to us (so it reaches the phone) on the pending copy's channel. + expectSingleAckNak(meshtastic_Routing_Error_NONE, kLocalNode, original.id, 1, /*hopLimit=*/0, /*ackWantsAck=*/false); + TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); +} + +void test_overheard_own_dm_via_mqtt_acks_but_keeps_retransmissions(void) +{ + // The MQTT copy still surfaces "Delivered to mesh" but must not cancel the LoRa retries. + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + + meshtastic_MeshPacket overheard = meshtastic_MeshPacket_init_zero; + overheard.from = kLocalNode; + overheard.to = kRemoteNode; + overheard.id = original.id; + overheard.hop_start = 3; + overheard.hop_limit = 2; + overheard.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT; + overheard.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + overheard.encrypted.size = 32; + + reliableShim->filterForTest(&overheard); + + expectSingleAckNak(meshtastic_Routing_Error_NONE, kLocalNode, original.id, 1, /*hopLimit=*/0, /*ackWantsAck=*/false); + TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount()); +} + +void test_overheard_foreign_packet_mints_no_implicit_ack(void) +{ + // Someone else's traffic must never mint an ACK, even with a colliding packet id. + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + + meshtastic_MeshPacket foreign = meshtastic_MeshPacket_init_zero; + foreign.from = kRemoteNode; + foreign.to = kThirdNode; + foreign.id = original.id; + foreign.hop_start = 3; + foreign.hop_limit = 2; + foreign.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + foreign.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + foreign.encrypted.size = 32; + + reliableShim->filterForTest(&foreign); + + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); + TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount()); +} + +// =========================================================================== +// Group 5b - the real #11502 wiring: an overheard own DM under a channel hash we cannot decode +// (a PKI DM we sent) is OPAQUE_RELAY_ONLY in Router::perhapsHandleReceived and returns BEFORE +// shouldFilterReceived; the fix is the isFromUs branch there. Driven through the public ingress +// queue (enqueueReceivedMessage + runOnce), so deleting that branch fails these tests. +// =========================================================================== + +// An encrypted copy of our own DM under an unknown channel hash: not to us (no PKI attempt), no +// hash match -> DECODE_OPAQUE -> OPAQUE_RELAY_ONLY. hop_limit > 0 so the opaque relay does not +// short-circuit before the ACK branch. +static meshtastic_MeshPacket makeOpaqueOwnOverheard(PacketId id, meshtastic_MeshPacket_TransportMechanism transport) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = kLocalNode; + p.to = kRemoteNode; + p.id = id; + p.channel = 0x5A; + p.hop_start = 3; + p.hop_limit = 2; + p.transport_mechanism = transport; + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + p.encrypted.size = 32; + memset(p.encrypted.bytes, 0xC3, p.encrypted.size); + return p; +} + +static void ingressOverheard(const meshtastic_MeshPacket &p) +{ + meshtastic_MeshPacket *copy = packetPool.allocCopy(p); + TEST_ASSERT_NOT_NULL(copy); + reliableShim->enqueueReceivedMessage(copy); + reliableShim->runOnce(); +} + +void test_ingress_opaque_own_dm_lora_mints_implicit_ack_and_stops_retries(void) +{ + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + + ingressOverheard(makeOpaqueOwnOverheard(original.id, meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA)); + + expectSingleAckNak(meshtastic_Routing_Error_NONE, kLocalNode, original.id, 1, /*hopLimit=*/0, /*ackWantsAck=*/false); + TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); +} + +void test_ingress_opaque_own_dm_mqtt_acks_but_keeps_retries(void) +{ + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + + ingressOverheard(makeOpaqueOwnOverheard(original.id, meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT)); + + expectSingleAckNak(meshtastic_Routing_Error_NONE, kLocalNode, original.id, 1, /*hopLimit=*/0, /*ackWantsAck=*/false); + TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount()); +} + +void test_ingress_opaque_foreign_packet_mints_no_implicit_ack(void) +{ + // The isFromUs guard on the opaque branch: someone else's opaque traffic with a colliding id + // is relayed but never ACKed. + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + + auto foreign = makeOpaqueOwnOverheard(original.id, meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA); + foreign.from = kRemoteNode; + foreign.to = kThirdNode; + ingressOverheard(foreign); + + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); + TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount()); +} + +// =========================================================================== +// Group 6 - pending-timer airtime extension in send() and shouldFilterReceived() +// =========================================================================== + +void test_send_extends_other_pending_deadlines_not_own(void) +{ + // While we transmit packet B we cannot hear an (implicit) ACK for pending A, so A's deadline + // must move out by B's airtime. B's own fresh record must not be self-extended. + radio->packetTimeMsec = 50000; // dwarfs any real time elapsed inside the test + + auto a = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(a, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + uint32_t aBefore = reliableShim->pendingNextTx(kLocalNode, a.id); + + auto b = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, NODENUM_BROADCAST, 0, /*wantAck=*/true); + auto *allocated = packetPool.allocCopy(b); + TEST_ASSERT_NOT_NULL(allocated); + TEST_ASSERT_EQUAL_INT(ERRNO_OK, reliableShim->send(allocated)); + + TEST_ASSERT_EQUAL_UINT32(2, reliableShim->pendingCount()); + TEST_ASSERT_EQUAL_UINT32(aBefore + 50000, reliableShim->pendingNextTx(kLocalNode, a.id)); + + // B's deadline is millis-at-set + getRetransmissionMsec(B); a self-extension would push it a + // further 50s out, past anything the wall clock could account for. + uint32_t bTx = reliableShim->pendingNextTx(kLocalNode, b.id); + uint32_t retrans = radio->getRetransmissionMsec(reliableShim->pendingPacket(kLocalNode, b.id)); + // Via Throttle rather than a bare millis() compare, per the house deadline rule. + TEST_ASSERT_TRUE_MESSAGE(Throttle::deadlinePassed(bTx - retrans), "own record must not be extended by its own send"); +} + +void test_receive_extends_all_pending_deadlines(void) +{ + // While receiving any packet we cannot hear an ACK either: every pending deadline moves out by + // the received packet's airtime. + radio->packetTimeMsec = 40000; + + auto a = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(a, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + auto b = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kThirdNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(b, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + uint32_t aBefore = reliableShim->pendingNextTx(kLocalNode, a.id); + uint32_t bBefore = reliableShim->pendingNextTx(kLocalNode, b.id); + + auto inbound = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/false); + reliableShim->filterForTest(&inbound); + + TEST_ASSERT_EQUAL_UINT32(aBefore + 40000, reliableShim->pendingNextTx(kLocalNode, a.id)); + TEST_ASSERT_EQUAL_UINT32(bBefore + 40000, reliableShim->pendingNextTx(kLocalNode, b.id)); +} + +// =========================================================================== + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + + airTimeFixture = std::make_unique(); + mockNodeDB = new MockNodeDB(); + nodeDB = mockNodeDB; + reliableShim = new ReliableRouterTestShim(); + + auto capture = std::make_unique(); + radio = capture.get(); + reliableShim->addInterface(std::move(capture)); + + mockRoutingModule = new MockRoutingModule(); + routingModule = mockRoutingModule; + + printf("\n=== want_ack ACK variants ===\n"); + RUN_TEST(test_text_dm_want_ack_gets_want_ack_ack); + RUN_TEST(test_text_reply_still_gets_want_ack_ack); + RUN_TEST(test_nontext_dm_want_ack_gets_plain_ack); + RUN_TEST(test_response_heard_directly_gets_zero_hop_ack); + RUN_TEST(test_response_relayed_gets_no_ack); + RUN_TEST(test_response_relayed_via_next_hop_gets_zero_hop_ack); + RUN_TEST(test_broadcast_want_ack_gets_no_ack); + + printf("\n=== undecodable want_ack NAKs ===\n"); + RUN_TEST(test_pki_unknown_sender_gets_pki_unknown_pubkey_nak); + RUN_TEST(test_pki_keyless_sender_record_gets_pki_unknown_pubkey_nak); + RUN_TEST(test_pki_known_key_sender_gets_no_channel_nak); + RUN_TEST(test_unknown_channel_hash_gets_no_channel_nak); + + printf("\n=== next-hop 0-hop ACK without want_ack ===\n"); + RUN_TEST(test_next_hop_addressed_to_us_gets_zero_hop_ack); + RUN_TEST(test_next_hop_with_hop_limit_zero_gets_no_ack); + RUN_TEST(test_next_hop_other_byte_gets_no_ack); + + printf("\n=== ACK/NAK vs pending retransmissions ===\n"); + RUN_TEST(test_explicit_ack_stops_retransmissions_and_clears_route_failures); + RUN_TEST(test_nak_stops_retransmissions_but_keeps_route_failures); + RUN_TEST(test_pki_unknown_pubkey_nak_stops_retransmissions); + RUN_TEST(test_own_ack_echo_via_mqtt_keeps_retransmissions); + RUN_TEST(test_own_ack_echo_via_lora_stops_retransmissions); + RUN_TEST(test_remote_ack_via_mqtt_still_stops_retransmissions); + + printf("\n=== implicit ACK for our own overheard DM ===\n"); + RUN_TEST(test_overheard_own_dm_rebroadcast_mints_implicit_ack); + RUN_TEST(test_overheard_own_dm_via_mqtt_acks_but_keeps_retransmissions); + RUN_TEST(test_overheard_foreign_packet_mints_no_implicit_ack); + + printf("\n=== implicit ACK through the opaque ingress short-circuit (#11502) ===\n"); + RUN_TEST(test_ingress_opaque_own_dm_lora_mints_implicit_ack_and_stops_retries); + RUN_TEST(test_ingress_opaque_own_dm_mqtt_acks_but_keeps_retries); + RUN_TEST(test_ingress_opaque_foreign_packet_mints_no_implicit_ack); + + printf("\n=== pending-timer airtime extension ===\n"); + RUN_TEST(test_send_extends_other_pending_deadlines_not_own); + RUN_TEST(test_receive_extends_all_pending_deadlines); + + int result = UNITY_END(); + airTimeFixture.reset(); + exit(result); +} + +void loop() {} diff --git a/test/test_routing_response_hops/test_main.cpp b/test/test_routing_response_hops/test_main.cpp new file mode 100644 index 000000000..53bacd979 --- /dev/null +++ b/test/test_routing_response_hops/test_main.cpp @@ -0,0 +1,273 @@ +// RoutingModule::getHopLimitForResponse - the hop budget stamped on every reply/ACK/NAK - and +// MeshModule::setReplyTo() applying it, driven through getHopsAway()'s sentinel rules. + +#include "MeshTypes.h" // before TestUtil.h: provides NodeNum etc. +#include "TestUtil.h" +#include // exit(), needed on both guard branches +#include + +// Event mode compiles out the uncapped long-path branch and swaps the configured limit for the +// event hop limit; this suite pins the standard-mode branches only (the event cap is covered by +// test_default's event-mode group). +#if !USERPREFS_EVENT_MODE + +#include "configuration.h" +#include "mesh/MeshModule.h" +#include "mesh/NodeDB.h" +#include "modules/RoutingModule.h" +#include + +static constexpr NodeNum kRequester = 0x22222222; + +static RoutingModule *testRoutingModule = nullptr; + +// A received request packet whose hop fields we control. Decoded packets carry the bitfield flag +// that getHopsAway() uses to decide whether hop_start==0 is genuine or a legacy-firmware zero. +static meshtastic_MeshPacket makeRequest(uint8_t hopStart, uint8_t hopLimit, bool decoded = true, bool hasBitfield = true) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = kRequester; + p.to = 0x11111111; + p.id = 0xABCD1234; + p.hop_start = hopStart; + p.hop_limit = hopLimit; + if (decoded) { + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.has_bitfield = hasBitfield; + } else { + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + p.encrypted.size = 8; + } + return p; +} + +static meshtastic_MeshPacket makeReply() +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + return p; +} + +void setUp(void) +{ + config.lora.hop_limit = 3; +} + +void tearDown(void) {} + +// =========================================================================== +// Group 1 - unknown hop distance: every unreliable-header shape must fall back +// to the configured limit, never to a value derived from the bogus fields. +// =========================================================================== + +void test_encrypted_hop_start_zero_falls_back_to_configured_limit(void) +{ + // Encrypted packet: the bitfield is unreadable, so hop_start==0 cannot be trusted. + auto request = makeRequest(0, 0, /*decoded=*/false); + TEST_ASSERT_EQUAL_UINT8(3, testRoutingModule->getHopLimitForResponse(request)); +} + +void test_decoded_legacy_no_bitfield_falls_back_to_configured_limit(void) +{ + // Pre-2.3.0 senders never populate hop_start and pre-2.5.0 senders never set the bitfield. + auto request = makeRequest(0, 0, /*decoded=*/true, /*hasBitfield=*/false); + TEST_ASSERT_EQUAL_UINT8(3, testRoutingModule->getHopLimitForResponse(request)); +} + +void test_forged_hop_start_below_hop_limit_falls_back_to_configured_limit(void) +{ + // hop_start < hop_limit is impossible for an honest sender; getHopsAway() rejects it. + auto request = makeRequest(2, 5); + TEST_ASSERT_EQUAL_UINT8(3, testRoutingModule->getHopLimitForResponse(request)); +} + +void test_hostile_hop_start_wraps_negative_falls_back_to_configured_limit(void) +{ + // hop_start is 3 bits on the wire but 8 bits via local injection: 255 - 0 narrows to + // int8_t -1 in getHopsAway(), which lands in the same "unknown" fallback (any + // hop_start - hop_limit >= 128 reads as negative). + auto request = makeRequest(255, 0); + TEST_ASSERT_EQUAL_UINT8(3, testRoutingModule->getHopLimitForResponse(request)); +} + +// =========================================================================== +// Group 2 - known hop distance: hopsUsed + 2 margin, its clamp boundary, and +// the intentionally uncapped long-path branch. +// =========================================================================== + +void test_direct_neighbor_response_gets_two_hop_margin(void) +{ + config.lora.hop_limit = 7; + auto request = makeRequest(3, 3); // 0 hops used + TEST_ASSERT_EQUAL_UINT8(2, testRoutingModule->getHopLimitForResponse(request)); +} + +void test_two_hops_used_gets_margin_of_two(void) +{ + config.lora.hop_limit = 7; + auto request = makeRequest(3, 1); // 2 hops used + TEST_ASSERT_EQUAL_UINT8(4, testRoutingModule->getHopLimitForResponse(request)); +} + +void test_margin_just_below_boundary_still_applies(void) +{ + config.lora.hop_limit = 7; + auto request = makeRequest(7, 3); // 4 hops used: 4 + 2 = 6 < 7 + TEST_ASSERT_EQUAL_UINT8(6, testRoutingModule->getHopLimitForResponse(request)); +} + +void test_margin_at_boundary_clamps_to_configured_limit(void) +{ + config.lora.hop_limit = 7; + auto request = makeRequest(7, 2); // 5 hops used: 5 + 2 == 7, not < 7 -> clamp + TEST_ASSERT_EQUAL_UINT8(7, testRoutingModule->getHopLimitForResponse(request)); +} + +void test_hops_equal_to_limit_returns_limit(void) +{ + config.lora.hop_limit = 7; + auto request = makeRequest(7, 0); // 7 hops used == limit: not "more than", no margin room + TEST_ASSERT_EQUAL_UINT8(7, testRoutingModule->getHopLimitForResponse(request)); +} + +void test_long_path_exceeds_configured_limit_uncapped(void) +{ + // Intentional exceed: a request that took more hops than our configured limit gets a + // response with the same hop count, otherwise the reply dies short of the requester. + auto request = makeRequest(7, 0); // 7 hops used, configured limit 3 + TEST_ASSERT_EQUAL_UINT8(7, testRoutingModule->getHopLimitForResponse(request)); +} + +// =========================================================================== +// Group 3 - zero-hop requester +// =========================================================================== + +void test_zero_hop_requester_gets_zero_hop_response(void) +{ + // hop_start==0 with the bitfield present is a genuine "0 hops requested": the sender is + // modern firmware that deliberately sent direct-only, so the response stays local too. + auto request = makeRequest(0, 0, /*decoded=*/true, /*hasBitfield=*/true); + TEST_ASSERT_EQUAL_UINT8(0, testRoutingModule->getHopLimitForResponse(request)); +} + +// =========================================================================== +// Group 4 - configured-limit edges through Default::getConfiguredOrDefaultHopLimit +// =========================================================================== + +void test_config_above_hop_max_clamps_to_hop_max(void) +{ + config.lora.hop_limit = 10; // out-of-range config (protobuf allows up to 255) + auto request = makeRequest(0, 0, /*decoded=*/false); + TEST_ASSERT_EQUAL_UINT8(HOP_MAX, testRoutingModule->getHopLimitForResponse(request)); +} + +void test_config_zero_yields_zero_for_unknown_hops(void) +{ + // Pins current behavior: getConfiguredOrDefaultHopLimit(0) passes the zero through (no + // default substitution), so an unknown-distance requester gets a 0-hop response. + config.lora.hop_limit = 0; + auto request = makeRequest(0, 0, /*decoded=*/false); + TEST_ASSERT_EQUAL_UINT8(0, testRoutingModule->getHopLimitForResponse(request)); +} + +void test_config_zero_known_hops_returns_hops_used(void) +{ + // With a zero configured limit, any known hop count is "more than the limit" and is used + // as-is - a zero config does not strand replies to multi-hop requesters. + config.lora.hop_limit = 0; + auto request = makeRequest(3, 1); // 2 hops used + TEST_ASSERT_EQUAL_UINT8(2, testRoutingModule->getHopLimitForResponse(request)); +} + +// =========================================================================== +// Group 5 - setReplyTo() stamps the computed hop limit onto reply packets +// =========================================================================== + +void test_setreplyto_stamps_computed_hop_limit_and_reply_fields(void) +{ + config.lora.hop_limit = 7; + auto request = makeRequest(3, 1); // 2 hops used -> response hop limit 4 + request.channel = 2; + request.want_ack = true; + + auto reply = makeReply(); + setReplyTo(&reply, request); + + TEST_ASSERT_EQUAL_HEX32(kRequester, reply.to); + TEST_ASSERT_EQUAL_UINT8(2, reply.channel); + TEST_ASSERT_EQUAL_UINT8(4, reply.hop_limit); + TEST_ASSERT_TRUE(reply.want_ack); + TEST_ASSERT_EQUAL_HEX32(request.id, reply.decoded.request_id); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_Priority_RELIABLE, reply.priority); +} + +void test_setreplyto_preserves_existing_priority(void) +{ + auto request = makeRequest(0, 0, /*decoded=*/false); // unknown hops -> configured limit 3 + request.want_ack = false; + + auto reply = makeReply(); + reply.priority = meshtastic_MeshPacket_Priority_ACK; + setReplyTo(&reply, request); + + TEST_ASSERT_EQUAL_UINT8(3, reply.hop_limit); + TEST_ASSERT_FALSE(reply.want_ack); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_Priority_ACK, reply.priority); +} + +// =========================================================================== + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + + testRoutingModule = new RoutingModule(); + routingModule = testRoutingModule; // setReplyTo() reaches the module through the global + + printf("\n=== unknown hop distance falls back to configured limit ===\n"); + RUN_TEST(test_encrypted_hop_start_zero_falls_back_to_configured_limit); + RUN_TEST(test_decoded_legacy_no_bitfield_falls_back_to_configured_limit); + RUN_TEST(test_forged_hop_start_below_hop_limit_falls_back_to_configured_limit); + RUN_TEST(test_hostile_hop_start_wraps_negative_falls_back_to_configured_limit); + + printf("\n=== known hop distance: margin, clamp, uncapped long path ===\n"); + RUN_TEST(test_direct_neighbor_response_gets_two_hop_margin); + RUN_TEST(test_two_hops_used_gets_margin_of_two); + RUN_TEST(test_margin_just_below_boundary_still_applies); + RUN_TEST(test_margin_at_boundary_clamps_to_configured_limit); + RUN_TEST(test_hops_equal_to_limit_returns_limit); + RUN_TEST(test_long_path_exceeds_configured_limit_uncapped); + + printf("\n=== zero-hop requester ===\n"); + RUN_TEST(test_zero_hop_requester_gets_zero_hop_response); + + printf("\n=== configured-limit edges ===\n"); + RUN_TEST(test_config_above_hop_max_clamps_to_hop_max); + RUN_TEST(test_config_zero_yields_zero_for_unknown_hops); + RUN_TEST(test_config_zero_known_hops_returns_hops_used); + + printf("\n=== setReplyTo integration ===\n"); + RUN_TEST(test_setreplyto_stamps_computed_hop_limit_and_reply_fields); + RUN_TEST(test_setreplyto_preserves_existing_priority); + + exit(UNITY_END()); +} + +void loop() {} + +#else // USERPREFS_EVENT_MODE + +void setUp(void) {} +void tearDown(void) {} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + exit(UNITY_END()); +} + +void loop() {} + +#endif diff --git a/test/test_rtc/test_main.cpp b/test/test_rtc/test_main.cpp index 02cad01c4..c03358d0a 100644 --- a/test/test_rtc/test_main.cpp +++ b/test/test_rtc/test_main.cpp @@ -1,5 +1,7 @@ #include "TestUtil.h" +#include "UptimeClock.h" #include "gps/RTC.h" +#include #include #include #include @@ -16,12 +18,51 @@ static const uint32_t kAllowedDriftSeconds = 2; static const time_t kUptimeSeconds = 21; // what gettimeofday() returns on RP2040 without a real clock +// Mirrors FORTY_YEARS in RTC.h, which is only visible when BUILD_EPOCH is defined. BUILD_EPOCH is +// injected by bin/platformio-custom.py into the src/ build (projenv) but not into test sources, so +// this TU cannot #ifdef on it; the bounds tests below probe for it at runtime instead. +static const uint64_t kFortyYears = 40ULL * 365 * SEC_PER_DAY; + +#define MSG_BUF_LEN 200 +#define TEST_MSG_FMT(fmt, ...) \ + do { \ + char _buf[MSG_BUF_LEN]; \ + snprintf(_buf, sizeof(_buf), fmt, __VA_ARGS__); \ + TEST_MESSAGE(_buf); \ + } while (0) + // A clearly-valid wall-clock epoch, safely inside any BUILD_EPOCH validity window. static time_t makeValidEpoch() { return time(NULL) + SEC_PER_DAY; } +static struct timeval makeTv(time_t secs) +{ + struct timeval tv; + tv.tv_sec = secs; + tv.tv_usec = 0; + return tv; +} + +// Freeze the injected uptime clock at baseMs. perhapsSetRTC() anchors timeStartMs64 at the fake +// "now", so while the clock is frozen getTime() returns the applied epoch exactly - no drift +// tolerance needed. Reset the wrap carry first: a prior test may have published a larger instant, +// and stepping the clock backwards past a published snapshot reads as a ~49.7-day wrap. +static void beginFakeClock(uint32_t baseMs) +{ + Time::resetMonotonicForTests(); + Time::setTestMillis(baseMs); + Time::serviceMonotonic(); +} + +// Step the injected clock the way the firmware does: every advance is followed by a publish. +static void advanceFakeClock(uint32_t deltaMs) +{ + Time::advanceTestMillis(deltaMs); + Time::serviceMonotonic(); +} + void setUp(void) { resetRTCStateForTests(); @@ -29,6 +70,8 @@ void setUp(void) void tearDown(void) { + Time::useRealClock(); // don't leak the fake clock into later tests or other suites + Time::resetMonotonicForTests(); resetRTCStateForTests(); } @@ -68,6 +111,316 @@ static void test_readFromRTC_initializes_time_when_no_better_source(void) TEST_ASSERT_UINT32_WITHIN(kAllowedDriftSeconds, (uint32_t)systemEpoch, getTime()); } +// --- perhapsSetRTC(timeval) quality arbitration --- + +// FromNet/Device sources are always rejected below a higher quality, and the rejection must +// leave quality and the running clock untouched (the #9828 mesh-time-poisoning family). NTP +// below GPS is rejected only while the 30-min drift throttle (stamped by the GPS set) is live; +// after it expires, NTP deliberately replaces even GPS-quality time (RTC.cpp drift-correction +// branch) - both halves are pinned here. +static void test_downgrade_rejected_state_untouched(void) +{ + beginFakeClock(60 * 1000); + const time_t gpsEpoch = makeValidEpoch(); + struct timeval tv = makeTv(gpsEpoch); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, &tv)); + TEST_ASSERT_EQUAL_INT(RTCQualityGPS, getRTCQuality()); + TEST_ASSERT_EQUAL_UINT32((uint32_t)gpsEpoch, getTime()); + + struct timeval poison = makeTv(gpsEpoch + 777); + TEST_ASSERT_EQUAL_INT(RTCSetResultNotSet, perhapsSetRTC(RTCQualityFromNet, &poison)); + TEST_ASSERT_EQUAL_INT(RTCSetResultNotSet, perhapsSetRTC(RTCQualityDevice, &poison)); + // NTP below GPS: within 30 minutes of the GPS set (which stamped the drift throttle), rejected. + TEST_ASSERT_EQUAL_INT(RTCSetResultNotSet, perhapsSetRTC(RTCQualityNTP, &poison)); + TEST_ASSERT_EQUAL_INT(RTCQualityGPS, getRTCQuality()); + + // Time still tracks the GPS epoch, not the rejected one. + advanceFakeClock(5 * 1000); + TEST_ASSERT_EQUAL_UINT32((uint32_t)gpsEpoch + 5, getTime()); + + // Once the drift throttle expires, NTP replaces GPS-quality time on purpose (drift + // correction), while FromNet/Device stay rejected: the throttle escape is NTP-only. + advanceFakeClock(31 * 60 * 1000); + struct timeval stillPoison = makeTv(gpsEpoch + 555); + TEST_ASSERT_EQUAL_INT(RTCSetResultNotSet, perhapsSetRTC(RTCQualityFromNet, &stillPoison)); + TEST_ASSERT_EQUAL_INT(RTCSetResultNotSet, perhapsSetRTC(RTCQualityDevice, &stillPoison)); + TEST_ASSERT_EQUAL_INT(RTCQualityGPS, getRTCQuality()); + struct timeval drift = makeTv(gpsEpoch + 999); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityNTP, &drift)); + TEST_ASSERT_EQUAL_INT(RTCQualityNTP, getRTCQuality()); + TEST_ASSERT_EQUAL_UINT32((uint32_t)gpsEpoch + 999, getTime()); +} + +// Equal-quality FromNet has no reapply branch: the second set is ignored. +static void test_equal_quality_fromnet_is_not_reapplied(void) +{ + beginFakeClock(60 * 1000); + const time_t firstEpoch = makeValidEpoch(); + struct timeval tv = makeTv(firstEpoch); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityFromNet, &tv)); + + struct timeval second = makeTv(firstEpoch + 500); + TEST_ASSERT_EQUAL_INT(RTCSetResultNotSet, perhapsSetRTC(RTCQualityFromNet, &second)); + TEST_ASSERT_EQUAL_INT(RTCQualityFromNet, getRTCQuality()); + TEST_ASSERT_EQUAL_UINT32((uint32_t)firstEpoch, getTime()); +} + +// Our own GPS is authoritative: a GPS-quality set is always applied, with no throttle. +static void test_gps_reapply_always_accepted(void) +{ + beginFakeClock(60 * 1000); + const time_t firstEpoch = makeValidEpoch(); + struct timeval tv = makeTv(firstEpoch); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, &tv)); + + struct timeval second = makeTv(firstEpoch + 123); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, &second)); + TEST_ASSERT_EQUAL_INT(RTCQualityGPS, getRTCQuality()); + TEST_ASSERT_EQUAL_UINT32((uint32_t)firstEpoch + 123, getTime()); +} + +// Equal-quality NTP reapplies only after the 30-minute drift-correction throttle. +static void test_ntp_drift_throttle(void) +{ + beginFakeClock(120 * 1000); + const time_t firstEpoch = makeValidEpoch(); + struct timeval tv = makeTv(firstEpoch); + // The upgrade from None stamps the (function-static, not reset by resetRTCStateForTests) + // throttle timestamp at a known fake instant, keeping this test order-independent. + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityNTP, &tv)); + + advanceFakeClock(10 * 60 * 1000); // +10 min: still inside the throttle window + struct timeval second = makeTv(firstEpoch + 900); + TEST_ASSERT_EQUAL_INT(RTCSetResultNotSet, perhapsSetRTC(RTCQualityNTP, &second)); + TEST_ASSERT_EQUAL_UINT32((uint32_t)firstEpoch + 600, getTime()); + + advanceFakeClock(21 * 60 * 1000); // total +31 min: past the window + struct timeval third = makeTv(firstEpoch + 2000); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityNTP, &third)); + TEST_ASSERT_EQUAL_INT(RTCQualityNTP, getRTCQuality()); + TEST_ASSERT_EQUAL_UINT32((uint32_t)firstEpoch + 2000, getTime()); +} + +// forceUpdate applies the incoming time even when it is a quality downgrade - the T-Watch +// RTC-pause workaround depends on this override. +static void test_force_update_overrides_downgrade(void) +{ + beginFakeClock(60 * 1000); + const time_t gpsEpoch = makeValidEpoch(); + struct timeval tv = makeTv(gpsEpoch); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, &tv)); + + struct timeval forced = makeTv(gpsEpoch + 42); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityDevice, &forced, true)); + TEST_ASSERT_EQUAL_INT(RTCQualityDevice, getRTCQuality()); + TEST_ASSERT_EQUAL_UINT32((uint32_t)gpsEpoch + 42, getTime()); +} + +// The BUILD_EPOCH validity window rejects implausible epochs before quality arbitration - even +// with forceUpdate - and leaves state untouched. BUILD_EPOCH is not visible to this TU (see +// kFortyYears above), so probe at runtime whether RTC.cpp was built with the window enabled. +static void test_build_epoch_bounds_rejected(void) +{ + beginFakeClock(60 * 1000); + const time_t gpsEpoch = makeValidEpoch(); + struct timeval tv = makeTv(gpsEpoch); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, &tv)); + + struct timeval ancient = makeTv(1000000); // Jan 1970: below any plausible build epoch + RTCSetResult probe = perhapsSetRTC(RTCQualityGPS, &ancient); + if (probe == RTCSetResultSuccess) { + TEST_IGNORE_MESSAGE("BUILD_EPOCH not defined in the RTC.cpp build; validity window inactive"); + } + TEST_ASSERT_EQUAL_INT(RTCSetResultInvalidTime, probe); + TEST_ASSERT_EQUAL_INT(RTCQualityGPS, getRTCQuality()); + TEST_ASSERT_EQUAL_UINT32((uint32_t)gpsEpoch, getTime()); + + // BUILD_EPOCH <= time(NULL) at run time, so this is strictly beyond BUILD_EPOCH + FORTY_YEARS. + struct timeval far = makeTv((time_t)((uint64_t)time(NULL) + kFortyYears + 2 * SEC_PER_DAY)); + TEST_ASSERT_EQUAL_INT(RTCSetResultInvalidTime, perhapsSetRTC(RTCQualityGPS, &far)); + + // The window is checked before the forceUpdate override: force cannot smuggle in garbage. + TEST_ASSERT_EQUAL_INT(RTCSetResultInvalidTime, perhapsSetRTC(RTCQualityGPS, &ancient, true)); + TEST_ASSERT_EQUAL_INT(RTCQualityGPS, getRTCQuality()); + TEST_ASSERT_EQUAL_UINT32((uint32_t)gpsEpoch, getTime()); +} + +// --- perhapsSetRTC(tm) overload --- + +// The tm overload converts via gm_mktime and lands on the timeval path: a valid broken-down UTC +// time round-trips to the exact epoch (host gmtime() is the independent inverse). +static void test_tm_overload_roundtrip(void) +{ + beginFakeClock(60 * 1000); + const time_t epoch = makeValidEpoch(); + struct tm t = *gmtime(&epoch); + + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, t)); + TEST_ASSERT_EQUAL_INT(RTCQualityGPS, getRTCQuality()); + TEST_ASSERT_EQUAL_UINT32((uint32_t)epoch, getTime()); +} + +// Implausible years are rejected with state untouched. On BUILD_EPOCH builds the validity window +// fires first, on windowless builds the tm_year guard (<0 or >=300) does; either way the caller +// must see RTCSetResultInvalidTime. +static void test_tm_overload_year_guard(void) +{ + beginFakeClock(60 * 1000); + const time_t gpsEpoch = makeValidEpoch(); + struct timeval tv = makeTv(gpsEpoch); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, &tv)); + + struct tm farFuture = {}; + farFuture.tm_year = 300; // year 2200 + farFuture.tm_mon = 5; + farFuture.tm_mday = 15; + TEST_ASSERT_EQUAL_INT(RTCSetResultInvalidTime, perhapsSetRTC(RTCQualityGPS, farFuture)); + + struct tm preEpoch = {}; + preEpoch.tm_year = -5; // year 1895 + preEpoch.tm_mon = 0; + preEpoch.tm_mday = 1; + TEST_ASSERT_EQUAL_INT(RTCSetResultInvalidTime, perhapsSetRTC(RTCQualityGPS, preEpoch)); + + TEST_ASSERT_EQUAL_INT(RTCQualityGPS, getRTCQuality()); + TEST_ASSERT_EQUAL_UINT32((uint32_t)gpsEpoch, getTime()); +} + +// --- getValidTime() threshold gating --- + +static void test_getvalidtime_threshold_gating(void) +{ + beginFakeClock(60 * 1000); + TEST_ASSERT_EQUAL_UINT32(0, getValidTime(RTCQualityDevice)); + TEST_ASSERT_EQUAL_UINT32(0, getValidTime(RTCQualityFromNet)); + + const time_t netEpoch = makeValidEpoch(); + struct timeval tv = makeTv(netEpoch); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityFromNet, &tv)); + TEST_ASSERT_EQUAL_UINT32((uint32_t)netEpoch, getValidTime(RTCQualityFromNet)); + TEST_ASSERT_EQUAL_UINT32((uint32_t)netEpoch, getValidTime(RTCQualityDevice)); // at-or-below passes + TEST_ASSERT_EQUAL_UINT32(0, getValidTime(RTCQualityNTP)); + TEST_ASSERT_EQUAL_UINT32(0, getValidTime(RTCQualityGPS)); + + struct timeval gps = makeTv(netEpoch + 60); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, &gps)); + TEST_ASSERT_EQUAL_UINT32((uint32_t)netEpoch + 60, getValidTime(RTCQualityGPS)); + TEST_ASSERT_EQUAL_UINT32((uint32_t)netEpoch + 60, getValidTime(RTCQualityNTP)); +} + +// --- lastSetFromPhoneNtpOrGps stamp --- + +// Stamped only for quality >= NTP: this is the input PositionModule::hasQualityTimesource() uses +// to gate mesh-time acceptance, so a FromNet or Device set must never refresh it. +static void test_lastSetFromPhoneNtpOrGps_stamp(void) +{ + beginFakeClock(200 * 1000); + TEST_ASSERT_EQUAL_UINT32(0, lastSetFromPhoneNtpOrGps); + + const time_t epoch = makeValidEpoch(); + struct timeval tv = makeTv(epoch); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityFromNet, &tv)); + TEST_ASSERT_EQUAL_UINT32(0, lastSetFromPhoneNtpOrGps); // FromNet does not stamp + + advanceFakeClock(1000); + struct timeval ntp = makeTv(epoch + 1); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityNTP, &ntp)); + TEST_ASSERT_EQUAL_UINT32(201 * 1000, lastSetFromPhoneNtpOrGps); + + advanceFakeClock(2000); + struct timeval net = makeTv(epoch + 3); + TEST_ASSERT_EQUAL_INT(RTCSetResultNotSet, perhapsSetRTC(RTCQualityFromNet, &net)); + TEST_ASSERT_EQUAL_UINT32(201 * 1000, lastSetFromPhoneNtpOrGps); // rejection leaves the stamp + + advanceFakeClock(3000); + struct timeval gps = makeTv(epoch + 6); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, &gps)); + TEST_ASSERT_EQUAL_UINT32(206 * 1000, lastSetFromPhoneNtpOrGps); + + // Device-quality set from a clean slate: applied, but still no stamp. + resetRTCStateForTests(); + struct timeval dev = makeTv(epoch + 9); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityDevice, &dev)); + TEST_ASSERT_EQUAL_UINT32(0, lastSetFromPhoneNtpOrGps); +} + +// --- gm_mktime known answers --- + +// Hardcoded expected epochs (no host timegm dependence). The native build compiles the hand-rolled +// UTC path (!MESHTASTIC_EXCLUDE_TZ), so these pin its leap-day and century rules directly. +static void test_gm_mktime_known_epochs(void) +{ + struct KnownAnswer { + int year, mon1, mday, hour, min, sec; // human calendar: year AD, month 1-12 + int64_t expected; + }; + static const KnownAnswer cases[] = { + {1970, 1, 1, 0, 0, 0, 0LL}, + {1970, 3, 1, 0, 0, 0, 5097600LL}, // non-leap February + {1972, 2, 29, 0, 0, 0, 68169600LL}, // first leap day after the epoch + {1999, 12, 31, 23, 59, 59, 946684799LL}, // second before Y2K + {2000, 1, 1, 0, 0, 0, 946684800LL}, + {2000, 2, 29, 12, 0, 0, 951825600LL}, // 400-year-rule leap day + {2000, 3, 1, 0, 0, 0, 951868800LL}, + {2023, 2, 28, 23, 59, 59, 1677628799LL}, // last second of a non-leap February + {2024, 2, 29, 0, 0, 0, 1709164800LL}, + {2024, 3, 1, 0, 0, 0, 1709251200LL}, + {2038, 1, 19, 3, 14, 7, 2147483647LL}, // INT32_MAX second + {2038, 1, 19, 3, 14, 8, 2147483648LL}, // one past it: 64-bit time_t on native + {2100, 2, 28, 0, 0, 0, 4107456000LL}, // 2100 is NOT leap (100-year rule) + {2100, 3, 1, 0, 0, 0, 4107542400LL}, + {2400, 2, 29, 0, 0, 0, 13574563200LL}, // 2400 IS leap (400-year rule) + }; + + for (const KnownAnswer &c : cases) { + struct tm t = {}; + t.tm_year = c.year - 1900; + t.tm_mon = c.mon1 - 1; + t.tm_mday = c.mday; + t.tm_hour = c.hour; + t.tm_min = c.min; + t.tm_sec = c.sec; + const int64_t got = (int64_t)gm_mktime(&t); + if (got != c.expected) { + TEST_MSG_FMT("gm_mktime(%04d-%02d-%02d %02d:%02d:%02d) = %lld, expected %lld", c.year, c.mon1, c.mday, c.hour, c.min, + c.sec, (long long)got, (long long)c.expected); + } + TEST_ASSERT_EQUAL_INT64(c.expected, got); + } +} + +// February length as seen by gm_mktime for the years around each leap rule: Mar 1 minus Feb 28 +// is two days in a leap year and one day otherwise. Self-consistent, anchored by the known +// answers above. +static void test_gm_mktime_leap_rule_sweep(void) +{ + static const int leapYears[] = {1972, 2000, 2024, 2096, 2104, 2400}; // by-4 and by-400 + static const int nonLeapYears[] = {1970, 2023, 2100, 2200, 2300}; // odd years and by-100 + + for (int year : leapYears) { + struct tm feb28 = {}, mar1 = {}; + feb28.tm_year = year - 1900; + feb28.tm_mon = 1; + feb28.tm_mday = 28; + mar1.tm_year = year - 1900; + mar1.tm_mon = 2; + mar1.tm_mday = 1; + TEST_MSG_FMT("leap year %d", year); + TEST_ASSERT_EQUAL_INT64(2 * SEC_PER_DAY, (int64_t)gm_mktime(&mar1) - (int64_t)gm_mktime(&feb28)); + } + for (int year : nonLeapYears) { + struct tm feb28 = {}, mar1 = {}; + feb28.tm_year = year - 1900; + feb28.tm_mon = 1; + feb28.tm_mday = 28; + mar1.tm_year = year - 1900; + mar1.tm_mon = 2; + mar1.tm_mday = 1; + TEST_MSG_FMT("non-leap year %d", year); + TEST_ASSERT_EQUAL_INT64(SEC_PER_DAY, (int64_t)gm_mktime(&mar1) - (int64_t)gm_mktime(&feb28)); + } +} + void setup() { delay(10); @@ -76,6 +429,27 @@ void setup() UNITY_BEGIN(); RUN_TEST(test_readFromRTC_preserves_better_network_time); RUN_TEST(test_readFromRTC_initializes_time_when_no_better_source); + + printf("\n=== perhapsSetRTC(timeval) quality arbitration ===\n"); + RUN_TEST(test_downgrade_rejected_state_untouched); + RUN_TEST(test_equal_quality_fromnet_is_not_reapplied); + RUN_TEST(test_gps_reapply_always_accepted); + RUN_TEST(test_ntp_drift_throttle); + RUN_TEST(test_force_update_overrides_downgrade); + RUN_TEST(test_build_epoch_bounds_rejected); + + printf("\n=== perhapsSetRTC(tm) overload ===\n"); + RUN_TEST(test_tm_overload_roundtrip); + RUN_TEST(test_tm_overload_year_guard); + + printf("\n=== getValidTime / quality-source stamp ===\n"); + RUN_TEST(test_getvalidtime_threshold_gating); + RUN_TEST(test_lastSetFromPhoneNtpOrGps_stamp); + + printf("\n=== gm_mktime known answers ===\n"); + RUN_TEST(test_gm_mktime_known_epochs); + RUN_TEST(test_gm_mktime_leap_rule_sweep); + exit(UNITY_END()); } diff --git a/test/test_stream_framing/test_main.cpp b/test/test_stream_framing/test_main.cpp new file mode 100644 index 000000000..99d5823f5 --- /dev/null +++ b/test/test_stream_framing/test_main.cpp @@ -0,0 +1,397 @@ +#include "MeshTypes.h" +#include "TestUtil.h" +#include "configuration.h" +#include "mesh/MeshService.h" +#include "mesh/StreamAPI.h" +#include +#include +#include +#include +#include +#include +#include + +// Framing constants mirrored from StreamAPI.cpp (defined only in that translation unit). +static constexpr uint8_t kStart1 = 0x94; +static constexpr uint8_t kStart2 = 0xc3; +static constexpr size_t kHeaderLen = 4; + +/// Input-scripted stream feeding queued bytes through the readStream() polling path. +class InputScriptedStream : public Stream +{ + public: + /// Report how many queued input bytes remain. + int available() override { return (int)input.size(); } + + /// Return the next queued byte as an unsigned value, or -1 when drained. + int read() override + { + if (input.empty()) + return -1; + int value = input.front(); + input.pop_front(); + return value; + } + + /// Return the next queued byte without consuming it. + int peek() override { return input.empty() ? -1 : input.front(); } + + /// Accept unlimited output; this suite only exercises the receive side. + int availableForWrite() override { return std::numeric_limits::max(); } + size_t write(uint8_t) override { return 1; } + size_t write(const uint8_t *, size_t size) override { return size; } + void flush() override {} + + /// Queue bytes for the next readStream() poll. + void feed(const std::vector &bytes) { input.insert(input.end(), bytes.begin(), bytes.end()); } + + std::deque input; +}; + +// The global `service` is installed in setUp() and restored in tearDown() rather than by RAII +// because a failed TEST_ASSERT longjmps out of the test without running destructors, which would +// leave `service` dangling for the rest of the suite. testService is intentionally never freed: +// it stays reachable through the static, so LeakSanitizer does not flag it. +static MeshService *testService = nullptr; +static MeshService *previousService = nullptr; + +/// Records every framed ToRadio payload the receive state machine delivers. +class FramingStreamAPIShim : public StreamAPI +{ + public: + /// Construct the shim over a scripted input stream. + explicit FramingStreamAPIShim(Stream *stream) : StreamAPI(stream) {} + + /// Keep connection-timeout handling inactive during tests. + bool checkIsConnected() override { return true; } + + /// Capture one delivered payload instead of running the real PhoneAPI decode. + bool handleToRadio(const uint8_t *buf, size_t len) override + { + deliveries.emplace_back(buf, buf + len); + return true; + } + + std::vector> deliveries; +}; + +/// Wrap a payload in the 0x94C3 big-endian-length stream framing. +static std::vector makeFrame(const std::vector &payload) +{ + std::vector frame = {kStart1, kStart2, (uint8_t)(payload.size() >> 8), (uint8_t)(payload.size() & 0xff)}; + frame.insert(frame.end(), payload.begin(), payload.end()); + return frame; +} + +/// Drive the buffer-fed receive path (SerialModule/native callers) with one burst. +static void feedBufferPath(FramingStreamAPIShim &api, const std::vector &bytes) +{ + std::vector copy = bytes; // runOncePart takes a mutable char* + api.runOncePart(reinterpret_cast(copy.data()), (uint16_t)copy.size()); +} + +/// Drive the stream-polling receive path with one burst. +static void feedStreamPath(FramingStreamAPIShim &api, InputScriptedStream &stream, const std::vector &bytes) +{ + stream.feed(bytes); + api.runOncePart(); +} + +/// Assert delivery `index` matches the expected payload, size first so a short delivery is a +/// clean assertion failure rather than an out-of-bounds read. +static void assertDeliveryAt(const FramingStreamAPIShim &api, size_t index, const std::vector &expected) +{ + TEST_ASSERT_TRUE_MESSAGE(index < api.deliveries.size(), "delivery index out of range"); + TEST_ASSERT_EQUAL_UINT(expected.size(), api.deliveries[index].size()); + if (!expected.empty()) // Unity rejects zero-length array asserts as pointless + TEST_ASSERT_EQUAL_UINT8_ARRAY(expected.data(), api.deliveries[index].data(), expected.size()); +} + +/// Assert the shim recorded exactly one delivery matching the expected payload. +static void assertSingleDelivery(const FramingStreamAPIShim &api, const std::vector &expected) +{ + TEST_ASSERT_EQUAL_UINT_MESSAGE(1, api.deliveries.size(), "expected exactly one handleToRadio delivery"); + assertDeliveryAt(api, 0, expected); +} + +/// Verify one well-formed frame off the scripted stream delivers its exact payload once. +void test_stream_single_frame_delivers_exact_payload() +{ + InputScriptedStream stream; + FramingStreamAPIShim api(&stream); + std::vector payload = {0x08, 0x01, 0x2a, 0x00, 0x7f}; + + feedStreamPath(api, stream, makeFrame(payload)); + + assertSingleDelivery(api, payload); + TEST_ASSERT_TRUE_MESSAGE(stream.input.empty(), "readStream must drain everything available"); +} + +/// Verify parser state persists across stream polls split mid-header and mid-payload. +void test_stream_partial_reads_persist_state() +{ + InputScriptedStream stream; + FramingStreamAPIShim api(&stream); + std::vector payload = {0xaa, 0xbb, 0xcc}; + std::vector frame = makeFrame(payload); + + // First poll sees only 3 of the 4 header bytes. + feedStreamPath(api, stream, std::vector(frame.begin(), frame.begin() + 3)); + TEST_ASSERT_EQUAL_UINT(0, api.deliveries.size()); + + // Second poll supplies the length byte and part of the payload. + feedStreamPath(api, stream, std::vector(frame.begin() + 3, frame.begin() + 5)); + TEST_ASSERT_EQUAL_UINT(0, api.deliveries.size()); + + // Final poll completes the payload: exactly one delivery. + feedStreamPath(api, stream, std::vector(frame.begin() + 5, frame.end())); + assertSingleDelivery(api, payload); +} + +/// Verify rxPtr persists across buffer-path invocations fed one byte at a time. +void test_buffer_path_one_byte_per_call_persists_state() +{ + InputScriptedStream stream; + FramingStreamAPIShim api(&stream); + std::vector payload = {0x12, 0x34}; + std::vector frame = makeFrame(payload); + + for (size_t i = 0; i + 1 < frame.size(); i++) { + feedBufferPath(api, {frame[i]}); + TEST_ASSERT_EQUAL_UINT_MESSAGE(0, api.deliveries.size(), "no delivery before the final byte"); + } + feedBufferPath(api, {frame.back()}); + + assertSingleDelivery(api, payload); +} + +/// Verify the parser hunts past leading ASCII boot-log garbage to the frame marker. +void test_leading_garbage_resyncs_to_frame() +{ + InputScriptedStream stream; + FramingStreamAPIShim api(&stream); + std::vector payload = {0x55, 0x66}; + + const char *bootLog = "INFO | ??:??:?? 1 Booting\r\n"; + std::vector burst(bootLog, bootLog + strlen(bootLog)); + std::vector frame = makeFrame(payload); + burst.insert(burst.end(), frame.begin(), frame.end()); + + feedStreamPath(api, stream, burst); + + assertSingleDelivery(api, payload); +} + +/// Verify a header advertising len 513 is rejected and a later frame in the burst still delivers. +void test_bogus_length_rejected_then_next_frame_recovered() +{ + InputScriptedStream stream; + FramingStreamAPIShim api(&stream); + std::vector payload = {0x77}; + + // MAX_TO_FROM_RADIO_SIZE is 512, so a big-endian length of 513 must fail header validation. + std::vector burst = {kStart1, kStart2, 0x02, 0x01}; + const char *junk = "junk"; + burst.insert(burst.end(), junk, junk + strlen(junk)); + std::vector frame = makeFrame(payload); + burst.insert(burst.end(), frame.begin(), frame.end()); + + feedBufferPath(api, burst); + + assertSingleDelivery(api, payload); +} + +/// Verify a len==512 frame (the exact cap, filling rxBuf to its last byte) is delivered intact +/// on both receive paths. +void test_max_length_frame_accepted_exactly() +{ + std::vector payload(MAX_TO_FROM_RADIO_SIZE); + for (size_t i = 0; i < payload.size(); i++) + payload[i] = (uint8_t)(i & 0xff); + std::vector frame = makeFrame(payload); + + // Total frame is 516 bytes == sizeof(rxBuf); ASan in the coverage env guards the bound. + InputScriptedStream stream; + FramingStreamAPIShim streamApi(&stream); + feedStreamPath(streamApi, stream, frame); + assertSingleDelivery(streamApi, payload); + + // Buffer path, split so the cap is reached with rxPtr state persisted across calls. + InputScriptedStream unusedStream; + FramingStreamAPIShim bufferApi(&unusedStream); + const size_t split = frame.size() / 2; + feedBufferPath(bufferApi, std::vector(frame.begin(), frame.begin() + split)); + TEST_ASSERT_EQUAL_UINT(0, bufferApi.deliveries.size()); + feedBufferPath(bufferApi, std::vector(frame.begin() + split, frame.end())); + assertSingleDelivery(bufferApi, payload); +} + +/// Verify a zero-length payload is a valid frame delivering len 0 on both receive paths. +void test_zero_length_payload_delivers_empty() +{ + std::vector frame = makeFrame({}); + TEST_ASSERT_EQUAL_UINT(kHeaderLen, frame.size()); + + InputScriptedStream stream; + FramingStreamAPIShim streamApi(&stream); + feedStreamPath(streamApi, stream, frame); + assertSingleDelivery(streamApi, {}); + + InputScriptedStream unusedStream; + FramingStreamAPIShim bufferApi(&unusedStream); + feedBufferPath(bufferApi, frame); + assertSingleDelivery(bufferApi, {}); +} + +/// Verify two back-to-back frames in one burst deliver twice, in order, on both paths. +void test_back_to_back_frames_deliver_in_order() +{ + std::vector first = {0x01, 0x02, 0x03}; + std::vector second = {0xf0, 0x0d}; + std::vector burst = makeFrame(first); + std::vector secondFrame = makeFrame(second); + burst.insert(burst.end(), secondFrame.begin(), secondFrame.end()); + + InputScriptedStream stream; + FramingStreamAPIShim streamApi(&stream); + feedStreamPath(streamApi, stream, burst); + TEST_ASSERT_EQUAL_UINT(2, streamApi.deliveries.size()); + assertDeliveryAt(streamApi, 0, first); + assertDeliveryAt(streamApi, 1, second); + + InputScriptedStream unusedStream; + FramingStreamAPIShim bufferApi(&unusedStream); + feedBufferPath(bufferApi, burst); + TEST_ASSERT_EQUAL_UINT(2, bufferApi.deliveries.size()); + assertDeliveryAt(bufferApi, 0, first); + assertDeliveryAt(bufferApi, 1, second); +} + +/// Verify payload bytes >= 0x80 survive the buffer path identically to the stream path. +/// Pins the unsigned read in StreamAPI::handleRecStream(const char *, uint16_t): a plain +/// (signed) char compare treated any high byte - START1 itself is 0x94 - as EOF and +/// silently dropped frames mid-buffer. +void test_high_bytes_in_payload_delivered_on_both_paths() +{ + // Includes the framing bytes themselves mid-payload: length counts them as data. + std::vector payload = {0x80, kStart1, kStart2, 0xff, 0x00, 0xfe, 0x7f, 0x81}; + std::vector frame = makeFrame(payload); + + InputScriptedStream stream; + FramingStreamAPIShim streamApi(&stream); + feedStreamPath(streamApi, stream, frame); + assertSingleDelivery(streamApi, payload); + + InputScriptedStream unusedStream; + FramingStreamAPIShim bufferApi(&unusedStream); + feedBufferPath(bufferApi, frame); + assertSingleDelivery(bufferApi, payload); + + TEST_ASSERT_EQUAL_UINT8_ARRAY(streamApi.deliveries[0].data(), bufferApi.deliveries[0].data(), payload.size()); +} + +/// A byte that fails START2 is re-tested as START1, so 0x94 0x94 0xc3 ... keeps the frame behind +/// the stray marker instead of consuming its real marker in the reset. +void test_stray_start1_before_frame_still_delivers() +{ + std::vector payload = {0x42}; + std::vector frame = makeFrame(payload); + std::vector burst = {kStart1}; // stray marker, then the real frame + burst.insert(burst.end(), frame.begin(), frame.end()); + + // The byte that fails START2 is itself START1 here, so the frame behind it must survive. + InputScriptedStream bufStream; + FramingStreamAPIShim bufferApi(&bufStream); + feedBufferPath(bufferApi, burst); + assertSingleDelivery(bufferApi, payload); + + InputScriptedStream stream; + FramingStreamAPIShim streamApi(&stream); + feedStreamPath(streamApi, stream, burst); + assertSingleDelivery(streamApi, payload); +} + +/// A run of stray markers before a frame must not consume it either. +void test_repeated_stray_start1_before_frame_still_delivers() +{ + std::vector payload = {0x43, 0x44}; + std::vector frame = makeFrame(payload); + std::vector burst = {kStart1, kStart1, kStart1}; + burst.insert(burst.end(), frame.begin(), frame.end()); + + InputScriptedStream bufStream; + FramingStreamAPIShim bufferApi(&bufStream); + feedBufferPath(bufferApi, burst); + assertSingleDelivery(bufferApi, payload); + + InputScriptedStream stream; + FramingStreamAPIShim streamApi(&stream); + feedStreamPath(streamApi, stream, burst); + assertSingleDelivery(streamApi, payload); +} + +/// START1 followed by a non-START1, non-START2 byte still resyncs on the next real frame. +void test_start1_then_unrelated_byte_resyncs() +{ + std::vector payload = {0x45}; + std::vector frame = makeFrame(payload); + std::vector burst = {kStart1, 0x00}; + burst.insert(burst.end(), frame.begin(), frame.end()); + + InputScriptedStream bufStream; + FramingStreamAPIShim bufferApi(&bufStream); + feedBufferPath(bufferApi, burst); + assertSingleDelivery(bufferApi, payload); + + InputScriptedStream stream; + FramingStreamAPIShim streamApi(&stream); + feedStreamPath(streamApi, stream, burst); + assertSingleDelivery(streamApi, payload); +} + +/// Unity per-test setup: install the test MeshService the StreamAPI fixtures expect. +void setUp(void) +{ + previousService = service; + if (!testService) + testService = new MeshService(); + service = testService; +} + +/// Unity per-test teardown: runs even after an aborted test, so the restore is failure-safe. +void tearDown(void) +{ + service = previousService; +} + +/// Initialize the native environment and run the receive-framing suite. +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + + printf("\n=== Frame delivery ===\n"); + RUN_TEST(test_stream_single_frame_delivers_exact_payload); + RUN_TEST(test_zero_length_payload_delivers_empty); + RUN_TEST(test_back_to_back_frames_deliver_in_order); + RUN_TEST(test_high_bytes_in_payload_delivered_on_both_paths); + + printf("\n=== Partial reads / state persistence ===\n"); + RUN_TEST(test_stream_partial_reads_persist_state); + RUN_TEST(test_buffer_path_one_byte_per_call_persists_state); + RUN_TEST(test_max_length_frame_accepted_exactly); + + printf("\n=== Resync and rejection ===\n"); + RUN_TEST(test_leading_garbage_resyncs_to_frame); + RUN_TEST(test_bogus_length_rejected_then_next_frame_recovered); + + printf("\n=== Stray framing markers ===\n"); + RUN_TEST(test_stray_start1_before_frame_still_delivers); + RUN_TEST(test_repeated_stray_start1_before_frame_still_delivers); + RUN_TEST(test_start1_then_unrelated_byte_resyncs); + + exit(UNITY_END()); +} + +/// Unused Arduino loop required by the native Unity runner. +void loop() {} diff --git a/test/test_xmodem/test_main.cpp b/test/test_xmodem/test_main.cpp index c6a20fdf0..7dbb9e04f 100644 --- a/test/test_xmodem/test_main.cpp +++ b/test/test_xmodem/test_main.cpp @@ -1,16 +1,27 @@ -// Tests for XModemAdapter::isValidFilename - the path-traversal guard on the XModem file-transfer -// handler (src/xmodem.cpp). The filename in a SOH/STX control frame is attacker-controlled and -// drives FSCom open/remove; on the Portduino daemon FSCom is the host filesystem, so a ".." -// component could escape the mountpoint. Absolute/subdirectory paths must still be accepted. +// Tests for the XModem file-transfer adapter (src/xmodem.cpp). +// +// Group 1: XModemAdapter::isValidFilename - the path-traversal guard on the XModem file-transfer +// handler. The filename in a SOH/STX control frame is attacker-controlled and drives FSCom +// open/remove; on the Portduino daemon FSCom is the host filesystem, so a ".." component could +// escape the mountpoint. Absolute/subdirectory paths must still be accepted. +// +// Group 2 onward: the handlePacket() state machine itself - session start, per-packet seq + CRC +// validation, NAK/retransmit, CAN cleanup, EOT close, and the getForPhone()/resetForPhone() +// contract PhoneAPI uses to drain replies. PhoneAPI feeds handlePacket attacker-controllable +// ToRadio protobufs, and none of this had pinning coverage. These tests assert what the code does +// today; the two tests marked "documents current behaviour" pin known state-confusion edges so a +// deliberate fix has to update them consciously. #include "TestUtil.h" #include "xmodem.h" #include -void setUp(void) {} -void tearDown(void) {} - #ifdef FSCom +#include "SPILock.h" +#include +#include +#include + void test_xmodem_rejects_dotdot_traversal(void) { TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("..")); @@ -57,18 +68,469 @@ void test_xmodem_allows_legit_paths(void) TEST_ASSERT_TRUE(XModemAdapter::isValidFilename("dir/1:30pm.txt")); } +// --- handlePacket state-machine fixture --- + +// Exposes the protected CRC helpers so crafted packets carry the exact checksum the adapter +// computes, and so the transmit-side crc16 field can be cross-checked. +class XModemTestShim : public XModemAdapter +{ + public: + using XModemAdapter::check; + using XModemAdapter::crc16_ccitt; +}; + +static XModemTestShim *xm = nullptr; + +static constexpr size_t kChunk = sizeof(meshtastic_XModem_buffer_t::bytes); // 128 +static const char *kRxPath = "/xmodem_test_rx.bin"; +static const char *kTxPath = "/xmodem_test_tx.bin"; + +// Control-only frame (EOT/ACK/NAK/CAN). +static meshtastic_XModem makeControl(meshtastic_XModem_Control control) +{ + meshtastic_XModem p = meshtastic_XModem_init_zero; + p.control = control; + return p; +} + +// Session-start frame: seq 0, filename in the buffer (NUL included, as the phone sends it). +static meshtastic_XModem makeStart(meshtastic_XModem_Control control, const char *path) +{ + meshtastic_XModem p = meshtastic_XModem_init_zero; + p.control = control; + p.seq = 0; + p.buffer.size = strlen(path) + 1; + memcpy(p.buffer.bytes, path, p.buffer.size); + return p; +} + +// Data frame with a correct (or deliberately corrupted) CRC. +static meshtastic_XModem makeData(uint16_t seq, const uint8_t *data, size_t len, bool goodCrc = true) +{ + meshtastic_XModem p = meshtastic_XModem_init_zero; + p.control = meshtastic_XModem_Control_SOH; + p.seq = seq; + p.buffer.size = len; + memcpy(p.buffer.bytes, data, len); + p.crc16 = xm->crc16_ccitt(p.buffer.bytes, (int)len); + if (!goodCrc) + p.crc16 ^= 0x1; + return p; +} + +static void fillPattern(uint8_t *buf, size_t len, uint8_t seed) +{ + for (size_t i = 0; i < len; i++) + buf[i] = (uint8_t)(seed + i * 7); +} + +static void writeAll(const char *path, const uint8_t *data, size_t len) +{ + File f = FSCom.open(path, FILE_O_WRITE); + TEST_ASSERT_TRUE_MESSAGE(f, path); + TEST_ASSERT_EQUAL_size_t(len, f.write(data, len)); + f.close(); +} + +static size_t readAll(const char *path, uint8_t *buf, size_t maxLen) +{ + File f = FSCom.open(path, FILE_O_READ); + TEST_ASSERT_TRUE_MESSAGE(f, path); + size_t n = f.read(buf, maxLen); + f.close(); + return n; +} + +// Starts a receive session into kRxPath and asserts the adapter accepted it. +static void startReceive(void) +{ + xm->handlePacket(makeStart(meshtastic_XModem_Control_SOH, kRxPath)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + TEST_ASSERT_TRUE(xm->isBusy()); +} + +// Writes a patterned file at kTxPath and starts a transmit session; returns the first outbound +// packet after asserting its shape. +static meshtastic_XModem startTransmit(const uint8_t *payload, size_t len) +{ + writeAll(kTxPath, payload, len); + xm->handlePacket(makeStart(meshtastic_XModem_Control_STX, kTxPath)); + meshtastic_XModem out = xm->getForPhone(); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_SOH, out.control); + TEST_ASSERT_EQUAL_UINT16(1, out.seq); + TEST_ASSERT_TRUE(xm->isBusy()); + return out; +} + +// --- CRC --- + +void test_xmodem_crc16_known_answer(void) +{ + // CRC-16/XMODEM check value: crc("123456789") == 0x31C3, and the zero-length CRC is 0. + const uint8_t check[] = {'1', '2', '3', '4', '5', '6', '7', '8', '9'}; + TEST_ASSERT_EQUAL_HEX16(0x31C3, xm->crc16_ccitt(check, sizeof(check))); + TEST_ASSERT_EQUAL_HEX16(0x0000, xm->crc16_ccitt(check, 0)); + TEST_ASSERT_TRUE(xm->check(check, sizeof(check), 0x31C3)); + TEST_ASSERT_FALSE(xm->check(check, sizeof(check), 0x31C2)); +} + +// --- Receive path --- + +void test_xmodem_receive_happy_path(void) +{ + uint8_t payload[300]; + fillPattern(payload, sizeof(payload), 31); + + startReceive(); + + size_t off = 0; + uint16_t seq = 1; + while (off < sizeof(payload)) { + const size_t chunk = std::min(kChunk, sizeof(payload) - off); + xm->handlePacket(makeData(seq, payload + off, chunk)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + off += chunk; + seq++; + } + + xm->handlePacket(makeControl(meshtastic_XModem_Control_EOT)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + TEST_ASSERT_FALSE(xm->isBusy()); + + uint8_t readBack[400]; + TEST_ASSERT_EQUAL_size_t(sizeof(payload), readAll(kRxPath, readBack, sizeof(readBack))); + TEST_ASSERT_EQUAL_HEX8_ARRAY(payload, readBack, sizeof(payload)); +} + +void test_xmodem_receive_truncates_a_stale_file(void) +{ + // FILE_O_WRITE on Adafruit_LittleFS is append, not truncate; xmodem.cpp removes the target + // before opening. A shorter transfer over a longer stale file must leave no tail bytes. + uint8_t stale[400]; + memset(stale, 'Z', sizeof(stale)); + writeAll(kRxPath, stale, sizeof(stale)); + + uint8_t payload[10]; + fillPattern(payload, sizeof(payload), 3); + + startReceive(); + xm->handlePacket(makeData(1, payload, sizeof(payload))); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + xm->handlePacket(makeControl(meshtastic_XModem_Control_EOT)); + + uint8_t readBack[400]; + TEST_ASSERT_EQUAL_size_t(sizeof(payload), readAll(kRxPath, readBack, sizeof(readBack))); + TEST_ASSERT_EQUAL_HEX8_ARRAY(payload, readBack, sizeof(payload)); +} + +void test_xmodem_receive_rejects_wrong_seq(void) +{ + uint8_t p1[kChunk], p2[kChunk]; + fillPattern(p1, sizeof(p1), 11); + fillPattern(p2, sizeof(p2), 97); + + startReceive(); + xm->handlePacket(makeData(1, p1, sizeof(p1))); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + + // Duplicate of an already-accepted packet: rejected (NAK), not rewritten. + xm->handlePacket(makeData(1, p1, sizeof(p1))); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_NAK, xm->getForPhone().control); + + // Skip ahead: also rejected, and packetno must not have advanced past 2. + xm->handlePacket(makeData(3, p2, sizeof(p2))); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_NAK, xm->getForPhone().control); + + // The expected seq still works after both rejections. + xm->handlePacket(makeData(2, p2, sizeof(p2))); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + + xm->handlePacket(makeControl(meshtastic_XModem_Control_EOT)); + + uint8_t readBack[3 * kChunk]; + TEST_ASSERT_EQUAL_size_t(2 * kChunk, readAll(kRxPath, readBack, sizeof(readBack))); + TEST_ASSERT_EQUAL_HEX8_ARRAY(p1, readBack, kChunk); + TEST_ASSERT_EQUAL_HEX8_ARRAY(p2, readBack + kChunk, kChunk); +} + +void test_xmodem_receive_rejects_bad_crc(void) +{ + uint8_t payload[64]; + fillPattern(payload, sizeof(payload), 55); + + startReceive(); + xm->handlePacket(makeData(1, payload, sizeof(payload), /*goodCrc=*/false)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_NAK, xm->getForPhone().control); + + // The sender retries the same seq with a good CRC; only that copy lands in the file. + xm->handlePacket(makeData(1, payload, sizeof(payload))); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + + xm->handlePacket(makeControl(meshtastic_XModem_Control_EOT)); + + uint8_t readBack[2 * kChunk]; + TEST_ASSERT_EQUAL_size_t(sizeof(payload), readAll(kRxPath, readBack, sizeof(readBack))); + TEST_ASSERT_EQUAL_HEX8_ARRAY(payload, readBack, sizeof(payload)); +} + +void test_xmodem_receive_naks_traversal_filename(void) +{ + xm->handlePacket(makeStart(meshtastic_XModem_Control_SOH, "../evil")); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_NAK, xm->getForPhone().control); + TEST_ASSERT_FALSE(xm->isBusy()); + + // isReceiving stayed false, so a follow-up data packet falls through with no reply at all. + xm->resetForPhone(); + uint8_t junk[16]; + fillPattern(junk, sizeof(junk), 1); + xm->handlePacket(makeData(1, junk, sizeof(junk))); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_NUL, xm->getForPhone().control); + TEST_ASSERT_FALSE(xm->isBusy()); +} + +// NOTE: the receive-side open-failure NAK branch (xmodem.cpp "open(%s, WRITE) failed") is not +// testable on native: Portduino's VFSImpl::open() returns a truthy File whenever the mode permits +// creation, even when the underlying fopen fails, so the branch is unreachable here. + +void test_xmodem_can_mid_receive_removes_the_file(void) +{ + uint8_t payload[kChunk]; + fillPattern(payload, sizeof(payload), 42); + + startReceive(); + xm->handlePacket(makeData(1, payload, sizeof(payload))); + xm->handlePacket(makeData(2, payload, sizeof(payload))); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + + xm->handlePacket(makeControl(meshtastic_XModem_Control_CAN)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + TEST_ASSERT_FALSE(xm->isBusy()); + TEST_ASSERT_FALSE(FSCom.exists(kRxPath)); +} + +void test_xmodem_can_after_eot_removes_completed_file(void) +{ + // Documents current behaviour: the CAN handler acts on the stale filename from the previous + // session even when no transfer is in flight, deleting a file that completed successfully. + // A deliberate fix (ignoring CAN while idle) should update this test. + uint8_t payload[8]; + fillPattern(payload, sizeof(payload), 5); + + startReceive(); + xm->handlePacket(makeData(1, payload, sizeof(payload))); + xm->handlePacket(makeControl(meshtastic_XModem_Control_EOT)); + TEST_ASSERT_FALSE(xm->isBusy()); + TEST_ASSERT_TRUE(FSCom.exists(kRxPath)); + + xm->handlePacket(makeControl(meshtastic_XModem_Control_CAN)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + TEST_ASSERT_FALSE(FSCom.exists(kRxPath)); +} + +// --- Transmit path --- + +void test_xmodem_transmit_happy_path(void) +{ + uint8_t payload[300]; + fillPattern(payload, sizeof(payload), 7); + + meshtastic_XModem out = startTransmit(payload, sizeof(payload)); + TEST_ASSERT_EQUAL_UINT16(kChunk, out.buffer.size); + TEST_ASSERT_EQUAL_HEX8_ARRAY(payload, out.buffer.bytes, kChunk); + TEST_ASSERT_EQUAL_HEX16(xm->crc16_ccitt(out.buffer.bytes, out.buffer.size), out.crc16); + + // ACK-drive the whole stream and reassemble it; the last (short) packet latches EOT, which + // arrives on the following ACK. + uint8_t reassembled[sizeof(payload) + kChunk]; + size_t got = 0; + uint16_t expectSeq = 1; + for (int guard = 0; guard < 10; guard++) { + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_SOH, out.control); + TEST_ASSERT_EQUAL_UINT16(expectSeq, out.seq); + TEST_ASSERT_EQUAL_HEX16(xm->crc16_ccitt(out.buffer.bytes, out.buffer.size), out.crc16); + memcpy(reassembled + got, out.buffer.bytes, out.buffer.size); + got += out.buffer.size; + expectSeq++; + + xm->handlePacket(makeControl(meshtastic_XModem_Control_ACK)); + out = xm->getForPhone(); + if (out.control == meshtastic_XModem_Control_EOT) + break; + } + + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_EOT, out.control); + TEST_ASSERT_FALSE(xm->isBusy()); + TEST_ASSERT_EQUAL_size_t(sizeof(payload), got); + TEST_ASSERT_EQUAL_HEX8_ARRAY(payload, reassembled, sizeof(payload)); +} + +void test_xmodem_transmit_nak_resends_same_packet(void) +{ + uint8_t payload[300]; + fillPattern(payload, sizeof(payload), 61); + + meshtastic_XModem first = startTransmit(payload, sizeof(payload)); + + // NAK seeks back and re-reads the same block: identical seq, bytes and CRC. + xm->handlePacket(makeControl(meshtastic_XModem_Control_NAK)); + meshtastic_XModem resent = xm->getForPhone(); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_SOH, resent.control); + TEST_ASSERT_EQUAL_UINT16(first.seq, resent.seq); + TEST_ASSERT_EQUAL_UINT16(first.buffer.size, resent.buffer.size); + TEST_ASSERT_EQUAL_HEX8_ARRAY(first.buffer.bytes, resent.buffer.bytes, first.buffer.size); + TEST_ASSERT_EQUAL_HEX16(first.crc16, resent.crc16); + + // A subsequent ACK still advances to the next block. + xm->handlePacket(makeControl(meshtastic_XModem_Control_ACK)); + meshtastic_XModem next = xm->getForPhone(); + TEST_ASSERT_EQUAL_UINT16(2, next.seq); + TEST_ASSERT_EQUAL_HEX8_ARRAY(payload + kChunk, next.buffer.bytes, kChunk); +} + +void test_xmodem_transmit_retry_cap_cancels(void) +{ + uint8_t payload[300]; + fillPattern(payload, sizeof(payload), 23); + + startTransmit(payload, sizeof(payload)); + + // retrans starts at MAXRETRANS on a fresh adapter; NAKs 1..MAXRETRANS-1 resend, the + // MAXRETRANS'th decrements it to zero and aborts with CAN. + for (int i = 1; i < MAXRETRANS; i++) { + xm->handlePacket(makeControl(meshtastic_XModem_Control_NAK)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_SOH, xm->getForPhone().control); + TEST_ASSERT_EQUAL_UINT16(1, xm->getForPhone().seq); + } + xm->handlePacket(makeControl(meshtastic_XModem_Control_NAK)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_CAN, xm->getForPhone().control); + TEST_ASSERT_FALSE(xm->isBusy()); +} + +void test_xmodem_transmit_naks_missing_file(void) +{ + xm->handlePacket(makeStart(meshtastic_XModem_Control_STX, "/xmodem_test_missing.bin")); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_NAK, xm->getForPhone().control); + TEST_ASSERT_FALSE(xm->isBusy()); +} + +void test_xmodem_soh_mid_transmit_cancels(void) +{ + uint8_t payload[300]; + fillPattern(payload, sizeof(payload), 89); + + startTransmit(payload, sizeof(payload)); + + // A data frame arriving while we are the sender is protocol confusion: cancel the transfer. + uint8_t junk[16]; + fillPattern(junk, sizeof(junk), 2); + xm->handlePacket(makeData(5, junk, sizeof(junk))); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_CAN, xm->getForPhone().control); + TEST_ASSERT_FALSE(xm->isBusy()); +} + +void test_xmodem_eot_mid_transmit_leaves_state_busy(void) +{ + // Documents current behaviour: the EOT handler only clears isReceiving, so an EOT received + // while transmitting ACKs, closes the file, and leaves the adapter wedged busy. A deliberate + // fix should update this test. + uint8_t payload[300]; + fillPattern(payload, sizeof(payload), 13); + + startTransmit(payload, sizeof(payload)); + xm->handlePacket(makeControl(meshtastic_XModem_Control_EOT)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + TEST_ASSERT_TRUE(xm->isBusy()); +} + +// --- Idle replies and the getForPhone/resetForPhone contract --- + +void test_xmodem_ack_nak_while_idle_provoke_can(void) +{ + xm->handlePacket(makeControl(meshtastic_XModem_Control_ACK)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_CAN, xm->getForPhone().control); + + // getForPhone() is a read, not a drain: the reply stays until resetForPhone() clears it. + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_CAN, xm->getForPhone().control); + xm->resetForPhone(); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_NUL, xm->getForPhone().control); + + xm->handlePacket(makeControl(meshtastic_XModem_Control_NAK)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_CAN, xm->getForPhone().control); + TEST_ASSERT_FALSE(xm->isBusy()); +} + +void test_xmodem_unknown_control_ignored(void) +{ + xm->handlePacket(makeControl(meshtastic_XModem_Control_CTRLZ)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_NUL, xm->getForPhone().control); + TEST_ASSERT_FALSE(xm->isBusy()); +} + +// --- Unity lifecycle --- + +void setUp(void) +{ + FSCom.remove(kRxPath); + FSCom.remove(kTxPath); + xm = new XModemTestShim(); +} + +void tearDown(void) +{ + delete xm; // File member closes any handle still held + xm = nullptr; + FSCom.remove(kRxPath); + FSCom.remove(kTxPath); +} + +#else // !FSCom + +void setUp(void) {} +void tearDown(void) {} + #endif // FSCom void setup() { initializeTestEnvironment(); +#ifdef FSCom + // handlePacket brackets every FSCom touch with spiLock; nothing in the test environment + // creates it, so do it here (initSPI asserts it only runs once). + if (!spiLock) + initSPI(); +#endif UNITY_BEGIN(); #ifdef FSCom + printf("\n=== isValidFilename ===\n"); RUN_TEST(test_xmodem_rejects_dotdot_traversal); RUN_TEST(test_xmodem_rejects_backslash_traversal); RUN_TEST(test_xmodem_rejects_drive_qualified); RUN_TEST(test_xmodem_rejects_empty); RUN_TEST(test_xmodem_allows_legit_paths); + + printf("\n=== CRC ===\n"); + RUN_TEST(test_xmodem_crc16_known_answer); + + printf("\n=== Receive path ===\n"); + RUN_TEST(test_xmodem_receive_happy_path); + RUN_TEST(test_xmodem_receive_truncates_a_stale_file); + RUN_TEST(test_xmodem_receive_rejects_wrong_seq); + RUN_TEST(test_xmodem_receive_rejects_bad_crc); + RUN_TEST(test_xmodem_receive_naks_traversal_filename); + RUN_TEST(test_xmodem_can_mid_receive_removes_the_file); + RUN_TEST(test_xmodem_can_after_eot_removes_completed_file); + + printf("\n=== Transmit path ===\n"); + RUN_TEST(test_xmodem_transmit_happy_path); + RUN_TEST(test_xmodem_transmit_nak_resends_same_packet); + RUN_TEST(test_xmodem_transmit_retry_cap_cancels); + RUN_TEST(test_xmodem_transmit_naks_missing_file); + RUN_TEST(test_xmodem_soh_mid_transmit_cancels); + RUN_TEST(test_xmodem_eot_mid_transmit_leaves_state_busy); + + printf("\n=== Idle replies / phone contract ===\n"); + RUN_TEST(test_xmodem_ack_nak_while_idle_provoke_can); + RUN_TEST(test_xmodem_unknown_control_ignored); #endif exit(UNITY_END()); }