* Harden TMM NodeInfo direct-response: staleness, key hygiene, throttle The NodeInfo direct-response path answered queries on behalf of other nodes from an unauthenticated, never-expiring cache while suppressing the genuine request (STOP). That let a long-gone or forged identity be served as a fresh-looking, authoritative reply indefinitely. Three fixes: - Staleness gate: refuse to spoof a reply for a node not actually heard within ~6h (PSRAM cache via lastObservedMs; NodeDB fallback via sinceLastSeen, which tolerates last_heard==0 when the clock is unset). A stale entry now falls through so the real request propagates instead of being answered for a dead node. - Key hygiene: mirror NodeDB::updateUser() PKI checks when caching overheard NodeInfo - reject a packet advertising our own public key, and pin keys (drop a NodeInfo whose key mismatches an already-known key from NodeDB or from our own cache). Prevents cache poisoning / key substitution via the spoofed-reply path. - Response throttle: at most one spoofed reply per target node per 30s. Direct responses bypass the per-sender rate limiter (they STOP the request first) and the reply target is attacker-controlled, so this bounds airtime exhaustion and reflected floods. Recorded in a new NodeInfoPayloadEntry.lastResponseMs (PSRAM; self-expiring timestamp compare, no sweep needed). Tests: native NodeDB-fallback staleness (stale drops, fresh serves) plus PSRAM-guarded staleness, throttle, and key-mismatch cases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Add TODO(T1-T9) markers for code-review findings on NodeInfo hardening Comment-only. Tags each site flagged in the review of the direct-response throttle/staleness/key-hygiene change so the follow-ups are visible in-context: T1 throttle black-holes distinct requestors; T2 per-target key doesn't bound aggregate TX; T3 non-PSRAM fallback unthrottled; T4 millis-wrap defeats the staleness gate; T5 redundant second O(n) scan for the throttle stamp; T6 duplicated 32-byte key compares; T7 sinceLastSeen() called twice; T8 two 6h constants can desync; T9 clockMs()==0 collides with the 0 sentinel. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Address code-review cleanups T6, T7, T8 in NodeInfo direct-response T8: derive kNodeInfoMaxServeAgeSecs from kNodeInfoMaxServeAgeMs so the PSRAM and NodeDB-fallback staleness windows cannot desync. T6: collapse the three duplicated 32-byte key compares in cacheNodeInfoPacket (owner-impersonation + NodeDB pin + cache TOFU pin) into one pubKeysEqual() helper, so the compare lives in one place. T7: compute sinceLastSeen(node) once on the fallback staleness path so the tested age and the logged age cannot diverge (it calls getTime() each time). Remaining review items still marked in-code: T1/T2/T3 (throttle design), T4/T9 (millis-wrap + 0-sentinel), T5 (redundant throttle-stamp scan). Native test_traffic_management suite: 48/48 passing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Throttle the NodeDB fallback direct-response path (T3) The per-target NodeInfo response throttle lived only in the PSRAM NodeInfoPayloadEntry, so non-PSRAM boards - which answer from the NodeDB fallback path - emitted spoofed direct replies with no rate limit at all, leaving the airtime/reflection surface the throttle exists to close. Add a single module-global fallback stamp (nodeInfoFallbackLastResponseMs, 4 bytes, guarded by cacheLock) that throttles the fallback path to one spoofed reply per window across all targets. Coarser than the PSRAM per-target throttle, but the fallback path has no per-node slot to stamp, and a global cap still bounds spoofed transmissions - which is the point. Add a native test exercising the fallback throttle window. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Make NodeInfo staleness/throttle wrap- and sentinel-safe (T4, T9) T9: the millis-based fields that use 0 as a "never" sentinel (lastObservedMs, lastResponseMs, nodeInfoFallbackLastResponseMs) could be stamped with a literal 0 during the one-millisecond clockMs()==0 instant at each ~49.7-day wrap, momentarily colliding with the sentinel and disabling the staleness/throttle gate for that entry. Route all such stamps through a new nowStampMs() that maps 0 -> 1; the 1 ms skew is irrelevant to every window these fields feed. T4: the staleness gate's modular age compare is only unambiguous while true age < 2^32 ms (~49.7 days); an entry that lingered unrefreshed that long could wrap to a small age and read as fresh, defeating the gate. Add a NodeInfo eviction pass to the maintenance sweep that drops entries past the serve window, so an entry is removed long before its age can approach the wrap boundary. This also frees slots holding stale identities. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Stamp PSRAM throttle entry by captured index, not a rescan (T5) The post-send throttle stamp did a second full O(n) scan of the PSRAM NodeInfo array under a fresh lock, even though findNodeInfoEntry() had already located the slot at the top of shouldRespondToNodeInfo(). Capture the slot index during that initial lookup and address the entry directly on the stamp path. Because the cache lock is released between the two accesses, the slot could have been evicted or reused, so re-validate node == p->to under the lock before stamping. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Document accepted per-target throttle tradeoffs (T1, T2) Convert the T1/T2 TODO markers into a NOTE: the per-target PSRAM throttle keying and its lack of an aggregate bound are deliberate design choices, not pending work. A distinct requestor being throttled for one window is harmless on a redundant mesh, and keeping the PSRAM path per-target preserves throughput for legitimate multi-target responders (the fallback path already bounds aggregate via its global stamp). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Track signed-provenance of cached NodeInfo public keys Add keySignerProven to NodeInfoPayloadEntry, set when an observed NODEINFO frame's XEdDSA signature was verified (mp.xeddsa_signed). This distinguishes a trust-on-first-use key from one proven to belong to a signer. The flag is monotonic - once proven it stays proven, and the existing key-pin checks forbid the underlying key from changing - so a later unsigned frame cannot downgrade it. A signature can only be verified against a key we already held, so a first-contact key is always TOFU until a later signed frame upgrades it. Groundwork for using the TMM cache as a last-resort public-key source, where this flag serves as a trust tier. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Draw public keys from the TMM NodeInfo cache as a last resort Add TrafficManagementModule::copyPublicKey() and consult it from NodeDB::copyPublicKey() after the hot (NodeInfoLite) and warm (WarmNodeStore) tiers miss. This extends the pool of peers the node can encrypt to: a key the NodeInfo direct-response cache overheard for a node that has since aged out of both NodeDB tiers can still be used. The getter reports whether the key is signer-proven or trust-on-first-use. NodeDB serves TOFU keys here too - the same first-contact trust NodeDB already applies in updateUser() - so the pool actually expands to new long-tail nodes rather than only re-confirming known keys. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Retain keyed NodeInfo entries as a pubkey pool; trust-tier eviction Now that NodeDB::copyPublicKey() draws keys from the NodeInfo cache, the flat 6 h serve-window eviction would discard useful keys the moment a node stops being servable. Split retention: an entry carrying a 32-byte public key is kept for kNodeInfoKeyRetentionMs (7 d), while a keyless entry still expires at the serve window. Both windows stay well under the ~49.7-day millis wrap, preserving the T4 wrap-safety guarantee. Make LRU victim selection trust-tiered to match: a keyless slot is sacrificed before any keyed one, and a trust-on-first-use key before a signer-proven key; within a tier the oldest loses. Mirrors WarmNodeStore's keyed-first admission so the most valuable keys are the stickiest under memory pressure. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Test signed-provenance flag and copyPublicKey key-pool source PSRAM-gated tests (run on the ESP32 build, alongside the existing NodeInfo PSRAM tests): - copyPublicKey serves a TOFU key learned from an unsigned NodeInfo and reports signerProven=false - a later signature-verified NodeInfo upgrades provenance to signer-proven while the pinned key bytes stay unchanged - copyPublicKey reports a miss for an uncached node Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Inherit signer provenance from NodeDB when caching a re-found node When the TrafficManagement NodeInfo cache re-caches a node, mark its key signer-proven if NodeDB already knows the node as a verified signer for that same key - even if this particular (unicast/unsigned) frame carried no signature. Previously the flag only upgraded on a frame we verified ourselves, so a node we had already proven elsewhere looked TOFU here. Add NodeDB::isVerifiedSignerForKey(), which checks both tiers - the hot store's signed bitfield and the warm tier's cached signer bit - and requires the key to match so a rotated/mismatched key never inherits a stale verdict. Add WarmNodeStore::isVerifiedSigner() to expose the warm signer bit (the rebase onto develop added the bit itself; this surfaces it for lookups). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Lock NodeInfoPayloadEntry packing with a size assert keySignerProven cost zero bytes: sourceChannel, the two bools, and decodedBitfield are four 1-byte fields that fill a single 4-byte tail word (struct alignment is 4), so the flag consumed former padding rather than growing the 2000-entry PSRAM array. Add a static_assert pinning the entry to sizeof(meshtastic_User) + 20 so a future 5th trailing byte - which would open a fresh word (~8 KB PSRAM across the array) - fails the build instead of silently costing memory, prompting new flags to be packed into existing bytes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Pack NodeInfo cache booleans into 1-bit fields Fold hasDecodedBitfield and keySignerProven into adjacent uint8_t:1 bitfields so they share a single byte, reserving 6 spare bits for future flags without growing the 2000-entry PSRAM array. sizeof is unchanged (still one packed tail word, sizeof(meshtastic_User) + 20); access is by name exactly as before, so no call sites change. Reorders decodedBitfield ahead of the flags so the two 1-bit members stay adjacent and the compiler packs them together. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Gate NodeInfo replay on signer-proven provenance (compile-time, default on) Add TMM_NODEINFO_REPLAY_REQUIRE_SIGNED (default 1): the direct-response path now only spoofs a reply for a node whose key is signer-proven, in addition to the staleness gate - so replay is based on flagging AND staleness. Replay is a courtesy feature, and vouching for an unverified (trust-on-first-use) identity to other nodes is the risk this closes, so signed-only is the safer default. Define the macro to 0 at build time to also serve fresh TOFU-only nodes. Both paths are gated: the PSRAM path checks the cached keySignerProven flag, the NodeDB fallback checks nodeInfoLiteHasXeddsaSigned(node). The effective gate (TMM_NODEINFO_REPLAY_SIGNED_GATE) is bypassed when PKI is excluded from the build, since nothing can be signed there and the courtesy feature would otherwise be disabled outright. Tests: existing reply-expecting tests establish signer-proven state (a new markKeySignerProvenForTest hook for the PSRAM cache, the NodeDB signed bit for the fallback path); add fallback and PSRAM tests asserting an unsigned/TOFU node is withheld under the default gate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Rehydrate re-admitted node names from the TMM NodeInfo cache The warm tier keeps an evicted node's key but not its name (the 40-byte record has no room), so a re-admitted long-tail node is nameless until its next NodeInfo broadcast. The TrafficManagement NodeInfo cache is much larger (2000 PSRAM entries) and commonly still holds the full User, so use it as a name reservoir the warm tier structurally cannot be. On re-admission (getOrCreateMeshNode), after the warm-tier restore, copy the cached identity from the TMM cache via a new copyUser() getter - but only when its cached key matches the key just restored from warm, so a name never attaches to a different identity than the one we encrypt to (key-matched trust). Guarded by HAS_TRAFFIC_MANAGEMENT and a null check; a no-op without the PSRAM cache or when no key is present. CopyUserToNodeInfoLite sets only user-related bits, so the warm-restored signer bit is preserved. Limitations (by design): PSRAM-only, and the TMM cache is RAM-only, so this helps within an uptime session, not across reboot. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Drop non-portable NodeInfoPayloadEntry size assert (fixes pico build) The static_assert pinned sizeof(NodeInfoPayloadEntry) to sizeof(meshtastic_User) + 20, but nanopb packs the generated User struct differently per platform: on pico its size is not a multiple of 4, so alignment padding before the uint32 timestamps makes the overhead 22, not 20, and the assert failed the build (native happened to be +20 and passed, hiding it). The bitfield packing it was guarding still stands; replace the fixed-count assert with a comment, since no portable byte count exists. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Run the NodeInfo cache paths in native tests (TMM_HAS_NODEINFO_CACHE) The NodeInfo direct-response cache and everything layered on it - key pinning, signer provenance, staleness, throttle - was compiled only for ARCH_ESP32 + BOARD_HAS_PSRAM, so its entire test set was skipped by the native CI suite and ran nowhere but real hardware. Fold the scattered platform guards into one TMM_HAS_NODEINFO_CACHE macro that also enables the cache (plain heap, delete[] path already existed) for ARCH_PORTDUINO unit-test builds. Production portduino and embedded test builds are unchanged. Tests that exercise the NodeDB fallback path drop the cache explicitly via a new dropNodeInfoCacheForTest() hook, so both response paths are covered in one binary. Native suite: 60/60 (was 50 with the 10 cache tests compiled out). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT (cherry picked from commit b2dafbbbd6501b42fc27175e599fb38be292b0e3) * Pin NodeInfo cache keys against the warm tier, not just the hot store cacheNodeInfoPacket() mirrored NodeDB::updateUser()'s key pin but checked only getMeshNode() - the hot store. updateUser's own pin effectively covers the warm tier too (getOrCreateMeshNode rehydrates the warm key before the check), so the mirror had a gap: for a node evicted to the warm tier whose cache slot was also gone, an attacker's NodeInfo with a bogus key passed the pin, and the cache's TOFU pin then locked the genuine node's frames out until the poisoned entry aged away. Split the authoritative lookup out of NodeDB::copyPublicKey() as copyPublicKeyAuthoritative() (hot store, then warm tier - never the opportunistic TMM tier, which would compare the cache against itself) and pin against that. Test: warm-tier-only key rejects a mismatching NodeInfo and accepts the matching one. Native suite: 61/61. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT (cherry picked from commit 28e880875ef1f8bf16bdf301a4e6036537a84b4b) * Convert NodeInfo cache clocks to uint8 tick counters Replace the entry's uint32 millis stamps (lastObservedMs, lastResponseMs) with three uint8 free-running tick clocks - the same modular scheme the UnifiedCache counters already use: obsTick 3 min/tick (12.8 h period) - replay staleness gate respTick 5 s/tick (21.3 min) - per-target response throttle retTick 1 h/tick (10.67 d) - retention TTL + LRU age Validity is an explicit flag bit (hasObserved / hasResponded), not a 0 sentinel, and the maintenance sweep saturates a stamp (clears its flag) once its window passes, so no stamp can age toward its ~256-tick aliasing horizon. That retires the wrap/sentinel special cases (T4, T9) - nowStampMs() survives only for the fallback path's module-global millis stamp. obsTick is separated from retention by design: only a genuinely heard NODEINFO frame stamps it, so later membership-based retention refresh can never make a silent node look servable. Also drops lastObservedRxTime, which was only echoed in a debug log. Entry shrinks 136 -> 128 B; the 2000-entry array 272 kB -> 256 kB, 8 kB below the original develop footprint while keeping the throttle and retention features. Tick granularity costs at most one tick per window (+-5 s on 30 s, +-3 min on 6 h, +-1 h on 7 d). Native suite: 61/61. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT (cherry picked from commit e48ed9cfacfed0131b77ed2f1992e8720720d387) * Keep the NodeInfo cache a superset of NodeDB (seed + keep-alive) The cache learned identities only from overheard NODEINFO frames, so its membership was opportunistic: a NodeDB-tier node whose frame was never heard this boot had no entry (no key pin to protect it, no name to rehydrate), and the retention TTL evicted entries for nodes that still lived in the hot store or warm tier. Add an anti-entropy pass to the maintenance sweep (reconcileNodeInfoMembershipLocked): every hot-store / warm-tier node gets an entry - full identity from the hot store (hasFullUser), key-only from the warm tier - with signer provenance inherited key-matched, and NodeDB's key adopted wholesale on conflict. Member entries (isMember) are re-stamped each sweep, so they never age toward the retention TTL; when a node leaves both tiers the keep-alive stops and its entry ages out from its final member re-stamp. Membership also outranks key trust in LRU victim selection, and the reconcile pass skips seeding rather than churn one member out for another when hot+warm exceeds the cache. Two properties are load-bearing: - seeding/keep-alive never touch obsTick/hasObserved, so a seeded or retained entry is never served as a spoofed reply - only genuinely heard frames make a node servable; - copyUser() now requires hasFullUser, so a key-only warm seed can never stamp HAS_USER onto a nameless node via name-rehydration. Native suite: 64/64. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT (cherry picked from commit 4f1b30d838b2f7b11173373b8f1ba97cf91bef6b) * Write-through NodeDB identity commits into the NodeInfo cache updateUser() is the single chokepoint through which every remote identity/key write enters NodeDB (key-verification keys stay in CryptoEngine's pending buffer until a NodeInfo carries them here), so one hook at its tail lets the NodeInfo cache reflect a commit immediately instead of waiting up to a minute for the reconcile sweep - which stays in place as the anti-entropy backstop. onNodeIdentityCommitted() upserts the full User: NodeDB's key is authoritative (a conflicting cached key is stale residue - replaced, provenance dropped), a keyless commit keeps an already-cached TOFU key, and signer provenance transfers only for the committed key. The observation stamp is never touched: knowledge is not observation, so a hook write can never make a never-heard node servable - covered by the new test alongside immediate copyUser/copyPublicKey visibility. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT (cherry picked from commit e160b899728be8d951d4e7bdf5106c777832b917) * Purge TrafficManagement caches on explicit node removal NodeDB::removeNodeByNum() already forgets the node everywhere NodeDB owns - hot store, satellite stores, warm tier - but the TrafficManagement caches kept the deleted identity: the NodeInfo entry went on feeding the key pool (NodeDB::copyPublicKey) and name rehydration, and the unified slot kept its role/next-hop/dedup state, resurrecting the node on next contact. Add purgeNode(): clears both the unified cache slot and the NodeInfo cache entry, called from removeNodeByNum() alongside the warm-tier removal. Removal means full removal; passive eviction never calls this, and the reconcile sweep will not re-seed a node absent from both NodeDB tiers. Test: an observed identity plus a next-hop hint both vanish after removeNodeByNum(). Left for CI to execute per request. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT (cherry picked from commit b5564c173337de99e15ef998a5c698550238fd3c) * Document the tmm-fix-2 / tmm-fix-superset reconciliation decisions Two parallel implementations of the same superset design are being combined on this branch. This decision matrix records every divergence and which side each reconciled feature takes, ahead of the code commits that apply them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT * Reconcile retention: no timed eviction, obsTick LRU, hourly seeding Adopt tmm-fix-superset's retention model (reconciliation decisions #3, #4, #9 in .notes/tmm-super-superset-reconciliation.md): - Entries are never evicted on a timer. The 7-day retention TTL and its third tick clock (retTick) are gone; wrap-safety rests entirely on the sweep's presence-bit saturation, and a quiet entry keeps its value (pubkey pool, name rehydration). Slots are reclaimed only by trust/membership-tiered LRU on insert - age scored by obsTick, with never-observed entries counting oldest - or by an explicit purge. - Membership refresh (entry -> NodeDB contains checks) runs every sweep; the heavy seeding pass runs at boot and then hourly, with the write-through hooks carrying immediacy in between. - Tick constants and helpers move into the header beside the existing UnifiedCache tick idiom, with static_asserts tying the tick windows to the fallback path's seconds/ms forms. Kept from tmm-fix-2 (decision #4): the warm tier is still seeded (key-only records via WarmNodeStore::entryAt) so the invariant remains cache superset-of hot AND warm, and the spareMembers guard still stops seeding from churning one member out for another when hot+warm exceeds the cache. Entry shrinks to 128 B (2000 entries = 256 kB). The TTL-based retention test is superseded by a no-timed-eviction test: a quiet keyed entry survives 9 days of sweeps while the serve gate saturates as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT * Reconcile hooks: key-commit write-through, purgeAll, key-matched signer Adopt tmm-fix-superset's hook coverage (reconciliation decisions #5-#8): - onNodeKeyCommitted(): write-through for the two key-write sites that bypass NodeDB::updateUser - the Router's admin-key learn (TOFU-grade) and KeyVerificationModule's manual-verification commit (proven=true, the strongest provenance the cache can carry). Both were coverage gaps in the tmm-fix-2 write-through design. - updateUser's hook call now transfers signer status key-matched (isVerifiedSignerForKey) instead of the node's bare signed flag, and runs on acceptance rather than only on change. - purgeAll(): factoryReset() and resetNodes() clear both TMM tables - removal-is-full-removal applies to bulk resets too, without relying on the usual post-reset reboot. - purgeNode() reuses the existing finders instead of open-coded scans and logs the (user-initiated) purge. - peekNodeInfoFlagsForTest(): flag introspection so saturation and membership tests can assert sweep effects directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT * Merge the two branches' test suites for the reconciled design Port tmm-fix-superset's unique tests, adapted to run natively under TMM_HAS_NODEINFO_CACHE (reconciliation decision #11): - keyHook_upsertsAndGovernsProvenance: TOFU learn -> manual-verification upgrade -> NodeDB-senior rotation resets provenance. - tickSaturation_sweepClearsObserved: the sweep saturates hasObserved past the serve window, the entry persists (no TTL), and a full 256-tick clock wrap cannot alias a saturated stamp back to fresh. - sweepMembershipMarking: reconciliation seeds a hot identity as member+fullUser+unobserved; the next sweep clears membership once the node leaves NodeDB, while the entry itself persists. tmm-fix-2's tests (warm pin, hot/warm seeding, updateUser write-through, removal purge - now also asserting the entry is gone via the new peek hook - and the no-timed-eviction rewrite) were already on this branch. Suite now counts 70 test functions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT * Adapt cherry-picked and seeding tests to the reconciled semantics Two tests needed updating for interactions the validation run surfaced: - The #11035 test (ignoresUnsignedSignerIdentity) was written for a world without the native NodeInfo cache: it needs the NodeDB fallback path (dropNodeInfoCacheForTest) and the signed replay gate satisfied for its target, like the other fallback tests on this branch. - The hot-seed test's "observed frame makes it servable" step now sends a signature-verified frame: its node is a known signer, and per #11035 an unsigned frame from a signer must not (and does not) drive cache writes - the gate working as designed. Full native suite: 70/70 under ASan. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT * trunk fml * Doc tidyup * TrafficManagement: key NodeInfo-cache maintenance to its own guard runOnce() nested the entire NodeInfo maintenance block (tick-stamp saturation, membership refresh, boot/hourly reconcile) inside #if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0, so a variant overriding the unified cache to 0 on a PSRAM board would compile the NodeInfo cache without its maintenance: hasObserved would never saturate, obsTick would alias past its 12.8 h wrap, and the 6 h staleness gate - whose wrap-safety explicitly depends on the sweep - would serve spoofed replies for long-gone nodes. Extract maintainNodeInfoCacheLocked(), guarded by TMM_HAS_NODEINFO_CACHE alone, and give runOnce() the same independent-guard structure purgeAll() already has. Also close the runtime cousin of the same mismatch: the write-through hooks (onNodeIdentityCommitted / onNodeKeyCommitted) now no-op while moduleConfig.has_traffic_management is off. Previously they kept filling the cache from NodeDB commits while runOnce() returned INT32_MAX, accumulating entries that were never swept or reconciled. Purges and reads stay ungated: removal must always work, and reads just miss an empty cache. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * TrafficManagement: forward throttled NodeInfo requests instead of consuming them The direct-response throttle returned true, which made handleReceived() STOP the request: within the 30 s window a repeat request was neither answered nor forwarded toward the genuine target, and the call site still logged "respond" and bumped nodeinfo_cache_hits for a reply that was never sent. A requester whose first reply was lost on a noisy link got silence for the whole window. Return false instead: the request flows through normal relay handling, so the genuine node (or another cache-holder) can answer, while our own spoofed TX stays bounded. Repeats of the same packet id are already absorbed by the router's duplicate detection, and the stats counter now only counts replies actually sent. Also log purgeNode() only when a slot was actually cleared - it runs for every NodeDB removal, including nodes the caches never tracked. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * TrafficManagement: consolidate NodeInfo-cache #if sprawl into one region Fourteen scattered #if TMM_HAS_NODEINFO_CACHE guards (one per function, each with its own #else stub of (void) casts) collapse into a single guarded region holding every NodeInfo-cache-only function, terminated by one #else block of no-op stub definitions. Because the stubs now exist on every build, the call sites in runOnce(), purgeNode() and purgeAll() drop their guards too; inner guards remain only for orthogonal features (PKI, warm tier) and for real conditional work (constructor allocation, PSRAM paths in shouldRespondToNodeInfo). No behavior change. Compile-checked on both sides of the macro: the native app build (stubs) and the native unit-test build (region). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Router: resolve sender key only for PKI-decrypt candidates perhapsDecode() resolved the sender's public key unconditionally for every encrypted packet, before even checking whether the packet could be PKI-decrypted (channel 0, unicast to us). Since copyPublicKey() grew its TrafficManagement fallback tier, a full hot+warm miss - the common case for channel traffic from senders outside both NodeDB tiers - additionally walked the 2000-entry NodeInfo cache under its lock, per packet, for a key that was then discarded. Move the resolution inside the PKI-candidate branch; remotePublic and haveRemoteKey had no consumers outside it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * TrafficManagement: refresh NodeInfo membership hourly, not per-sweep The 60 s sweep re-derived isMember with a per-entry NodeDB lookup: O(entries x members) - about 700k node-number comparisons per minute on a full PSRAM cache (2000 entries x 250 hot + 100 warm), with strided PSRAM reads, all while holding cacheLock against the packet path. Move the refresh into the hourly reconcile pass, which is already O(members x entries): after the seeding loops (so the upsert pass still sees last pass's bits for spareMembers protection), clear every isMember bit and re-mark from both NodeDB tiers - including keyless warm records, which seed nothing but are still members. Accepted tradeoff, now documented on the field: membership lags a passive NodeDB eviction by up to an hour (the entry just stays LRU-sticky slightly longer). Additions stay immediate via the write-through hooks, explicit removals via purgeNode(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * NodeDB: centralize bare-key commits in commitRemoteKey() The two key-write sites that bypass updateUser() (admin-channel learn in Router::perhapsDecode, manual verification in KeyVerificationModule) each carried their own #if HAS_TRAFFIC_MANAGEMENT write-through boilerplate - the pattern this PR itself had to retrofit twice, and which any future direct key write would silently miss, leaving the TrafficManagement cache divergent until the next hourly reconcile. Add NodeDB::commitRemoteKey(n, key32, KeyCommitTrust): writes the key to the hot store and routes the TrafficManagement write-through in one place, with provenance explicit at the call site (AdminChannelProven maps to TOFU-grade, ManuallyVerified to proven). The bypass sites keep their reason for existing - bare-key commits with provenance that updateUser's User-payload/TOFU-pin path cannot express - but no longer know about TrafficManagement at all; both stale includes are dropped (Router's was already unused on develop). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: tick wrap-safety analysis, updated cadences, CodeRabbit doc fixes node_info_stores.md gains a "Tick clocks and wrap safety" section recording which mechanism keeps each modular tick clock honest: the unified-cache clocks pair the 60 s sweep with read-time window resets, the NodeInfo obs/resp clocks are sweep-only (hence the compile-time invariant that maintainNodeInfoCacheLocked() is guarded by TMM_HAS_NODEINFO_CACHE alone), and the warm tier is immune by design - absolute unix-seconds, no wrap until 2106. Also brought current with this series: membership refresh moved to the hourly reconcile, throttled direct-response requests forward instead of being consumed, the commitRemoteKey() bare-key funnel, and the module-disabled gate on the write-through hooks. CodeRabbit doc review (PR #11050): present the NodeInfo payload cache as the third *identity* tier with the unified cache beside the chain, and describe NodeInfoLite's flattened fields / satellite copy-out accessors instead of the removed nested members. Two stale docs/tmm_node_stores.md references in the header now point at the real file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * TrafficManagement: adapt tests to forwarded throttles and hourly membership Throttle tests (PSRAM and NodeDB-fallback paths): a request inside the 30 s window now CONTINUEs into normal relay handling instead of being consumed - assert no spoofed TX, no NAK suppression, and (PSRAM path) that nodeinfo_cache_hits counts only replies actually sent. Membership test (renamed reconcileMembershipMarking): the per-minute sweep no longer refreshes isMember, so the test now pins down both halves of the new contract - the very next sweep after a passive NodeDB eviction still shows the stale member bit (the documented up-to-an-hour lag), and a reconcile interval's worth of sweeps clears it. Disabled-module test additionally proves the write-through hooks share the has_traffic_management gate: a key commit while disabled must not land in the NodeInfo cache. Not covered here: a build permutation with TRAFFIC_MANAGEMENT_CACHE_SIZE overridden to 0 (the configuration the maintenance-guard fix protects) would need its own PlatformIO env plus guards on every unified-cache test - left as a follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * expand test coverage * nitpicks and docs update * more comment fixes * nitpicks * test: make TMM fixture cleanup abort-safe in tearDown() Several tests reset per-test global state (trafficManagementModule pointing at a stack `module`, owner.public_key, the RTC fake clock) only after their assertions. A TEST_ASSERT_* failure longjmps out and skips that trailing cleanup, leaving the state to dangle into later cases - concretely, the next setUp()'s resetNodes() would call trafficManagementModule->purgeAll() on a destroyed object. Move the resets into tearDown(), which runs unconditionally between tests, and drop the now-redundant per-test cleanups. Addresses a CodeRabbit review comment on PR #11050. clod helped too * docs tidy * Throttle NodeInfo direct responses A direct response is addressed to the requesting packet's from field, which is unauthenticated, and is sent by every neighbour that holds the target in cache. One request therefore makes several nodes transmit at an address the requester chose, and nothing limited how often that could be repeated. Replies are now spaced per requester, which bounds how much any single node can be made to receive, plus a global floor on how much airtime the feature can consume. The check sits where a reply is about to be sent, so requests declined for other reasons do not consume the budget. * test: reconcile TMM direct-response tests with #11104 throttle Makes the suite green for now. #11104's per-requester + global-floor throttle (60 s) is layered over the branch's per-target throttle, so the three existing "served again" checks (psram, fallback, sweep) now use a fresh requester to avoid the per-requester window masking the mechanism each one actually exercises. Adds a dedicated test for the new per-requester + 1 s global-floor behaviour (the reflector-flood gap the per-target throttle leaves open, and the bound that survives on non-PSRAM builds). Deferred: the branch's now-redundant fallback global stamp (nodeInfoFallbackLastResponseMs) is left in place as a follow-up cleanup. Clod wants credit. * TMM: unify direct-response throttles into per-sender + per-target RAM tables Replace the two path-specific NodeInfo-spoof throttles (per-entry respTick on the PSRAM cache path, single global stamp on the fallback path) with three symmetric bounds that behave identically with and without PSRAM: - per requester (60 s): how much any one node can be made to receive; - per target (60 s): how often we vouch for the same identity; - global floor (1 s): total airtime, the backstop once an attacker cycles requester/target past the 8-slot tables. Both axes are fixed 8-slot LRU tables in internal RAM (not the PSRAM NodeInfo cache), compared by wrap-safe uint32 ms subtraction, so there is no tick clock and no sweep to maintain. directResponseAllowed() resolves both slots before stamping either, so a reply one axis throttles never consumes the other's budget, and records the send itself. Retires: nodeInfoFallbackLastResponseMs, kNodeInfoResponseThrottleMs, nowStampMs, the respTick byte + hasResponded bit on every cache entry, currentRespTick/kNodeInfoRespTickMs/kNodeInfoThrottleTicks, and the sweep's respTick clear. Renumbers peekNodeInfoFlagsForTest (drops the responded bit). Tests: the psram/fallback throttle tests now assert the per-target axis at 60 s, isolated via a different requester, proving it holds with and without PSRAM; perRequesterAndGlobalFloor isolates the other two axes; the obsolete respTick wrap-safety test is removed. 83/83 native cases pass. clod helped too * whats up, doc? * trimming the comments * test: bump native-suite-count to 38 after upstream rebase Upstream develop carries 38 test_* suite directories but its native-suite-count file still reads 37 (a suite was added without bumping the count). Rebasing onto develop inherits that stale file, so the runner flags AMBER. Correct the registered total to 38. clod helped too --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
3319 lines
149 KiB
C++
3319 lines
149 KiB
C++
#include "MeshTypes.h" // Include BEFORE TestUtil.h - provides HAS_TRAFFIC_MANAGEMENT (via mesh-pb-constants.h)
|
||
#include "TestUtil.h"
|
||
#include <cstdlib>
|
||
#include <unity.h>
|
||
|
||
#if defined(ARCH_PORTDUINO)
|
||
#define TM_TEST_ENTRY extern "C"
|
||
#else
|
||
#define TM_TEST_ENTRY
|
||
#endif
|
||
|
||
#if HAS_TRAFFIC_MANAGEMENT
|
||
|
||
#include "airtime.h"
|
||
#include "gps/RTC.h"
|
||
#include "mesh/CryptoEngine.h"
|
||
#include "mesh/Default.h"
|
||
#include "mesh/MeshService.h"
|
||
#include "mesh/NodeDB.h"
|
||
#include "mesh/Router.h"
|
||
#include "modules/TrafficManagementModule.h"
|
||
#include "support/DeterministicRng.h" // rngSeed/rngNext/rngRange - shared seeded LCG
|
||
#include <climits>
|
||
#include <cstring>
|
||
#include <memory>
|
||
#include <pb_encode.h>
|
||
#include <vector>
|
||
|
||
namespace
|
||
{
|
||
|
||
constexpr NodeNum kLocalNode = 0x11111111;
|
||
constexpr NodeNum kRemoteNode = 0x22222222;
|
||
constexpr NodeNum kTargetNode = 0x33333333;
|
||
// A second, distinct requester. The per-requester direct-response throttle (60 s) is keyed on the
|
||
// requesting packet's `from`, so tests that exercise the per-target / fallback / sweep throttles use
|
||
// a fresh requester for their "served again" step to avoid the per-requester window masking them.
|
||
constexpr NodeNum kRemoteNode2 = 0x44444444;
|
||
|
||
// Telemetry hop exhaustion is gated on channel congestion (alterReceived checks
|
||
// airTime->isTxAllowedChannelUtil/isTxAllowedAirUtil). Installs a global
|
||
// airTime reporting 100% channel utilization for the enclosing scope.
|
||
class ScopedBusyAirTime
|
||
{
|
||
public:
|
||
ScopedBusyAirTime() : previous(airTime)
|
||
{
|
||
for (uint32_t i = 0; i < CHANNEL_UTILIZATION_PERIODS; i++)
|
||
busy.channelUtilization[i] = 10000; // 10 s of airtime per 10 s period
|
||
airTime = &busy;
|
||
}
|
||
~ScopedBusyAirTime() { airTime = previous; }
|
||
|
||
private:
|
||
AirTime busy;
|
||
AirTime *previous;
|
||
};
|
||
|
||
class MockNodeDB : public NodeDB
|
||
{
|
||
public:
|
||
meshtastic_NodeInfoLite *getMeshNode(NodeNum n) override
|
||
{
|
||
if (hasCachedNode && n == cachedNodeNum)
|
||
return &cachedNode;
|
||
return NodeDB::getMeshNode(n);
|
||
}
|
||
|
||
void clearCachedNode()
|
||
{
|
||
hasCachedNode = false;
|
||
cachedNodeNum = 0;
|
||
cachedNode = meshtastic_NodeInfoLite_init_zero;
|
||
}
|
||
|
||
void setCachedNode(NodeNum n)
|
||
{
|
||
clearCachedNode();
|
||
hasCachedNode = true;
|
||
cachedNodeNum = n;
|
||
cachedNode.num = n;
|
||
cachedNode.bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK;
|
||
}
|
||
|
||
// Role the TMM should see for the cached node (sender-role-aware throttles).
|
||
void setCachedNodeRole(meshtastic_Config_DeviceConfig_Role role) { cachedNode.role = role; }
|
||
|
||
// Direct mutable access to the cached node for fine-grained bitfield manipulation in tests.
|
||
meshtastic_NodeInfoLite &cachedNodeForTest()
|
||
{
|
||
hasCachedNode = true;
|
||
return cachedNode;
|
||
}
|
||
|
||
// Seed a node into the hot-store buffer at index 1 (index 0 is reserved for
|
||
// "self"). Respects the fixed-buffer invariant: `meshNodes` is a buffer of
|
||
// MAX_NUM_NODES slots with `numMeshNodes` as the logical count - we grow the
|
||
// buffer if needed and bump the count, never clear()/push_back() (which would
|
||
// shrink it and break NodeDB::resetNodes()'s begin()+1..end() fill).
|
||
void setHotNode(NodeNum n, uint8_t nextHop)
|
||
{
|
||
if (meshNodes->size() < 2)
|
||
meshNodes->resize(2);
|
||
(*meshNodes)[1] = meshtastic_NodeInfoLite_init_zero;
|
||
(*meshNodes)[1].num = n;
|
||
(*meshNodes)[1].next_hop = nextHop;
|
||
numMeshNodes = 2;
|
||
}
|
||
|
||
// Seed a full identity (name, 32-byte key of `keyByte`, optional signer bit) into the
|
||
// hot-store buffer at index 1, for reconcile/seeding tests that iterate
|
||
// getMeshNodeByIndex().
|
||
void setHotNodeIdentity(NodeNum n, const char *longName, uint8_t keyByte, bool signer)
|
||
{
|
||
setHotNode(n, 0);
|
||
meshtastic_NodeInfoLite &info = (*meshNodes)[1];
|
||
strncpy(info.long_name, longName, sizeof(info.long_name) - 1);
|
||
info.public_key.size = 32;
|
||
memset(info.public_key.bytes, keyByte, 32);
|
||
info.bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK;
|
||
if (signer)
|
||
info.bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK;
|
||
}
|
||
|
||
// Evict everything but "self" - simulates the hot DB rolling over. Logical
|
||
// count only; the buffer is left intact so the invariant holds.
|
||
void rollHotStore()
|
||
{
|
||
numMeshNodes = 1;
|
||
clearCachedNode();
|
||
}
|
||
|
||
// Seed a hot-store node that has been learned as an XEdDSA signer, with a known name, so the
|
||
// identity-update gate can be exercised. Distinct from setCachedNode() so a separate cached
|
||
// node (the direct-response target) can coexist.
|
||
void setSignerHotNode(NodeNum n, const char *longName)
|
||
{
|
||
if (meshNodes->size() < 2)
|
||
meshNodes->resize(2);
|
||
(*meshNodes)[1] = meshtastic_NodeInfoLite_init_zero;
|
||
(*meshNodes)[1].num = n;
|
||
nodeInfoLiteSetBit(&(*meshNodes)[1], NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true);
|
||
nodeInfoLiteSetBit(&(*meshNodes)[1], NODEINFO_BITFIELD_HAS_USER_MASK, true);
|
||
strncpy((*meshNodes)[1].long_name, longName, sizeof((*meshNodes)[1].long_name) - 1);
|
||
numMeshNodes = 2;
|
||
}
|
||
|
||
private:
|
||
bool hasCachedNode = false;
|
||
NodeNum cachedNodeNum = 0;
|
||
meshtastic_NodeInfoLite cachedNode = meshtastic_NodeInfoLite_init_zero;
|
||
};
|
||
|
||
class MockRadioInterface : public RadioInterface
|
||
{
|
||
public:
|
||
ErrorCode send(meshtastic_MeshPacket *p) override
|
||
{
|
||
packetPool.release(p);
|
||
return ERRNO_OK;
|
||
}
|
||
|
||
uint32_t getPacketTime(uint32_t totalPacketLen, bool received = false) override
|
||
{
|
||
(void)totalPacketLen;
|
||
(void)received;
|
||
return 0;
|
||
}
|
||
};
|
||
|
||
class MockRouter : public Router
|
||
{
|
||
public:
|
||
~MockRouter()
|
||
{
|
||
// Router allocates a global crypt lock in its constructor.
|
||
// Clean it up here so each test can build a fresh mock router.
|
||
delete cryptLock;
|
||
cryptLock = nullptr;
|
||
}
|
||
|
||
ErrorCode send(meshtastic_MeshPacket *p) override
|
||
{
|
||
sentPackets.push_back(*p);
|
||
packetPool.release(p);
|
||
return ERRNO_OK;
|
||
}
|
||
|
||
std::vector<meshtastic_MeshPacket> sentPackets;
|
||
};
|
||
|
||
class TrafficManagementModuleTestShim : public TrafficManagementModule
|
||
{
|
||
public:
|
||
using TrafficManagementModule::alterReceived;
|
||
using TrafficManagementModule::dropNodeInfoCacheForTest;
|
||
using TrafficManagementModule::flushCache;
|
||
using TrafficManagementModule::handleReceived;
|
||
using TrafficManagementModule::markKeySignerProvenForTest;
|
||
using TrafficManagementModule::nodeInfoCacheCapacityForTest;
|
||
using TrafficManagementModule::peekCachedRole;
|
||
using TrafficManagementModule::peekNodeInfoFlagsForTest;
|
||
using TrafficManagementModule::runOnce;
|
||
|
||
bool ignoreRequestFlag() const { return ignoreRequest; }
|
||
};
|
||
|
||
MockNodeDB *mockNodeDB = nullptr;
|
||
|
||
static void resetTrafficConfig()
|
||
{
|
||
moduleConfig = meshtastic_LocalModuleConfig_init_zero;
|
||
moduleConfig.has_traffic_management = true;
|
||
moduleConfig.traffic_management = meshtastic_ModuleConfig_TrafficManagementConfig_init_zero;
|
||
|
||
config = meshtastic_LocalConfig_init_zero;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||
|
||
channelFile = meshtastic_ChannelFile_init_zero;
|
||
owner.is_licensed = false;
|
||
|
||
myNodeInfo.my_node_num = kLocalNode;
|
||
|
||
router = nullptr;
|
||
service = nullptr;
|
||
|
||
mockNodeDB->resetNodes();
|
||
mockNodeDB->clearCachedNode();
|
||
nodeDB = mockNodeDB;
|
||
|
||
// Virtual clock base (1 h in, so tick subtraction never underflows). Tests advance time by
|
||
// bumping TrafficManagementModule::s_testNowMs instead of sleeping real seconds across a tick.
|
||
TrafficManagementModule::s_testNowMs = 3600000;
|
||
}
|
||
|
||
static meshtastic_MeshPacket makeDecodedPacket(meshtastic_PortNum port, NodeNum from, NodeNum to = NODENUM_BROADCAST)
|
||
{
|
||
meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero;
|
||
packet.from = from;
|
||
packet.to = to;
|
||
packet.id = 0x1001;
|
||
packet.channel = 0;
|
||
packet.hop_start = 3;
|
||
packet.hop_limit = 3;
|
||
packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
|
||
packet.decoded.portnum = port;
|
||
packet.decoded.has_bitfield = true;
|
||
packet.decoded.bitfield = 0;
|
||
return packet;
|
||
}
|
||
|
||
static meshtastic_MeshPacket makeUnknownPacket(NodeNum from, NodeNum to = NODENUM_BROADCAST)
|
||
{
|
||
meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero;
|
||
packet.from = from;
|
||
packet.to = to;
|
||
packet.id = 0x2001;
|
||
packet.channel = 0;
|
||
packet.hop_start = 3;
|
||
packet.hop_limit = 3;
|
||
packet.which_payload_variant = meshtastic_MeshPacket_encrypted_tag;
|
||
packet.encrypted.size = 0;
|
||
return packet;
|
||
}
|
||
|
||
static meshtastic_MeshPacket makePositionPacket(NodeNum from, int32_t lat, int32_t lon, NodeNum to = NODENUM_BROADCAST)
|
||
{
|
||
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, from, to);
|
||
meshtastic_Position pos = meshtastic_Position_init_zero;
|
||
pos.has_latitude_i = true;
|
||
pos.has_longitude_i = true;
|
||
pos.latitude_i = lat;
|
||
pos.longitude_i = lon;
|
||
|
||
packet.decoded.payload.size =
|
||
pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), &meshtastic_Position_msg, &pos);
|
||
return packet;
|
||
}
|
||
|
||
static meshtastic_MeshPacket makePositionPacketWithPrecision(NodeNum from, int32_t lat, int32_t lon, uint32_t precisionBits)
|
||
{
|
||
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, from, NODENUM_BROADCAST);
|
||
meshtastic_Position pos = meshtastic_Position_init_zero;
|
||
pos.has_latitude_i = true;
|
||
pos.has_longitude_i = true;
|
||
pos.latitude_i = lat;
|
||
pos.longitude_i = lon;
|
||
pos.precision_bits = precisionBits;
|
||
|
||
packet.decoded.payload.size =
|
||
pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), &meshtastic_Position_msg, &pos);
|
||
return packet;
|
||
}
|
||
|
||
static bool decodePositionPayload(const meshtastic_MeshPacket &packet, meshtastic_Position &out)
|
||
{
|
||
out = meshtastic_Position_init_zero;
|
||
return pb_decode_from_bytes(packet.decoded.payload.bytes, packet.decoded.payload.size, &meshtastic_Position_msg, &out);
|
||
}
|
||
|
||
// Primary channel with a well-known single-byte PSK and the (empty -> preset)
|
||
// default name, so Channels::isWellKnownChannel(0) is true.
|
||
static void installWellKnownPrimaryChannel()
|
||
{
|
||
channelFile = meshtastic_ChannelFile_init_zero;
|
||
channelFile.channels_count = 1;
|
||
channelFile.channels[0].index = 0;
|
||
channelFile.channels[0].has_settings = true;
|
||
channelFile.channels[0].role = meshtastic_Channel_Role_PRIMARY;
|
||
channelFile.channels[0].settings.psk.size = 1;
|
||
channelFile.channels[0].settings.psk.bytes[0] = 1;
|
||
config.lora.use_preset = true;
|
||
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
|
||
}
|
||
|
||
// Install the well-known primary channel AND set a specific position_precision so
|
||
// shouldDropPosition() uses that precision ceiling rather than the default fallback.
|
||
// precision=0 means "no channel ceiling" and falls back to the firmware default (19 bits).
|
||
static void installWellKnownPrimaryChannelWithPrecision(uint32_t precision)
|
||
{
|
||
installWellKnownPrimaryChannel();
|
||
channelFile.channels[0].settings.has_module_settings = true;
|
||
channelFile.channels[0].settings.module_settings.position_precision = precision;
|
||
}
|
||
|
||
static meshtastic_MeshPacket makeNodeInfoPacket(NodeNum from, const char *longName, const char *shortName)
|
||
{
|
||
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, from, NODENUM_BROADCAST);
|
||
|
||
meshtastic_User user = meshtastic_User_init_zero;
|
||
snprintf(user.id, sizeof(user.id), "!%08x", from);
|
||
strncpy(user.long_name, longName, sizeof(user.long_name) - 1);
|
||
strncpy(user.short_name, shortName, sizeof(user.short_name) - 1);
|
||
|
||
packet.decoded.payload.size =
|
||
pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), &meshtastic_User_msg, &user);
|
||
return packet;
|
||
}
|
||
|
||
static meshtastic_MeshPacket makeNodeInfoPacketWithRole(NodeNum from, meshtastic_Config_DeviceConfig_Role role)
|
||
{
|
||
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, from, NODENUM_BROADCAST);
|
||
|
||
meshtastic_User user = meshtastic_User_init_zero;
|
||
snprintf(user.id, sizeof(user.id), "!%08x", from);
|
||
strncpy(user.long_name, "rolenode", sizeof(user.long_name) - 1);
|
||
strncpy(user.short_name, "rn", sizeof(user.short_name) - 1);
|
||
user.role = role;
|
||
|
||
packet.decoded.payload.size =
|
||
pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), &meshtastic_User_msg, &user);
|
||
return packet;
|
||
}
|
||
|
||
/**
|
||
* Verify the module is a no-op when traffic management is disabled.
|
||
* Important so config toggles cannot accidentally change routing behavior.
|
||
*/
|
||
static void test_tm_moduleDisabled_doesNothing(void)
|
||
{
|
||
moduleConfig.has_traffic_management = false;
|
||
TrafficManagementModuleTestShim module;
|
||
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode);
|
||
|
||
ProcessMessage result = module.handleReceived(packet);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(result));
|
||
TEST_ASSERT_EQUAL_UINT32(0, stats.packets_inspected);
|
||
TEST_ASSERT_EQUAL_UINT32(0, stats.unknown_packet_drops);
|
||
TEST_ASSERT_FALSE(module.ignoreRequestFlag());
|
||
|
||
// The write-through hooks share the disabled gate: with maintenance (sweep + reconcile)
|
||
// off, NodeDB commits must not fill the NodeInfo cache either.
|
||
uint8_t key[32];
|
||
memset(key, 0x42, sizeof(key));
|
||
module.onNodeKeyCommitted(kRemoteNode, key, false);
|
||
uint8_t out[32];
|
||
TEST_ASSERT_FALSE(module.copyPublicKey(kRemoteNode, out, nullptr));
|
||
}
|
||
|
||
/**
|
||
* Verify unknown-packet dropping uses N+1 threshold semantics.
|
||
* Important to catch off-by-one regressions in drop decisions.
|
||
*/
|
||
static void test_tm_unknownPackets_dropOnNPlusOne(void)
|
||
{
|
||
moduleConfig.traffic_management.unknown_packet_threshold = 2;
|
||
TrafficManagementModuleTestShim module;
|
||
meshtastic_MeshPacket packet = makeUnknownPacket(kRemoteNode);
|
||
|
||
ProcessMessage r1 = module.handleReceived(packet);
|
||
ProcessMessage r2 = module.handleReceived(packet);
|
||
ProcessMessage r3 = module.handleReceived(packet);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r2));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r3));
|
||
TEST_ASSERT_EQUAL_UINT32(1, stats.unknown_packet_drops);
|
||
TEST_ASSERT_EQUAL_UINT32(3, stats.packets_inspected);
|
||
TEST_ASSERT_TRUE(module.ignoreRequestFlag());
|
||
}
|
||
|
||
/**
|
||
* Verify duplicate position broadcasts inside the dedup window are dropped.
|
||
* Important because this is the primary airtime-saving behavior.
|
||
*/
|
||
static void test_tm_positionDedup_dropsDuplicateWithinWindow(void)
|
||
{
|
||
moduleConfig.traffic_management.position_min_interval_secs = 300;
|
||
installWellKnownPrimaryChannelWithPrecision(16);
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
|
||
ProcessMessage r1 = module.handleReceived(first);
|
||
ProcessMessage r2 = module.handleReceived(second);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_GREATER_THAN_UINT32(0, first.decoded.payload.size);
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r2));
|
||
TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops);
|
||
TEST_ASSERT_TRUE(module.ignoreRequestFlag());
|
||
}
|
||
|
||
/**
|
||
* Verify changed coordinates are forwarded even with dedup enabled.
|
||
* Important so real movement updates are never suppressed as duplicates.
|
||
*/
|
||
static void test_tm_positionDedup_allowsMovedPosition(void)
|
||
{
|
||
moduleConfig.traffic_management.position_min_interval_secs = 300;
|
||
installWellKnownPrimaryChannelWithPrecision(16);
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
meshtastic_MeshPacket moved = makePositionPacket(kRemoteNode, 384221234, -1210845678);
|
||
|
||
ProcessMessage r1 = module.handleReceived(first);
|
||
ProcessMessage r2 = module.handleReceived(moved);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r2));
|
||
TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops);
|
||
}
|
||
|
||
/**
|
||
* Verify rate limiting drops only after exceeding the configured threshold.
|
||
* Important to protect threshold semantics from off-by-one regressions.
|
||
*/
|
||
static void test_tm_rateLimit_dropsOnlyAfterThreshold(void)
|
||
{
|
||
moduleConfig.traffic_management.rate_limit_window_secs = 60;
|
||
moduleConfig.traffic_management.rate_limit_max_packets = 3;
|
||
TrafficManagementModuleTestShim module;
|
||
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode);
|
||
|
||
ProcessMessage r1 = module.handleReceived(packet);
|
||
ProcessMessage r2 = module.handleReceived(packet);
|
||
ProcessMessage r3 = module.handleReceived(packet);
|
||
ProcessMessage r4 = module.handleReceived(packet);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r2));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r3));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r4));
|
||
TEST_ASSERT_EQUAL_UINT32(1, stats.rate_limit_drops);
|
||
TEST_ASSERT_TRUE(module.ignoreRequestFlag());
|
||
}
|
||
/**
|
||
* Verify packets sourced from this node bypass dedup and rate limiting.
|
||
* Important so local transmissions are not accidentally self-throttled.
|
||
*/
|
||
static void test_tm_fromUs_bypassesPositionAndRateFilters(void)
|
||
{
|
||
moduleConfig.traffic_management.position_min_interval_secs = 300;
|
||
moduleConfig.traffic_management.rate_limit_window_secs = 60;
|
||
moduleConfig.traffic_management.rate_limit_max_packets = 1;
|
||
installWellKnownPrimaryChannelWithPrecision(16);
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
meshtastic_MeshPacket positionPacket = makePositionPacket(kLocalNode, 374221234, -1220845678);
|
||
meshtastic_MeshPacket textPacket = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode);
|
||
|
||
ProcessMessage p1 = module.handleReceived(positionPacket);
|
||
ProcessMessage p2 = module.handleReceived(positionPacket);
|
||
ProcessMessage t1 = module.handleReceived(textPacket);
|
||
ProcessMessage t2 = module.handleReceived(textPacket);
|
||
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(p1));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(p2));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(t1));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(t2));
|
||
TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops);
|
||
TEST_ASSERT_EQUAL_UINT32(0, stats.rate_limit_drops);
|
||
}
|
||
|
||
/**
|
||
* Verify locally addressed packets are never dropped by transit shaping.
|
||
* Important so dedup/rate limiting do not suppress end-user delivery.
|
||
*/
|
||
static void test_tm_localDestination_bypassesTransitFilters(void)
|
||
{
|
||
moduleConfig.traffic_management.position_min_interval_secs = 300;
|
||
moduleConfig.traffic_management.rate_limit_window_secs = 60;
|
||
moduleConfig.traffic_management.rate_limit_max_packets = 1;
|
||
installWellKnownPrimaryChannelWithPrecision(16);
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
meshtastic_MeshPacket position1 = makePositionPacket(kRemoteNode, 374221234, -1220845678, kLocalNode);
|
||
meshtastic_MeshPacket position2 = makePositionPacket(kRemoteNode, 374221234, -1220845678, kLocalNode);
|
||
meshtastic_MeshPacket text1 = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, kLocalNode);
|
||
meshtastic_MeshPacket text2 = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, kLocalNode);
|
||
|
||
ProcessMessage p1 = module.handleReceived(position1);
|
||
ProcessMessage p2 = module.handleReceived(position2);
|
||
ProcessMessage t1 = module.handleReceived(text1);
|
||
ProcessMessage t2 = module.handleReceived(text2);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(p1));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(p2));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(t1));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(t2));
|
||
TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops);
|
||
TEST_ASSERT_EQUAL_UINT32(0, stats.rate_limit_drops);
|
||
}
|
||
|
||
/**
|
||
* Verify router role clamps NodeInfo response hops to router-safe maximum.
|
||
* Important so large config values cannot widen response scope unexpectedly.
|
||
*/
|
||
static void test_tm_nodeinfo_routerClamp_skipsWhenTooManyHops(void)
|
||
{
|
||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_ROUTER;
|
||
mockNodeDB->setCachedNode(kTargetNode);
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
|
||
request.decoded.want_response = true;
|
||
request.hop_start = 5;
|
||
request.hop_limit = 1; // 4 hops away; router clamp should cap max at 3
|
||
|
||
ProcessMessage result = module.handleReceived(request);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(result));
|
||
TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits);
|
||
TEST_ASSERT_FALSE(module.ignoreRequestFlag());
|
||
}
|
||
|
||
/**
|
||
* Verify NodeInfo direct-response success path and reply packet fields.
|
||
* Important because this path consumes the request and generates a spoofed cached reply.
|
||
*/
|
||
static void test_tm_nodeinfo_directResponse_respondsFromCache(void)
|
||
{
|
||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||
config.lora.config_ok_to_mqtt = true;
|
||
mockNodeDB->setCachedNode(kTargetNode);
|
||
// Signed-only replay gate (default) requires the target be a known signer to be served.
|
||
mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK;
|
||
|
||
MockRouter mockRouter;
|
||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||
MeshService mockService;
|
||
router = &mockRouter;
|
||
service = &mockService;
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path
|
||
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
|
||
request.decoded.want_response = true;
|
||
request.id = 0x13572468;
|
||
request.hop_start = 3;
|
||
request.hop_limit = 3; // direct request (0 hops away)
|
||
|
||
ProcessMessage result = module.handleReceived(request);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(result));
|
||
TEST_ASSERT_TRUE(module.ignoreRequestFlag());
|
||
TEST_ASSERT_EQUAL_UINT32(1, stats.nodeinfo_cache_hits);
|
||
TEST_ASSERT_EQUAL_UINT32(1, static_cast<uint32_t>(mockRouter.sentPackets.size()));
|
||
|
||
const meshtastic_MeshPacket &reply = mockRouter.sentPackets.front();
|
||
TEST_ASSERT_EQUAL_INT(meshtastic_PortNum_NODEINFO_APP, reply.decoded.portnum);
|
||
TEST_ASSERT_EQUAL_UINT32(kTargetNode, reply.from);
|
||
TEST_ASSERT_EQUAL_UINT32(kRemoteNode, reply.to);
|
||
TEST_ASSERT_EQUAL_UINT32(request.id, reply.decoded.request_id);
|
||
TEST_ASSERT_FALSE(reply.decoded.want_response);
|
||
TEST_ASSERT_EQUAL_UINT8(0, reply.hop_limit);
|
||
TEST_ASSERT_EQUAL_UINT8(0, reply.hop_start);
|
||
TEST_ASSERT_EQUAL_UINT8(mockNodeDB->getLastByteOfNodeNum(kRemoteNode), reply.next_hop);
|
||
TEST_ASSERT_TRUE(reply.decoded.has_bitfield);
|
||
TEST_ASSERT_EQUAL_UINT8(BITFIELD_OK_TO_MQTT_MASK, reply.decoded.bitfield);
|
||
}
|
||
|
||
/**
|
||
* Verify cached direct replies still preserve requester NodeInfo learning.
|
||
* Important so consuming the request does not skip NodeDB refresh for observers.
|
||
*/
|
||
static void test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo(void)
|
||
{
|
||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||
mockNodeDB->setCachedNode(kTargetNode);
|
||
// Signed-only replay gate (default) requires the target be a known signer to be served.
|
||
mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK;
|
||
|
||
MockRouter mockRouter;
|
||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||
MeshService mockService;
|
||
router = &mockRouter;
|
||
service = &mockService;
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path
|
||
meshtastic_MeshPacket request = makeNodeInfoPacket(kRemoteNode, "requester-long", "rq");
|
||
request.to = kTargetNode;
|
||
request.decoded.want_response = true;
|
||
request.id = 0x01020304;
|
||
request.hop_start = 3;
|
||
request.hop_limit = 3;
|
||
|
||
ProcessMessage result = module.handleReceived(request);
|
||
meshtastic_NodeInfoLite *requestor = mockNodeDB->getMeshNode(kRemoteNode);
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(result));
|
||
TEST_ASSERT_NOT_NULL(requestor);
|
||
TEST_ASSERT_TRUE((requestor->bitfield & NODEINFO_BITFIELD_HAS_USER_MASK) != 0);
|
||
TEST_ASSERT_EQUAL_STRING("requester-long", requestor->long_name);
|
||
TEST_ASSERT_EQUAL_STRING("rq", requestor->short_name);
|
||
TEST_ASSERT_EQUAL_UINT8(request.channel, requestor->channel);
|
||
}
|
||
|
||
/**
|
||
* A unicast NodeInfo request is never signed, so a known signer's identity claim on the
|
||
* direct-response path is unauthenticated. It must not overwrite the stored name (spoofing
|
||
* defense), while the direct response itself still goes out. The non-signer case
|
||
* (test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo) is the control: it still learns.
|
||
*/
|
||
static void test_tm_nodeinfo_directResponse_ignoresUnsignedSignerIdentity(void)
|
||
{
|
||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||
mockNodeDB->setCachedNode(kTargetNode); // the direct-response target
|
||
// Signed-only replay gate (default) requires the target be a known signer to be served.
|
||
mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK;
|
||
mockNodeDB->setSignerHotNode(kRemoteNode, "victim-real"); // the requester is a known signer
|
||
|
||
MockRouter mockRouter;
|
||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||
MeshService mockService;
|
||
router = &mockRouter;
|
||
service = &mockService;
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path this test was written for
|
||
meshtastic_MeshPacket request = makeNodeInfoPacket(kRemoteNode, "attacker-name", "atk");
|
||
request.to = kTargetNode;
|
||
request.decoded.want_response = true;
|
||
request.id = 0x0A0B0C0D;
|
||
request.hop_start = 3;
|
||
request.hop_limit = 3;
|
||
request.xeddsa_signed = false; // unicast: never signed
|
||
|
||
ProcessMessage result = module.handleReceived(request);
|
||
|
||
// The response still went out (the request was consumed from cache)...
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(result));
|
||
TEST_ASSERT_EQUAL_UINT32(1, module.getStats().nodeinfo_cache_hits);
|
||
// ...but the known signer's stored name was not overwritten by the unauthenticated claim.
|
||
const meshtastic_NodeInfoLite *requestor = mockNodeDB->getMeshNode(kRemoteNode);
|
||
TEST_ASSERT_NOT_NULL(requestor);
|
||
TEST_ASSERT_EQUAL_STRING("victim-real", requestor->long_name);
|
||
}
|
||
|
||
/**
|
||
* Verify client role only answers direct (0-hop) NodeInfo requests.
|
||
* Important so clients do not answer relayed requests outside intended scope.
|
||
*/
|
||
static void test_tm_nodeinfo_clientClamp_skipsWhenNotDirect(void)
|
||
{
|
||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||
mockNodeDB->setCachedNode(kTargetNode);
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
|
||
request.decoded.want_response = true;
|
||
request.hop_start = 2;
|
||
request.hop_limit = 1; // 1 hop away; clients are clamped to max 0
|
||
|
||
ProcessMessage result = module.handleReceived(request);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(result));
|
||
TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits);
|
||
TEST_ASSERT_FALSE(module.ignoreRequestFlag());
|
||
}
|
||
|
||
/**
|
||
* Verify the NodeDB-fallback path requires NodeDB for direct NodeInfo responses.
|
||
* Important because fallback should only happen through node-wide data when
|
||
* the dedicated NodeInfo cache does not exist.
|
||
*/
|
||
static void test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips(void)
|
||
{
|
||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||
mockNodeDB->clearCachedNode();
|
||
|
||
MockRouter mockRouter;
|
||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||
MeshService mockService;
|
||
router = &mockRouter;
|
||
service = &mockService;
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path
|
||
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
|
||
request.decoded.want_response = true;
|
||
request.hop_start = 3;
|
||
request.hop_limit = 3;
|
||
|
||
ProcessMessage result = module.handleReceived(request);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(result));
|
||
TEST_ASSERT_FALSE(module.ignoreRequestFlag());
|
||
TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits);
|
||
TEST_ASSERT_EQUAL_UINT32(0, static_cast<uint32_t>(mockRouter.sentPackets.size()));
|
||
}
|
||
|
||
#if TMM_HAS_NODEINFO_CACHE
|
||
/**
|
||
* Verify the NodeInfo cache can answer requests without NodeDB and that
|
||
* shouldRespondToNodeInfo() uses cached bitfield metadata.
|
||
*/
|
||
static void test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfield(void)
|
||
{
|
||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||
config.lora.config_ok_to_mqtt = true;
|
||
mockNodeDB->clearCachedNode();
|
||
|
||
MockRouter mockRouter;
|
||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||
MeshService mockService;
|
||
router = &mockRouter;
|
||
service = &mockService;
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
meshtastic_MeshPacket observed = makeNodeInfoPacket(kTargetNode, "target-long", "tg");
|
||
observed.decoded.has_bitfield = true;
|
||
observed.decoded.bitfield = BITFIELD_WANT_RESPONSE_MASK;
|
||
observed.channel = 2;
|
||
observed.rx_time = 123456;
|
||
|
||
ProcessMessage observedResult = module.handleReceived(observed);
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(observedResult));
|
||
// Signed-only replay gate (default) requires signer-proven provenance to serve.
|
||
module.markKeySignerProvenForTest(kTargetNode);
|
||
|
||
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
|
||
request.decoded.want_response = true;
|
||
request.id = 0x24681357;
|
||
request.channel = 1;
|
||
request.hop_start = 3;
|
||
request.hop_limit = 3;
|
||
|
||
ProcessMessage result = module.handleReceived(request);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(result));
|
||
TEST_ASSERT_TRUE(module.ignoreRequestFlag());
|
||
TEST_ASSERT_EQUAL_UINT32(1, stats.nodeinfo_cache_hits);
|
||
TEST_ASSERT_EQUAL_UINT32(1, static_cast<uint32_t>(mockRouter.sentPackets.size()));
|
||
|
||
const meshtastic_MeshPacket &reply = mockRouter.sentPackets.front();
|
||
TEST_ASSERT_TRUE(reply.decoded.has_bitfield);
|
||
TEST_ASSERT_EQUAL_UINT8(static_cast<uint8_t>(BITFIELD_WANT_RESPONSE_MASK | BITFIELD_OK_TO_MQTT_MASK), reply.decoded.bitfield);
|
||
TEST_ASSERT_EQUAL_UINT32(kTargetNode, reply.from);
|
||
TEST_ASSERT_EQUAL_UINT32(kRemoteNode, reply.to);
|
||
TEST_ASSERT_EQUAL_UINT8(request.channel, reply.channel);
|
||
TEST_ASSERT_EQUAL_UINT32(request.id, reply.decoded.request_id);
|
||
}
|
||
|
||
/**
|
||
* Verify NodeInfo cache misses do not fall back to NodeDB.
|
||
* Important so the dedicated cache index stays logically separate from
|
||
* NodeInfoModule/NodeDB when the cache is available.
|
||
*/
|
||
static void test_tm_nodeinfo_directResponse_psramMissDoesNotFallbackToNodeDb(void)
|
||
{
|
||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||
mockNodeDB->setCachedNode(kTargetNode);
|
||
|
||
MockRouter mockRouter;
|
||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||
MeshService mockService;
|
||
router = &mockRouter;
|
||
service = &mockService;
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
|
||
request.decoded.want_response = true;
|
||
request.hop_start = 3;
|
||
request.hop_limit = 3;
|
||
|
||
ProcessMessage result = module.handleReceived(request);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(result));
|
||
TEST_ASSERT_FALSE(module.ignoreRequestFlag());
|
||
TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits);
|
||
TEST_ASSERT_EQUAL_UINT32(0, static_cast<uint32_t>(mockRouter.sentPackets.size()));
|
||
}
|
||
|
||
/**
|
||
* Verify a cached NodeInfo is NOT served once it ages past the serve window.
|
||
* Important: without this gate a long-gone (or forged) node's cached NodeInfo would be
|
||
* spoofed back to requestors indefinitely while the genuine request is suppressed.
|
||
*/
|
||
static void test_tm_nodeinfo_directResponse_psramStaleEntryNotServed(void)
|
||
{
|
||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||
mockNodeDB->clearCachedNode();
|
||
|
||
MockRouter mockRouter;
|
||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||
MeshService mockService;
|
||
router = &mockRouter;
|
||
service = &mockService;
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
// Learn a NodeInfo for the target into the NodeInfo cache (broadcast, so it is only cached).
|
||
meshtastic_MeshPacket observed = makeNodeInfoPacket(kTargetNode, "target-long", "tg");
|
||
module.handleReceived(observed);
|
||
// Signer-proven so staleness is the sole reason it is not served (isolates the gate under test).
|
||
module.markKeySignerProvenForTest(kTargetNode);
|
||
|
||
// Advance the virtual clock just past the 6 h serve window.
|
||
// 6 h + two 3-min observation ticks: guarantees the modular obs-tick age exceeds the
|
||
// 120-tick serve window whatever the clock's offset within its current tick.
|
||
TrafficManagementModule::s_testNowMs += (6UL * 60UL * 60UL * 1000UL) + (2UL * 3UL * 60UL * 1000UL);
|
||
|
||
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
|
||
request.decoded.want_response = true;
|
||
request.hop_start = 3;
|
||
request.hop_limit = 3;
|
||
|
||
ProcessMessage result = module.handleReceived(request);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(result));
|
||
TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits);
|
||
TEST_ASSERT_EQUAL_UINT32(0, static_cast<uint32_t>(mockRouter.sentPackets.size()));
|
||
}
|
||
|
||
/**
|
||
* Per-target direct-response throttle on the PSRAM cache path: repeated NodeInfo requests for the
|
||
* same target yield one spoofed reply per 60 s window - even from a DIFFERENT requester, since the
|
||
* target axis is independent of the requester axis - then a reply is allowed again once it elapses.
|
||
*/
|
||
static void test_tm_nodeinfo_directResponse_psramThrottlesWithinWindow(void)
|
||
{
|
||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||
mockNodeDB->clearCachedNode();
|
||
|
||
MockRouter mockRouter;
|
||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||
MeshService mockService;
|
||
router = &mockRouter;
|
||
service = &mockService;
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
meshtastic_MeshPacket observed = makeNodeInfoPacket(kTargetNode, "target-long", "tg");
|
||
module.handleReceived(observed);
|
||
// Signed-only replay gate (default) requires signer-proven provenance to serve.
|
||
module.markKeySignerProvenForTest(kTargetNode);
|
||
|
||
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
|
||
request.decoded.want_response = true;
|
||
request.hop_start = 3;
|
||
request.hop_limit = 3;
|
||
|
||
// First request: served.
|
||
request.id = 0xAAAA0001;
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(module.handleReceived(request)));
|
||
TEST_ASSERT_EQUAL_UINT32(1, static_cast<uint32_t>(mockRouter.sentPackets.size()));
|
||
|
||
// Second request within the window, from a DIFFERENT requester so only the per-target axis can
|
||
// throttle it: our spoofed TX is suppressed, but the request is NOT consumed - it CONTINUEs into
|
||
// normal relay handling so the genuine target can answer itself. (Previously it was black-holed:
|
||
// STOPped with nothing sent.) The cache-hit stat counts only replies actually sent.
|
||
TrafficManagementModule::s_testNowMs += 5000; // 5 s < 60 s window
|
||
request.from = kRemoteNode2;
|
||
request.id = 0xAAAA0002;
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(module.handleReceived(request)));
|
||
TEST_ASSERT_FALSE(module.ignoreRequestFlag());
|
||
TEST_ASSERT_EQUAL_UINT32(1, static_cast<uint32_t>(mockRouter.sentPackets.size()));
|
||
TEST_ASSERT_EQUAL_UINT32(1, module.getStats().nodeinfo_cache_hits);
|
||
|
||
// Past the per-target window: served again. Back to the original requester, whose own 60 s
|
||
// per-requester window from the first reply has also elapsed, so only the per-target release is
|
||
// under test here.
|
||
TrafficManagementModule::s_testNowMs += 60000; // now > 60 s since first reply
|
||
request.from = kRemoteNode;
|
||
request.id = 0xAAAA0003;
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(module.handleReceived(request)));
|
||
TEST_ASSERT_EQUAL_UINT32(2, static_cast<uint32_t>(mockRouter.sentPackets.size()));
|
||
}
|
||
|
||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||
// Build a NODEINFO_APP broadcast whose User carries a 32-byte public key of `keyByte`.
|
||
static meshtastic_MeshPacket makeNodeInfoPacketWithKey(NodeNum from, const char *longName, uint8_t keyByte)
|
||
{
|
||
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, from, NODENUM_BROADCAST);
|
||
meshtastic_User user = meshtastic_User_init_zero;
|
||
snprintf(user.id, sizeof(user.id), "!%08x", from);
|
||
strncpy(user.long_name, longName, sizeof(user.long_name) - 1);
|
||
user.public_key.size = 32;
|
||
memset(user.public_key.bytes, keyByte, 32);
|
||
packet.decoded.payload.size =
|
||
pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), &meshtastic_User_msg, &user);
|
||
return packet;
|
||
}
|
||
|
||
/**
|
||
* Verify the NodeInfo cache pins the first-seen public key: a later NodeInfo for the same
|
||
* node carrying a DIFFERENT key is rejected, and the served reply keeps the original key.
|
||
* Mirrors NodeDB::updateUser()'s "Public Key mismatch, dropping NodeInfo" protection.
|
||
*/
|
||
static void test_tm_nodeinfo_cache_rejectsMismatchedKey(void)
|
||
{
|
||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||
mockNodeDB->clearCachedNode(); // no authoritative NodeDB key -> exercise the cache's own TOFU pin
|
||
|
||
MockRouter mockRouter;
|
||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||
MeshService mockService;
|
||
router = &mockRouter;
|
||
service = &mockService;
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
// First-seen key (0x11...) is pinned.
|
||
module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "genuine", 0x11));
|
||
// Poisoning attempt with a different key (0x22...) must be rejected.
|
||
module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "attacker", 0x22));
|
||
// Signed-only replay gate (default) requires signer-proven provenance to serve the reply.
|
||
module.markKeySignerProvenForTest(kTargetNode);
|
||
|
||
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
|
||
request.decoded.want_response = true;
|
||
request.hop_start = 3;
|
||
request.hop_limit = 3;
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(module.handleReceived(request)));
|
||
TEST_ASSERT_EQUAL_UINT32(1, static_cast<uint32_t>(mockRouter.sentPackets.size()));
|
||
|
||
// The served reply must still carry the original (pinned) key, not the attacker's.
|
||
meshtastic_User served = meshtastic_User_init_zero;
|
||
const meshtastic_MeshPacket &reply = mockRouter.sentPackets.front();
|
||
TEST_ASSERT_TRUE(
|
||
pb_decode_from_bytes(reply.decoded.payload.bytes, reply.decoded.payload.size, &meshtastic_User_msg, &served));
|
||
TEST_ASSERT_EQUAL_UINT32(32, served.public_key.size);
|
||
TEST_ASSERT_EQUAL_UINT8(0x11, served.public_key.bytes[0]);
|
||
TEST_ASSERT_EQUAL_UINT8(0x11, served.public_key.bytes[31]);
|
||
}
|
||
|
||
#if WARM_NODE_COUNT > 0
|
||
/**
|
||
* Verify the NodeInfo cache pin also covers the WARM tier: for a node evicted from the hot
|
||
* store whose key lives only in the warm tier (and whose cache slot is empty), a NodeInfo
|
||
* carrying a different key must be rejected, and one carrying the warm key accepted.
|
||
* Important: with a hot-store-only pin, an attacker could seed this cache with a bogus key
|
||
* for a warm-evicted node; the cache's own TOFU pin would then lock the genuine node's
|
||
* frames out until the poisoned entry aged away.
|
||
*/
|
||
static void test_tm_nodeinfo_cache_pinsAgainstWarmTierKey(void)
|
||
{
|
||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||
mockNodeDB->clearCachedNode(); // hot store misses...
|
||
uint8_t warmKey[32];
|
||
memset(warmKey, 0x55, 32);
|
||
mockNodeDB->warmStore.clear();
|
||
mockNodeDB->warmStore.absorb(kTargetNode, 1000000, warmKey); // ...but the warm tier holds the key
|
||
|
||
MockRouter mockRouter;
|
||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||
MeshService mockService;
|
||
router = &mockRouter;
|
||
service = &mockService;
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
// Poisoning attempt with a key that mismatches the warm tier: must not be cached.
|
||
module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "attacker", 0x66));
|
||
uint8_t out[32] = {0};
|
||
TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, out, nullptr));
|
||
|
||
// The genuine key (matching the warm tier) is accepted.
|
||
module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "genuine", 0x55));
|
||
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, out, nullptr));
|
||
TEST_ASSERT_EQUAL_UINT8(0x55, out[0]);
|
||
TEST_ASSERT_EQUAL_UINT8(0x55, out[31]);
|
||
|
||
mockNodeDB->warmStore.clear();
|
||
}
|
||
|
||
/**
|
||
* Unsigned-identity gate, warm tier: a verified signer evicted to the warm tier must not be
|
||
* impersonatable. An attacker can forge an unsigned NodeInfo carrying the signer's real
|
||
* (public!) key - it passes the key pin and would inherit warm signer provenance - so the
|
||
* gate must classify warm-tier signers, not only hot-store ones. A signature-verified frame
|
||
* (control) is still learned.
|
||
*/
|
||
static void test_tm_nodeinfo_gate_blocksUnsignedWarmSignerForgery(void)
|
||
{
|
||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||
mockNodeDB->clearCachedNode();
|
||
mockNodeDB->rollHotStore(); // hot store misses; only the warm tier knows the signer
|
||
uint8_t warmKey[32];
|
||
memset(warmKey, 0x5A, 32);
|
||
mockNodeDB->warmStore.clear();
|
||
mockNodeDB->warmStore.absorb(kTargetNode, 1000000, warmKey, 0, 0, /*signer=*/true);
|
||
|
||
MockRouter mockRouter;
|
||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||
MeshService mockService;
|
||
router = &mockRouter;
|
||
service = &mockService;
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
// Forgery: unsigned NodeInfo with the REAL key (passes the pin) and an attacker name.
|
||
meshtastic_MeshPacket forged = makeNodeInfoPacketWithKey(kTargetNode, "attacker", 0x5A);
|
||
forged.xeddsa_signed = false;
|
||
module.handleReceived(forged);
|
||
TEST_ASSERT_EQUAL_INT(-1, module.peekNodeInfoFlagsForTest(kTargetNode)); // nothing cached
|
||
meshtastic_User out = meshtastic_User_init_zero;
|
||
TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr));
|
||
|
||
// Control: the same identity, signature-verified, is learned.
|
||
meshtastic_MeshPacket genuine = makeNodeInfoPacketWithKey(kTargetNode, "genuine", 0x5A);
|
||
genuine.xeddsa_signed = true;
|
||
module.handleReceived(genuine);
|
||
TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr));
|
||
TEST_ASSERT_EQUAL_STRING("genuine", out.long_name);
|
||
|
||
mockNodeDB->warmStore.clear();
|
||
}
|
||
|
||
/**
|
||
* Reconcile seeding, serve-gate honesty: a hot-store identity is seeded into the cache by
|
||
* the maintenance sweep (name + key + signer provenance usable via copyUser/copyPublicKey),
|
||
* but is NEVER served as a spoofed reply until a genuine NODEINFO frame is heard - seeding
|
||
* and retention must not make a silent node look alive.
|
||
*/
|
||
static void test_tm_nodeinfo_reconcile_seedsFromHotStoreButNeverServes(void)
|
||
{
|
||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||
mockNodeDB->clearCachedNode();
|
||
mockNodeDB->warmStore.clear();
|
||
mockNodeDB->setHotNodeIdentity(kTargetNode, "hot-name", 0x77, /*signer=*/true);
|
||
|
||
MockRouter mockRouter;
|
||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||
MeshService mockService;
|
||
router = &mockRouter;
|
||
service = &mockService;
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
module.runOnce(); // maintenance sweep -> reconcile seeds the hot identity
|
||
|
||
// Seeded identity is available to the key pool and name rehydration...
|
||
uint8_t key[32] = {0};
|
||
bool proven = false;
|
||
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven));
|
||
TEST_ASSERT_EQUAL_UINT8(0x77, key[0]);
|
||
TEST_ASSERT_TRUE(proven); // inherited from the hot store's signer bit, key-matched
|
||
meshtastic_User seeded = meshtastic_User_init_zero;
|
||
TEST_ASSERT_TRUE(module.copyUser(kTargetNode, seeded, nullptr));
|
||
TEST_ASSERT_EQUAL_STRING("hot-name", seeded.long_name);
|
||
|
||
// ...but a request for it is NOT answered: never observed, so never servable.
|
||
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
|
||
request.decoded.want_response = true;
|
||
request.hop_start = 3;
|
||
request.hop_limit = 3;
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(module.handleReceived(request)));
|
||
TEST_ASSERT_EQUAL_UINT32(0, static_cast<uint32_t>(mockRouter.sentPackets.size()));
|
||
|
||
// A genuinely observed frame (key matches the NodeDB pin) makes it servable. The node is
|
||
// a known signer, so per #11035 only a signature-verified frame may drive cache writes -
|
||
// mark it as Router-verified, as the real receive path would.
|
||
meshtastic_MeshPacket observed = makeNodeInfoPacketWithKey(kTargetNode, "hot-name", 0x77);
|
||
observed.xeddsa_signed = true;
|
||
module.handleReceived(observed);
|
||
request.id = 0xCCCC0002;
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(module.handleReceived(request)));
|
||
TEST_ASSERT_EQUAL_UINT32(1, static_cast<uint32_t>(mockRouter.sentPackets.size()));
|
||
|
||
mockNodeDB->rollHotStore();
|
||
}
|
||
|
||
/**
|
||
* Reconcile seeding from the warm tier yields a key-only record: usable by copyPublicKey
|
||
* (with the warm signer bit inherited), but never by copyUser - the warm tier keeps no
|
||
* names, and a nameless User must not reach name-rehydration.
|
||
*/
|
||
static void test_tm_nodeinfo_reconcile_seedsKeyOnlyFromWarmTier(void)
|
||
{
|
||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||
mockNodeDB->clearCachedNode();
|
||
mockNodeDB->rollHotStore();
|
||
uint8_t warmKey[32];
|
||
memset(warmKey, 0x44, 32);
|
||
mockNodeDB->warmStore.clear();
|
||
mockNodeDB->warmStore.absorb(kTargetNode, 1000000, warmKey, 0, 0, /*signer=*/true);
|
||
|
||
MockRouter mockRouter;
|
||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||
MeshService mockService;
|
||
router = &mockRouter;
|
||
service = &mockService;
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
module.runOnce();
|
||
|
||
uint8_t key[32] = {0};
|
||
bool proven = false;
|
||
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven));
|
||
TEST_ASSERT_EQUAL_UINT8(0x44, key[31]);
|
||
TEST_ASSERT_TRUE(proven);
|
||
meshtastic_User out = meshtastic_User_init_zero;
|
||
TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr)); // key-only record
|
||
|
||
mockNodeDB->warmStore.clear();
|
||
}
|
||
|
||
/**
|
||
* Reconcile must not let a keyless hot-store identity erase a TOFU key this cache already
|
||
* learned (the same merge rule as onNodeIdentityCommitted): the hot name is adopted, the
|
||
* kept key survives for the copyPublicKey pool - and stays unproven, because the hot signer
|
||
* bit vouches only for a NodeDB-supplied key, never the kept TOFU one.
|
||
*/
|
||
static void test_tm_nodeinfo_reconcile_keepsTofuKeyOnKeylessHotIdentity(void)
|
||
{
|
||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||
mockNodeDB->clearCachedNode();
|
||
mockNodeDB->rollHotStore();
|
||
mockNodeDB->warmStore.clear();
|
||
|
||
MockRouter mockRouter;
|
||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||
MeshService mockService;
|
||
router = &mockRouter;
|
||
service = &mockService;
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
// TOFU learn while NodeDB knows nothing about the node.
|
||
module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "tofu-name", 0x33));
|
||
uint8_t key[32] = {0};
|
||
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr));
|
||
|
||
// NodeDB then learns the node with a User but NO key; its signed bit is even set, which
|
||
// must vouch for nothing here since NodeDB supplies no key to match it against.
|
||
mockNodeDB->setSignerHotNode(kTargetNode, "hot-name");
|
||
module.runOnce(); // maintenance sweep -> reconcile adopts the hot identity
|
||
|
||
bool proven = true;
|
||
memset(key, 0, sizeof(key));
|
||
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); // TOFU key survived
|
||
TEST_ASSERT_EQUAL_UINT8(0x33, key[0]);
|
||
TEST_ASSERT_EQUAL_UINT8(0x33, key[31]);
|
||
TEST_ASSERT_FALSE(proven);
|
||
meshtastic_User out = meshtastic_User_init_zero;
|
||
TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr));
|
||
TEST_ASSERT_EQUAL_STRING("hot-name", out.long_name); // hot identity adopted
|
||
|
||
mockNodeDB->rollHotStore();
|
||
}
|
||
|
||
/**
|
||
* Write-through hook: an identity committed through NodeDB::updateUser() lands in the
|
||
* NodeInfo cache immediately (name + key), without waiting for a maintenance sweep - and
|
||
* is still not servable, because the node was never actually heard.
|
||
*/
|
||
static void test_tm_nodeinfo_updateUserHook_writesThrough(void)
|
||
{
|
||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||
mockNodeDB->clearCachedNode();
|
||
mockNodeDB->rollHotStore();
|
||
mockNodeDB->warmStore.clear();
|
||
|
||
MockRouter mockRouter;
|
||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||
MeshService mockService;
|
||
router = &mockRouter;
|
||
service = &mockService;
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
trafficManagementModule = &module; // the NodeDB hook reaches the module via the global
|
||
|
||
meshtastic_User user = meshtastic_User_init_zero;
|
||
strncpy(user.long_name, "committed", sizeof(user.long_name) - 1);
|
||
user.public_key.size = 32;
|
||
memset(user.public_key.bytes, 0x5A, 32);
|
||
mockNodeDB->updateUser(kTargetNode, user, 0);
|
||
|
||
// Immediately visible to the key pool and name rehydration (no sweep has run)...
|
||
uint8_t key[32] = {0};
|
||
bool proven = true;
|
||
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven));
|
||
TEST_ASSERT_EQUAL_UINT8(0x5A, key[0]);
|
||
TEST_ASSERT_FALSE(proven); // committed TOFU key: no signer bit on the node yet
|
||
meshtastic_User out = meshtastic_User_init_zero;
|
||
TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr));
|
||
TEST_ASSERT_EQUAL_STRING("committed", out.long_name);
|
||
|
||
// ...but known-not-heard is never servable.
|
||
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
|
||
request.decoded.want_response = true;
|
||
request.hop_start = 3;
|
||
request.hop_limit = 3;
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(module.handleReceived(request)));
|
||
TEST_ASSERT_EQUAL_UINT32(0, static_cast<uint32_t>(mockRouter.sentPackets.size()));
|
||
// trafficManagementModule and the hot store are reset in tearDown()/setUp().
|
||
}
|
||
|
||
/**
|
||
* Full removal: NodeDB::removeNodeByNum() must purge this module's caches too - both the
|
||
* NodeInfo identity entry (name/key) and the unified slot (next-hop hint etc.) - or the
|
||
* deleted identity would keep feeding the key pool and resurrect on next contact.
|
||
*/
|
||
static void test_tm_nodeinfo_removeNode_purgesCaches(void)
|
||
{
|
||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||
mockNodeDB->clearCachedNode();
|
||
mockNodeDB->rollHotStore();
|
||
mockNodeDB->warmStore.clear();
|
||
|
||
MockRouter mockRouter;
|
||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||
MeshService mockService;
|
||
router = &mockRouter;
|
||
service = &mockService;
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
trafficManagementModule = &module; // the NodeDB purge hook reaches the module via the global
|
||
|
||
// Learn an identity (observed frame) and a routing hint for the node.
|
||
module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "victim", 0x37));
|
||
module.setNextHop(kTargetNode, 0x42);
|
||
uint8_t key[32] = {0};
|
||
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr));
|
||
TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode));
|
||
|
||
mockNodeDB->removeNodeByNum(kTargetNode);
|
||
|
||
TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr));
|
||
meshtastic_User out = meshtastic_User_init_zero;
|
||
TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr));
|
||
TEST_ASSERT_EQUAL_UINT8(0, module.getNextHopHint(kTargetNode));
|
||
TEST_ASSERT_EQUAL_INT(-1, module.peekNodeInfoFlagsForTest(kTargetNode));
|
||
// trafficManagementModule is reset in tearDown().
|
||
}
|
||
|
||
/**
|
||
* No timed eviction: a quiet keyed entry survives arbitrarily long (its key keeps feeding
|
||
* the pubkey pool via copyPublicKey), while tick saturation stops it being SERVED long
|
||
* before that. Slots are reclaimed only by LRU pressure on insert or an explicit purge.
|
||
*/
|
||
static void test_tm_nodeinfo_noTimedEviction_quietKeyedEntrySurvives(void)
|
||
{
|
||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||
mockNodeDB->clearCachedNode();
|
||
mockNodeDB->rollHotStore();
|
||
mockNodeDB->warmStore.clear();
|
||
|
||
MockRouter mockRouter;
|
||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||
MeshService mockService;
|
||
router = &mockRouter;
|
||
service = &mockService;
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "quiet", 0x21));
|
||
module.markKeySignerProvenForTest(kTargetNode); // isolate: staleness, not the signed gate
|
||
|
||
// Nine days of silence, swept every three days. The old design would have evicted the
|
||
// entry at the 7-day retention TTL; now nothing expires by timer.
|
||
for (int i = 0; i < 3; i++) {
|
||
TrafficManagementModule::s_testNowMs += 3UL * 24UL * 60UL * 60UL * 1000UL;
|
||
module.runOnce();
|
||
}
|
||
|
||
// The key still feeds the pool...
|
||
uint8_t key[32] = {0};
|
||
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr));
|
||
TEST_ASSERT_EQUAL_UINT8(0x21, key[0]);
|
||
|
||
// ...but the serve gate saturated long ago: the request propagates unanswered.
|
||
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
|
||
request.decoded.want_response = true;
|
||
request.hop_start = 3;
|
||
request.hop_limit = 3;
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(module.handleReceived(request)));
|
||
TEST_ASSERT_EQUAL_UINT32(0, static_cast<uint32_t>(mockRouter.sentPackets.size()));
|
||
}
|
||
#endif // WARM_NODE_COUNT > 0
|
||
|
||
/**
|
||
* Feature #2: a key learned from an (unsigned) NodeInfo is served by copyPublicKey() as a
|
||
* trust-on-first-use key, so it can extend the encryption pool. signerProven must be false.
|
||
*/
|
||
static void test_tm_nodeinfo_copyPublicKey_servesTofuKey(void)
|
||
{
|
||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||
mockNodeDB->clearCachedNode();
|
||
|
||
MockRouter mockRouter;
|
||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||
MeshService mockService;
|
||
router = &mockRouter;
|
||
service = &mockService;
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "genuine", 0x33));
|
||
|
||
uint8_t key[32] = {0};
|
||
bool proven = true; // must be overwritten to false
|
||
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven));
|
||
TEST_ASSERT_EQUAL_UINT8(0x33, key[0]);
|
||
TEST_ASSERT_EQUAL_UINT8(0x33, key[31]);
|
||
TEST_ASSERT_FALSE(proven);
|
||
}
|
||
|
||
/**
|
||
* Feature #1: a later signature-verified NodeInfo upgrades the cached key's provenance to
|
||
* signer-proven (monotonic), while the key bytes stay pinned.
|
||
*/
|
||
static void test_tm_nodeinfo_copyPublicKey_upgradesToSignerProven(void)
|
||
{
|
||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||
mockNodeDB->clearCachedNode();
|
||
|
||
MockRouter mockRouter;
|
||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||
MeshService mockService;
|
||
router = &mockRouter;
|
||
service = &mockService;
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
// First contact is unsigned -> TOFU.
|
||
module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "genuine", 0x44));
|
||
uint8_t key[32] = {0};
|
||
bool proven = true;
|
||
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven));
|
||
TEST_ASSERT_FALSE(proven);
|
||
|
||
// A later frame whose signature we verified upgrades provenance.
|
||
meshtastic_MeshPacket signed_ni = makeNodeInfoPacketWithKey(kTargetNode, "genuine", 0x44);
|
||
signed_ni.xeddsa_signed = true;
|
||
module.handleReceived(signed_ni);
|
||
|
||
proven = false;
|
||
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven));
|
||
TEST_ASSERT_TRUE(proven);
|
||
TEST_ASSERT_EQUAL_UINT8(0x44, key[0]); // key unchanged
|
||
}
|
||
|
||
/**
|
||
* copyPublicKey() reports a miss (false) for a node we have never cached.
|
||
*/
|
||
static void test_tm_nodeinfo_copyPublicKey_missReturnsFalse(void)
|
||
{
|
||
mockNodeDB->clearCachedNode();
|
||
TrafficManagementModuleTestShim module;
|
||
uint8_t key[32] = {0};
|
||
TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr));
|
||
}
|
||
|
||
/**
|
||
* copyUser() returns the full cached identity (name + key) for name rehydration, and reports a
|
||
* miss for an uncached node.
|
||
*/
|
||
static void test_tm_nodeinfo_copyUser_returnsCachedIdentity(void)
|
||
{
|
||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||
mockNodeDB->clearCachedNode();
|
||
|
||
MockRouter mockRouter;
|
||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||
MeshService mockService;
|
||
router = &mockRouter;
|
||
service = &mockService;
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "target-long", 0x77));
|
||
|
||
meshtastic_User out = meshtastic_User_init_zero;
|
||
TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr));
|
||
TEST_ASSERT_EQUAL_STRING("target-long", out.long_name);
|
||
TEST_ASSERT_EQUAL_UINT32(32, out.public_key.size);
|
||
TEST_ASSERT_EQUAL_UINT8(0x77, out.public_key.bytes[0]);
|
||
|
||
// Uncached node -> miss.
|
||
meshtastic_User miss = meshtastic_User_init_zero;
|
||
TEST_ASSERT_FALSE(module.copyUser(kRemoteNode, miss, nullptr));
|
||
}
|
||
|
||
#if TMM_NODEINFO_REPLAY_SIGNED_GATE
|
||
/**
|
||
* Replay gate (cache path): a fresh but trust-on-first-use (never signer-proven) cached entry
|
||
* is withheld - the reply is suppressed though the entry is fresh.
|
||
*/
|
||
static void test_tm_nodeinfo_directResponse_psramUnsignedNotServed(void)
|
||
{
|
||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||
mockNodeDB->clearCachedNode();
|
||
|
||
MockRouter mockRouter;
|
||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||
MeshService mockService;
|
||
router = &mockRouter;
|
||
service = &mockService;
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
// Cache a fresh but unsigned (TOFU) NodeInfo and do NOT mark it signer-proven.
|
||
module.handleReceived(makeNodeInfoPacket(kTargetNode, "target-long", "tg"));
|
||
|
||
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
|
||
request.decoded.want_response = true;
|
||
request.hop_start = 3;
|
||
request.hop_limit = 3;
|
||
|
||
ProcessMessage result = module.handleReceived(request);
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(result));
|
||
TEST_ASSERT_EQUAL_UINT32(0, static_cast<uint32_t>(mockRouter.sentPackets.size()));
|
||
}
|
||
#endif // TMM_NODEINFO_REPLAY_SIGNED_GATE
|
||
#endif // !MESHTASTIC_EXCLUDE_PKI
|
||
|
||
// Bit positions returned by peekNodeInfoFlagsForTest().
|
||
constexpr int kFlagObserved = 1;
|
||
constexpr int kFlagMember = 2;
|
||
constexpr int kFlagFullUser = 4;
|
||
|
||
/**
|
||
* Key-commit hook (ported from tmm-fix-superset): a TOFU learn lands the key in the pool
|
||
* without a User payload; manual verification upgrades provenance; a NodeDB-senior rotation
|
||
* replaces the key and never inherits the old key's verdict.
|
||
*/
|
||
static void test_tm_nodeinfo_keyHook_upsertsAndGovernsProvenance(void)
|
||
{
|
||
mockNodeDB->clearCachedNode();
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
uint8_t k[32];
|
||
memset(k, 0x99, sizeof(k));
|
||
module.onNodeKeyCommitted(kTargetNode, k, false); // TOFU-grade learn
|
||
uint8_t key[32] = {0};
|
||
bool proven = true;
|
||
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven));
|
||
TEST_ASSERT_EQUAL_UINT8(0x99, key[0]);
|
||
TEST_ASSERT_FALSE(proven);
|
||
meshtastic_User out = meshtastic_User_init_zero;
|
||
TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr)); // key-only: nothing to rehydrate
|
||
|
||
module.onNodeKeyCommitted(kTargetNode, k, true); // manual verification of the same key
|
||
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven));
|
||
TEST_ASSERT_TRUE(proven);
|
||
|
||
uint8_t rotated[32];
|
||
memset(rotated, 0xAA, sizeof(rotated));
|
||
module.onNodeKeyCommitted(kTargetNode, rotated, false); // NodeDB-senior rotation
|
||
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven));
|
||
TEST_ASSERT_EQUAL_UINT8(0xAA, key[0]);
|
||
TEST_ASSERT_FALSE(proven); // rotated key must not inherit the old key's verdict
|
||
}
|
||
|
||
/**
|
||
* Tick saturation (ported from tmm-fix-superset): the sweep clears hasObserved once the
|
||
* serve window passes, the entry itself persists (no TTL eviction), and a full 256-tick
|
||
* wrap of the clock cannot alias a saturated stamp back to "fresh".
|
||
*/
|
||
static void test_tm_nodeinfo_tickSaturation_sweepClearsObserved(void)
|
||
{
|
||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||
mockNodeDB->clearCachedNode();
|
||
mockNodeDB->warmStore.clear();
|
||
mockNodeDB->rollHotStore();
|
||
|
||
MockRouter mockRouter;
|
||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||
MeshService mockService;
|
||
router = &mockRouter;
|
||
service = &mockService;
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
module.handleReceived(makeNodeInfoPacket(kTargetNode, "target-long", "tg"));
|
||
module.markKeySignerProvenForTest(kTargetNode);
|
||
const uint32_t stampMs = TrafficManagementModule::s_testNowMs;
|
||
int flags = module.peekNodeInfoFlagsForTest(kTargetNode);
|
||
TEST_ASSERT_TRUE(flags >= 0 && (flags & kFlagObserved));
|
||
|
||
// Past the serve window (plus one tick for granularity): the sweep saturates the stamp
|
||
// but keeps the entry.
|
||
TrafficManagementModule::s_testNowMs += (6UL * 60UL * 60UL * 1000UL) + 180000UL + 1000UL;
|
||
module.runOnce();
|
||
flags = module.peekNodeInfoFlagsForTest(kTargetNode);
|
||
TEST_ASSERT_TRUE(flags >= 0);
|
||
TEST_ASSERT_FALSE(flags & kFlagObserved);
|
||
|
||
// Advance to exactly one full uint8 tick period after the original stamp: the raw tick
|
||
// age would read ~0 (fresh) if the bit were still trusted. It isn't - the cleared bit,
|
||
// not the tick value, is authoritative.
|
||
TrafficManagementModule::s_testNowMs = stampMs + 256UL * 180000UL;
|
||
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
|
||
request.decoded.want_response = true;
|
||
request.hop_start = 3;
|
||
request.hop_limit = 3;
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(module.handleReceived(request)));
|
||
TEST_ASSERT_EQUAL_UINT32(0, static_cast<uint32_t>(mockRouter.sentPackets.size()));
|
||
}
|
||
|
||
/**
|
||
* Membership marking: the hourly reconcile marks entries whose node exists in NodeDB and
|
||
* clears the mark when the node is gone. A plain 60 s sweep does NOT refresh membership
|
||
* (that per-entry NodeDB scan was moved into the reconcile), so a passive NodeDB eviction
|
||
* lags by up to an hour; the entry itself persists (no TTL) and reconciliation-seeded
|
||
* identities stay unservable.
|
||
*/
|
||
static void test_tm_nodeinfo_reconcileMembershipMarking(void)
|
||
{
|
||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||
mockNodeDB->clearCachedNode();
|
||
mockNodeDB->warmStore.clear();
|
||
mockNodeDB->setHotNodeIdentity(kTargetNode, "seeded-name", 0x5C, /*signer=*/false);
|
||
|
||
MockRouter mockRouter;
|
||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||
MeshService mockService;
|
||
router = &mockRouter;
|
||
service = &mockService;
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
module.runOnce(); // boot reconciliation pass seeds the hot identity
|
||
int flags = module.peekNodeInfoFlagsForTest(kTargetNode);
|
||
TEST_ASSERT_TRUE(flags >= 0);
|
||
TEST_ASSERT_TRUE(flags & kFlagMember);
|
||
TEST_ASSERT_TRUE(flags & kFlagFullUser);
|
||
TEST_ASSERT_FALSE(flags & kFlagObserved);
|
||
|
||
// Node drops out of NodeDB entirely. Membership is refreshed by the hourly reconcile,
|
||
// not the per-minute sweep, so the very next sweep still shows the stale member bit -
|
||
// the documented up-to-an-hour lag for passive evictions.
|
||
mockNodeDB->rollHotStore();
|
||
module.runOnce();
|
||
flags = module.peekNodeInfoFlagsForTest(kTargetNode);
|
||
TEST_ASSERT_TRUE(flags >= 0);
|
||
TEST_ASSERT_TRUE(flags & kFlagMember); // stale by design between reconciles
|
||
|
||
// After a reconcile interval's worth of sweeps, the mark is cleared.
|
||
for (int i = 0; i < 60; i++)
|
||
module.runOnce();
|
||
flags = module.peekNodeInfoFlagsForTest(kTargetNode);
|
||
TEST_ASSERT_TRUE(flags >= 0); // entry persists (no TTL) ...
|
||
TEST_ASSERT_FALSE(flags & kFlagMember); // ... but is no longer pinned as a member
|
||
}
|
||
|
||
/**
|
||
* cacheNodeInfoPacket() normalizes user.id to the packet sender: a payload claiming another
|
||
* node's id string must not plant a mismatched id into served replies or rehydrated names.
|
||
*/
|
||
static void test_tm_nodeinfo_cache_normalizesSpoofedUserId(void)
|
||
{
|
||
mockNodeDB->clearCachedNode();
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kTargetNode, NODENUM_BROADCAST);
|
||
meshtastic_User user = meshtastic_User_init_zero;
|
||
snprintf(user.id, sizeof(user.id), "!%08x", static_cast<unsigned>(kRemoteNode)); // spoofed id
|
||
strncpy(user.long_name, "spoofer", sizeof(user.long_name) - 1);
|
||
packet.decoded.payload.size =
|
||
pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), &meshtastic_User_msg, &user);
|
||
module.handleReceived(packet);
|
||
|
||
meshtastic_User out = meshtastic_User_init_zero;
|
||
TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr));
|
||
char expected[16];
|
||
snprintf(expected, sizeof(expected), "!%08x", static_cast<unsigned>(kTargetNode));
|
||
TEST_ASSERT_EQUAL_STRING(expected, out.id);
|
||
}
|
||
|
||
/**
|
||
* The write-through hooks share the module-enabled gate with maintenance: while traffic
|
||
* management is disabled in moduleConfig, neither hook fills the cache (content and
|
||
* maintenance stay keyed to the same condition); re-enabling restores write-through.
|
||
*/
|
||
static void test_tm_nodeinfo_hooks_noopWhileModuleDisabled(void)
|
||
{
|
||
mockNodeDB->clearCachedNode();
|
||
TrafficManagementModuleTestShim module; // constructed while enabled, so the cache is allocated
|
||
|
||
moduleConfig.has_traffic_management = false;
|
||
uint8_t k[32];
|
||
memset(k, 0x6B, sizeof(k));
|
||
module.onNodeKeyCommitted(kTargetNode, k, true);
|
||
meshtastic_User user = meshtastic_User_init_zero;
|
||
strncpy(user.long_name, "disabled", sizeof(user.long_name) - 1);
|
||
module.onNodeIdentityCommitted(kTargetNode, user, false);
|
||
|
||
uint8_t key[32] = {0};
|
||
TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr));
|
||
meshtastic_User out = meshtastic_User_init_zero;
|
||
TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr));
|
||
TEST_ASSERT_EQUAL_INT(-1, module.peekNodeInfoFlagsForTest(kTargetNode));
|
||
|
||
// Control: the same hook lands once the module is enabled again.
|
||
moduleConfig.has_traffic_management = true;
|
||
module.onNodeKeyCommitted(kTargetNode, k, true);
|
||
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr));
|
||
TEST_ASSERT_EQUAL_UINT8(0x6B, key[0]);
|
||
}
|
||
|
||
// Build a User for onNodeIdentityCommitted; keyByte < 0 means a keyless commit.
|
||
static meshtastic_User makeCommittedUser(const char *longName, int keyByte)
|
||
{
|
||
meshtastic_User user = meshtastic_User_init_zero;
|
||
strncpy(user.long_name, longName, sizeof(user.long_name) - 1);
|
||
if (keyByte >= 0) {
|
||
user.public_key.size = 32;
|
||
memset(user.public_key.bytes, static_cast<uint8_t>(keyByte), 32);
|
||
}
|
||
return user;
|
||
}
|
||
|
||
/**
|
||
* Identity write-through hook, key semantics: a keyless commit keeps an already-learned key
|
||
* (NodeDB may commit an unpinned identity); provenance follows the committed key - kept
|
||
* while the key is unchanged, granted by signerKnown, and reset by a rotation.
|
||
*/
|
||
static void test_tm_nodeinfo_identityHook_keySemantics(void)
|
||
{
|
||
mockNodeDB->clearCachedNode();
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
// TOFU-grade identity commit with key K1.
|
||
module.onNodeIdentityCommitted(kTargetNode, makeCommittedUser("first", 0x51), false);
|
||
uint8_t key[32] = {0};
|
||
bool proven = true;
|
||
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven));
|
||
TEST_ASSERT_EQUAL_UINT8(0x51, key[0]);
|
||
TEST_ASSERT_FALSE(proven);
|
||
|
||
// Keyless commit: the name updates but the learned key must survive, still unproven.
|
||
module.onNodeIdentityCommitted(kTargetNode, makeCommittedUser("renamed", -1), false);
|
||
meshtastic_User out = meshtastic_User_init_zero;
|
||
TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, &proven));
|
||
TEST_ASSERT_EQUAL_STRING("renamed", out.long_name);
|
||
TEST_ASSERT_EQUAL_UINT32(32, out.public_key.size);
|
||
TEST_ASSERT_EQUAL_UINT8(0x51, out.public_key.bytes[0]);
|
||
TEST_ASSERT_FALSE(proven);
|
||
|
||
// signerKnown commit of the same key grants provenance...
|
||
module.onNodeIdentityCommitted(kTargetNode, makeCommittedUser("proven", 0x51), true);
|
||
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven));
|
||
TEST_ASSERT_TRUE(proven);
|
||
|
||
// ...which a later same-key commit without the signer verdict does not revoke...
|
||
module.onNodeIdentityCommitted(kTargetNode, makeCommittedUser("still-proven", 0x51), false);
|
||
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven));
|
||
TEST_ASSERT_TRUE(proven);
|
||
|
||
// ...but a rotated key never inherits the old key's verdict.
|
||
module.onNodeIdentityCommitted(kTargetNode, makeCommittedUser("rotated", 0x52), false);
|
||
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven));
|
||
TEST_ASSERT_EQUAL_UINT8(0x52, key[0]);
|
||
TEST_ASSERT_FALSE(proven);
|
||
}
|
||
|
||
/**
|
||
* Read gate: copyPublicKey()/copyUser() share the module-enabled gate with the writers, so a
|
||
* cache populated while enabled stops feeding PKI resolution and name rehydration the moment
|
||
* the module is disabled - and resumes when re-enabled (the entry itself is never freed).
|
||
*/
|
||
static void test_tm_nodeinfo_reads_gatedWhileModuleDisabled(void)
|
||
{
|
||
mockNodeDB->clearCachedNode();
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
// Populate while enabled (setUp left has_traffic_management = true).
|
||
module.onNodeIdentityCommitted(kTargetNode, makeCommittedUser("present", 0x7C), false);
|
||
uint8_t key[32] = {0};
|
||
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr));
|
||
meshtastic_User out = meshtastic_User_init_zero;
|
||
TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr));
|
||
|
||
// Disabled: the frozen entry persists but the accessors refuse to serve it.
|
||
moduleConfig.has_traffic_management = false;
|
||
TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr));
|
||
TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr));
|
||
|
||
// Re-enabled: same entry served again (nothing was purged).
|
||
moduleConfig.has_traffic_management = true;
|
||
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr));
|
||
TEST_ASSERT_EQUAL_UINT8(0x7C, key[0]);
|
||
TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr));
|
||
TEST_ASSERT_EQUAL_STRING("present", out.long_name);
|
||
}
|
||
|
||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||
// Fill `count` NodeInfo cache slots with keyless observed strangers (eviction tier 0),
|
||
// numbered from `baseNode`.
|
||
static void fillNodeInfoCacheWithKeylessStrangers(TrafficManagementModuleTestShim &module, uint32_t count, NodeNum baseNode)
|
||
{
|
||
for (uint32_t i = 0; i < count; i++)
|
||
module.handleReceived(makeNodeInfoPacket(baseNode + i, "filler", "fl"));
|
||
}
|
||
|
||
// Fill `count` NodeInfo cache slots with TOFU-keyed observed strangers (eviction tier 1),
|
||
// numbered from `baseNode`, all sharing key byte 0x0F.
|
||
static void fillNodeInfoCacheWithTofuStrangers(TrafficManagementModuleTestShim &module, uint32_t count, NodeNum baseNode)
|
||
{
|
||
for (uint32_t i = 0; i < count; i++)
|
||
module.handleReceived(makeNodeInfoPacketWithKey(baseNode + i, "filler", 0x0F));
|
||
}
|
||
|
||
/**
|
||
* Tiered LRU eviction, tier boundaries: with the cache exactly full, a new stranger's insert
|
||
* evicts a keyless stranger - never a TOFU-keyed entry, a signer-proven entry, or a NodeDB
|
||
* member - even though those higher-tier entries are the OLDEST observations in the cache
|
||
* (tier outranks recency).
|
||
*/
|
||
static void test_tm_nodeinfo_eviction_keyedTiersOutrankKeyless(void)
|
||
{
|
||
mockNodeDB->clearCachedNode();
|
||
mockNodeDB->rollHotStore();
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
constexpr NodeNum kTofu = 0x51000001, kProven = 0x51000002, kMember = 0x51000003, kNewcomer = 0x51000004;
|
||
module.handleReceived(makeNodeInfoPacketWithKey(kTofu, "tofu", 0x11));
|
||
module.handleReceived(makeNodeInfoPacketWithKey(kProven, "proven", 0x22));
|
||
module.markKeySignerProvenForTest(kProven);
|
||
uint8_t memberKey[32];
|
||
memset(memberKey, 0x33, sizeof(memberKey));
|
||
module.onNodeKeyCommitted(kMember, memberKey, false);
|
||
|
||
// Age the specials by two observation ticks, then fill every remaining slot with
|
||
// fresher keyless strangers so the cache is exactly full.
|
||
TrafficManagementModule::s_testNowMs += 2UL * 180000UL;
|
||
const uint16_t cap = TrafficManagementModuleTestShim::nodeInfoCacheCapacityForTest();
|
||
fillNodeInfoCacheWithKeylessStrangers(module, cap - 3u, 0x40000000);
|
||
|
||
// The newcomer's insert must claim a keyless slot.
|
||
module.handleReceived(makeNodeInfoPacket(kNewcomer, "newcomer", "nc"));
|
||
|
||
uint8_t key[32] = {0};
|
||
TEST_ASSERT_TRUE(module.copyPublicKey(kTofu, key, nullptr));
|
||
TEST_ASSERT_TRUE(module.copyPublicKey(kProven, key, nullptr));
|
||
TEST_ASSERT_TRUE(module.copyPublicKey(kMember, key, nullptr));
|
||
TEST_ASSERT_TRUE(module.peekNodeInfoFlagsForTest(kNewcomer) >= 0);
|
||
}
|
||
|
||
/**
|
||
* Tiered LRU eviction, keyed tiers: in a cache saturated with TOFU-keyed strangers, keyed
|
||
* inserts displace TOFU entries while a signer-proven stranger and a NodeDB member survive
|
||
* (keyless < TOFU < signer-proven, +membership).
|
||
*/
|
||
static void test_tm_nodeinfo_eviction_tofuLosesBeforeProvenAndMember(void)
|
||
{
|
||
mockNodeDB->clearCachedNode();
|
||
mockNodeDB->rollHotStore();
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
constexpr NodeNum kFillBase = 0x40000000;
|
||
constexpr NodeNum kProven = 0x52000001, kMember = 0x52000002, kNew1 = 0x52000003, kNew2 = 0x52000004;
|
||
|
||
const uint16_t cap = TrafficManagementModuleTestShim::nodeInfoCacheCapacityForTest();
|
||
fillNodeInfoCacheWithTofuStrangers(module, cap - 2u, kFillBase);
|
||
module.handleReceived(makeNodeInfoPacketWithKey(kProven, "proven", 0x22));
|
||
module.markKeySignerProvenForTest(kProven);
|
||
uint8_t memberKey[32];
|
||
memset(memberKey, 0x33, sizeof(memberKey));
|
||
module.onNodeKeyCommitted(kMember, memberKey, false);
|
||
|
||
// Age the saturated cache so each newcomer is fresher than the remaining fillers
|
||
// (otherwise the second insert would reclaim the first newcomer's equal-age slot).
|
||
TrafficManagementModule::s_testNowMs += 2UL * 180000UL;
|
||
module.handleReceived(makeNodeInfoPacketWithKey(kNew1, "new1", 0x44));
|
||
module.handleReceived(makeNodeInfoPacketWithKey(kNew2, "new2", 0x55));
|
||
|
||
uint8_t key[32] = {0};
|
||
TEST_ASSERT_TRUE(module.copyPublicKey(kProven, key, nullptr));
|
||
TEST_ASSERT_TRUE(module.copyPublicKey(kMember, key, nullptr));
|
||
TEST_ASSERT_TRUE(module.copyPublicKey(kNew1, key, nullptr));
|
||
TEST_ASSERT_TRUE(module.copyPublicKey(kNew2, key, nullptr));
|
||
// The two victims were the first TOFU fillers scanned, not the protected entries.
|
||
TEST_ASSERT_FALSE(module.copyPublicKey(kFillBase + 0, key, nullptr));
|
||
TEST_ASSERT_FALSE(module.copyPublicKey(kFillBase + 1, key, nullptr));
|
||
}
|
||
|
||
/**
|
||
* Within-tier LRU: among same-tier entries the stalest observation is evicted first, not
|
||
* whichever slot happens to be scanned first.
|
||
*/
|
||
static void test_tm_nodeinfo_eviction_withinTierStalestLoses(void)
|
||
{
|
||
mockNodeDB->clearCachedNode();
|
||
mockNodeDB->rollHotStore();
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
constexpr NodeNum kFillBase = 0x40000000;
|
||
constexpr NodeNum kStale = 0x53000001, kNewcomer = 0x53000002;
|
||
module.handleReceived(makeNodeInfoPacketWithKey(kStale, "stale", 0x11));
|
||
|
||
// Everything else is observed two ticks later...
|
||
TrafficManagementModule::s_testNowMs += 2UL * 180000UL;
|
||
const uint16_t cap = TrafficManagementModuleTestShim::nodeInfoCacheCapacityForTest();
|
||
fillNodeInfoCacheWithTofuStrangers(module, cap - 1u, kFillBase);
|
||
|
||
// ...so the newcomer's insert reclaims kStale's slot specifically.
|
||
module.handleReceived(makeNodeInfoPacketWithKey(kNewcomer, "newcomer", 0x22));
|
||
|
||
uint8_t key[32] = {0};
|
||
TEST_ASSERT_FALSE(module.copyPublicKey(kStale, key, nullptr));
|
||
TEST_ASSERT_TRUE(module.peekNodeInfoFlagsForTest(kNewcomer) >= 0);
|
||
TEST_ASSERT_TRUE(module.copyPublicKey(kFillBase + 0, key, nullptr)); // fresher same-tier entry survives
|
||
}
|
||
|
||
/**
|
||
* Member saturation: with every slot holding a NodeDB member, the write-through hooks skip
|
||
* rather than churn one member out for another (spareMembers), while the packet path - a
|
||
* genuinely observed frame - does evict a member.
|
||
*/
|
||
static void test_tm_nodeinfo_memberSaturated_hooksSkipPacketPathEvicts(void)
|
||
{
|
||
mockNodeDB->clearCachedNode();
|
||
mockNodeDB->rollHotStore();
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
constexpr NodeNum kFillBase = 0x40000000;
|
||
constexpr NodeNum kExtra = 0x54000001, kObserved = 0x54000002;
|
||
|
||
const uint16_t cap = TrafficManagementModuleTestShim::nodeInfoCacheCapacityForTest();
|
||
uint8_t k[32];
|
||
memset(k, 0x11, sizeof(k));
|
||
for (uint32_t i = 0; i < cap; i++)
|
||
module.onNodeKeyCommitted(kFillBase + i, k, false);
|
||
|
||
// Hook inserts for one more member: skipped rather than evicting an existing member.
|
||
uint8_t extraKey[32];
|
||
memset(extraKey, 0x22, sizeof(extraKey));
|
||
module.onNodeKeyCommitted(kExtra, extraKey, false);
|
||
uint8_t key[32] = {0};
|
||
TEST_ASSERT_FALSE(module.copyPublicKey(kExtra, key, nullptr));
|
||
module.onNodeIdentityCommitted(kExtra, makeCommittedUser("extra", 0x22), false);
|
||
meshtastic_User out = meshtastic_User_init_zero;
|
||
TEST_ASSERT_FALSE(module.copyUser(kExtra, out, nullptr));
|
||
|
||
// The packet path may churn a member: the observed stranger lands (in the first-scanned
|
||
// member's slot) and is correctly NOT marked a member itself.
|
||
module.handleReceived(makeNodeInfoPacketWithKey(kObserved, "observed", 0x33));
|
||
TEST_ASSERT_TRUE(module.copyPublicKey(kObserved, key, nullptr));
|
||
const int flags = module.peekNodeInfoFlagsForTest(kObserved);
|
||
TEST_ASSERT_TRUE(flags >= 0);
|
||
TEST_ASSERT_TRUE(flags & kFlagObserved);
|
||
TEST_ASSERT_FALSE(flags & kFlagMember);
|
||
TEST_ASSERT_FALSE(module.copyPublicKey(kFillBase + 0, key, nullptr)); // the evicted member
|
||
}
|
||
|
||
/**
|
||
* Full DB reset: NodeDB::resetNodes() purges this module's caches through purgeAll(), so no
|
||
* identity, key, or next-hop hint survives a user-initiated "forget everything".
|
||
*/
|
||
static void test_tm_nodeinfo_resetNodes_purgesAllCaches(void)
|
||
{
|
||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||
mockNodeDB->clearCachedNode();
|
||
mockNodeDB->rollHotStore();
|
||
|
||
MockRouter mockRouter;
|
||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||
MeshService mockService;
|
||
router = &mockRouter;
|
||
service = &mockService;
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
trafficManagementModule = &module; // the NodeDB purge hook reaches the module via the global
|
||
|
||
module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "victim", 0x37));
|
||
module.setNextHop(kTargetNode, 0x42);
|
||
uint8_t key[32] = {0};
|
||
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr));
|
||
TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode));
|
||
|
||
mockNodeDB->resetNodes();
|
||
|
||
TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr));
|
||
meshtastic_User out = meshtastic_User_init_zero;
|
||
TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr));
|
||
TEST_ASSERT_EQUAL_INT(-1, module.peekNodeInfoFlagsForTest(kTargetNode));
|
||
TEST_ASSERT_EQUAL_UINT8(0, module.getNextHopHint(kTargetNode));
|
||
// trafficManagementModule is reset in tearDown().
|
||
}
|
||
|
||
/**
|
||
* A NodeInfo advertising OUR OWN public key is impersonating us and must never be cached
|
||
* (mirrors NodeDB::updateUser()'s key hygiene for the store that feeds spoofed replies).
|
||
*/
|
||
static void test_tm_nodeinfo_cache_dropsFrameCarryingOwnerKey(void)
|
||
{
|
||
mockNodeDB->clearCachedNode();
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
owner.public_key.size = 32;
|
||
memset(owner.public_key.bytes, 0x42, 32);
|
||
|
||
module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "imposter", 0x42));
|
||
|
||
TEST_ASSERT_EQUAL_INT(-1, module.peekNodeInfoFlagsForTest(kTargetNode));
|
||
uint8_t key[32] = {0};
|
||
TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr));
|
||
// owner.public_key is reset in tearDown() (guaranteed even if an assertion above aborts).
|
||
}
|
||
#endif // !MESHTASTIC_EXCLUDE_PKI
|
||
#endif
|
||
|
||
/**
|
||
* Verify the NodeDB-fallback direct-response path (no NodeInfo cache) refuses to spoof a
|
||
* reply for a node NodeDB last heard beyond the serve window, but still answers a fresh one.
|
||
*/
|
||
static void test_tm_nodeinfo_directResponse_fallbackStaleEntryNotServed(void)
|
||
{
|
||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||
|
||
// Drive getTime() to a known uptime so sinceLastSeen() is deterministic.
|
||
setBootRelativeTimeForUnitTest(1000000);
|
||
const uint32_t now = getTime();
|
||
|
||
mockNodeDB->setCachedNode(kTargetNode);
|
||
mockNodeDB->cachedNodeForTest().last_heard = now - (7UL * 60UL * 60UL); // 7 h ago -> stale
|
||
// Signer-proven so staleness is the sole reason this is not served (isolates the gate under test).
|
||
mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK;
|
||
|
||
MockRouter mockRouter;
|
||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||
MeshService mockService;
|
||
router = &mockRouter;
|
||
service = &mockService;
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path
|
||
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
|
||
request.decoded.want_response = true;
|
||
request.hop_start = 3;
|
||
request.hop_limit = 3;
|
||
|
||
ProcessMessage result = module.handleReceived(request);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(result));
|
||
TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits);
|
||
TEST_ASSERT_EQUAL_UINT32(0, static_cast<uint32_t>(mockRouter.sentPackets.size()));
|
||
|
||
resetRTCStateForTests();
|
||
}
|
||
|
||
/**
|
||
* Verify the NodeDB-fallback path still answers when the node was heard recently.
|
||
*/
|
||
static void test_tm_nodeinfo_directResponse_fallbackFreshEntryServed(void)
|
||
{
|
||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||
|
||
setBootRelativeTimeForUnitTest(1000000);
|
||
const uint32_t now = getTime();
|
||
|
||
mockNodeDB->setCachedNode(kTargetNode);
|
||
mockNodeDB->cachedNodeForTest().last_heard = now - 60UL; // 1 min ago -> fresh
|
||
// Signed-only replay gate (default) requires the target be a known signer to be served.
|
||
mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK;
|
||
|
||
MockRouter mockRouter;
|
||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||
MeshService mockService;
|
||
router = &mockRouter;
|
||
service = &mockService;
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path
|
||
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
|
||
request.decoded.want_response = true;
|
||
request.hop_start = 3;
|
||
request.hop_limit = 3;
|
||
|
||
ProcessMessage result = module.handleReceived(request);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(result));
|
||
TEST_ASSERT_EQUAL_UINT32(1, stats.nodeinfo_cache_hits);
|
||
TEST_ASSERT_EQUAL_UINT32(1, static_cast<uint32_t>(mockRouter.sentPackets.size()));
|
||
|
||
resetRTCStateForTests();
|
||
}
|
||
|
||
/**
|
||
* Per-target direct-response throttle on the NodeDB-fallback path (no PSRAM NodeInfo cache). The
|
||
* per-target RAM table is not the cache, so it throttles this path identically: a burst for a fresh
|
||
* node yields one spoofed reply per 60 s window, even from a different requester, then serves again.
|
||
*/
|
||
static void test_tm_nodeinfo_directResponse_fallbackThrottlesWithinWindow(void)
|
||
{
|
||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||
|
||
setBootRelativeTimeForUnitTest(1000000);
|
||
const uint32_t now = getTime();
|
||
TrafficManagementModule::s_testNowMs = 3600000; // known base for the throttle windows
|
||
|
||
mockNodeDB->setCachedNode(kTargetNode);
|
||
mockNodeDB->cachedNodeForTest().last_heard = now - 60UL; // fresh, passes staleness gate
|
||
// Signed-only replay gate (default) requires the target be a known signer to be served.
|
||
mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK;
|
||
|
||
MockRouter mockRouter;
|
||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||
MeshService mockService;
|
||
router = &mockRouter;
|
||
service = &mockService;
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path
|
||
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
|
||
request.decoded.want_response = true;
|
||
request.hop_start = 3;
|
||
request.hop_limit = 3;
|
||
|
||
// First request: served from the NodeDB fallback.
|
||
request.id = 0xBBBB0001;
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(module.handleReceived(request)));
|
||
TEST_ASSERT_EQUAL_UINT32(1, static_cast<uint32_t>(mockRouter.sentPackets.size()));
|
||
|
||
// Second request within the window, from a DIFFERENT requester so only the per-target axis can
|
||
// throttle it: our spoofed TX is suppressed, but the request is NOT consumed - it CONTINUEs so
|
||
// the genuine target (or another cache-holder) can answer.
|
||
TrafficManagementModule::s_testNowMs += 5000; // 5 s < 60 s window
|
||
request.from = kRemoteNode2;
|
||
request.id = 0xBBBB0002;
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(module.handleReceived(request)));
|
||
TEST_ASSERT_FALSE(module.ignoreRequestFlag());
|
||
TEST_ASSERT_EQUAL_UINT32(1, static_cast<uint32_t>(mockRouter.sentPackets.size()));
|
||
|
||
// Past the per-target window: served again. Back to the original requester (its 60 s per-requester
|
||
// window from the first reply has also elapsed), so only the per-target release is under test.
|
||
TrafficManagementModule::s_testNowMs += 60000; // now > 60 s since first reply
|
||
request.from = kRemoteNode;
|
||
request.id = 0xBBBB0003;
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(module.handleReceived(request)));
|
||
TEST_ASSERT_EQUAL_UINT32(2, static_cast<uint32_t>(mockRouter.sentPackets.size()));
|
||
|
||
resetRTCStateForTests();
|
||
}
|
||
|
||
/**
|
||
* The per-requester and global-floor axes, isolated from per-target (which has its own cache/fallback
|
||
* tests). Both bound spoofed direct replies by the unauthenticated requester address and by total
|
||
* airtime; each step keeps the per-target axis fresh (distinct targets) so only the axis under test
|
||
* gates the reply:
|
||
* - the same requester asking for a DIFFERENT target within 60 s is still throttled (the
|
||
* reflector-flood case: the per-target axis alone would not bound this);
|
||
* - a fresh requester is served, but only once the 1 s global floor has passed;
|
||
* - after the per-requester window elapses, the original requester is served again.
|
||
*/
|
||
static void test_tm_nodeinfo_directResponse_perRequesterAndGlobalFloor(void)
|
||
{
|
||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||
mockNodeDB->clearCachedNode();
|
||
|
||
const uint32_t base = 3600000;
|
||
TrafficManagementModule::s_testNowMs = base;
|
||
|
||
MockRouter mockRouter;
|
||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||
MeshService mockService;
|
||
router = &mockRouter;
|
||
service = &mockService;
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
// Three distinct, freshly-observed, signer-proven targets. Using a fresh target on each step keeps
|
||
// the per-target axis from ever being the bound here, so the reply is gated only by the axis under
|
||
// test: per-requester (step 2) or the global floor (step 4).
|
||
constexpr NodeNum kTargetA = 0x33330001, kTargetB = 0x33330002, kTargetC = 0x33330003;
|
||
constexpr NodeNum kRemoteNode3 = 0x66666666;
|
||
for (NodeNum t : {kTargetA, kTargetB, kTargetC}) {
|
||
module.handleReceived(makeNodeInfoPacket(t, "target-long", "tg"));
|
||
module.markKeySignerProvenForTest(t);
|
||
}
|
||
|
||
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetA);
|
||
request.decoded.want_response = true;
|
||
request.hop_start = 3;
|
||
request.hop_limit = 3;
|
||
|
||
// First reply for this requester: served.
|
||
request.id = 0xC0DE0001;
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(module.handleReceived(request)));
|
||
TEST_ASSERT_EQUAL_UINT32(1, static_cast<uint32_t>(mockRouter.sentPackets.size()));
|
||
|
||
// Same requester, DIFFERENT target, 10 s later: the per-target throttle can't fire (fresh entry),
|
||
// but the per-requester window (60 s) still suppresses our TX. Forwarded, not sent.
|
||
TrafficManagementModule::s_testNowMs = base + 10000;
|
||
request.to = kTargetB;
|
||
request.id = 0xC0DE0002;
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(module.handleReceived(request)));
|
||
TEST_ASSERT_EQUAL_UINT32(1, static_cast<uint32_t>(mockRouter.sentPackets.size()));
|
||
|
||
// A DIFFERENT requester (fresh slot) is served - the 1 s global floor has long passed.
|
||
request.from = kRemoteNode2;
|
||
request.id = 0xC0DE0003;
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(module.handleReceived(request)));
|
||
TEST_ASSERT_EQUAL_UINT32(2, static_cast<uint32_t>(mockRouter.sentPackets.size()));
|
||
|
||
// Yet a third fresh requester in the SAME instant, for a fresh target (so per-target can't be the
|
||
// cause), is deferred by the 1 s global airtime floor. Forwarded, not sent.
|
||
request.from = kRemoteNode3;
|
||
request.to = kTargetC;
|
||
request.id = 0xC0DE0004;
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(module.handleReceived(request)));
|
||
TEST_ASSERT_EQUAL_UINT32(2, static_cast<uint32_t>(mockRouter.sentPackets.size()));
|
||
|
||
// After the per-requester window elapses, the original requester is served again.
|
||
TrafficManagementModule::s_testNowMs = base + 70000;
|
||
request.from = kRemoteNode;
|
||
request.to = kTargetA;
|
||
request.id = 0xC0DE0005;
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(module.handleReceived(request)));
|
||
TEST_ASSERT_EQUAL_UINT32(3, static_cast<uint32_t>(mockRouter.sentPackets.size()));
|
||
}
|
||
|
||
#if TMM_NODEINFO_REPLAY_SIGNED_GATE
|
||
/**
|
||
* Replay gate (fallback path): a fresh NodeDB node that is NOT a known signer is withheld -
|
||
* the courtesy reply is suppressed and the genuine request propagates (CONTINUE, no TX).
|
||
*/
|
||
static void test_tm_nodeinfo_directResponse_fallbackUnsignedNotServed(void)
|
||
{
|
||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||
setBootRelativeTimeForUnitTest(1000000);
|
||
const uint32_t now = getTime();
|
||
|
||
mockNodeDB->setCachedNode(kTargetNode);
|
||
mockNodeDB->cachedNodeForTest().last_heard = now - 60UL; // fresh, passes staleness
|
||
// Deliberately NOT flagged as a signer -> the signed-only gate must withhold the reply.
|
||
|
||
MockRouter mockRouter;
|
||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||
MeshService mockService;
|
||
router = &mockRouter;
|
||
service = &mockService;
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path
|
||
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
|
||
request.decoded.want_response = true;
|
||
request.hop_start = 3;
|
||
request.hop_limit = 3;
|
||
|
||
ProcessMessage result = module.handleReceived(request);
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(result));
|
||
TEST_ASSERT_EQUAL_UINT32(0, static_cast<uint32_t>(mockRouter.sentPackets.size()));
|
||
|
||
resetRTCStateForTests();
|
||
}
|
||
#endif // TMM_NODEINFO_REPLAY_SIGNED_GATE
|
||
|
||
/**
|
||
* Verify relayed telemetry broadcasts are NOT hop-exhausted.
|
||
* exhaust_hop_telemetry / exhaust_hop_position have been removed from the config
|
||
* as "not suitable right now" - alterReceived must leave hop_limit unchanged.
|
||
*/
|
||
static void test_tm_alterReceived_telemetryBroadcast_hopLimitUnchanged(void)
|
||
{
|
||
ScopedBusyAirTime busyChannel; // congestion present but exhaust is disabled
|
||
TrafficManagementModuleTestShim module;
|
||
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST);
|
||
packet.hop_start = 5;
|
||
packet.hop_limit = 3;
|
||
|
||
module.alterReceived(packet);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_UINT8(3, packet.hop_limit); // unchanged
|
||
TEST_ASSERT_EQUAL_UINT8(5, packet.hop_start); // unchanged
|
||
TEST_ASSERT_FALSE(module.shouldExhaustHops(packet));
|
||
TEST_ASSERT_EQUAL_UINT32(0, stats.hop_exhausted_packets);
|
||
}
|
||
|
||
/**
|
||
* Verify alterReceived does not modify unicast or local-origin packets.
|
||
* The precision clamp (the only active alterReceived path) only fires for
|
||
* broadcast position packets from remote nodes - these should be untouched.
|
||
*/
|
||
static void test_tm_alterReceived_skipsLocalAndUnicast(void)
|
||
{
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
meshtastic_MeshPacket unicast = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kTargetNode);
|
||
unicast.hop_start = 5;
|
||
unicast.hop_limit = 3;
|
||
module.alterReceived(unicast);
|
||
TEST_ASSERT_EQUAL_UINT8(3, unicast.hop_limit);
|
||
TEST_ASSERT_FALSE(module.shouldExhaustHops(unicast));
|
||
|
||
meshtastic_MeshPacket fromUs = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kLocalNode, NODENUM_BROADCAST);
|
||
fromUs.hop_start = 5;
|
||
fromUs.hop_limit = 3;
|
||
module.alterReceived(fromUs);
|
||
TEST_ASSERT_EQUAL_UINT8(3, fromUs.hop_limit);
|
||
TEST_ASSERT_FALSE(module.shouldExhaustHops(fromUs));
|
||
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
TEST_ASSERT_EQUAL_UINT32(0, stats.hop_exhausted_packets);
|
||
}
|
||
|
||
/**
|
||
* Verify position dedup window expires and later duplicates are allowed.
|
||
* Important so periodic identical reports can resume after cooldown.
|
||
*/
|
||
static void test_tm_positionDedup_allowsDuplicateAfterIntervalExpires(void)
|
||
{
|
||
// 360 s = 1 pos-tick (kPosTimeTickMs); advance the virtual clock past one tick period.
|
||
moduleConfig.traffic_management.position_min_interval_secs = 360;
|
||
installWellKnownPrimaryChannelWithPrecision(16);
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
meshtastic_MeshPacket third = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
|
||
ProcessMessage r1 = module.handleReceived(first);
|
||
ProcessMessage r2 = module.handleReceived(second);
|
||
TrafficManagementModule::s_testNowMs += 360001; // advance past one 6-min pos-tick (virtual clock)
|
||
ProcessMessage r3 = module.handleReceived(third);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r2));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r3));
|
||
TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops);
|
||
}
|
||
|
||
/**
|
||
* Verify interval=0 disables position deduplication.
|
||
* Important because this is an explicit configuration escape hatch.
|
||
*/
|
||
static void test_tm_positionDedup_intervalZero_neverDrops(void)
|
||
{
|
||
// position_min_interval_secs=0 disables the drop gate (shouldDropPosition returns false for any packet).
|
||
moduleConfig.traffic_management.position_min_interval_secs = 0;
|
||
installWellKnownPrimaryChannelWithPrecision(16);
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
|
||
ProcessMessage r1 = module.handleReceived(first);
|
||
ProcessMessage r2 = module.handleReceived(second);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r2));
|
||
TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops);
|
||
}
|
||
|
||
/**
|
||
* Verify precision values above 32 fall back to default precision.
|
||
* Important so invalid config uses the documented default behavior.
|
||
*/
|
||
static void test_tm_positionDedup_precisionAbove32_usesDefaultPrecision(void)
|
||
{
|
||
// Channel precision=99 is out of range; sanitizePositionPrecision falls back to default (19 bits).
|
||
moduleConfig.traffic_management.position_min_interval_secs = 300;
|
||
installWellKnownPrimaryChannelWithPrecision(99);
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 384221234, -1210845678);
|
||
|
||
ProcessMessage r1 = module.handleReceived(first);
|
||
ProcessMessage r2 = module.handleReceived(second);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r2));
|
||
TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops);
|
||
}
|
||
|
||
/**
|
||
* Verify the dedup fingerprint does not collapse positions that are distinct at the
|
||
* channel's *effective* precision. Dedup only runs on well-known (public) channels,
|
||
* where precision is capped at MAX_POSITION_PRECISION_PUBLIC_KEY (15) regardless of the
|
||
* channel's configured value - so the requested 32 is clamped to 15. Positions must
|
||
* therefore differ in the top 15 bits (>= 2^17 raw units) to read as distinct; here
|
||
* they differ by 2^18, well clear of the precision-15 grid, so neither is dropped.
|
||
*/
|
||
static void test_tm_positionDedup_distinctAtClampedChannelPrecision(void)
|
||
{
|
||
moduleConfig.traffic_management.position_min_interval_secs = 300;
|
||
installWellKnownPrimaryChannelWithPrecision(32); // clamped to 15 on a public channel
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 374221234 + (1 << 18), -1220845678 + (1 << 18));
|
||
|
||
ProcessMessage r1 = module.handleReceived(first);
|
||
ProcessMessage r2 = module.handleReceived(second);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r2));
|
||
TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops);
|
||
}
|
||
|
||
/**
|
||
* Verify channel precision=0 (no channel ceiling set) falls back to the firmware
|
||
* default precision (19 bits / ~90 m cells). Positions more than one default grid
|
||
* cell apart must remain distinct, not collapse into one fingerprint.
|
||
*/
|
||
static void test_tm_positionDedup_precisionZero_channelFallsBackToDefault(void)
|
||
{
|
||
moduleConfig.traffic_management.position_min_interval_secs = 300;
|
||
// precision=0 in the channel → getPositionPrecisionForChannel returns 0 → falls back to default 19 bits.
|
||
installWellKnownPrimaryChannelWithPrecision(0);
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 384221234, -1210845678);
|
||
|
||
ProcessMessage r1 = module.handleReceived(first);
|
||
ProcessMessage r2 = module.handleReceived(second);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r2));
|
||
TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops);
|
||
}
|
||
|
||
/**
|
||
* Verify epoch reset invalidates stale position identity for dedup.
|
||
* Important so reset paths cannot leak prior packet identity into new windows.
|
||
*/
|
||
static void test_tm_positionDedup_cacheFlush_doesNotDropFirstPacketAfterFlush(void)
|
||
{
|
||
moduleConfig.traffic_management.position_min_interval_secs = 300;
|
||
installWellKnownPrimaryChannelWithPrecision(16);
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
meshtastic_MeshPacket afterFlush = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
meshtastic_MeshPacket duplicate = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
|
||
ProcessMessage r1 = module.handleReceived(first);
|
||
module.flushCache();
|
||
ProcessMessage r2 = module.handleReceived(afterFlush);
|
||
ProcessMessage r3 = module.handleReceived(duplicate);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r2));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r3));
|
||
TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops);
|
||
}
|
||
|
||
/**
|
||
* Verify non-position cache state does not make the first fingerprint-0 position look duplicated.
|
||
* Important so unified cache entries from other features cannot leak into dedup decisions.
|
||
*/
|
||
static void test_tm_positionDedup_priorRateState_doesNotDropFirstFingerprintZero(void)
|
||
{
|
||
moduleConfig.traffic_management.position_min_interval_secs = 300;
|
||
moduleConfig.traffic_management.rate_limit_window_secs = 60;
|
||
moduleConfig.traffic_management.rate_limit_max_packets = 10;
|
||
installWellKnownPrimaryChannelWithPrecision(16);
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
meshtastic_MeshPacket telemetry = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode);
|
||
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 0x12300000, 0x45600000);
|
||
meshtastic_MeshPacket duplicate = makePositionPacket(kRemoteNode, 0x12300000, 0x45600000);
|
||
|
||
ProcessMessage seeded = module.handleReceived(telemetry);
|
||
ProcessMessage r1 = module.handleReceived(first);
|
||
ProcessMessage r2 = module.handleReceived(duplicate);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(seeded));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r2));
|
||
TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops);
|
||
}
|
||
|
||
/**
|
||
* Verify rate-limit counters reset after the window expires.
|
||
* Important so temporary bursts do not cause persistent throttling.
|
||
*/
|
||
static void test_tm_rateLimit_resetsAfterWindowExpires(void)
|
||
{
|
||
// 300 s = 1 rate-tick (kRateTimeTickMs); advance the virtual clock past one tick period.
|
||
moduleConfig.traffic_management.rate_limit_window_secs = 300;
|
||
moduleConfig.traffic_management.rate_limit_max_packets = 1;
|
||
TrafficManagementModuleTestShim module;
|
||
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode);
|
||
|
||
ProcessMessage r1 = module.handleReceived(packet);
|
||
ProcessMessage r2 = module.handleReceived(packet);
|
||
TrafficManagementModule::s_testNowMs += 300001; // advance past one 5-min rate-tick (virtual clock)
|
||
ProcessMessage r3 = module.handleReceived(packet);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r2));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r3));
|
||
TEST_ASSERT_EQUAL_UINT32(1, stats.rate_limit_drops);
|
||
}
|
||
/**
|
||
* Verify unknown-packet tracking resets after its active window expires.
|
||
* Important so old unknown traffic does not trigger delayed drops.
|
||
*/
|
||
static void test_tm_unknownPackets_resetAfterWindowExpires(void)
|
||
{
|
||
moduleConfig.traffic_management.unknown_packet_threshold = 1;
|
||
TrafficManagementModuleTestShim module;
|
||
meshtastic_MeshPacket packet = makeUnknownPacket(kRemoteNode);
|
||
|
||
ProcessMessage r1 = module.handleReceived(packet);
|
||
ProcessMessage r2 = module.handleReceived(packet);
|
||
TrafficManagementModule::s_testNowMs += 300001; // advance past 5 unknown-ticks (5 × 60s) (virtual clock)
|
||
ProcessMessage r3 = module.handleReceived(packet);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r2));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r3));
|
||
TEST_ASSERT_EQUAL_UINT32(1, stats.unknown_packet_drops);
|
||
}
|
||
|
||
/**
|
||
* Verify unknown threshold values above the implementation cap (60) are clamped.
|
||
* The counter is a 6-bit field (saturates at 63); threshold is capped at 60 so a
|
||
* saturated reading always exceeds it. A config value of 300 should behave as 60.
|
||
*/
|
||
static void test_tm_unknownPackets_thresholdAbove255_clamps(void)
|
||
{
|
||
moduleConfig.traffic_management.unknown_packet_threshold = 300;
|
||
TrafficManagementModuleTestShim module;
|
||
meshtastic_MeshPacket packet = makeUnknownPacket(kRemoteNode);
|
||
|
||
for (int i = 0; i < 60; i++) {
|
||
ProcessMessage result = module.handleReceived(packet);
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(result));
|
||
}
|
||
ProcessMessage dropped = module.handleReceived(packet);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(dropped));
|
||
TEST_ASSERT_EQUAL_UINT32(1, stats.unknown_packet_drops);
|
||
}
|
||
|
||
/**
|
||
* Verify relayed position broadcasts are NOT hop-exhausted.
|
||
* exhaust_hop_position has been removed - alterReceived must leave hop_limit unchanged.
|
||
*/
|
||
static void test_tm_alterReceived_positionBroadcast_hopLimitUnchanged(void)
|
||
{
|
||
TrafficManagementModuleTestShim module;
|
||
meshtastic_MeshPacket packet = makePositionPacket(kRemoteNode, 374221234, -1220845678, NODENUM_BROADCAST);
|
||
packet.hop_start = 5;
|
||
packet.hop_limit = 2;
|
||
|
||
module.alterReceived(packet);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_UINT8(2, packet.hop_limit); // unchanged
|
||
TEST_ASSERT_EQUAL_UINT8(5, packet.hop_start); // unchanged
|
||
TEST_ASSERT_FALSE(module.shouldExhaustHops(packet));
|
||
TEST_ASSERT_EQUAL_UINT32(0, stats.hop_exhausted_packets);
|
||
}
|
||
/**
|
||
* Verify alterReceived ignores undecoded/encrypted packets.
|
||
* Important so we never mutate packets that were not decoded by this module.
|
||
*/
|
||
static void test_tm_alterReceived_skipsUndecodedPackets(void)
|
||
{
|
||
TrafficManagementModuleTestShim module;
|
||
meshtastic_MeshPacket packet = makeUnknownPacket(kRemoteNode, NODENUM_BROADCAST);
|
||
packet.hop_start = 5;
|
||
packet.hop_limit = 3;
|
||
|
||
module.alterReceived(packet);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_UINT8(5, packet.hop_start);
|
||
TEST_ASSERT_EQUAL_UINT8(3, packet.hop_limit);
|
||
TEST_ASSERT_FALSE(module.shouldExhaustHops(packet));
|
||
TEST_ASSERT_EQUAL_UINT32(0, stats.hop_exhausted_packets);
|
||
}
|
||
|
||
/**
|
||
* Verify shouldExhaustHops() always returns false - exhaust_hop_* features are
|
||
* removed, so the exhaustRequested flag is never set.
|
||
* Guards against accidental re-enablement without updating the flag logic.
|
||
*/
|
||
static void test_tm_alterReceived_exhaustFlagAlwaysFalse(void)
|
||
{
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
meshtastic_MeshPacket telemetry = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST);
|
||
telemetry.hop_start = 5;
|
||
telemetry.hop_limit = 3;
|
||
module.alterReceived(telemetry);
|
||
TEST_ASSERT_FALSE(module.shouldExhaustHops(telemetry));
|
||
|
||
meshtastic_MeshPacket text = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode);
|
||
ProcessMessage result = module.handleReceived(text);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(result));
|
||
TEST_ASSERT_FALSE(module.shouldExhaustHops(telemetry));
|
||
TEST_ASSERT_EQUAL_UINT32(0, stats.hop_exhausted_packets);
|
||
}
|
||
|
||
/**
|
||
* Verify shouldExhaustHops() returns false for any packet regardless of from/id.
|
||
* Since exhaust is removed, the from+id scope check is moot - this guards that
|
||
* the always-false invariant holds across multiple distinct packets.
|
||
*/
|
||
static void test_tm_alterReceived_exhaustFlag_isPacketScoped(void)
|
||
{
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
meshtastic_MeshPacket p1 = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST);
|
||
p1.id = 0x1010;
|
||
p1.hop_start = 5;
|
||
p1.hop_limit = 3;
|
||
module.alterReceived(p1);
|
||
|
||
meshtastic_MeshPacket p2 = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kTargetNode, NODENUM_BROADCAST);
|
||
p2.id = 0x2020;
|
||
p2.hop_start = 4;
|
||
p2.hop_limit = 0;
|
||
|
||
TEST_ASSERT_FALSE(module.shouldExhaustHops(p1));
|
||
TEST_ASSERT_FALSE(module.shouldExhaustHops(p2));
|
||
}
|
||
|
||
/**
|
||
* Verify runOnce() returns sleep-forever interval when has_traffic_management is false.
|
||
* TMM has no runtime enable flag - the presence bit is the only runtime gate.
|
||
*/
|
||
static void test_tm_runOnce_disabledReturnsMaxInterval(void)
|
||
{
|
||
moduleConfig.has_traffic_management = false;
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
int32_t interval = module.runOnce();
|
||
|
||
TEST_ASSERT_EQUAL_INT32(INT32_MAX, interval);
|
||
}
|
||
|
||
/**
|
||
* Verify runOnce() returns the maintenance cadence when enabled.
|
||
* Important so periodic cache housekeeping continues at expected interval.
|
||
*/
|
||
static void test_tm_runOnce_enabledReturnsMaintenanceInterval(void)
|
||
{
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
int32_t interval = module.runOnce();
|
||
|
||
TEST_ASSERT_EQUAL_INT32(60 * 1000, interval);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Next-hop overflow cache
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Round-trip set/get of a confirmed next hop, plus the input guards.
|
||
*/
|
||
static void test_tm_nextHop_setAndGetRoundTrip(void)
|
||
{
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
// Unknown node yields no hint.
|
||
TEST_ASSERT_EQUAL_UINT8(0, module.getNextHopHint(kTargetNode));
|
||
|
||
// Store a confirmed hop and read it back.
|
||
module.setNextHop(kTargetNode, 0x42);
|
||
TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode));
|
||
|
||
// Zero dest and zero byte are rejected (no spurious entry created).
|
||
module.setNextHop(0, 0x42);
|
||
module.setNextHop(kRemoteNode, 0);
|
||
TEST_ASSERT_EQUAL_UINT8(0, module.getNextHopHint(kRemoteNode));
|
||
|
||
// Last-write-wins on re-confirmation.
|
||
module.setNextHop(kTargetNode, 0x99);
|
||
TEST_ASSERT_EQUAL_UINT8(0x99, module.getNextHopHint(kTargetNode));
|
||
}
|
||
|
||
/**
|
||
* The headline scenario: a node carrying a next hop in the hot NodeInfoLite DB
|
||
* is warm-loaded into the TMM cache, then the hot DB is "rolled" (the node ages
|
||
* out entirely). The hint must still be served - now exclusively from TMM.
|
||
*/
|
||
static void test_tm_nextHop_servedAfterNodeDbRoll(void)
|
||
{
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
// Seed the hot NodeInfoLite DB with a node that has a confirmed next hop.
|
||
mockNodeDB->setHotNode(kTargetNode, 0x42);
|
||
|
||
// Warm-start the overflow cache from the hot DB.
|
||
module.preloadNextHopsFromNodeDB();
|
||
TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode));
|
||
|
||
// Roll the main NodeInfoLite DB: the node is evicted from the hot store.
|
||
mockNodeDB->rollHotStore();
|
||
TEST_ASSERT_NULL(nodeDB->getMeshNode(kTargetNode)); // gone from the hot store
|
||
|
||
// Hit is still served - proving it now comes from the TMM overflow cache.
|
||
TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode));
|
||
}
|
||
|
||
/**
|
||
* Preload must not clobber a freshly-learned (confirmed) hop with a possibly
|
||
* stale persisted one from NodeInfoLite.
|
||
*/
|
||
static void test_tm_nextHop_preloadDoesNotClobberLearned(void)
|
||
{
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
// A fresher confirmed hop is already cached.
|
||
module.setNextHop(kTargetNode, 0x99);
|
||
|
||
// The hot DB carries an older next hop for the same node.
|
||
mockNodeDB->setHotNode(kTargetNode, 0x42);
|
||
|
||
module.preloadNextHopsFromNodeDB();
|
||
|
||
// The freshly-learned hop survives.
|
||
TEST_ASSERT_EQUAL_UINT8(0x99, module.getNextHopHint(kTargetNode));
|
||
}
|
||
|
||
/**
|
||
* A pure routing hint (no dedup/rate/unknown state) must survive the maintenance
|
||
* sweep - next_hop != 0 keeps the slot alive even though it has no TTL.
|
||
*/
|
||
static void test_tm_nextHop_keptAliveAcrossMaintenanceSweep(void)
|
||
{
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
module.setNextHop(kTargetNode, 0x42);
|
||
|
||
// The sweep frees slots whose sub-stores are all empty; next_hop must veto that.
|
||
module.runOnce();
|
||
|
||
TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Role exceptions: TRACKER and LOST_AND_FOUND
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Verify TRACKER role caps the dedup window at 1 hour.
|
||
* A duplicate position that would normally be blocked for 11 h (default) must
|
||
* be forwarded once the 1-hour tracker cap expires.
|
||
*/
|
||
static void test_tm_trackerRole_capsDedupWindowAtOneHour(void)
|
||
{
|
||
// Operator interval is 11 h - longer than the tracker cap.
|
||
moduleConfig.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs;
|
||
installWellKnownPrimaryChannelWithPrecision(16);
|
||
|
||
mockNodeDB->setCachedNode(kRemoteNode);
|
||
mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_TRACKER);
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
meshtastic_MeshPacket dup = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
meshtastic_MeshPacket afterCap = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
|
||
ProcessMessage r1 = module.handleReceived(first);
|
||
ProcessMessage r2 = module.handleReceived(dup); // still within 1-hour cap
|
||
// Advance past 1 hour (tracker cap = 3600 s; pos-tick = 360 s → 10 ticks).
|
||
TrafficManagementModule::s_testNowMs += (default_traffic_mgmt_tracker_position_min_interval_secs * 1000UL) + 1;
|
||
ProcessMessage r3 = module.handleReceived(afterCap);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r2));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r3));
|
||
TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops);
|
||
}
|
||
|
||
/**
|
||
* Verify TAK_TRACKER role receives the same 1-hour dedup cap as TRACKER.
|
||
* Both roles share the same exception branch; this guards against future divergence.
|
||
*/
|
||
static void test_tm_takTrackerRole_capsDedupWindowAtOneHour(void)
|
||
{
|
||
moduleConfig.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs;
|
||
installWellKnownPrimaryChannelWithPrecision(16);
|
||
|
||
mockNodeDB->setCachedNode(kRemoteNode);
|
||
mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_TAK_TRACKER);
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
meshtastic_MeshPacket dup = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
meshtastic_MeshPacket afterCap = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
|
||
ProcessMessage r1 = module.handleReceived(first);
|
||
ProcessMessage r2 = module.handleReceived(dup);
|
||
TrafficManagementModule::s_testNowMs += (default_traffic_mgmt_tracker_position_min_interval_secs * 1000UL) + 1;
|
||
ProcessMessage r3 = module.handleReceived(afterCap);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r2));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r3));
|
||
TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops);
|
||
}
|
||
|
||
/**
|
||
* Verify the tracker role exception survives the node being evicted from BOTH the
|
||
* hot and warm NodeDB stores - the TMM unified cache is the third fallback. The
|
||
* role is cached on the entry while NodeDB still knows the node; once NodeDB
|
||
* forgets it (getNodeRole → CLIENT), the cached role must keep the 1-hour cap
|
||
* applied instead of reverting to the 11-hour default interval.
|
||
*/
|
||
static void test_tm_trackerRole_survivesNodeDbEvictionViaCachedRole(void)
|
||
{
|
||
// Operator interval is 11 h - longer than the tracker cap.
|
||
moduleConfig.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs;
|
||
installWellKnownPrimaryChannelWithPrecision(16);
|
||
|
||
mockNodeDB->setCachedNode(kRemoteNode);
|
||
mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_TRACKER);
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
meshtastic_MeshPacket dup = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
meshtastic_MeshPacket afterCap = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
|
||
// First packet: NodeDB knows the role; it is cached onto the TMM entry.
|
||
ProcessMessage r1 = module.handleReceived(first);
|
||
|
||
// Node ages out of NodeDB entirely (hot + warm). getNodeRole now returns CLIENT.
|
||
mockNodeDB->clearCachedNode();
|
||
|
||
ProcessMessage r2 = module.handleReceived(dup); // within 1-hour cap - still drop
|
||
// Advance past the tracker cap (3600 s) but stay well under the 11-hour default.
|
||
// Without the cached-role fallback this would still be inside the 11-hour window
|
||
// (CLIENT → no exception) and wrongly drop; with it, the 1-hour cap lets it pass.
|
||
TrafficManagementModule::s_testNowMs += (default_traffic_mgmt_tracker_position_min_interval_secs * 1000UL) + 1;
|
||
ProcessMessage r3 = module.handleReceived(afterCap);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r2));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r3));
|
||
TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops);
|
||
}
|
||
|
||
/**
|
||
* Verify a role change observed via NodeInfo updates the cached role (write-time update),
|
||
* so an exception is dropped when a node is demoted from TRACKER back to CLIENT. The role
|
||
* is read from the NodeInfo's User payload - the same event that updates NodeDB - not by
|
||
* re-scanning NodeDB on the position path.
|
||
*/
|
||
static void test_tm_roleChange_viaNodeInfo_dropsTrackerException(void)
|
||
{
|
||
moduleConfig.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs;
|
||
installWellKnownPrimaryChannelWithPrecision(16);
|
||
|
||
mockNodeDB->setCachedNode(kRemoteNode);
|
||
mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_TRACKER);
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
// First position seeds the TMM entry with TRACKER (from NodeDB on isNew).
|
||
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
ProcessMessage r1 = module.handleReceived(first);
|
||
|
||
// The node demotes to CLIENT and announces it. The NodeInfo refresh must overwrite
|
||
// the cached TRACKER role with CLIENT - even though the packet payload, not NodeDB,
|
||
// is the source of truth here (NodeDB role left stale on purpose to prove the path).
|
||
meshtastic_MeshPacket info = makeNodeInfoPacketWithRole(kRemoteNode, meshtastic_Config_DeviceConfig_Role_CLIENT);
|
||
module.handleReceived(info);
|
||
|
||
// Past the 1-hour tracker cap but within the 11-hour CLIENT interval. With the stale
|
||
// TRACKER role this would pass; after the demotion it must drop (full interval applies).
|
||
TrafficManagementModule::s_testNowMs += (default_traffic_mgmt_tracker_position_min_interval_secs * 1000UL) + 1;
|
||
meshtastic_MeshPacket afterCap = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
ProcessMessage r2 = module.handleReceived(afterCap);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r2));
|
||
TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops);
|
||
}
|
||
|
||
/**
|
||
* Verify a cached special (non-CLIENT) role pins the TMM entry through the maintenance
|
||
* sweep: once the node's position state has expired and been cleared, the entry - and its
|
||
* role - must still survive (role has no TTL), the same way a confirmed next-hop hint does.
|
||
*/
|
||
static void test_tm_specialRole_pinsEntryThroughSweep(void)
|
||
{
|
||
// Short position interval → short pos TTL so the sweep clears pos state quickly.
|
||
moduleConfig.traffic_management.position_min_interval_secs = 300;
|
||
installWellKnownPrimaryChannelWithPrecision(16);
|
||
|
||
mockNodeDB->setCachedNode(kRemoteNode);
|
||
mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_TRACKER);
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
module.handleReceived(makePositionPacket(kRemoteNode, 374221234, -1220845678));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(meshtastic_Config_DeviceConfig_Role_TRACKER), module.peekCachedRole(kRemoteNode));
|
||
|
||
// Advance well past the position TTL, then sweep: pos state is cleared but the role
|
||
// (no TTL) must keep the entry alive. Without the role pin the entry would be reclaimed
|
||
// and peekCachedRole would return -1.
|
||
TrafficManagementModule::s_testNowMs += 60UL * 60UL * 1000UL; // 1 h
|
||
module.runOnce();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(meshtastic_Config_DeviceConfig_Role_TRACKER), module.peekCachedRole(kRemoteNode));
|
||
}
|
||
|
||
/**
|
||
* Verify special-role entries are evicted last. A tracker that is the OLDEST entry must
|
||
* survive cache pressure that evicts many newer CLIENT entries, because a cached
|
||
* special role marks the entry "preferred" (like a next-hop hint) in victim selection.
|
||
*/
|
||
static void test_tm_specialRole_evictedLastUnderPressure(void)
|
||
{
|
||
moduleConfig.traffic_management.position_min_interval_secs = 300;
|
||
installWellKnownPrimaryChannelWithPrecision(16);
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
// Oldest entry: a tracker. Seed its role from NodeDB on first position.
|
||
const NodeNum tracker = 0xAA0000FF;
|
||
mockNodeDB->setCachedNode(tracker);
|
||
mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_TRACKER);
|
||
module.handleReceived(makePositionPacket(tracker, 100, 200));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(meshtastic_Config_DeviceConfig_Role_TRACKER), module.peekCachedRole(tracker));
|
||
|
||
mockNodeDB->clearCachedNode(); // every subsequent filler resolves to CLIENT (unprotected)
|
||
|
||
// Fill past capacity with newer CLIENT nodes, forcing many evictions. The tracker is
|
||
// the stalest entry but is "preferred", so an unprotected client must always be the
|
||
// victim instead - the tracker must never be evicted.
|
||
for (uint32_t i = 0; i < static_cast<uint32_t>(TRAFFIC_MANAGEMENT_CACHE_SIZE) + 50; i++) {
|
||
const NodeNum filler = 0x01000000u + i;
|
||
module.handleReceived(makePositionPacket(filler, 300 + static_cast<int>(i), 400 + static_cast<int>(i)));
|
||
}
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(meshtastic_Config_DeviceConfig_Role_TRACKER), module.peekCachedRole(tracker));
|
||
}
|
||
|
||
/**
|
||
* Verify the tracker cap never lengthens an operator-configured interval shorter
|
||
* than the cap default. The cap is a ceiling, not a floor.
|
||
*/
|
||
static void test_tm_trackerRole_doesNotLengthenShorterOperatorInterval(void)
|
||
{
|
||
// Operator set 5-minute interval - shorter than the 1-hour tracker cap.
|
||
moduleConfig.traffic_management.position_min_interval_secs = 300;
|
||
installWellKnownPrimaryChannelWithPrecision(16);
|
||
|
||
mockNodeDB->setCachedNode(kRemoteNode);
|
||
mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_TRACKER);
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
meshtastic_MeshPacket dup = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
meshtastic_MeshPacket afterShortInterval = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
|
||
ProcessMessage r1 = module.handleReceived(first);
|
||
ProcessMessage r2 = module.handleReceived(dup);
|
||
// The 300 s operator interval rounds to 1 pos-tick (360 s) - dedup is tick-granular.
|
||
// Advance past one full tick to verify the window is 1 tick (not the 10-tick tracker cap).
|
||
TrafficManagementModule::s_testNowMs += 360'000UL + 1;
|
||
ProcessMessage r3 = module.handleReceived(afterShortInterval);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r2));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r3));
|
||
TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops);
|
||
}
|
||
|
||
/**
|
||
* Verify LOST_AND_FOUND role caps duplicate-position dedup at ~15 min (2 pos-ticks),
|
||
* not the old one-tick fast-announce. A configured 11-hour interval is shortened to the
|
||
* 15-min cap; a duplicate one tick later still drops, but one past the 2-tick cap passes.
|
||
*/
|
||
static void test_tm_lostAndFoundRole_capsDedupAtFifteenMinutes(void)
|
||
{
|
||
// Long interval that would normally suppress duplicates for 11 h.
|
||
moduleConfig.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs;
|
||
installWellKnownPrimaryChannelWithPrecision(16);
|
||
|
||
mockNodeDB->setCachedNode(kRemoteNode);
|
||
mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_LOST_AND_FOUND);
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
meshtastic_MeshPacket dup = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
meshtastic_MeshPacket afterOneTick = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
meshtastic_MeshPacket afterCap = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
|
||
ProcessMessage r1 = module.handleReceived(first);
|
||
ProcessMessage r2 = module.handleReceived(dup); // same tick - drop
|
||
// One pos-tick later: STILL within the 15-min (~2-tick) cap, unlike the old 1-tick exception.
|
||
// (360 s = kPosTimeTickMs, kept as a literal because the constant is private to the module.)
|
||
TrafficManagementModule::s_testNowMs += 360'000UL + 1;
|
||
ProcessMessage r3 = module.handleReceived(afterOneTick); // still drop
|
||
// Jump past the full cap (>= 2 ticks since the last packet): now it passes.
|
||
TrafficManagementModule::s_testNowMs += 2 * 360'000UL;
|
||
ProcessMessage r4 = module.handleReceived(afterCap);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r2));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r3));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r4));
|
||
TEST_ASSERT_EQUAL_UINT32(2, stats.position_dedup_drops);
|
||
}
|
||
|
||
/**
|
||
* Verify a node with unknown role (absent from NodeDB) is not granted any role
|
||
* exception: the full configured interval applies, so a duplicate inside that
|
||
* window is dropped exactly as for an ordinary CLIENT.
|
||
*/
|
||
static void test_tm_unknownRole_noDbEntry_appliesFullInterval(void)
|
||
{
|
||
moduleConfig.traffic_management.position_min_interval_secs = 300;
|
||
installWellKnownPrimaryChannelWithPrecision(16);
|
||
|
||
// No cached node - getMeshNode returns nullptr for kRemoteNode.
|
||
mockNodeDB->clearCachedNode();
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
meshtastic_MeshPacket dup = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
meshtastic_MeshPacket afterShort = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
|
||
ProcessMessage r1 = module.handleReceived(first);
|
||
ProcessMessage r2 = module.handleReceived(dup); // within 300-s window - must drop
|
||
// The 300 s operator interval rounds to 1 pos-tick (360 s) - dedup is tick-granular.
|
||
// Advance past one full tick to confirm the packet passes without any tracker exception.
|
||
TrafficManagementModule::s_testNowMs += 360'000UL + 1;
|
||
ProcessMessage r3 = module.handleReceived(afterShort);
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r2));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r3));
|
||
TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops);
|
||
}
|
||
|
||
/**
|
||
* Verify a node present in NodeDB but without user info (HAS_USER bit clear)
|
||
* is not granted a role exception: it falls back to CLIENT and the full
|
||
* configured interval applies.
|
||
*/
|
||
static void test_tm_unknownRole_noUserBit_appliesFullInterval(void)
|
||
{
|
||
moduleConfig.traffic_management.position_min_interval_secs = 300;
|
||
installWellKnownPrimaryChannelWithPrecision(16);
|
||
|
||
// Node is in NodeDB but the HAS_USER bit is NOT set - role must be ignored.
|
||
mockNodeDB->setCachedNode(kRemoteNode);
|
||
// Clear the HAS_USER bit that setCachedNode sets, leaving role at CLIENT default.
|
||
mockNodeDB->cachedNodeForTest().bitfield &= ~NODEINFO_BITFIELD_HAS_USER_MASK;
|
||
mockNodeDB->cachedNodeForTest().role = meshtastic_Config_DeviceConfig_Role_TRACKER;
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
meshtastic_MeshPacket dup = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||
|
||
ProcessMessage r1 = module.handleReceived(first);
|
||
ProcessMessage r2 = module.handleReceived(dup); // within 300-s window - must drop
|
||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
|
||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r2));
|
||
TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops);
|
||
}
|
||
|
||
/**
|
||
* Verify a LOST_AND_FOUND origin now GETS the relayed precision clamp - the
|
||
* anti-dox exemption was removed, so a relayed position more precise than the
|
||
* channel setting is clamped down to the channel ceiling like any other node's.
|
||
*/
|
||
static void test_tm_lostAndFoundRole_getsAlterReceivedPrecisionClamp(void)
|
||
{
|
||
// Set channel precision ceiling to 13 bits. Must be <= MAX_POSITION_PRECISION_PUBLIC_KEY
|
||
// (15) - well-known channels have a public PSK (size==1), so getPositionPrecisionForChannel
|
||
// clamps any value above 15 via usesPublicKey().
|
||
installWellKnownPrimaryChannelWithPrecision(13);
|
||
|
||
mockNodeDB->setCachedNode(kRemoteNode);
|
||
mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_LOST_AND_FOUND);
|
||
|
||
TrafficManagementModuleTestShim module;
|
||
|
||
// Full-precision packet - 32 bits - exceeds the channel cap.
|
||
const uint32_t fullPrecision = 32;
|
||
meshtastic_MeshPacket packet = makePositionPacketWithPrecision(kRemoteNode, 374221234, -1220845678, fullPrecision);
|
||
packet.hop_start = 3;
|
||
packet.hop_limit = 2; // relayed (hop_limit < hop_start) so clamp logic applies
|
||
|
||
module.alterReceived(packet);
|
||
|
||
meshtastic_Position out;
|
||
TEST_ASSERT_TRUE(decodePositionPayload(packet, out));
|
||
// Clamped to channel ceiling (13 bits) - lost-and-found is no longer exempt.
|
||
// Note: precision must be <= MAX_POSITION_PRECISION_PUBLIC_KEY (15); well-known
|
||
// channels always have a public PSK so getPositionPrecisionForChannel caps at 15.
|
||
TEST_ASSERT_EQUAL_UINT32(13, out.precision_bits);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Fuzz - crafted-nodenum blitz of the unified cache
|
||
// ---------------------------------------------------------------------------
|
||
// Floods handleReceived/alterReceived with crafted packets over a tiny node pool so the fixed-size
|
||
// cache churns hard while the virtual clock sweeps the rate/unknown/position windows; no crash, counters bounded.
|
||
static constexpr uint64_t FUZZ_SEED = 0x00D07E5701ULL;
|
||
|
||
static void test_tm_fuzz_nodenum_blitz(void)
|
||
{
|
||
printf(" seed=0x%llx\n", (unsigned long long)FUZZ_SEED);
|
||
rngSeed(FUZZ_SEED);
|
||
|
||
// Activate the cache-tracked windows (the crafted-nodenum target). The nodeinfo direct-response
|
||
// path (nodeinfo_direct_response_max_hops) is left OFF: it calls service->sendToMesh, and driving
|
||
// that at fuzz volume needs a fully-wired MeshService/phone queue this fixture doesn't provide - the
|
||
// deterministic test_tm_nodeinfo_directResponse_* tests cover it with single packets instead.
|
||
moduleConfig.traffic_management.rate_limit_window_secs = 60;
|
||
moduleConfig.traffic_management.rate_limit_max_packets = 3;
|
||
moduleConfig.traffic_management.unknown_packet_threshold = 4;
|
||
moduleConfig.traffic_management.position_min_interval_secs = 300;
|
||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||
installWellKnownPrimaryChannel();
|
||
|
||
static TrafficManagementModuleTestShim module; // static: OSThread-derived (see note in test_fuzz_packets E2)
|
||
|
||
// Shared boundary pool (0/1/broadcast) plus this suite's well-known nodes.
|
||
const NodeNum wellKnown[] = {kLocalNode, kRemoteNode, kTargetNode};
|
||
const size_t wellKnownN = sizeof(wellKnown) / sizeof(wellKnown[0]);
|
||
const meshtastic_PortNum ports[] = {
|
||
meshtastic_PortNum_TEXT_MESSAGE_APP, meshtastic_PortNum_NODEINFO_APP, meshtastic_PortNum_POSITION_APP,
|
||
meshtastic_PortNum_ROUTING_APP, meshtastic_PortNum_ADMIN_APP, meshtastic_PortNum_TELEMETRY_APP,
|
||
};
|
||
const size_t portsN = sizeof(ports) / sizeof(ports[0]);
|
||
|
||
const unsigned ITERS = 30000;
|
||
for (unsigned k = 0; k < ITERS; k++) {
|
||
meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
|
||
p.from = (rngRange(8) == 0) ? (NodeNum)rngNext() : rngEdgeNodeNum(wellKnown, wellKnownN);
|
||
p.to = rngEdgeNodeNum(wellKnown, wellKnownN);
|
||
p.id = rngNext();
|
||
p.channel = 0;
|
||
p.hop_start = (uint8_t)rngRange(8); // 0..7, wire-bounded
|
||
p.hop_limit = (uint8_t)rngRange(8);
|
||
p.decoded.want_response = (rngRange(2) == 0);
|
||
|
||
if (rngRange(5) == 0) {
|
||
// Undecoded / unknown packet - exercises the unknown-packet threshold path.
|
||
p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag;
|
||
p.encrypted.size = rngRange(sizeof(p.encrypted.bytes) + 1);
|
||
rngFill(p.encrypted.bytes, p.encrypted.size);
|
||
} else {
|
||
p.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
|
||
p.decoded.portnum = (rngRange(8) == 0) ? (meshtastic_PortNum)rngRange(80) : ports[rngRange(portsN)];
|
||
p.decoded.has_bitfield = true;
|
||
p.decoded.bitfield = (uint32_t)rngNext();
|
||
if (p.decoded.portnum == meshtastic_PortNum_POSITION_APP && rngRange(2)) {
|
||
meshtastic_Position pos = meshtastic_Position_init_zero;
|
||
pos.has_latitude_i = true;
|
||
pos.has_longitude_i = true;
|
||
pos.latitude_i = (int32_t)rngNext();
|
||
pos.longitude_i = (int32_t)rngNext();
|
||
pos.precision_bits = rngRange(40); // includes >32 (the default-precision fallback)
|
||
p.decoded.payload.size =
|
||
pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_Position_msg, &pos);
|
||
} else {
|
||
// Random payload bytes: TMM's nested User/Position decode must fail cleanly.
|
||
p.decoded.payload.size = rngRange(sizeof(p.decoded.payload.bytes) + 1);
|
||
rngFill(p.decoded.payload.bytes, p.decoded.payload.size);
|
||
}
|
||
}
|
||
|
||
(void)module.handleReceived(p);
|
||
if (rngRange(3) == 0)
|
||
module.alterReceived(p);
|
||
|
||
// Advance the virtual clock so rate / unknown / position windows open and close under churn.
|
||
if (rngRange(16) == 0)
|
||
TrafficManagementModule::s_testNowMs += (rngRange(120) + 1) * 1000u;
|
||
if (rngRange(1024) == 0)
|
||
(void)module.runOnce(); // maintenance sweep: cache aging / eviction
|
||
}
|
||
|
||
// The cache never inspected more packets than we fed, and the run reached here without an ASan fault.
|
||
TEST_ASSERT_TRUE_MESSAGE(module.getStats().packets_inspected <= ITERS, "packets_inspected overcounted");
|
||
}
|
||
} // namespace
|
||
|
||
void setUp(void)
|
||
{
|
||
resetTrafficConfig();
|
||
}
|
||
void tearDown(void)
|
||
{
|
||
// Runs even when a TEST_ASSERT_* aborts a test mid-way (Unity longjmps out, skipping any
|
||
// cleanup the test itself does after the assertion), so per-test global state can never
|
||
// dangle into later cases. The dangling-pointer path is concrete: a test points the global
|
||
// at its stack `module`, and the next setUp()'s resetNodes() would then call
|
||
// trafficManagementModule->purgeAll() on a destroyed object.
|
||
trafficManagementModule = nullptr;
|
||
owner.public_key.size = 0;
|
||
memset(owner.public_key.bytes, 0, sizeof(owner.public_key.bytes));
|
||
// Neutralize the RTC fake clock a fallback test may have set (the virtual s_testNowMs is
|
||
// already reset by setUp via resetTrafficConfig).
|
||
setBootRelativeTimeForUnitTest(0);
|
||
}
|
||
|
||
TM_TEST_ENTRY void setup()
|
||
{
|
||
delay(10);
|
||
delay(2000);
|
||
|
||
initializeTestEnvironment();
|
||
mockNodeDB = new MockNodeDB();
|
||
nodeDB = mockNodeDB;
|
||
|
||
UNITY_BEGIN();
|
||
RUN_TEST(test_tm_moduleDisabled_doesNothing);
|
||
RUN_TEST(test_tm_unknownPackets_dropOnNPlusOne);
|
||
RUN_TEST(test_tm_positionDedup_dropsDuplicateWithinWindow);
|
||
RUN_TEST(test_tm_positionDedup_allowsMovedPosition);
|
||
RUN_TEST(test_tm_rateLimit_dropsOnlyAfterThreshold);
|
||
RUN_TEST(test_tm_fromUs_bypassesPositionAndRateFilters);
|
||
RUN_TEST(test_tm_localDestination_bypassesTransitFilters);
|
||
RUN_TEST(test_tm_nodeinfo_routerClamp_skipsWhenTooManyHops);
|
||
RUN_TEST(test_tm_nodeinfo_directResponse_respondsFromCache);
|
||
RUN_TEST(test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo);
|
||
RUN_TEST(test_tm_nodeinfo_directResponse_ignoresUnsignedSignerIdentity);
|
||
RUN_TEST(test_tm_nodeinfo_clientClamp_skipsWhenNotDirect);
|
||
RUN_TEST(test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips);
|
||
RUN_TEST(test_tm_nodeinfo_directResponse_fallbackStaleEntryNotServed);
|
||
RUN_TEST(test_tm_nodeinfo_directResponse_fallbackFreshEntryServed);
|
||
RUN_TEST(test_tm_nodeinfo_directResponse_fallbackThrottlesWithinWindow);
|
||
RUN_TEST(test_tm_nodeinfo_directResponse_perRequesterAndGlobalFloor);
|
||
#if TMM_NODEINFO_REPLAY_SIGNED_GATE
|
||
RUN_TEST(test_tm_nodeinfo_directResponse_fallbackUnsignedNotServed);
|
||
#endif
|
||
#if TMM_HAS_NODEINFO_CACHE
|
||
RUN_TEST(test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfield);
|
||
RUN_TEST(test_tm_nodeinfo_directResponse_psramMissDoesNotFallbackToNodeDb);
|
||
RUN_TEST(test_tm_nodeinfo_directResponse_psramStaleEntryNotServed);
|
||
RUN_TEST(test_tm_nodeinfo_directResponse_psramThrottlesWithinWindow);
|
||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||
RUN_TEST(test_tm_nodeinfo_cache_rejectsMismatchedKey);
|
||
#if WARM_NODE_COUNT > 0
|
||
RUN_TEST(test_tm_nodeinfo_cache_pinsAgainstWarmTierKey);
|
||
RUN_TEST(test_tm_nodeinfo_gate_blocksUnsignedWarmSignerForgery);
|
||
RUN_TEST(test_tm_nodeinfo_reconcile_seedsFromHotStoreButNeverServes);
|
||
RUN_TEST(test_tm_nodeinfo_reconcile_seedsKeyOnlyFromWarmTier);
|
||
RUN_TEST(test_tm_nodeinfo_reconcile_keepsTofuKeyOnKeylessHotIdentity);
|
||
RUN_TEST(test_tm_nodeinfo_updateUserHook_writesThrough);
|
||
RUN_TEST(test_tm_nodeinfo_removeNode_purgesCaches);
|
||
RUN_TEST(test_tm_nodeinfo_noTimedEviction_quietKeyedEntrySurvives);
|
||
#endif
|
||
RUN_TEST(test_tm_nodeinfo_copyPublicKey_servesTofuKey);
|
||
RUN_TEST(test_tm_nodeinfo_copyPublicKey_upgradesToSignerProven);
|
||
RUN_TEST(test_tm_nodeinfo_copyPublicKey_missReturnsFalse);
|
||
RUN_TEST(test_tm_nodeinfo_copyUser_returnsCachedIdentity);
|
||
#if TMM_NODEINFO_REPLAY_SIGNED_GATE
|
||
RUN_TEST(test_tm_nodeinfo_directResponse_psramUnsignedNotServed);
|
||
#endif
|
||
#endif
|
||
RUN_TEST(test_tm_nodeinfo_keyHook_upsertsAndGovernsProvenance);
|
||
RUN_TEST(test_tm_nodeinfo_tickSaturation_sweepClearsObserved);
|
||
RUN_TEST(test_tm_nodeinfo_reconcileMembershipMarking);
|
||
RUN_TEST(test_tm_nodeinfo_cache_normalizesSpoofedUserId);
|
||
RUN_TEST(test_tm_nodeinfo_hooks_noopWhileModuleDisabled);
|
||
RUN_TEST(test_tm_nodeinfo_identityHook_keySemantics);
|
||
RUN_TEST(test_tm_nodeinfo_reads_gatedWhileModuleDisabled);
|
||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||
RUN_TEST(test_tm_nodeinfo_eviction_keyedTiersOutrankKeyless);
|
||
RUN_TEST(test_tm_nodeinfo_eviction_tofuLosesBeforeProvenAndMember);
|
||
RUN_TEST(test_tm_nodeinfo_eviction_withinTierStalestLoses);
|
||
RUN_TEST(test_tm_nodeinfo_memberSaturated_hooksSkipPacketPathEvicts);
|
||
RUN_TEST(test_tm_nodeinfo_resetNodes_purgesAllCaches);
|
||
RUN_TEST(test_tm_nodeinfo_cache_dropsFrameCarryingOwnerKey);
|
||
#endif
|
||
#endif
|
||
RUN_TEST(test_tm_alterReceived_telemetryBroadcast_hopLimitUnchanged);
|
||
RUN_TEST(test_tm_alterReceived_skipsLocalAndUnicast);
|
||
RUN_TEST(test_tm_positionDedup_allowsDuplicateAfterIntervalExpires);
|
||
RUN_TEST(test_tm_positionDedup_intervalZero_neverDrops);
|
||
RUN_TEST(test_tm_positionDedup_precisionAbove32_usesDefaultPrecision);
|
||
RUN_TEST(test_tm_positionDedup_distinctAtClampedChannelPrecision);
|
||
RUN_TEST(test_tm_positionDedup_precisionZero_channelFallsBackToDefault);
|
||
RUN_TEST(test_tm_positionDedup_cacheFlush_doesNotDropFirstPacketAfterFlush);
|
||
RUN_TEST(test_tm_positionDedup_priorRateState_doesNotDropFirstFingerprintZero);
|
||
RUN_TEST(test_tm_rateLimit_resetsAfterWindowExpires);
|
||
RUN_TEST(test_tm_unknownPackets_resetAfterWindowExpires);
|
||
RUN_TEST(test_tm_unknownPackets_thresholdAbove255_clamps);
|
||
RUN_TEST(test_tm_alterReceived_positionBroadcast_hopLimitUnchanged);
|
||
RUN_TEST(test_tm_alterReceived_skipsUndecodedPackets);
|
||
RUN_TEST(test_tm_alterReceived_exhaustFlagAlwaysFalse);
|
||
RUN_TEST(test_tm_alterReceived_exhaustFlag_isPacketScoped);
|
||
RUN_TEST(test_tm_runOnce_disabledReturnsMaxInterval);
|
||
RUN_TEST(test_tm_runOnce_enabledReturnsMaintenanceInterval);
|
||
RUN_TEST(test_tm_nextHop_setAndGetRoundTrip);
|
||
RUN_TEST(test_tm_nextHop_servedAfterNodeDbRoll);
|
||
RUN_TEST(test_tm_nextHop_preloadDoesNotClobberLearned);
|
||
RUN_TEST(test_tm_nextHop_keptAliveAcrossMaintenanceSweep);
|
||
RUN_TEST(test_tm_trackerRole_capsDedupWindowAtOneHour);
|
||
RUN_TEST(test_tm_takTrackerRole_capsDedupWindowAtOneHour);
|
||
RUN_TEST(test_tm_trackerRole_survivesNodeDbEvictionViaCachedRole);
|
||
RUN_TEST(test_tm_roleChange_viaNodeInfo_dropsTrackerException);
|
||
RUN_TEST(test_tm_specialRole_pinsEntryThroughSweep);
|
||
RUN_TEST(test_tm_specialRole_evictedLastUnderPressure);
|
||
RUN_TEST(test_tm_trackerRole_doesNotLengthenShorterOperatorInterval);
|
||
RUN_TEST(test_tm_lostAndFoundRole_capsDedupAtFifteenMinutes);
|
||
RUN_TEST(test_tm_lostAndFoundRole_getsAlterReceivedPrecisionClamp);
|
||
RUN_TEST(test_tm_unknownRole_noDbEntry_appliesFullInterval);
|
||
RUN_TEST(test_tm_unknownRole_noUserBit_appliesFullInterval);
|
||
RUN_TEST(test_tm_fuzz_nodenum_blitz);
|
||
exit(UNITY_END());
|
||
}
|
||
|
||
TM_TEST_ENTRY void loop() {}
|
||
|
||
#else
|
||
|
||
void setUp(void) {}
|
||
void tearDown(void) {}
|
||
|
||
TM_TEST_ENTRY void setup()
|
||
{
|
||
initializeTestEnvironment();
|
||
UNITY_BEGIN();
|
||
exit(UNITY_END());
|
||
}
|
||
|
||
TM_TEST_ENTRY void loop() {}
|
||
|
||
#endif
|