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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Ben Meadors
2026-08-18 12:41:08 +00:00
committed by GitHub
co-authored by Claude Opus 5
parent e1ea653a45
commit 83fd62b756
18 files changed
+6710 -30

No files matched your search

+12
View File
@@ -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);
+4 -5
View File
@@ -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;
+9 -8
View File
@@ -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
+7
View File
@@ -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)
Can't render this file because it contains an unexpected character in line 5 and column 48.
+586
View File
@@ -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 <cstdio> // printf() group separators
#include <cstring>
#include <unity.h>
#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() {}
+352
View File
@@ -0,0 +1,352 @@
#include "MeshTypes.h" // Include BEFORE TestUtil.h (provides NodeNum, isFromUs)
#include "TestUtil.h"
#include <unity.h>
#include "configuration.h" // MESHTASTIC_PREHOP_DROP
#include "mesh/NodeDB.h" // classifyHopStart, shouldDropPacketForPreHop, HopStartStatus
#include <cstdio>
#include <cstring>
// 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_start<hop_limit | any variant | INVALID | drop pre-decode + post-decode"},
{makeDecoded(kRemoteNode, 3, 3, false), HopStartStatus::VALID, false, false,
"start>0, >=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() {}
+253 -10
View File
@@ -27,6 +27,7 @@
#include <algorithm>
#include <list>
#include <map>
#include <optional>
#include <set>
#include <sstream>
@@ -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<NodeNum, meshtastic_NodeInfoLite> 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<meshtastic_MeshPacket *>(&decoded),
.channel_id = NULL,
.gateway_id = const_cast<char *>("!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<meshtastic_MeshPacket *>(&decoded),
.channel_id = const_cast<char *>("test"),
.gateway_id = const_cast<char *>("!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);
@@ -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 <unity.h>
#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 <ErriezCRC32.h>
#include <cstdio>
#include <cstring>
#include <vector>
// 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<uint8_t> 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<uint8_t> &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<uint8_t> 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
@@ -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 <unity.h>
#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 <cstdio>
#include <cstring>
// 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 <typename KeyT> 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() {}
@@ -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 <unity.h>
#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 <algorithm>
#include <cstdio>
#include <cstring>
#include <pb_decode.h>
#include <pb_encode.h>
#include <string>
#include <vector>
// 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<uint8_t> encodeLegacyNodes(uint32_t version, const std::vector<meshtastic_NodeInfoLite_Legacy> &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<uint8_t> 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<meshtastic_NodeInfoLite_Legacy> &nodes)
{
const std::vector<uint8_t> 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<uint8_t> &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<const uint8_t *>(placeholder),
reinterpret_cast<const uint8_t *>(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<meshtastic_NodeInfoLite_Legacy> 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<uint8_t> 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<meshtastic_NodeInfoLite_Legacy> 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
@@ -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 <unity.h>
#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 <cstdio>
#include <cstring>
#include <pb_encode.h>
#include <vector>
// 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<uint8_t> &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<NodeNum> &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<uint8_t> 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<uint8_t> 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
+378
View File
@@ -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 <cstdio>
#include <string>
#include <unity.h>
// 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<int>
{
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<int> *detachWho = nullptr;
Observable<int> *detachFrom = nullptr;
// When set, onNotify attaches attachWho to attachTo before returning.
Observer<int> *attachWho = nullptr;
Observable<int> *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<int> subject;
TEST_ASSERT_EQUAL(0, subject.notifyObservers(99));
}
void test_notify_order_and_arg()
{
Observable<int> 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<int> 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<int> subject;
CallbackTarget target;
CallbackObserver<CallbackTarget, int> 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<int> subject;
CallbackTarget target;
CallbackObserver<CallbackTarget, int> 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<int> 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<int> subject1;
Observable<int> 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<int> 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<int> 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<int> 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<int> 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<int> 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<int> 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<int> 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() {}
@@ -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 <unity.h>
#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 <cstdio>
#include <cstring>
#include <vector>
// 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<meshtastic_ToRadio *>(&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<pb_size_t> variants; // outer which_payload_variant per message
std::vector<pb_size_t> configVariants; // inner variant of each FromRadio.config
std::vector<pb_size_t> moduleConfigVariants; // inner variant of each FromRadio.moduleConfig
std::vector<int> channelIndices;
std::vector<uint32_t> 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() {}
+853
View File
@@ -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 <unity.h>
#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 <cstdio>
#include <cstring>
#include <list>
#include <memory>
#include <tuple>
#include <vector>
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<meshtastic_NodeInfoLite> 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<meshtastic_MeshPacket> 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<std::tuple<meshtastic_Routing_Error, NodeNum, PacketId, ChannelIndex, uint8_t, bool>> 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<ScopedAirTimeFixture> 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<ScopedAirTimeFixture>();
mockNodeDB = new MockNodeDB();
nodeDB = mockNodeDB;
reliableShim = new ReliableRouterTestShim();
auto capture = std::make_unique<TimedCaptureRadio>();
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() {}
@@ -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 <cstdlib> // exit(), needed on both guard branches
#include <unity.h>
// 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 <cstdio>
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
+374
View File
@@ -1,5 +1,7 @@
#include "TestUtil.h"
#include "UptimeClock.h"
#include "gps/RTC.h"
#include <cstdio>
#include <sys/time.h>
#include <time.h>
#include <unity.h>
@@ -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());
}
+397
View File
@@ -0,0 +1,397 @@
#include "MeshTypes.h"
#include "TestUtil.h"
#include "configuration.h"
#include "mesh/MeshService.h"
#include "mesh/StreamAPI.h"
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <deque>
#include <limits>
#include <unity.h>
#include <vector>
// 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<int>::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<uint8_t> &bytes) { input.insert(input.end(), bytes.begin(), bytes.end()); }
std::deque<uint8_t> 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<std::vector<uint8_t>> deliveries;
};
/// Wrap a payload in the 0x94C3 big-endian-length stream framing.
static std::vector<uint8_t> makeFrame(const std::vector<uint8_t> &payload)
{
std::vector<uint8_t> 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<uint8_t> &bytes)
{
std::vector<uint8_t> copy = bytes; // runOncePart takes a mutable char*
api.runOncePart(reinterpret_cast<char *>(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<uint8_t> &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<uint8_t> &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<uint8_t> &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<uint8_t> 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<uint8_t> payload = {0xaa, 0xbb, 0xcc};
std::vector<uint8_t> frame = makeFrame(payload);
// First poll sees only 3 of the 4 header bytes.
feedStreamPath(api, stream, std::vector<uint8_t>(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<uint8_t>(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<uint8_t>(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<uint8_t> payload = {0x12, 0x34};
std::vector<uint8_t> 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<uint8_t> payload = {0x55, 0x66};
const char *bootLog = "INFO | ??:??:?? 1 Booting\r\n";
std::vector<uint8_t> burst(bootLog, bootLog + strlen(bootLog));
std::vector<uint8_t> 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<uint8_t> payload = {0x77};
// MAX_TO_FROM_RADIO_SIZE is 512, so a big-endian length of 513 must fail header validation.
std::vector<uint8_t> burst = {kStart1, kStart2, 0x02, 0x01};
const char *junk = "junk";
burst.insert(burst.end(), junk, junk + strlen(junk));
std::vector<uint8_t> 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<uint8_t> payload(MAX_TO_FROM_RADIO_SIZE);
for (size_t i = 0; i < payload.size(); i++)
payload[i] = (uint8_t)(i & 0xff);
std::vector<uint8_t> 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<uint8_t>(frame.begin(), frame.begin() + split));
TEST_ASSERT_EQUAL_UINT(0, bufferApi.deliveries.size());
feedBufferPath(bufferApi, std::vector<uint8_t>(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<uint8_t> 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<uint8_t> first = {0x01, 0x02, 0x03};
std::vector<uint8_t> second = {0xf0, 0x0d};
std::vector<uint8_t> burst = makeFrame(first);
std::vector<uint8_t> 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<uint8_t> payload = {0x80, kStart1, kStart2, 0xff, 0x00, 0xfe, 0x7f, 0x81};
std::vector<uint8_t> 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<uint8_t> payload = {0x42};
std::vector<uint8_t> frame = makeFrame(payload);
std::vector<uint8_t> 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<uint8_t> payload = {0x43, 0x44};
std::vector<uint8_t> frame = makeFrame(payload);
std::vector<uint8_t> 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<uint8_t> payload = {0x45};
std::vector<uint8_t> frame = makeFrame(payload);
std::vector<uint8_t> 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() {}
+469 -7
View File
@@ -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 <unity.h>
void setUp(void) {}
void tearDown(void) {}
#ifdef FSCom
#include "SPILock.h"
#include <algorithm>
#include <cstdio>
#include <cstring>
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());
}