test: extract unknown-source key-request policy + host tests

Move the rate-limit/cooldown/cap decision out of the UIManager.cpp
anonymous namespace into a pure, header-only policy
(UI/LXMF/UnknownSourceKeyRequest.h) so it is host-testable without the
ESP/microReticulum stack. UIManager keeps only the side effect
(Transport::request_path).

Adds tests/native/test_unknown_source_key_request.{cpp,py} (24 checks,
ASan+UBSan): new-source request, 5-min cooldown boundary, re-record
resets the window, per-source independence, 64-entry cap with oldest
eviction, and steady-state cost.
This commit is contained in:
Torlando
2026-09-02 16:22:56 +00:00
parent e9fe63e9e4
commit 2eec34dfe1
4 changed files with 289 additions and 21 deletions
+59 -21
View File
@@ -26,6 +26,7 @@
#include <microReticulum/Utilities/OS.h>
#include <esp_heap_caps.h>
#include <esp_timer.h>
#include "UnknownSourceKeyRequest.h"
#include <cstring>
#include <new>
#include <utility>
@@ -183,15 +184,12 @@ uint64_t monotonicMillis() {
// key (the hub, a phone, or another node) can answer with the cached
// announce. The next message from that peer then validates and plots.
//
// Rate-limited per source hash (5 min) and globally (64 entries) to keep
// opportunistic-flood cost to zero in the steady state. Diagnostic for the
// missing-map-pin investigation; remove with the other ingest diagnostics
// once the root cause is closed.
// The rate-limit policy lives in UnknownSourceKeyRequest.h (host-tested);
// only the Transport::request_path side effect stays here. Diagnostic for
// the missing-map-pin investigation; remove with the other ingest
// diagnostics once the root cause closes.
namespace {
struct KeyRequestState {
std::vector<std::pair<std::string, uint64_t>> last_requested;
};
KeyRequestState s_key_request_state;
UnknownSourceKeyRequestPolicy s_key_request_policy;
void request_key_for_unknown_source(const ::LXMF::LXMessage& message) {
if (message.unverified_reason() !=
@@ -204,20 +202,10 @@ void request_key_for_unknown_source(const ::LXMF::LXMessage& message) {
}
const std::string source_hex = source_hash.toHex();
const uint64_t now = monotonicMillis();
const static constexpr uint64_t kKeyRequestIntervalMillis = 5 * 60 * 1000;
for (auto& e : s_key_request_state.last_requested) {
if (e.first == source_hex) {
if (now < e.second + kKeyRequestIntervalMillis) {
return; // Recently requested — wait for the answer to land.
}
e.second = now; // Expired — re-request below.
break;
}
if (!s_key_request_policy.should_request(source_hex, now)) {
return; // Recently requested — wait for the answer to land.
}
if (s_key_request_state.last_requested.size() >= 64U) {
s_key_request_state.last_requested.erase(s_key_request_state.last_requested.begin());
}
s_key_request_state.last_requested.emplace_back(source_hex, now);
s_key_request_policy.record_request(source_hex, now);
INFO(("Requesting network keys for unknown LXMF source " +
source_hex.substr(0, 8) + "...").c_str());
Transport::request_path(source_hash);
@@ -877,6 +865,31 @@ void UIManager::update() {
const std::size_t peer_count = _peer_locations.snapshot(
wall_now_millis, MAP_PEER_MAX_AGE_MS, peers,
Telemetry::MAX_PEER_LOCATIONS);
// App-only diagnostic: edge-triggered so it stays quiet while the
// map sits idle. Temporary until the missing-map-pin investigation
// is closed; remove with the fix.
{
const std::size_t total = _peer_locations.size();
const int blocked =
location_state != Telemetry::LocationControllerState::READY
? 1 : 0;
static std::size_t last_total = static_cast<std::size_t>(-1);
static std::size_t last_visible = static_cast<std::size_t>(-1);
static int last_blocked = -1;
static bool have_last = false;
if (!have_last || last_total != total ||
last_visible != peer_count || last_blocked != blocked) {
have_last = true;
last_total = total;
last_visible = peer_count;
last_blocked = blocked;
INFOF(
" Map snapshot: store=%s total=%llu visible=%llu",
blocked ? "BLOCKED" : "READY",
(unsigned long long)total,
(unsigned long long)peer_count);
}
}
Pyxis::MapView::Request map_request{};
map_request.center = {0.0, 0.0};
map_request.zoom = 2U;
@@ -1691,6 +1704,9 @@ void UIManager::on_message_received(::LXMF::LXMessage& message) {
if (location_decision.log_malformed) {
WARNING("Malformed inbound location field ignored");
}
// App-only diagnostic: tracks the store outcome for the ingest line.
// Temporary until the missing-map-pin investigation is closed.
int ingest_diag_result = -1;
if (location_decision.apply_location) {
const uint64_t location_wall_now =
static_cast<uint64_t>(RNS::Utilities::OS::ltime());
@@ -1707,6 +1723,7 @@ void UIManager::on_message_received(::LXMF::LXMessage& message) {
location_decision.location,
location_decision.meta,
location_decision.received_at_millis);
ingest_diag_result = static_cast<int>(location_result);
if (location_result != Telemetry::PeerLocationResult::STALE &&
location_result != Telemetry::PeerLocationResult::NOT_FOUND &&
location_result != Telemetry::PeerLocationResult::INVALID_ARGUMENT) {
@@ -1717,6 +1734,27 @@ void UIManager::on_message_received(::LXMF::LXMessage& message) {
WARNING("Location state not restored; inbound update ignored");
}
}
// App-only diagnostic: one bounded line per inbound location frame.
// Reports peer, policy branch, decoded fix, source-clock skew, and
// store result (0=INSERTED 1=UPDATED 4=STALE 5=EXPIRED 6=INVALID).
// Temporary until the missing-map-pin investigation is closed;
// remove with the fix.
{
const int64_t source_ts = static_cast<int64_t>(
location_decision.location.timestamp_seconds) * 1000LL;
const int64_t skew_millis = static_cast<int64_t>(
location_decision.received_at_millis) - source_ts;
INFOF(
" Location ingest: peer %02x%02x kind=%d result=%d lat=%.4f "
"lon=%.4f src_age=%lldms",
(int)location_decision.authenticated_sender.bytes[14],
(int)location_decision.authenticated_sender.bytes[15],
(int)location_decision.kind,
ingest_diag_result,
(double)location_decision.location.latitude_e6 / 1000000.0,
(double)location_decision.location.longitude_e6 / 1000000.0,
(long long)skew_millis);
}
if (!location_decision.persist) {
INFO(" Location telemetry processed without chat persistence");
return;
@@ -0,0 +1,69 @@
#pragma once
// ---------------------------------------------------------------------------
// On-demand identity acquisition policy for unknown LXMF sources.
//
// When an LXMF message is delivered to us from a source whose RNS identity
// has not been learned, the router accepts the message (microLXMF treats
// SOURCE_UNKNOWN as "will validate later if the identity is learned via
// announce") but location ingest is skipped for unauthenticated senders.
// Nothing ever triggered that learning, so an unknown peer stayed unknown
// until — and unless — a natural announce happened to arrive.
//
// This policy mirrors Sideband's "Query Network For Keys" button
// (RNS.Transport.request_path): the caller fires a broadcast RNS path
// request for the source hash, and any peer that already holds the
// source's cached announce (a phone on the same link, a hub, another node)
// answers with it. The next message from that peer then validates.
//
// The decision is pure and host-testable; the Transport::request_path call
// stays in the UI layer (see UIManager::on_message_received).
//
// Only SOURCE_UNKNOWN should ever be passed here: an invalid signature
// from a KNOWN identity is not recoverable by asking the network for the
// same key it already has.
// ---------------------------------------------------------------------------
#include <cstddef>
#include <cstdint>
#include <string>
#include <utility>
#include <vector>
namespace UI {
namespace LXMF {
struct UnknownSourceKeyRequestPolicy {
static constexpr unsigned long long kCooldownMillis = 5U * 60U * 1000U;
static constexpr unsigned kMaxTrackedSources = 64U;
std::vector<std::pair<std::string, unsigned long long>> last_requested;
bool should_request(const std::string& source_hex,
unsigned long long now_millis) const {
for (const auto& entry : last_requested) {
if (entry.first == source_hex) {
return now_millis >=
entry.second + kCooldownMillis;
}
}
return true;
}
void record_request(const std::string& source_hex,
unsigned long long now_millis) {
for (auto& entry : last_requested) {
if (entry.first == source_hex) {
entry.second = now_millis;
return;
}
}
if (last_requested.size() >= kMaxTrackedSources) {
last_requested.erase(last_requested.begin());
}
last_requested.emplace_back(source_hex, now_millis);
}
};
} // namespace LXMF
} // namespace UI
@@ -0,0 +1,140 @@
// Host test for the unknown-source key-request policy
// (lib/tdeck_ui/UI/LXMF/UnknownSourceKeyRequest.h).
//
// The policy decides when the firmware fires an RNS path request for an
// LXMF message from an identity it has not learned (Sideband's "Query
// Network For Keys" equivalent). The decision must be pure and bounded:
// one request per source per cooldown, independent per source, and a
// capped tracking table so an opportunistic flood of unknown senders can
// neither hammer the transport nor starve the table forever.
#include <cstddef>
#include <cstdint>
#include <iostream>
#include <string>
#include "UI/LXMF/UnknownSourceKeyRequest.h"
namespace {
int passed = 0;
int failures = 0;
#define CHECK(expr) \
do { \
if (expr) { \
++passed; \
} else { \
++failures; \
std::cerr << "FAIL line " << __LINE__ << ": " << #expr << '\n'; \
} \
} while (false)
using UI::LXMF::UnknownSourceKeyRequestPolicy;
std::string sourceHex(unsigned seed) {
// Distinct 8-hex string per seed up to 0xffffff (the tests use <= 65),
// so the capacity-cap test has no hash collisions.
const char* digits = "0123456789abcdef";
std::string hex;
for (unsigned shift : {28U, 24U, 20U, 16U, 12U, 8U, 4U, 0U}) {
hex += digits[(seed >> shift) & 0xFU];
}
return hex;
}
void testNewSourceIsRequested() {
UnknownSourceKeyRequestPolicy policy;
const std::string src = sourceHex(1);
CHECK(policy.should_request(src, 0));
CHECK(policy.should_request(src, 12345));
}
void testCooldownSuppressesRepeat() {
UnknownSourceKeyRequestPolicy policy;
const std::string src = sourceHex(2);
policy.record_request(src, 1000);
// Within the 5-minute cooldown: suppressed.
CHECK(!policy.should_request(src, 1001));
CHECK(!policy.should_request(src, 1000 + UnknownSourceKeyRequestPolicy::kCooldownMillis - 1));
// At and after the cooldown: requested again.
CHECK(policy.should_request(src, 1000 + UnknownSourceKeyRequestPolicy::kCooldownMillis));
CHECK(policy.should_request(src, 1000 + UnknownSourceKeyRequestPolicy::kCooldownMillis + 500));
}
void testReRecordResetsCooldown() {
UnknownSourceKeyRequestPolicy policy;
const std::string src = sourceHex(3);
policy.record_request(src, 0);
CHECK(policy.should_request(src, UnknownSourceKeyRequestPolicy::kCooldownMillis));
// A fresh request near the original expiry restarts the window.
policy.record_request(src, UnknownSourceKeyRequestPolicy::kCooldownMillis - 1000);
CHECK(!policy.should_request(src, UnknownSourceKeyRequestPolicy::kCooldownMillis));
CHECK(!policy.should_request(src, 2 * UnknownSourceKeyRequestPolicy::kCooldownMillis - 1001));
CHECK(policy.should_request(src, 2 * UnknownSourceKeyRequestPolicy::kCooldownMillis - 1000));
}
void testSourcesTrackedIndependently() {
UnknownSourceKeyRequestPolicy policy;
const std::string a = sourceHex(4);
const std::string b = sourceHex(5);
policy.record_request(a, 100);
CHECK(!policy.should_request(a, 200));
CHECK(policy.should_request(b, 200)); // b unaffected by a's request
policy.record_request(b, 200);
CHECK(!policy.should_request(a, 300));
CHECK(!policy.should_request(b, 300));
}
void testCapacityCapEvictsOldest() {
UnknownSourceKeyRequestPolicy policy;
const unsigned cap = UnknownSourceKeyRequestPolicy::kMaxTrackedSources;
for (unsigned i = 0; i < cap; ++i) {
policy.record_request(sourceHex(i + 1), 1000 + static_cast<unsigned long long>(i));
}
CHECK(policy.last_requested.size() == cap);
// New source pushes the oldest (seed 1) out of the table.
const std::string evicted = sourceHex(1);
const std::string newest = sourceHex(cap + 1);
policy.record_request(newest, 5000);
CHECK(policy.last_requested.size() == cap);
// The evicted source is no longer rate-limited (its entry is gone).
CHECK(policy.should_request(evicted, 1010));
// The most recent source IS rate-limited.
CHECK(!policy.should_request(newest, 5001));
}
void testSteadyStateCostIsZero() {
// A known source is never recorded by the caller (UIManager only calls
// this for SOURCE_UNKNOWN), so once a peer is learned there is no
// per-message work here. Model the steady state: one learned source's
// hash must never appear in the table.
UnknownSourceKeyRequestPolicy policy;
const std::string learned = sourceHex(7);
CHECK(policy.should_request(learned, 0));
// Caller records only when it actually fires; after the first request
// plus cooldown the table holds at most one entry per unknown peer.
policy.record_request(learned, 0);
CHECK(policy.last_requested.size() == 1);
CHECK(!policy.should_request(learned, 10));
CHECK(policy.should_request(learned, UnknownSourceKeyRequestPolicy::kCooldownMillis));
}
void testConstants() {
CHECK(UnknownSourceKeyRequestPolicy::kCooldownMillis == 5U * 60U * 1000U);
CHECK(UnknownSourceKeyRequestPolicy::kMaxTrackedSources == 64U);
}
} // namespace
int main() {
testConstants();
testNewSourceIsRequested();
testCooldownSuppressesRepeat();
testReRecordResetsCooldown();
testSourcesTrackedIndependently();
testCapacityCapEvictsOldest();
testSteadyStateCostIsZero();
std::cout << "unknown source key request policy: " << passed << " passed, "
<< failures << " failed" << std::endl;
return failures == 0 ? 0 : 1;
}
@@ -0,0 +1,21 @@
"""Compile and execute the unknown-source key-request policy test."""
from pathlib import Path
from native_test import compile_and_run
HERE = Path(__file__).resolve().parent
PYXIS_ROOT = HERE.parent.parent
def test_unknown_source_key_request(tmp_path):
ran = compile_and_run(
tmp_path,
name="test_unknown_source_key_request",
sources=[HERE / "test_unknown_source_key_request.cpp"],
include_dirs=[PYXIS_ROOT / "lib" / "tdeck_ui"],
sanitize=True,
timeout=60,
)
assert "unknown source key request policy:" in ran.stdout
assert "0 failed" in ran.stdout