Files
Thomas Göttgens 34190aac07 fix(nodedb): drop satellite entries that no hot node owns (#11808)
* fix(nodedb): drop satellite entries that no hot node owns

* test(nodedb): assert every persisted satellite key is owned
2026-09-10 15:50:48 +00:00

764 lines
30 KiB
C++

// 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;
}
/// A header row as a saved nodes.proto carries it. HAS_USER matters: cleanupMeshDB
/// purges a userless row on load and erases its satellites with it.
meshtastic_NodeInfoLite craftedOwner(NodeNum num, uint32_t lastHeard)
{
meshtastic_NodeInfoLite n = meshtastic_NodeInfoLite_init_zero;
n.num = num;
n.last_heard = lastHeard;
n.bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK;
return n;
}
meshtastic_NodePositionEntry craftedPosition(NodeNum num, int32_t lat)
{
meshtastic_NodePositionEntry e = meshtastic_NodePositionEntry_init_zero;
e.num = num;
e.has_position = true;
e.position.latitude_i = lat;
e.position.time = 1000 + (uint32_t)lat;
return e;
}
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. Every entry has a hot
// owner, so the boot orphan sweep keeps all of them and only the cap trims.
meshtastic_NodeDatabase crafted{};
crafted.version = DEVICESTATE_CUR_VER;
for (size_t i = 0; i < (size_t)MAX_SATELLITE_NODES + overBy; i++) {
const NodeNum num = base + (uint32_t)i;
crafted.nodes.push_back(craftedOwner(num, 1000 + (uint32_t)i));
crafted.positions.push_back(craftedPosition(num, (int32_t)(1000 + i)));
}
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, stalest owner 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
// --- Boot-time heal of a nodes.proto carrying unowned satellite entries ---
#if !MESHTASTIC_EXCLUDE_POSITIONDB
// Guards #11798: satellite entries whose key names no hot node are dropped on boot and the
// healed store is rewritten once; keys that cannot name a node (0, NODENUM_BROADCAST) are
// refused at decode.
static void test_bootHeal_unownedSatellitesDropped(void)
{
const NodeNum ownedBase = 0x72000000u;
const NodeNum orphanBase = 0x73000000u;
const size_t owned = 5;
const size_t orphans = 6;
meshtastic_NodeDatabase crafted{};
crafted.version = DEVICESTATE_CUR_VER;
for (size_t i = 0; i < owned; i++) {
const NodeNum num = ownedBase + (uint32_t)i;
crafted.nodes.push_back(craftedOwner(num, 1000 + (uint32_t)i));
crafted.positions.push_back(craftedPosition(num, (int32_t)(100 + i)));
}
for (size_t i = 0; i < orphans; i++)
crafted.positions.push_back(craftedPosition(orphanBase + (uint32_t)i, (int32_t)(200 + i)));
// Keys no NodeNum derivation can produce; these must never reach the map.
crafted.positions.push_back(craftedPosition(0, 300));
crafted.positions.push_back(craftedPosition(NODENUM_BROADCAST, 301));
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();
// In RAM: every owned entry kept, every unowned one gone.
for (size_t i = 0; i < owned; i++)
TEST_ASSERT_TRUE_MESSAGE(db->hasNodePosition(ownedBase + (uint32_t)i), "hot-owned entry must survive the sweep");
for (size_t i = 0; i < orphans; i++)
TEST_ASSERT_FALSE_MESSAGE(db->hasNodePosition(orphanBase + (uint32_t)i), "orphan must be swept on boot");
TEST_ASSERT_FALSE_MESSAGE(db->hasNodePosition(0), "key 0 must be refused at decode");
TEST_ASSERT_FALSE_MESSAGE(db->hasNodePosition(NODENUM_BROADCAST), "broadcast key must be refused at decode");
// 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)
continue;
TEST_ASSERT_TRUE_MESSAGE(e.num >= ownedBase && e.num < ownedBase + owned, "healed store must contain only owned entries");
persisted++;
}
TEST_ASSERT_EQUAL_UINT_MESSAGE((unsigned)owned, (unsigned)persisted, "boot must rewrite the store without the orphans");
}
#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);
RUN_TEST(test_bootHeal_unownedSatellitesDropped);
#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