367 lines
16 KiB
C++
367 lines
16 KiB
C++
#include <cassert>
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <string>
|
|
#include <vector>
|
|
#include "../room_history_store/filesystem.h"
|
|
#include <helpers/RoomAccessPolicy.h>
|
|
#include <helpers/ClientPathPersistence.h>
|
|
#include <helpers/RoomClientPathCommand.h>
|
|
|
|
#define PUB_KEY_SIZE 32
|
|
#define MAX_PATH_SIZE 64
|
|
#define MAX_HASH_SIZE 8
|
|
#define MAX_PACKET_PAYLOAD 184
|
|
#define MAX_POST_TEXT_LEN 151
|
|
#define MAX_UNSYNCED_POSTS 4
|
|
#define TXT_TYPE_SIGNED_PLAIN 2
|
|
#define PAYLOAD_TYPE_TXT_MSG 2
|
|
#define POST_SYNC_DELAY_SECS 6
|
|
#define PUSH_NOTIFY_DELAY_MILLIS 2000
|
|
#define PUSH_ACK_TIMEOUT_FLOOD 12000
|
|
#define PUSH_TIMEOUT_BASE 4000
|
|
#define PUSH_ACK_TIMEOUT_FACTOR 2000
|
|
#define SYNC_PUSH_INTERVAL 1200
|
|
#define MESH_DEBUG_PRINTLN(...) ((void)0)
|
|
|
|
static uint32_t ticks = 100000;
|
|
static unsigned long futureMillis(unsigned long delay) { return uint32_t(ticks + delay); }
|
|
static bool millisHasNowPassed(unsigned long deadline) {
|
|
return int32_t(ticks - uint32_t(deadline)) > 0;
|
|
}
|
|
namespace mesh {
|
|
struct Identity {
|
|
uint8_t pub_key[PUB_KEY_SIZE] = {};
|
|
bool matches(const Identity& other) const {
|
|
return memcmp(pub_key, other.pub_key, PUB_KEY_SIZE) == 0;
|
|
}
|
|
};
|
|
struct Packet {
|
|
bool radio_reply = false;
|
|
uint8_t payload[MAX_PACKET_PAYLOAD] = {};
|
|
size_t payload_len = 0;
|
|
static bool isValidPathLen(uint8_t path);
|
|
};
|
|
struct Utils {
|
|
static void sha256(uint8_t* out, size_t n, const uint8_t* a, size_t na) {
|
|
sha256(out, n, a, na, nullptr, 0);
|
|
}
|
|
static void sha256(uint8_t* out, size_t n, const uint8_t* a, size_t na,
|
|
const uint8_t* b, size_t nb) {
|
|
// Deterministic fixture fingerprint; no cryptography is replaced in firmware.
|
|
uint32_t crc = 2166136261U;
|
|
for (size_t i = 0; i < na; ++i) crc = (crc ^ a[i]) * 16777619U;
|
|
for (size_t i = 0; i < nb; ++i) crc = (crc ^ b[i]) * 16777619U;
|
|
for (size_t i = 0; i < n; ++i) out[i] = uint8_t(crc >> ((i % 4) * 8));
|
|
}
|
|
};
|
|
struct Store { bool fail = false; std::string text; };
|
|
static bool saveRoomTopic(Store* store, const char* text) {
|
|
if (store->fail) return false;
|
|
store->text = text;
|
|
return true;
|
|
}
|
|
}
|
|
#include "state.inc"
|
|
static_assert(sizeof(void*) != 4 || sizeof(((ClientInfo*)0)->extra.room) <= sizeof(((ClientInfo*)0)->extra.sensor),
|
|
"topic state must fit the existing shared union");
|
|
struct Clock : mesh::RTCClock {
|
|
uint32_t now = 1000;
|
|
uint32_t getCurrentTime() override { return now; }
|
|
void setCurrentTime(uint32_t epoch) override { now = epoch; }
|
|
};
|
|
struct Random {
|
|
uint8_t attempt = 0;
|
|
void random(uint8_t* out, size_t size) { assert(size == 1); *out = attempt++ & 3; }
|
|
};
|
|
struct ACL {
|
|
ClientInfo clients[2] = {};
|
|
int count = 1;
|
|
int getNumClients() const { return count; }
|
|
ClientInfo* getClientByIdx(int at) { assert(at >= 0 && at < count); return &clients[at]; }
|
|
};
|
|
struct Sent {
|
|
uint32_t timestamp;
|
|
std::string text;
|
|
uint8_t author[4];
|
|
uint8_t path[MAX_PATH_SIZE] = {};
|
|
uint8_t path_len = 0;
|
|
bool direct = false, retry_enabled = false;
|
|
};
|
|
struct MyMesh {
|
|
ACL acl;
|
|
MemoryFS policy_fs;
|
|
mesh::RoomAccessPolicy room_access;
|
|
Clock clock;
|
|
Random rng;
|
|
mesh::Identity self_id;
|
|
mesh::Store store;
|
|
mesh::Store* _fs = &store;
|
|
mesh::Packet packet;
|
|
mesh::Packet alternate_packet;
|
|
std::vector<Sent> sent;
|
|
bool pool_available = true, queue_available = true, alternate_available = true;
|
|
unsigned releases = 0;
|
|
uint8_t reply_data[MAX_PACKET_PAYLOAD] = {};
|
|
unsigned long next_push = 0, room_topic_ready_at = 0;
|
|
bool room_topic_ready = true;
|
|
uint32_t room_topic_revision = 0, room_topic_timestamp = 0;
|
|
int next_client_idx = 0, next_post_idx = 0;
|
|
uint16_t _num_post_pushes = 0;
|
|
char room_topic[MAX_POST_TEXT_LEN + 1] = {};
|
|
PostInfo posts[MAX_UNSYNCED_POSTS] = {};
|
|
uint32_t post_ready_at[MAX_UNSYNCED_POSTS] = {};
|
|
uint32_t post_ready_mask = UINT32_MAX;
|
|
struct { uint8_t path_hash_mode = 0, direct_retry_enabled = 1; } _prefs;
|
|
int default_scope = 0;
|
|
MyMesh() {
|
|
metadata_filesystem = &policy_fs;
|
|
assert(room_access.load(&policy_fs));
|
|
self_id.pub_key[0] = 77;
|
|
acl.clients[0].id.pub_key[0] = 1;
|
|
acl.clients[0].last_activity = 1000;
|
|
for (auto& client : acl.clients) client.alt_path_len = OUT_PATH_UNKNOWN;
|
|
}
|
|
Clock* getRTCClock() { return &clock; }
|
|
Random* getRNG() { return &rng; }
|
|
mesh::Packet* createDatagram(int type, const mesh::Identity&, const uint8_t*,
|
|
const uint8_t* data, int len) {
|
|
assert(type == PAYLOAD_TYPE_TXT_MSG && len >= 9 && len <= 160);
|
|
if (!pool_available) return nullptr;
|
|
assert((data[4] >> 2) == TXT_TYPE_SIGNED_PLAIN);
|
|
memcpy(packet.payload, data, len);
|
|
packet.payload_len = len;
|
|
return &packet;
|
|
}
|
|
mesh::Packet* obtainNewPacket() { return alternate_available ? &alternate_packet : nullptr; }
|
|
void releasePacket(mesh::Packet* out) { assert(out == &alternate_packet); ++releases; }
|
|
bool queue(mesh::Packet* out) {
|
|
assert(out->radio_reply);
|
|
if (!queue_available) return false;
|
|
Sent entry;
|
|
memcpy(&entry.timestamp, out->payload, 4);
|
|
memcpy(entry.author, out->payload + 5, 4);
|
|
entry.text = std::string(reinterpret_cast<char*>(out->payload + 9), out->payload_len - 9);
|
|
sent.push_back(entry);
|
|
return true;
|
|
}
|
|
bool sendFloodScoped(int, mesh::Packet* out, unsigned long, uint8_t) { return queue(out); }
|
|
bool sendFloodReply(mesh::Packet* out, unsigned long delay, uint8_t width) {
|
|
return sendFloodScoped(default_scope, out, delay, width);
|
|
}
|
|
bool sendDirect(mesh::Packet* out, const uint8_t* path, uint8_t length, unsigned long = 0) {
|
|
if (!queue(out)) return false;
|
|
auto& entry = sent.back();
|
|
entry.direct = true; entry.path_len = length;
|
|
entry.retry_enabled = _prefs.direct_retry_enabled != 0;
|
|
memcpy(entry.path, path, mesh::encodedClientPathByteLength(length));
|
|
return true;
|
|
}
|
|
void replaceActiveMessageRetries(mesh::Packet*, const uint8_t*, uint32_t) {}
|
|
bool pushPostToClient(ClientInfo*, PostInfo&);
|
|
bool sendClientReply(ClientInfo*, mesh::Packet*, unsigned long, uint8_t);
|
|
bool pushRoomTextToClient(ClientInfo*, uint32_t, const mesh::Identity&, const char*, uint32_t = 0);
|
|
bool processAck(const uint8_t*);
|
|
void activateRoomTopic();
|
|
bool handleRoomTopicCommand(const char*, char*);
|
|
void serviceRoomPush();
|
|
void tick(uint32_t delta = 0) {
|
|
ticks += delta + 1;
|
|
next_push = uint32_t(ticks - 1);
|
|
memset(reply_data, 0, sizeof(reply_data));
|
|
serviceRoomPush();
|
|
}
|
|
void ack() {
|
|
const uint32_t ack = acl.clients[0].extra.room.pending_ack;
|
|
assert(ack && processAck(reinterpret_cast<const uint8_t*>(&ack)));
|
|
}
|
|
void topic(const char* text) {
|
|
std::string command = std::string("set topic ") + text;
|
|
char reply[160] = {};
|
|
assert(handleRoomTopicCommand(command.c_str(), reply));
|
|
assert(strncmp(reply, "OK", 2) == 0);
|
|
}
|
|
void post(int index, uint32_t timestamp, const char* text) {
|
|
posts[index].post_timestamp = timestamp;
|
|
posts[index].author.pub_key[0] = 99;
|
|
strcpy(posts[index].text, text);
|
|
}
|
|
};
|
|
#include "production.inc"
|
|
int main() {
|
|
{
|
|
MyMesh m; m.topic("Welcome");
|
|
auto& client = m.acl.clients[0];
|
|
client.extra.room.sync_since = 50;
|
|
m.post(0, 100, "old-one"); m.post(1, 200, "old-two");
|
|
m.post(2, 1100, "newer"); m.clock.now = 1200;
|
|
m.tick(6000); assert(m.sent.back().text == "old-one"); m.ack();
|
|
m.tick(); assert(m.sent.back().text == "old-two"); m.ack();
|
|
m.tick(); assert(m.sent.back().text == "Welcome");
|
|
assert(m.sent.back().author[0] == 77);
|
|
assert(client.extra.room.sync_since == 200); m.ack();
|
|
assert(client.extra.room.sync_since == 200);
|
|
assert(client.extra.room.topic_seen_revision == m.room_topic_revision);
|
|
// An app echoing the latest topic timestamp cannot skip the newer post.
|
|
client.extra.room.sync_since = m.room_topic_timestamp;
|
|
m.tick(); assert(m.sent.back().text == "newer"); m.ack();
|
|
assert(client.extra.room.sync_since == 1100);
|
|
const auto count = m.sent.size(); m.tick(); assert(m.sent.size() == count);
|
|
}
|
|
{
|
|
MyMesh m; m.topic("first"); m.tick(6000);
|
|
const uint32_t old_revision = m.room_topic_revision;
|
|
const uint32_t old_ack = m.acl.clients[0].extra.room.pending_ack;
|
|
m.topic("second");
|
|
assert(m.processAck(reinterpret_cast<const uint8_t*>(&old_ack)));
|
|
assert(m.acl.clients[0].extra.room.topic_seen_revision == old_revision);
|
|
assert(m.acl.clients[0].extra.room.sync_since == 0);
|
|
m.tick(6000); assert(m.sent.back().text == "second"); m.ack();
|
|
}
|
|
{
|
|
MyMesh m; m.topic("lost"); m.post(0, 1100, "chat"); m.clock.now = 1200;
|
|
m.tick(6000);
|
|
for (int attempt = 0; attempt < 3; ++attempt) {
|
|
auto& c = m.acl.clients[0];
|
|
ticks = uint32_t(c.extra.room.ack_timeout); m.tick();
|
|
}
|
|
assert(m.sent.size() == 4 && m.sent.back().text == "chat");
|
|
assert(m.acl.clients[0].extra.room.push_failures == 0);
|
|
m.ack(); assert(m.acl.clients[0].extra.room.sync_since == 1100);
|
|
assert(m.acl.clients[0].extra.room.topic_failures == 3);
|
|
m.topic("changed"); m.tick(6000); assert(m.sent.back().text == "changed");
|
|
}
|
|
{
|
|
MyMesh m; m.topic("topic"); m.clock.now = 1003;
|
|
m.post(0, 1000, "unaged"); m.post_ready_mask &= ~UINT32_C(1);
|
|
m.post_ready_at[0] = futureMillis(10000); m.tick(6000);
|
|
assert(m.sent.empty()); // Unaged older posts still protect app forceSince.
|
|
m.tick(4000); assert(m.sent.back().text == "unaged");
|
|
m.ack(); m.tick(); assert(m.sent.back().text == "topic");
|
|
}
|
|
for (bool flood : {false, true}) for (bool pool : {false, true}) {
|
|
MyMesh m; m.topic("transport");
|
|
m.acl.clients[0].out_path_len = flood ? OUT_PATH_UNKNOWN : 0;
|
|
m.pool_available = pool; m.queue_available = !pool;
|
|
m.tick(6000);
|
|
assert(m.sent.empty() && m.acl.clients[0].extra.room.pending_ack == 0);
|
|
assert(m.acl.clients[0].extra.room.pending_topic_revision == 0);
|
|
assert(m.acl.clients[0].extra.room.topic_seen_revision == 0);
|
|
m.pool_available = m.queue_available = true; m.tick(); m.ack();
|
|
assert(m.acl.clients[0].extra.room.topic_seen_revision == m.room_topic_revision);
|
|
}
|
|
{
|
|
MyMesh m; const std::string maximum(151, 'x'); m.topic(maximum.c_str());
|
|
char guarded[164]; memset(guarded, 0xa5, sizeof(guarded));
|
|
assert(m.handleRoomTopicCommand("get topic", guarded + 3));
|
|
assert(std::string(guarded + 3) == "> " + maximum);
|
|
assert(uint8_t(guarded[0]) == 0xa5 && uint8_t(guarded[160]) == 0xa5);
|
|
m.tick(6000); assert(m.sent.back().text == maximum); m.ack();
|
|
char reply[160] = {};
|
|
std::string oversized = "set topic " + maximum + "x";
|
|
assert(m.handleRoomTopicCommand(oversized.c_str(), reply));
|
|
assert(strncmp(reply, "Err", 3) == 0 && m.room_topic == maximum);
|
|
m.store.fail = true;
|
|
assert(m.handleRoomTopicCommand("set topic replacement", reply));
|
|
assert(strncmp(reply, "Err", 3) == 0 && m.room_topic == maximum);
|
|
m.store.fail = false; m.topic("");
|
|
const auto count = m.sent.size(); m.tick(6000); assert(m.sent.size() == count);
|
|
assert(m.store.text.empty());
|
|
assert(!m.handleRoomTopicCommand("set topic-other text", reply));
|
|
assert(!m.handleRoomTopicCommand("get topic-other", reply));
|
|
}
|
|
{
|
|
MyMesh m; m.acl.count = 0; m.next_client_idx = 4; m.tick();
|
|
m.acl.count = 1; m.acl.clients[0].last_activity = 0;
|
|
m.topic("inactive"); m.tick(6000); assert(m.sent.empty());
|
|
m.acl.clients[0].last_activity = 1000; m.tick(); m.ack();
|
|
assert(m.next_client_idx == 0);
|
|
}
|
|
{
|
|
MyMesh m; m.clock.now = 2000; m.topic("corrected clock");
|
|
m.clock.setCurrentTime(1000); m.clock.resetUniqueTime(1000);
|
|
m.post(0, 900, "older"); m.post(1, 1100, "newer");
|
|
m.tick(6000); assert(m.room_topic_timestamp == 1000);
|
|
assert(m.sent.back().text == "older"); m.ack();
|
|
m.tick(); assert(m.sent.back().text == "corrected clock");
|
|
assert(m.sent.back().timestamp == 1000); m.ack();
|
|
m.acl.clients[0].extra.room.sync_since = m.room_topic_timestamp;
|
|
m.clock.now = 1200; m.tick(); assert(m.sent.back().text == "newer"); m.ack();
|
|
}
|
|
{
|
|
// Maturity is latched without subscribers, across millis wrap and half-range.
|
|
ticks = UINT32_MAX - 3000;
|
|
MyMesh m; m.topic("long uptime"); m.acl.count = 0;
|
|
m.tick(6000); assert(m.room_topic_ready && m.sent.empty());
|
|
m.tick(UINT32_C(0x80000000));
|
|
m.acl.count = 1; m.tick();
|
|
assert(m.sent.back().text == "long uptime"); m.ack();
|
|
m.acl.clients[0].extra.room.topic_seen_revision = 0;
|
|
m.tick(UINT32_C(0x80000000));
|
|
assert(m.sent.size() == 2); m.ack();
|
|
}
|
|
{
|
|
MyMesh m; m.topic("banned topic"); m.post(0, 900, "banned post");
|
|
assert(m.room_access.addBan(&m.policy_fs, m.acl.clients[0].id.pub_key) == mesh::RoomAccessPolicy::BanResult::Saved);
|
|
m.tick(6000); assert(m.sent.empty());
|
|
assert(m.acl.clients[0].extra.room.pending_ack == 0);
|
|
assert(m.room_access.removeBan(&m.policy_fs, m.acl.clients[0].id.pub_key) == mesh::RoomAccessPolicy::BanResult::Saved);
|
|
m.tick(); assert(m.sent.back().text == "banned post"); m.ack();
|
|
m.tick(); assert(m.sent.back().text == "banned topic"); m.ack();
|
|
}
|
|
{
|
|
// A banned first slot cannot swallow an ACK for a later allowed identity.
|
|
MyMesh m; m.acl.count = 2;
|
|
auto& banned = m.acl.clients[0]; auto& allowed = m.acl.clients[1];
|
|
allowed.id.pub_key[0] = 2;
|
|
banned.extra.room.pending_ack = allowed.extra.room.pending_ack = 0x12345678;
|
|
banned.extra.room.push_post_timestamp = 200;
|
|
allowed.extra.room.push_post_timestamp = 300;
|
|
assert(m.room_access.addBan(&m.policy_fs, banned.id.pub_key) == mesh::RoomAccessPolicy::BanResult::Saved);
|
|
const auto ban_before = banned;
|
|
const uint32_t ack = 0x12345678;
|
|
assert(m.processAck(reinterpret_cast<const uint8_t*>(&ack)));
|
|
assert(memcmp(&ban_before, &banned, sizeof(banned)) == 0);
|
|
assert(allowed.extra.room.pending_ack == 0 && allowed.extra.room.sync_since == 300);
|
|
}
|
|
{
|
|
MyMesh m; auto& client = m.acl.clients[0];
|
|
client.out_path_len = client.alt_path_len = 1;
|
|
client.out_path[0] = 0xA1; client.alt_path[0] = 0xB2;
|
|
m.topic("two-route topic"); m.post(0, 900, "two-route post");
|
|
m.tick(6000);
|
|
assert(m.sent.size() == 2 && m.sent[0].text == "two-route post");
|
|
assert(m.sent[1].text == m.sent[0].text && m.sent[0].timestamp == m.sent[1].timestamp);
|
|
assert(m.sent[0].path[0] == 0xA1 && m.sent[1].path[0] == 0xB2);
|
|
assert(m.sent[0].retry_enabled && !m.sent[1].retry_enabled);
|
|
assert(m._prefs.direct_retry_enabled == 1 && m._num_post_pushes == 1);
|
|
assert(client.extra.room.ack_timeout == futureMillis(PUSH_TIMEOUT_BASE + 2 * PUSH_ACK_TIMEOUT_FACTOR));
|
|
const uint32_t post_ack = client.extra.room.pending_ack;
|
|
m.ack(); assert(client.extra.room.sync_since == 900);
|
|
assert(!m.processAck(reinterpret_cast<const uint8_t*>(&post_ack)));
|
|
m.tick(); assert(m.sent.size() == 4 && m.sent[2].text == "two-route topic");
|
|
assert(m.sent[3].text == m.sent[2].text && m.sent[2].timestamp == m.sent[3].timestamp);
|
|
assert(m._num_post_pushes == 2 && client.extra.room.sync_since == 900);
|
|
m.ack(); assert(client.extra.room.sync_since == 900);
|
|
assert(client.extra.room.topic_seen_revision == m.room_topic_revision);
|
|
}
|
|
for (bool alternate_available : {false, true}) {
|
|
MyMesh m; auto& client = m.acl.clients[0];
|
|
client.out_path_len = client.alt_path_len = 1;
|
|
client.out_path[0] = 0xA1; client.alt_path[0] = 0xB2;
|
|
m.alternate_available = alternate_available;
|
|
m.queue_available = !alternate_available;
|
|
m.topic("admission"); m.tick(6000);
|
|
if (alternate_available) {
|
|
assert(m.sent.empty() && m.releases == 1 && m._num_post_pushes == 0);
|
|
assert(client.extra.room.pending_ack == 0);
|
|
} else {
|
|
assert(m.sent.size() == 1 && m.sent[0].retry_enabled && m._num_post_pushes == 1);
|
|
m.ack();
|
|
}
|
|
assert(m._prefs.direct_retry_enabled == 1);
|
|
}
|
|
puts("room topic delivery regressions passed");
|
|
}
|