diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index e53ebb699b5..045af019a2a 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1852,7 +1852,8 @@ void NodeDB::cleanupMeshDB() // Keep any key we learned (e.g. via a DM before the NodeInfo // exchange completed) rather than losing it with the purge. if (n.public_key.size == 32) - warmStore.absorb(gone, n.last_heard, n.public_key.bytes, n.role, warmProtectedCategory(n)); + warmStore.absorb(gone, n.last_heard, n.public_key.bytes, n.role, warmProtectedCategory(n), + nodeInfoLiteHasXeddsaSigned(&n)); #endif eraseNodeSatellites(gone); @@ -2036,7 +2037,7 @@ void NodeDB::demoteOldestHotNodesToWarm() // Keep the public key if we have one (40 B warm record); keyless nodes // still get a placeholder so re-admission restores last_heard. warmStore.absorb(n.num, n.last_heard, n.public_key.size > 0 ? n.public_key.bytes : nullptr, n.role, - warmProtectedCategory(n)); + warmProtectedCategory(n), nodeInfoLiteHasXeddsaSigned(&n)); // Demotion drops the node from the header table, so drop its satellites // too (the eviction chokepoint) - they'd otherwise orphan until the next // enforceSatelliteCaps pass. @@ -3728,7 +3729,7 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n) // Demote to the warm tier so the identity (and crucially the // PKI key) outlives the hot-store slot. warmStore.absorb(evicted.num, evicted.last_heard, evicted.public_key.size == 32 ? evicted.public_key.bytes : NULL, - evicted.role, warmProtectedCategory(evicted)); + evicted.role, warmProtectedCategory(evicted), nodeInfoLiteHasXeddsaSigned(&evicted)); #endif eraseNodeSatellites(evicted.num); // Shove the remaining nodes down the chain @@ -3757,10 +3758,13 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n) // Re-admission: restore what the warm tier kept for this node WarmNodeEntry warm; if (warmStore.take(n, warm)) { - lite->last_heard = warmTimeOf(warm); // mask off the stolen role/protected metadata bits + lite->last_heard = warmTimeOf(warm); // mask off the stolen metadata bits // Restore the role the warm tier cached, so re-admission isn't stuck at CLIENT // until the next NodeInfo arrives. lite->role = static_cast(warmRoleOf(warm)); + // Restore the signer bit too: it is learned from verified traffic, not from + // NodeInfo, so a round trip through the warm tier must not relearn it from zero. + nodeInfoLiteSetBit(lite, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, warmSignerOf(warm)); if (!memfll(warm.public_key, 0, sizeof(warm.public_key))) { lite->public_key.size = 32; memcpy(lite->public_key.bytes, warm.public_key, 32); diff --git a/src/mesh/WarmNodeStore.cpp b/src/mesh/WarmNodeStore.cpp index 550549ad5c4..cce88e6b058 100644 --- a/src/mesh/WarmNodeStore.cpp +++ b/src/mesh/WarmNodeStore.cpp @@ -13,12 +13,11 @@ #if defined(NRF52840_XXAA) #include "flash/flash_nrf5x.h" -#define WARM_RING_MAGIC 0x324E5257u // "WRN2" - v2: last_heard low bits carry role + protected category +#define WARM_RING_MAGIC 0x334E5257u // "WRN3" - v3: last_heard low bits carry role + protected + signer +#define WARM_RING_MAGIC_V2 0x324E5257u // "WRN2" - v2: role + protected only; bit 6 was still timestamp. #define WARM_RING_MAGIC_V1 0x474E5257u // "WRNG" - v1: last_heard was a plain timestamp. -// v1 pages are still read on upgrade: we keep each record's identity + public key but -// DISCARD its last_heard (the old timestamp would be misread as role/protected bits). -// Records re-rank and re-learn their role on the next contact. Legacy pages convert to -// v2 naturally as the ring rotates. +// Older pages are still read on upgrade: v1 keeps identity + key but discards last_heard, +// v2 keeps the word but clears bit 6, which was still part of the timestamp then. // A tombstone is an entry record whose last_heard is all-ones - getTime() // (unix seconds) cannot reach 0xFFFFFFFF until 2106, and erased flash is // detected via num == 0xFFFFFFFF before last_heard is ever inspected. @@ -34,10 +33,13 @@ struct WarmStoreHeader { }; static_assert(sizeof(WarmStoreHeader) == 16, "header layout is part of the persistence format"); -#define WARM_STORE_MAGIC 0x324D5257u // "WRM2" - v2: last_heard low bits carry role + protected category +#define WARM_STORE_MAGIC 0x334D5257u // "WRM3" - v3: last_heard low bits carry role + protected + signer +#define WARM_STORE_MAGIC_V2 \ + 0x324D5257u // "WRM2" - v2: role + protected only; bit 6 was still timestamp. On upgrade + // we clear the signer bit, then rewrite as v3. #define WARM_STORE_MAGIC_V1 \ 0x314D5257u // "WRM1" - v1: last_heard was a plain timestamp. On upgrade we keep - // identity + key but discard last_heard, then rewrite as v2. + // identity + key but discard last_heard, then rewrite as v3. #ifdef FSCom static const char *warmFileName = "/prefs/warm.dat"; @@ -134,18 +136,18 @@ WarmNodeEntry *WarmNodeStore::place(NodeNum num, uint32_t lastHeard, const uint8 return slot; } -bool WarmNodeStore::absorb(NodeNum num, uint32_t lastHeard, const uint8_t *key32, uint8_t role, uint8_t protectedCat) +bool WarmNodeStore::absorb(NodeNum num, uint32_t lastHeard, const uint8_t *key32, uint8_t role, uint8_t protectedCat, bool signer) { - // Pack role + protected category into the low bits of last_heard. place() and ring - // replay store the raw word verbatim, so the metadata round-trips through flash. - const uint32_t packed = warmPackLastHeard(lastHeard, role, protectedCat); + // Pack role + protected category + signer into the low bits of last_heard. place() and + // ring replay store the raw word verbatim, so the metadata round-trips through flash. + const uint32_t packed = warmPackLastHeard(lastHeard, role, protectedCat, signer); const WarmNodeEntry *slot = place(num, packed, key32); if (!slot) return false; persistEntry(*slot); - LOG_MIGRATION("WarmStore absorb 0x%08x key=%d last_heard=%u role=%u prot=%u (now %u/%u)", (unsigned)num, + LOG_MIGRATION("WarmStore absorb 0x%08x key=%d last_heard=%u role=%u prot=%u signer=%u (now %u/%u)", (unsigned)num, keyIsSet(slot->public_key) ? 1 : 0, (unsigned)warmTimeOf(*slot), (unsigned)role, (unsigned)protectedCat, - (unsigned)count(), (unsigned)capacity()); + signer ? 1u : 0u, (unsigned)count(), (unsigned)capacity()); return true; } @@ -258,19 +260,24 @@ bool WarmNodeStore::saveIfDirty() // (stranded live entries re-appended, then erased). Flash access holds spiLock - // the page cache is shared with InternalFS/LittleFS. -bool WarmNodeStore::ringReadHeader(uint8_t page, WarmPageHeader &h, bool *legacy) const +bool WarmNodeStore::ringReadHeader(uint8_t page, WarmPageHeader &h, WarmFormat *fmt) const { flash_nrf5x_read(&h, WARM_FLASH_PAGE_ADDR(page), sizeof(h)); if (h.seq == 0xFFFFFFFFu) return false; // erased page if (h.magic == WARM_RING_MAGIC) { - if (legacy) - *legacy = false; + if (fmt) + *fmt = WarmFormat::Current; + return true; + } + if (h.magic == WARM_RING_MAGIC_V2) { + if (fmt) + *fmt = WarmFormat::V2; // replay it, but clear the signer bit (see WARM_RING_MAGIC_V2) return true; } if (h.magic == WARM_RING_MAGIC_V1) { - if (legacy) - *legacy = true; // v1 page: replay it, but discard last_heard (see WARM_RING_MAGIC_V1) + if (fmt) + *fmt = WarmFormat::V1; // replay it, but discard last_heard (see WARM_RING_MAGIC_V1) return true; } return false; @@ -386,13 +393,13 @@ void WarmNodeStore::load() // Order valid pages by ascending seq so replay applies oldest first uint8_t order[WARM_FLASH_PAGES] = {}; uint32_t seqs[WARM_FLASH_PAGES] = {}; - bool legacyOf[WARM_FLASH_PAGES] = {}; // per-page: v1 (WRNG) → discard last_heard on replay + WarmFormat fmtOf[WARM_FLASH_PAGES] = {}; // per-page on-flash format; older ones are normalised on replay uint8_t nValid = 0; uint8_t nCorrupt = 0; for (uint8_t p = 0; p < WARM_FLASH_PAGES; p++) { WarmPageHeader h; - bool legacy = false; - if (!ringReadHeader(p, h, &legacy)) { + WarmFormat fmt = WarmFormat::Current; + if (!ringReadHeader(p, h, &fmt)) { // An erased page reads back all-ones; any other magic is a // partially-written or bit-rotted header we're dropping, so flag it // rather than silently treating the loss as a clean empty ring. @@ -400,7 +407,7 @@ void WarmNodeStore::load() nCorrupt++; continue; } - legacyOf[p] = legacy; + fmtOf[p] = fmt; uint8_t pos = nValid; while (pos > 0 && static_cast(h.seq - seqs[pos - 1]) < 0) { order[pos] = order[pos - 1]; @@ -427,7 +434,7 @@ void WarmNodeStore::load() uint32_t migrated = 0; for (uint8_t k = 0; k < nValid; k++) { const uint8_t p = order[k]; - const bool legacy = legacyOf[p]; + const WarmFormat fmt = fmtOf[p]; uint16_t slot = 0; for (; slot < kRecordsPerPage; slot++) { WarmNodeEntry rec; @@ -444,12 +451,15 @@ void WarmNodeStore::load() memset(e, 0, sizeof(*e)); } } else { - // v1 (legacy) record: keep identity + key, but discard the old timestamp - - // its low bits would otherwise be misread as role/protected metadata. + // Normalise older records: v1's timestamp would be misread as role/protected, + // and v2's bit 6 as a signer we never verified. uint32_t lh = rec.last_heard; - if (legacy) { + if (fmt == WarmFormat::V1) { lh = 0; migrated++; + } else if (fmt == WarmFormat::V2) { + lh &= ~(WARM_SIGNER_MASK << WARM_SIGNER_SHIFT); + migrated++; } const WarmNodeEntry *e = place(rec.num, lh, rec.public_key); if (e) @@ -460,17 +470,16 @@ void WarmNodeStore::load() activePage = p; writeSlot = slot; nextSeq = seqs[k] + 1; - // If the head is a v1 page, force the next append to rotate into a fresh v2 page, - // so new (v2) records never land in a page whose header says v1 (which would make - // a later load discard their last_heard - including the role/protected we just set). - if (legacy) + // Rotate rather than append into an older-format page: a later load would + // normalise the new records to that page's format and drop metadata. + if (fmt != WarmFormat::Current) writeSlot = kRecordsPerPage; } } if (nCorrupt) LOG_WARN("WarmStore: dropped %u corrupt ring page(s), some nodes lost", nCorrupt); if (migrated) - LOG_INFO("WarmStore: migrated %u v1 record(s) (kept key, discarded last_heard)", (unsigned)migrated); + LOG_INFO("WarmStore: migrated %u legacy record(s) to the current format", (unsigned)migrated); LOG_INFO("WarmStore: replayed %u ring records -> %u live nodes (page %u, slot %u)", (unsigned)replayed, (unsigned)count(), activePage, writeSlot); } @@ -537,10 +546,14 @@ void WarmNodeStore::load() LOG_WARN("WarmStore: %s header read failed, starting empty", warmFileName); return; } - // v1 (WRM1) is still accepted: same record size, but its last_heard was a plain - // timestamp. We keep identity + key and discard last_heard on load (see below). - const bool legacy = (h.magic == WARM_STORE_MAGIC_V1); - if ((h.magic != WARM_STORE_MAGIC && !legacy) || h.entrySize != sizeof(WarmNodeEntry) || h.count > WARM_NODE_COUNT) { + // Older snapshots are still accepted: same record size, fewer metadata bits in + // last_heard. Both are normalised to the current format below. + const bool known = h.magic == WARM_STORE_MAGIC || h.magic == WARM_STORE_MAGIC_V2 || h.magic == WARM_STORE_MAGIC_V1; + const WarmFormat fmt = h.magic == WARM_STORE_MAGIC_V1 ? WarmFormat::V1 + : h.magic == WARM_STORE_MAGIC_V2 ? WarmFormat::V2 + : WarmFormat::Current; + const bool legacy = fmt != WarmFormat::Current; + if (!known || h.entrySize != sizeof(WarmNodeEntry) || h.count > WARM_NODE_COUNT) { f.close(); LOG_WARN("WarmStore: %s header invalid (magic=0x%08x entrySize=%u count=%u), starting empty", warmFileName, h.magic, h.entrySize, h.count); @@ -560,19 +573,24 @@ void WarmNodeStore::load() memset(entries, 0, WARM_NODE_COUNT * sizeof(WarmNodeEntry)); return; } - if (legacy) { - // Migrate v1 → v2: discard the old last_heard (its low bits would be misread as - // role/protected); keep num + public_key. Mark dirty so save() rewrites as v2. + // Normalise older records, then mark dirty so save() rewrites in the current format: + // v1's timestamp would be misread as role/protected, v2's bit 6 as an unverified signer. + if (fmt == WarmFormat::V1) { for (size_t i = 0; i < WARM_NODE_COUNT; i++) if (entries[i].num) entries[i].last_heard = 0; dirty = true; + } else if (fmt == WarmFormat::V2) { + for (size_t i = 0; i < WARM_NODE_COUNT; i++) + if (entries[i].num) + entries[i].last_heard &= ~(WARM_SIGNER_MASK << WARM_SIGNER_SHIFT); + dirty = true; } } else { f.close(); } LOG_INFO("WarmStore: loaded %u warm nodes from %s%s", h.count, warmFileName, - legacy ? " (v1 migrated: discarded last_heard)" : ""); + legacy ? " (migrated from an older format)" : ""); } bool WarmNodeStore::save() diff --git a/src/mesh/WarmNodeStore.h b/src/mesh/WarmNodeStore.h index 4cacbbc0ca3..96bfd3ec0ee 100644 --- a/src/mesh/WarmNodeStore.h +++ b/src/mesh/WarmNodeStore.h @@ -43,27 +43,33 @@ static_assert(sizeof(WarmNodeEntry) == 40, "WarmNodeEntry must stay 40 B - persi // // The warm tier only uses last_heard to LRU-rank evicted (long-tail) nodes, so ~minute // recency resolution is plenty. We reclaim the low WARM_META_BITS of that field to carry -// the evicted node's device role + a protected category, at zero cost to record size -// (entry stays 40 B; no RAM/flash growth). The high bits remain a real unix-seconds -// timestamp quantised to (1 << WARM_META_BITS) seconds. +// the evicted node's device role, a protected category + a signer flag, at zero cost to +// record size (entry stays 40 B; no RAM/flash growth). The high bits remain a real +// unix-seconds timestamp quantised to (1 << WARM_META_BITS) seconds. // // Safe because: a real timestamp can never be all-ones (the tombstone sentinel) before // 2106, and tombstones/erased flash are detected via num before last_heard is read. Only // the LOW bits are stolen - the high (era) bits are untouched, so the time range is intact. -static constexpr uint32_t WARM_META_BITS = 6; // role(4) + protected(2) -static constexpr uint32_t WARM_META_MASK = (1u << WARM_META_BITS) - 1; // 0x3F → 64 s quantum -static constexpr uint32_t WARM_TIME_MASK = ~WARM_META_MASK; // 0xFFFFFFC0 +static constexpr uint32_t WARM_META_BITS = 7; // role(4) + protected(2) + signer(1) +static constexpr uint32_t WARM_META_MASK = (1u << WARM_META_BITS) - 1; // 0x7F → 128 s quantum +static constexpr uint32_t WARM_TIME_MASK = ~WARM_META_MASK; // 0xFFFFFF80 static constexpr uint32_t WARM_ROLE_MASK = 0x0Fu; // bits [3:0] device role (0..12) static constexpr uint32_t WARM_PROT_SHIFT = 4; // bits [5:4] protected category static constexpr uint32_t WARM_PROT_MASK = 0x03u; +static constexpr uint32_t WARM_SIGNER_SHIFT = 6; // bit [6] we verified an XEdDSA signature from this node +static constexpr uint32_t WARM_SIGNER_MASK = 0x01u; + +// On-disk record format, from the page/file magic; older ones are normalised by load(). +enum class WarmFormat : uint8_t { Current, V2, V1 }; // Protected category cached alongside role so consumers needn't re-derive the mapping. enum class WarmProtected : uint8_t { None = 0, Role = 1, Flag = 2 }; -inline uint32_t warmPackLastHeard(uint32_t lastHeard, uint8_t role, uint8_t prot) +inline uint32_t warmPackLastHeard(uint32_t lastHeard, uint8_t role, uint8_t prot, bool signer) { return (lastHeard & WARM_TIME_MASK) | (static_cast(role) & WARM_ROLE_MASK) | - ((static_cast(prot) & WARM_PROT_MASK) << WARM_PROT_SHIFT); + ((static_cast(prot) & WARM_PROT_MASK) << WARM_PROT_SHIFT) | + ((signer ? WARM_SIGNER_MASK : 0u) << WARM_SIGNER_SHIFT); } inline uint32_t warmTimeOf(const WarmNodeEntry &e) { @@ -77,6 +83,10 @@ inline uint8_t warmProtOf(const WarmNodeEntry &e) { return static_cast((e.last_heard >> WARM_PROT_SHIFT) & WARM_PROT_MASK); } +inline bool warmSignerOf(const WarmNodeEntry &e) +{ + return ((e.last_heard >> WARM_SIGNER_SHIFT) & WARM_SIGNER_MASK) != 0; +} // Gated on NRF52840_XXAA: the ring sits at 0xEA000 // valid only on the 1 MB-flash nRF52840. @@ -99,9 +109,11 @@ class WarmNodeStore /// entries; otherwise the oldest (keyless-first) entry is replaced. /// @param role the node's device role (meshtastic_Config_DeviceConfig_Role, 0..12) /// @param protectedCat WarmProtected category cached for the hop-trim path + /// @param signer true if we ever verified an XEdDSA signature from this node, so + /// re-admission restores the bit rather than relearning it /// @return true if the node was stored or updated bool absorb(NodeNum num, uint32_t lastHeard, const uint8_t *key32 /* may be NULL */, uint8_t role = 0, - uint8_t protectedCat = 0); + uint8_t protectedCat = 0, bool signer = false); /// Look up the cached device role + protected category for a warm node. /// @return false if the node is not in the warm tier. @@ -167,7 +179,7 @@ class WarmNodeStore void ringAppend(const WarmNodeEntry &rec, int storeSlot /* -1 for tombstones */); void ringRotate(); // reclaim oldest page, compacting stranded live entries void ringOpenPage(uint8_t page); // erase + write header (seq = nextSeq++) - bool ringReadHeader(uint8_t page, WarmPageHeader &h, bool *legacy = nullptr) const; + bool ringReadHeader(uint8_t page, WarmPageHeader &h, WarmFormat *fmt = nullptr) const; #endif bool save(); diff --git a/test/test_nodedb_blocked/test_main.cpp b/test/test_nodedb_blocked/test_main.cpp index a67ba920eb6..04c1933defd 100644 --- a/test/test_nodedb_blocked/test_main.cpp +++ b/test/test_nodedb_blocked/test_main.cpp @@ -132,6 +132,34 @@ static void test_migration_carriesRoleAndProtectedIntoWarm(void) TEST_ASSERT_EQUAL((uint8_t)WarmProtected::None, prot); } +// The signer bit is learned from verified traffic, not NodeInfo, so it must survive a warm +// round trip. The plain node is the control: re-admission restores it, it doesn't invent it. +static void test_migration_carriesSignerBitThroughWarm(void) +{ + db->seedSelf(); + const NodeNum signerNum = 2000 + 3; + const NodeNum plainNum = 2000 + 4; + const int extra = MAX_NUM_NODES + 30; // overflow so the oldest non-protected are demoted + for (int i = 1; i <= extra; i++) + db->push(2000 + i, /*last_heard=*/i, /*favorite=*/false, /*ignored=*/false, /*withUser=*/true, /*withKey=*/true); + nodeInfoLiteSetBit(db->getMeshNode(signerNum), NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true); + TEST_ASSERT_TRUE(nodeInfoLiteHasXeddsaSigned(db->getMeshNode(signerNum))); + + db->runDemote(); + + // Both are out of the hot store and held in the warm tier. + TEST_ASSERT_NULL(db->getMeshNode(signerNum)); + TEST_ASSERT_NULL(db->getMeshNode(plainNum)); + + const meshtastic_NodeInfoLite *back = db->getOrCreateMeshNode(signerNum); + TEST_ASSERT_NOT_NULL(back); + TEST_ASSERT_TRUE_MESSAGE(nodeInfoLiteHasXeddsaSigned(back), "signer bit must survive a warm-tier round trip"); + + const meshtastic_NodeInfoLite *plainBack = db->getOrCreateMeshNode(plainNum); + TEST_ASSERT_NOT_NULL(plainBack); + TEST_ASSERT_FALSE_MESSAGE(nodeInfoLiteHasXeddsaSigned(plainBack), "re-admission must not invent the signer bit"); +} + // Favourite handling: a favourite is never the eviction victim, even when it is // the oldest node in a full hot store. static void test_eviction_preservesFavorite(void) @@ -206,6 +234,7 @@ NDB_TEST_ENTRY void setup() UNITY_BEGIN(); RUN_TEST(test_migration_demotesOldestKeepsKeepersAndSelf); RUN_TEST(test_migration_carriesRoleAndProtectedIntoWarm); + RUN_TEST(test_migration_carriesSignerBitThroughWarm); RUN_TEST(test_eviction_preservesFavorite); RUN_TEST(test_ignored_survivesEvictionAndCleanup); RUN_TEST(test_protectedCap_refusesBeyondLimit); diff --git a/test/test_warm_store/test_main.cpp b/test/test_warm_store/test_main.cpp index e26d9ee456d..389ddc56027 100644 --- a/test/test_warm_store/test_main.cpp +++ b/test/test_warm_store/test_main.cpp @@ -116,15 +116,15 @@ void test_ws_keyedCandidate_evictsOldestKeylessFirst() // Fill with keyed entries except two keyless ones in the middle for (size_t i = 0; i < ws.capacity(); i++) { const bool keyless = (i == 5 || i == 10); - // Timestamps spaced by the 64 s warm metadata quantum (<<6) so LRU order survives - // quantisation: keyless i=10 is oldest (50), i=5 next (60), keyed all older (10). - TEST_ASSERT_TRUE(ws.absorb(0x1000 + i, (keyless ? (i == 10 ? 50u : 60u) : 10u) << 6, keyless ? NULL : key)); + // One quantum apart (shift by WARM_META_BITS) so LRU order survives quantisation: + // keyless i=10 is oldest (50), i=5 next (60), keyed all older (10). + TEST_ASSERT_TRUE(ws.absorb(0x1000 + i, (keyless ? (i == 10 ? 50u : 60u) : 10u) << WARM_META_BITS, keyless ? NULL : key)); } // Keyed candidate must displace the OLDEST KEYLESS entry (0x100A, ts=50), // even though every keyed entry is older (ts=10) uint8_t k2[32]; makeKey(k2, 0x43); - TEST_ASSERT_TRUE(ws.absorb(0x8888, 70u << 6, k2)); + TEST_ASSERT_TRUE(ws.absorb(0x8888, 70u << WARM_META_BITS, k2)); TEST_ASSERT_FALSE(ws.contains(0x1000 + 10)); TEST_ASSERT_TRUE(ws.contains(0x1000 + 5)); TEST_ASSERT_TRUE(ws.contains(0x8888)); @@ -171,6 +171,31 @@ void test_ws_meta_roundTrip() TEST_ASSERT_EQUAL((uint8_t)WarmProtected::None, prot); } +// The signer flag rides the same packed word as role/protected, so it must survive a +// round trip without disturbing them (or the quantised timestamp). +void test_ws_signer_roundTrip() +{ + WarmNodeStore ws; + uint8_t key[32]; + makeKey(key, 0x78); + TEST_ASSERT_TRUE(ws.absorb(0x710, 1234, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role, /*signer=*/true)); + TEST_ASSERT_TRUE(ws.absorb(0x711, 1234, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role, /*signer=*/false)); + + WarmNodeEntry e; + TEST_ASSERT_TRUE(ws.take(0x710, e)); + TEST_ASSERT_TRUE_MESSAGE(warmSignerOf(e), "signer flag must round trip"); + TEST_ASSERT_EQUAL(5, warmRoleOf(e)); // and must not disturb its neighbours in the word + TEST_ASSERT_EQUAL((uint8_t)WarmProtected::Role, warmProtOf(e)); + TEST_ASSERT_EQUAL(1234u & WARM_TIME_MASK, warmTimeOf(e)); + + // Control: without the flag the same entry reads back clear, so the accessor is + // reporting the stored bit rather than always-true. + TEST_ASSERT_TRUE(ws.take(0x711, e)); + TEST_ASSERT_FALSE(warmSignerOf(e)); + TEST_ASSERT_EQUAL(5, warmRoleOf(e)); + TEST_ASSERT_EQUAL((uint8_t)WarmProtected::Role, warmProtOf(e)); +} + void test_ws_remove_and_clear() { WarmNodeStore ws; @@ -257,6 +282,63 @@ void test_ws_v1_migration_discardsLastHeard() b.saveIfDirty(); } +// A v2 (WRM2) warm.dat used bit 6 as a timestamp bit, so loading one must not read it as a +// signer, while role/protected/time carry over. File backend only. +void test_ws_v2_migration_clearsSignerBit() +{ + WarmNodeStore a; + uint8_t key[32], got[32]; + makeKey(key, 0x67); + // signer=true sets bit 6, standing in for a v2 record whose timestamp had it set. + a.absorb(0x910, 123456, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role, /*signer=*/true); + if (!a.saveIfDirty()) { + TEST_IGNORE_MESSAGE("Filesystem not available in this test environment"); + return; + } + + // Flip the 4-byte header magic to v2 ("WRM2"). CRC covers only the entry bytes, so + // patching the header keeps it valid. + std::vector buf; + { + auto f = FSCom.open("/prefs/warm.dat", FILE_O_READ); + if (!f) { + TEST_IGNORE_MESSAGE("warm.dat not readable in this environment"); + return; + } + buf.resize(f.size()); + const size_t got = f.read(buf.data(), buf.size()); + f.close(); + TEST_ASSERT_EQUAL_MESSAGE(buf.size(), got, "short read patching warm.dat"); + } + TEST_ASSERT_TRUE(buf.size() >= 4); + const uint32_t v2magic = 0x324D5257u; // "WRM2" + memcpy(buf.data(), &v2magic, sizeof(v2magic)); + { + auto f = FSCom.open("/prefs/warm.dat", FILE_O_WRITE); + TEST_ASSERT_TRUE((bool)f); + const size_t wrote = f.write(buf.data(), buf.size()); + f.close(); + TEST_ASSERT_EQUAL_MESSAGE(buf.size(), wrote, "short write patching warm.dat"); + } + + WarmNodeStore b; + b.load(); + TEST_ASSERT_TRUE(b.contains(0x910)); + TEST_ASSERT_TRUE(b.copyKey(0x910, got)); + TEST_ASSERT_EQUAL_MEMORY(key, got, 32); + + WarmNodeEntry e; + TEST_ASSERT_TRUE(b.take(0x910, e)); + TEST_ASSERT_FALSE_MESSAGE(warmSignerOf(e), "a v2 timestamp bit must not read as a signer"); + // Unlike v1, v2 kept role/protected/time in place, so they survive the migration. + TEST_ASSERT_EQUAL(123456u & WARM_TIME_MASK, warmTimeOf(e)); + TEST_ASSERT_EQUAL(5, warmRoleOf(e)); + TEST_ASSERT_EQUAL((uint8_t)WarmProtected::Role, warmProtOf(e)); + + b.clear(); + b.saveIfDirty(); +} + // Shrink safety: a warm.dat snapshot recording more entries than this build's // WARM_NODE_COUNT (e.g. written before a per-platform tier reduction) must be // rejected cleanly at the header check - load() starts empty instead of reading @@ -314,9 +396,11 @@ WS_TEST_ENTRY void setup() RUN_TEST(test_ws_keyedCandidate_evictsOldestKeylessFirst); RUN_TEST(test_ws_keyedCandidate_evictsOldestKeyedWhenNoKeyless); RUN_TEST(test_ws_meta_roundTrip); + RUN_TEST(test_ws_signer_roundTrip); RUN_TEST(test_ws_remove_and_clear); RUN_TEST(test_ws_persistence_roundTrip); RUN_TEST(test_ws_v1_migration_discardsLastHeard); + RUN_TEST(test_ws_v2_migration_clearsSignerBit); RUN_TEST(test_ws_load_rejectsOversizedSnapshot); exit(UNITY_END()); }