mirror of
https://github.com/torlando-tech/pyxis.git
synced 2026-09-25 06:04:51 +00:00
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:
@@ -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
|
||||
Reference in New Issue
Block a user