From 2eec34dfe1b0aecdd8620117649dc0f04ef8c7e4 Mon Sep 17 00:00:00 2001 From: Torlando <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:22:56 +0000 Subject: [PATCH] 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. --- lib/tdeck_ui/UI/LXMF/UIManager.cpp | 80 +++++++--- .../UI/LXMF/UnknownSourceKeyRequest.h | 69 +++++++++ .../test_unknown_source_key_request.cpp | 140 ++++++++++++++++++ .../native/test_unknown_source_key_request.py | 21 +++ 4 files changed, 289 insertions(+), 21 deletions(-) create mode 100644 lib/tdeck_ui/UI/LXMF/UnknownSourceKeyRequest.h create mode 100644 tests/native/test_unknown_source_key_request.cpp create mode 100644 tests/native/test_unknown_source_key_request.py diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.cpp b/lib/tdeck_ui/UI/LXMF/UIManager.cpp index a0885c47..921e1c03 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.cpp +++ b/lib/tdeck_ui/UI/LXMF/UIManager.cpp @@ -26,6 +26,7 @@ #include #include #include +#include "UnknownSourceKeyRequest.h" #include #include #include @@ -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> 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(-1); + static std::size_t last_visible = static_cast(-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(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(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( + location_decision.location.timestamp_seconds) * 1000LL; + const int64_t skew_millis = static_cast( + 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; diff --git a/lib/tdeck_ui/UI/LXMF/UnknownSourceKeyRequest.h b/lib/tdeck_ui/UI/LXMF/UnknownSourceKeyRequest.h new file mode 100644 index 00000000..9ec2e754 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/UnknownSourceKeyRequest.h @@ -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 +#include +#include +#include +#include + +namespace UI { +namespace LXMF { + +struct UnknownSourceKeyRequestPolicy { + static constexpr unsigned long long kCooldownMillis = 5U * 60U * 1000U; + static constexpr unsigned kMaxTrackedSources = 64U; + + std::vector> 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 diff --git a/tests/native/test_unknown_source_key_request.cpp b/tests/native/test_unknown_source_key_request.cpp new file mode 100644 index 00000000..4541a006 --- /dev/null +++ b/tests/native/test_unknown_source_key_request.cpp @@ -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 +#include +#include +#include + +#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(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; +} diff --git a/tests/native/test_unknown_source_key_request.py b/tests/native/test_unknown_source_key_request.py new file mode 100644 index 00000000..e68f82ce --- /dev/null +++ b/tests/native/test_unknown_source_key_request.py @@ -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