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:
co-authored by
Claude Opus 5
parent
e1ea653a45
commit
83fd62b756
18 files changed
+6710
-30
No files matched your search
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
@@ -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() {}
|
||||
@@ -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
@@ -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
|
||||
@@ -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() {}
|
||||
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user