fix(room): preserve ACL roles and persist room topics

Allow unknown blank-password guests only when read-only access is enabled,
disable empty configured credentials, and preserve explicit ACL roles on
reconnect unless a valid admin password selects Admin.

Save room topics through verified, recoverable filesystem transactions. Deliver
topics after older unread posts with independent ACK state and bounded retries,
including clock correction and long-uptime timer handling.

Exercise production handlers and pinned unmodified app JavaScript in CI.
Document topic commands and login policy. Native tests (1829), fault-injection
and compatibility suites, Pico W, V4 Full, G2 Full, and RAK4631 Full/Reduced
build qualification passed. Hardware behavior remains untested for this change.
This commit is contained in:
mikecarper committed 2026-10-09 22:31:35 -07:00
1 parent 7bdd9adec1
commit 6e4b501dd6
20 files changed
+2404 -78

No files matched your search

@@ -89,7 +89,10 @@ struct ClientInfo {
uint8_t permissions = 3;
uint8_t out_path_len = 0, out_path[MAX_PATH_SIZE] = {};
uint32_t last_timestamp = 0, last_activity = 0;
struct { struct { uint32_t sync_since = 0, pending_ack = 0; uint8_t push_failures = 0; } room; } extra;
struct { struct {
uint32_t sync_since = 0, pending_ack = 0, pending_topic_revision = 0;
uint8_t push_failures = 0;
} room; } extra;
bool isAdmin() const { return (permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_ADMIN; }
};
struct ACL {
@@ -0,0 +1,173 @@
// SHA-pinned stock app room-message and login witness; no app parser copies.
// Reuse only the existing adapter's Dart runtime/scheduler setup. The methods
// below are extracted unchanged from the pinned app and run inside its VM.
"use strict";
const assert = require("assert/strict");
const fs = require("fs");
const path = require("path");
const vm = require("vm");
const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const runtimeFile = path.join(__dirname, "../official_app_compatibility/app_contract.js");
const runtime = fs.readFileSync(runtimeFile, "utf8");
const boundary = runtime.indexOf("\nfunction connection() {");
assert(boundary > 0, "Existing app VM runtime boundary changed; review the adapter");
const moduleBoundary = {exports: {}};
vm.runInNewContext(runtime.slice(0, boundary) +
"\nmodule.exports = {A, B, J, t, $, type, context, extract, constructor};", {
require, process, module: moduleBoundary, console, Uint8Array, DataView,
ArrayBuffer, Math, Date, Promise, queueMicrotask
});
const {A, B, J, t, $, type, context, extract, constructor} = moduleBoundary.exports;
type.c = type;
type.b = value => value instanceof Uint8Array || Boolean(value && value.then);
B.j.cg = (bytes, start, end) => bytes.slice(start, end);
B.O = {aA: text => new TextEncoder().encode(text)};
// UTF-8 is a Dart standard-library boundary, not the room protocol decoder.
B.a1 = {Mf: (_, bytes) => new TextDecoder("utf-8").decode(bytes)};
B.a6 = B.W = {a: false};
$.lw = $.c5 = () => {};
$.a4 = null;
A.au = value => Promise.resolve(value);
A.aN = bytes => Buffer.from(bytes).toString("hex");
A.jV = (items, predicate) => items.find(item => predicate.$1(item)) || null;
// SQLite's map and insert boundaries retain values produced by real app code.
A.z = () => ({fields: {}, l(_, key, value) { this.fields[key] = value.a; }});
context.Date = {now: () => 1900000000000};
for (const name of ["VU", "Nu", "be", "ax", "VW", "bZ7", "aS1", "aS2", "aS3", "aS4"])
constructor(name);
for (const name of ["VW", "bZ7", "aS1", "aS2", "aS3", "aS4"])
vm.runInContext(extract(`A.${name}.prototype={`), context);
Object.assign(A, vm.runInContext(`({${extract("czU(a,b,c,d,e,f,g,h,i,j,k,l,m,n){")}})`, context));
const methods = vm.runInContext(`({${extract("\nbA0(a,b,c){")},${extract("\nbCv(a,b,c){")}})`, context);
function connection() {
const client = new A.wc(), listeners = new Map();
client.a = {
cu(_, key, factory) { if (!listeners.has(key)) listeners.set(key, factory.$0()); },
i: (_, key) => listeners.get(key), Z: (_, key) => listeners.has(key),
ey: (_, fn) => { for (const [key, value] of listeners) fn.$2(key, value); }
};
for (const name of ["eY", "N", "bCk", "bu", "dT", "W7"])
client[name] = A.b0V.prototype[name];
client.writes = [];
client.d6 = async bytes => { client.writes.push(Buffer.from(bytes)); };
return client;
}
async function settle() { for (let i = 0; i < 30; ++i) await Promise.resolve(); }
const serverKey = Uint8Array.from({length: 32}, (_, i) => i === 0 ? 77 : 0);
const ownerKey = Uint8Array.from({length: 32}, (_, i) => i === 0 ? 1 : 0);
async function decode(frame) {
const client = connection(), decoded = [];
client.eY(0, 7, {$1: value => decoded.push(value)}, t.z);
client.aL9(Buffer.from(frame, "hex"));
await settle();
assert.equal(decoded.length, 1, "Actual app must emit one contact-message response");
return decoded[0];
}
async function persist(message) {
const rows = [], room = {b: serverKey, c: 3};
const database = {
gFh: () => null,
IO: () => ({GL: async (_, row) => { rows.push(row.eI(null).fields); return rows.length; }}),
abv: async () => false,
abP(owner, contact, decoded) { return methods.bA0.call(this, owner, contact, decoded); },
a33: async () => null
};
$.e_ = {d: ownerKey}; $.aj = database;
// This is the stock app's ordinary/backlog message intake, with widget
// notifications disabled and its SQL duplicate-query boundary mocked.
await methods.bCv.call({k2: [room]}, message, false, false);
return rows;
}
async function buildLogins() {
const result = [];
for (const password of ["", "admin", "legacy"]) {
const client = connection();
client.WM(serverKey, password).catch(() => {}); await settle();
assert.equal(client.writes.length, 1);
result.push({password, command: client.writes[0].toString("hex")});
}
return result;
}
async function checkPosts() {
let count = 0;
const ordering = [];
for (const record of input.posts) {
const message = await decode(record.frame);
assert.equal(message.c, 2, "Topic must retain the standard signed-plain type");
assert.equal(message.d, record.timestamp);
assert.equal(message.e, Buffer.from(record.text, "hex").toString("utf8"));
assert.equal(Buffer.from(message.f).toString("hex"), record.author);
assert.equal(Buffer.from(message.a).toString("hex"), Buffer.from(serverKey).subarray(0, 6).toString("hex"));
assert.equal(message.b, record.flood ? 2 : -1);
assert.equal(message.r, record.version >= 3 ? 10.75 : null);
const rows = await persist(message); assert.equal(rows.length, 1);
assert.equal(rows[0].sender_timestamp, record.timestamp);
assert.equal(rows[0].text, message.e);
assert.equal(rows[0].room_post_author_pub_key_prefix, record.author);
assert.equal(rows[0].from, Buffer.from(serverKey).toString("hex"));
assert.equal(rows[0].to, Buffer.from(ownerKey).toString("hex"));
assert.equal(rows[0].txt_type, 2);
assert.equal(rows[0].status, "received");
assert.equal(rows[0].timestamp, 1900000000000);
if (record.group === "ordered") ordering.push(rows[0].sender_timestamp);
if (record.group === "maximum" || record.group === "utf8")
assert.equal(Buffer.byteLength(rows[0].text, "utf8"), 151);
++count;
}
assert.equal(ordering.length, 4);
for (let i = 1; i < ordering.length; ++i) assert(ordering[i] > ordering[i - 1]);
const sample = input.posts.find(record => record.group === "maximum" && record.version === 3);
const wrongType = Buffer.from(sample.frame, "hex"); wrongType[11] = 0;
const plain = await decode(wrongType.toString("hex"));
assert.equal(plain.c, 0); assert.equal(plain.f, null);
assert.notEqual(plain.e, Buffer.from(sample.text, "hex").toString("utf8"));
const wrongServer = Buffer.from(sample.frame, "hex"); wrongServer[4] ^= 0x80;
assert.equal((await persist(await decode(wrongServer.toString("hex")))).length, 0);
const shortSigned = Buffer.from(sample.frame, "hex").subarray(0, 19);
const client = connection(); assert.throws(() => client.aL9(shortSigned));
return {post_cases: count, ordered_timestamps: ordering, negative_controls: 3};
}
async function checkLogins() {
let count = 0;
for (const record of input.logins) {
const client = connection(); let completed = false;
const pending = client.WM(serverKey, record.password).then(value => { completed = true; return value; });
await settle(); assert.equal(client.writes[0].toString("hex"), record.command);
client.aL9(Buffer.from(record.sent, "hex")); await settle();
const unrelated = connection(); let unrelatedCompleted = false;
unrelated.WM(serverKey, record.password).then(() => { unrelatedCompleted = true; }).catch(() => {});
await settle(); unrelated.aL9(Buffer.from(record.sent, "hex")); await settle();
const wrongServer = Buffer.from(record.frame, "hex"); wrongServer[2] ^= 0x80;
// WM registers a one-shot success listener. A mismatched event consumes
// it before the actual callback ignores the server prefix; use a separate
// request so that stock behavior does not contaminate the valid case.
unrelated.aL9(wrongServer); await settle(); assert.equal(unrelatedCompleted, false);
assert.equal(completed, false);
client.aL9(Buffer.from(record.frame, "hex")); await settle();
assert.equal(completed, true, `Actual app login must complete: ${record.password || "guest"}`);
const login = await pending;
assert.equal(login.a, record.admin);
assert.equal(Buffer.from(login.b).toString("hex"), Buffer.from(serverKey).subarray(0, 6).toString("hex"));
assert.equal(login.c, record.timestamp);
assert.equal(login.d, record.permissions);
assert.equal(login.e, record.firmware_level);
++count;
}
assert.equal(count, 3);
return {login_cases: count, guest_and_admin_distinct: true, wrong_server_ignored: true};
}
async function main() {
if (input.mode === "build_logins") return console.log(JSON.stringify(await buildLogins()));
if (input.mode === "posts") return console.log(JSON.stringify(await checkPosts()));
if (input.mode === "logins") return console.log(JSON.stringify(await checkLogins()));
throw Error(`Unknown room app mode: ${input.mode}`);
}
main().catch(error => { console.error(error); process.exitCode = 1; });
@@ -0,0 +1,44 @@
// Reuse actual Companion request/response and route-admission methods from
// the existing delayed-reply fixture, with only radio login envelopes mocked.
#include "companion_login_fixture.inc"
static Bytes fromHex(const char* text) {
Bytes bytes;
for (size_t at = 0; text[at]; at += 2) {
unsigned byte; assert(text[at + 1]); assert(sscanf(text + at, "%2x", &byte) == 1);
bytes.push_back(uint8_t(byte));
}
return bytes;
}
static void hex(const Bytes& bytes) {
for (uint8_t byte : bytes) printf("%02x", byte);
}
int main(int argc, char** argv) {
assert(argc == 3);
const Bytes command = fromHex(argv[1]);
const unsigned format = unsigned(atoi(argv[2]));
assert(command.size() >= 33 && command.size() < MAX_FRAME_SIZE && format < 3);
assert(command[0] == CMD_SEND_LOGIN);
Fixture fixture;
memcpy(fixture.mesh.recipient.id.pub_key, command.data() + 1, 32);
memcpy(fixture.mesh.cmd_frame, command.data(), command.size());
fixture.mesh.handleRequestFrame(command.size());
assert(slot(fixture, Tracker::Login).phase == Tracker::AwaitRadio);
const uint32_t request_tag = slot(fixture, Tracker::Login).tag;
uint8_t data[13] = {};
const uint32_t timestamp = 0x76543210;
memcpy(data, &timestamp, 4);
if (format == 2) { data[4] = 'O'; data[5] = 'K'; }
else {
data[4] = RESP_SERVER_LOGIN_OK; data[5] = 1;
data[6] = uint8_t(format); data[7] = format ? 3 : 1; data[12] = 13;
}
fixture.mesh.onContactResponse(fixture.mesh.recipient, data, format == 2 ? 6 : 13);
fixture.drain();
assertSentBeforeFinal(fixture.wire(), Tracker::Login, request_tag);
assert(count(fixture.wire(), PUSH_CODE_LOGIN_SUCCESS) == 1);
printf("LOGIN:"); hex(frameWith(fixture.wire(), RESP_CODE_SENT)); printf(":");
hex(frameWith(fixture.wire(), PUSH_CODE_LOGIN_SUCCESS)); printf("\n");
return 0;
}
@@ -0,0 +1,83 @@
// Real room push/service methods and real Companion presentation prefix are
// supplied in generated includes. Packet encryption, transport and UI are
// boundaries; no app or firmware text-message parser is reproduced here.
#include "room_delivery_fixture.inc"
#include "helpers/CompanionFrameLimits.h"
#include "frame_codes.inc"
struct RoomContact { mesh::Identity id; };
struct CompanionPacket {
bool flood;
uint8_t path_len = 2;
float getSNR() const { return 10.75f; }
bool isRouteFlood() const { return flood; }
};
struct WirePresentation {
int app_target_ver;
uint8_t out_frame[MAX_FRAME_SIZE] = {};
std::vector<uint8_t> frame;
void queueMessage(const RoomContact&, uint8_t, CompanionPacket*, uint32_t,
const uint8_t*, int, const char*, bool, uint32_t);
};
#include "presentation.inc"
static void hex(const uint8_t* bytes, size_t size) {
for (size_t at = 0; at < size; ++at) printf("%02x", bytes[at]);
}
static void emit(MyMesh& room, const char* group, unsigned index, int version, bool flood) {
const auto& sent = room.sent.back();
if (strcmp(group, "ordered") != 0 || index == 2)
assert(memcmp(sent.author, room.self_id.pub_key, 4) == 0);
RoomContact from; from.id = room.self_id;
CompanionPacket packet{flood};
WirePresentation app{version};
app.queueMessage(from, TXT_TYPE_SIGNED_PLAIN, &packet, sent.timestamp,
sent.author, 4, sent.text.c_str(), false, 0);
assert(app.frame.size() == sent.text.size() + (version >= 3 ? 20 : 17));
printf("POST:%s:%u:%d:%d:%u:", group, index, version, int(flood), sent.timestamp);
hex(sent.author, 4); printf(":");
hex(reinterpret_cast<const uint8_t*>(sent.text.data()), sent.text.size()); printf(":");
hex(app.frame.data(), app.frame.size()); printf("\n");
}
int main() {
{
MyMesh room; room.topic("Welcome");
auto& client = room.acl.clients[0]; client.extra.room.sync_since = 50;
room.post(0, 100, "old-one"); room.post(1, 200, "old-two");
room.post(2, 1100, "newer"); room.clock.now = 1200;
room.tick(6000); assert(room.sent.back().text == "old-one");
emit(room, "ordered", 0, 3, false); room.ack();
room.tick(); assert(room.sent.back().text == "old-two");
emit(room, "ordered", 1, 3, false); room.ack();
room.tick(); assert(room.sent.back().text == "Welcome");
emit(room, "ordered", 2, 3, false);
assert(client.extra.room.sync_since == 200); room.ack();
assert(client.extra.room.sync_since == 200);
// Actual app/Companion stores the signed timestamp. Echoing it as the
// subsequent cursor cannot omit an older unread post or this newer post.
client.extra.room.sync_since = room.room_topic_timestamp;
room.tick(); assert(room.sent.back().text == "newer");
emit(room, "ordered", 3, 3, false); room.ack();
assert(client.extra.room.sync_since == 1100);
}
std::string utf8;
for (unsigned at = 0; at < 49; ++at) utf8 += "\xe2\x98\x83";
utf8 += "test"; assert(utf8.size() == 151);
for (int version : {2, 3}) for (bool flood : {false, true})
for (bool multibyte : {false, true}) {
MyMesh room;
const std::string text = multibyte ? utf8 : std::string(151, 'x');
room.acl.clients[0].out_path_len = flood ? OUT_PATH_UNKNOWN : 0;
room.topic(text.c_str()); room.tick(6000);
assert(room.sent.back().text == text);
emit(room, multibyte ? "utf8" : "maximum", 0, version, flood); room.ack();
}
{
MyMesh room;
assert(room.pushRoomTextToClient(&room.acl.clients[0], 0xffffffffU,
room.self_id, "timestamp-boundary"));
emit(room, "timestamp", 0, 3, false);
}
return 0;
}
+270
View File
@@ -0,0 +1,270 @@
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
#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;
static bool isValidPathLen(uint8_t path) { return (path & 63) <= 20 && path < 0xc0; }
};
struct Utils {
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(((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];
};
struct MyMesh {
ACL acl;
Clock clock;
Random rng;
mesh::Identity self_id;
mesh::Store store;
mesh::Store* _fs = &store;
mesh::Packet packet;
std::vector<Sent> sent;
bool pool_available = true, queue_available = true;
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] = {};
struct { uint8_t path_hash_mode = 0; } _prefs;
int default_scope = 0;
MyMesh() {
self_id.pub_key[0] = 77;
acl.clients[0].id.pub_key[0] = 1;
acl.clients[0].last_activity = 1000;
}
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);
return &packet;
}
bool queue(mesh::Packet* out) {
assert(out->radio_reply);
if (!queue_available) return false;
Sent entry;
memcpy(&entry.timestamp, reply_data, 4);
memcpy(entry.author, reply_data + 5, 4);
entry.text = std::string(reinterpret_cast<char*>(reply_data + 9));
// The production payload isn't terminated; test messages below are fixed
// lengths or the whole buffer is cleared before each send by tick().
sent.push_back(entry);
return true;
}
bool sendFloodScoped(int, mesh::Packet* out, unsigned long, uint8_t) { return queue(out); }
bool sendDirect(mesh::Packet* out, const uint8_t*, uint8_t) { return queue(out); }
void replaceActiveMessageRetries(mesh::Packet*, const uint8_t*, uint32_t) {}
bool pushPostToClient(ClientInfo*, PostInfo&);
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.tick(6000);
assert(m.sent.empty()); // Unaged older posts still protect app forceSince.
m.clock.now = 1007; m.tick(); 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();
}
puts("room topic delivery regressions passed");
}
+10
View File
@@ -104,6 +104,8 @@ static_assert(sizeof(((ClientInfo*)0)->extra) == 4, "only persisted sync field r
#else
static_assert(sizeof(((ClientInfo*)0)->extra.sensor.min_deltas) == 14, "sensor state retained");
static_assert(sizeof(((ClientInfo*)0)->extra.room.pending_ack) == 4, "room state retained");
static_assert(sizeof(((ClientInfo*)0)->extra.room) <= sizeof(((ClientInfo*)0)->extra.sensor),
"topic delivery must fit the existing shared union");
#endif
int main() {
CHECK(full_fixture_main() == 0); // replay, corruption, OOM and rollback coverage
@@ -121,6 +123,9 @@ int main() {
c->alt_path_len = 1; c->alt_path[0] = uint8_t(i + 5);
#if !MESH_CLIENT_REPEATER_ONLY
c->extra.room.pending_ack = 99; c->extra.room.push_failures = 3;
c->extra.room.topic_seen_revision = 7;
c->extra.room.pending_topic_revision = 8;
c->extra.room.topic_failures = 2;
#endif
}
CHECK(acl.save(&fs));
@@ -133,6 +138,11 @@ int main() {
CHECK(c->out_path_len == 1 && c->out_path[0] == i + 3);
CHECK(c->alt_path_len == 1 && c->alt_path[0] == i + 5);
CHECK(c->isAdmin());
#if !MESH_CLIENT_REPEATER_ONLY
CHECK(c->extra.room.topic_seen_revision == 0);
CHECK(c->extra.room.pending_topic_revision == 0);
CHECK(c->extra.room.topic_failures == 0);
#endif
}
std::printf("SIZE:%zu\nCONTACTS:", sizeof(ClientInfo));
for (auto byte : fs.files["/s_contacts"]) std::printf("%02x", unsigned(byte));
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Run pinned stock JavaScript room handlers against real firmware output.
This witnesses the pinned public web app, not the installed phone UI. The
production room queue and Companion framing/login code are extracted without
edits. Dart runtime, SQLite, packet encryption and transports are boundaries.
No PlatformIO, hardware, app fork or persistent settings are involved.
"""
from pathlib import Path
import json
import re
import shutil
import tempfile
import unittest
from test_companion_delayed_reply_delivery import production_delayed_reply_inputs
from test_official_app_compatibility import checked, official_app_bundle, SANITIZERS
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
FIXTURES = ROOT / "test/fixtures/official_room_app_compatibility"
def room_outputs(work, compiler):
source = (ROOT / "examples/simple_room_server/MyMesh.cpp").read_text()
acl = (ROOT / "src/helpers/ClientACL.h").read_text()
header = (ROOT / "examples/simple_room_server/MyMesh.h").read_text()
state = "\n".join(line for line in acl.splitlines()
if line.startswith(("#define PERM_ACL_", "#define OUT_PATH_")))
state += "\n" + extract_braced(acl, "struct ClientInfo") + ";\n"
state += extract_braced(header, "struct PostInfo") + ";\n"
clock = (ROOT / "src/MeshCore.h").read_text()
state += "namespace mesh {\n" + extract_braced(clock, "class RTCClock") + ";\n}\n"
(work / "state.inc").write_text(state, encoding="ascii")
definitions = "\n".join(extract_braced(source, signature) for signature in (
"bool MyMesh::pushPostToClient(", "bool MyMesh::pushRoomTextToClient(",
"bool MyMesh::processAck(", "void MyMesh::activateRoomTopic(",
"bool MyMesh::handleRoomTopicCommand(", "void MyMesh::serviceRoomPush("))
(work / "production.inc").write_text(definitions, encoding="ascii")
fixture = (ROOT / "test/fixtures/room_topic_delivery/test.cpp").read_text()
fixture = fixture[:fixture.index("int main()")]
signed_type = next(line for line in (ROOT / "src/helpers/TxtDataHelpers.h").read_text().splitlines()
if line.startswith("#define TXT_TYPE_SIGNED_PLAIN"))
maximum = next(line for line in header.splitlines() if line.startswith("#define MAX_POST_TEXT_LEN"))
fixture = re.sub(r"^#define TXT_TYPE_SIGNED_PLAIN .*", signed_type, fixture, flags=re.M)
fixture = re.sub(r"^#define MAX_POST_TEXT_LEN .*", maximum, fixture, flags=re.M)
(work / "room_delivery_fixture.inc").write_text(fixture, encoding="ascii")
companion = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
codes = "\n".join(line for line in companion.splitlines()
if line.startswith("#define RESP_CODE_CONTACT_MSG_RECV"))
(work / "frame_codes.inc").write_text(codes, encoding="ascii")
presentation = extract_braced(companion, "void MyMesh::queueMessage(")
# The entire original framing prefix runs unchanged; the offline queue/UI
# boundary is replaced by capture of those exact bytes, not a serializer.
presentation = presentation[:presentation.index(" const char* sensitive_reply = text;")]
presentation += " (void)path_len;\n frame.assign(out_frame, out_frame + i);\n}\n"
presentation = presentation.replace("MyMesh::queueMessage", "WirePresentation::queueMessage")
presentation = presentation.replace("const ContactInfo &from", "const RoomContact &from")
presentation = presentation.replace("mesh::Packet *pkt", "CompanionPacket *pkt")
(work / "presentation.inc").write_text(presentation, encoding="ascii")
binary = work / "room-messages"
checked([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-parameter", "-Wno-missing-field-initializers", *SANITIZERS,
f"-I{work}", f"-I{ROOT / 'src'}", str(FIXTURES / "room_messages.cpp"), "-o", str(binary)])
records = []
for line in checked([str(binary)], timeout=20).splitlines():
kind, group, index, version, flood, timestamp, author, text, frame = line.split(":")
if kind != "POST":
raise AssertionError(line)
records.append({"group": group, "index": int(index), "version": int(version),
"flood": bool(int(flood)), "timestamp": int(timestamp),
"author": author, "text": text, "frame": frame})
if len(records) != 13:
raise AssertionError(records)
return records
def login_binary(work, compiler):
production_delayed_reply_inputs(work)
fixture = (ROOT / "test/fixtures/companion_delayed_reply_delivery/test.cpp").read_text()
(work / "companion_login_fixture.inc").write_text(
fixture[:fixture.index("int main()")], encoding="ascii")
binary = work / "room-login"
checked([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-parameter", "-Wno-unused-function", "-Wno-sign-compare", "-Wno-class-memaccess",
*SANITIZERS, "-DCOMPANION_FEATURE_TEXT_TERMINAL=0", "-DMESH_ENABLE_ONE_KEY_DM=0",
f"-I{work}", f"-I{ROOT / 'test/mocks'}",
f"-I{ROOT / 'test/fixtures/serial_wifi_sessions/mocks'}", f"-I{ROOT / 'src'}",
str(FIXTURES / "room_login.cpp"), *[str(ROOT / "src/helpers" / name) for name in (
"CompanionDelayedReplies.cpp", "ArduinoSerialInterface.cpp",
"wifi/SerialWifiInterface.cpp", "TxtDataHelpers.cpp")], "-o", str(binary)])
return binary
class OfficialRoomAppCompatibilityTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.node = shutil.which("node")
compiler = shutil.which("g++") or shutil.which("clang++")
if not cls.node or not compiler:
raise AssertionError("Node.js and a host C++ compiler are required for stock app room compatibility")
cls.bundle = official_app_bundle()
temporary = tempfile.TemporaryDirectory(prefix="official-room-app-")
cls.addClassCleanup(temporary.cleanup)
cls.work = Path(temporary.name)
cls.posts = room_outputs(cls.work, compiler)
cls.login = login_binary(cls.work, compiler)
def app(self, mode, **values):
config = self.work / (mode + ".json")
config.write_text(json.dumps({"bundle": str(self.bundle), "mode": mode, **values}), encoding="ascii")
return json.loads(checked([self.node, str(FIXTURES / "room_contract.js"), str(config)], timeout=20))
def test_actual_app_room_decode_storage_and_maximum_topic(self):
result = self.app("posts", posts=self.posts)
self.assertEqual(result["post_cases"], 13)
self.assertEqual(result["ordered_timestamps"], [100, 200, 1000, 1100])
self.assertEqual(result["negative_controls"], 3)
def test_actual_app_guest_admin_and_legacy_login(self):
requests = self.app("build_logins")
self.assertEqual([request["password"] for request in requests], ["", "admin", "legacy"])
replies = []
for format, request in enumerate(requests):
output = checked([str(self.login), request["command"], str(format)], timeout=20)
kind, sent, frame = output.strip().split(":")
self.assertEqual(kind, "LOGIN")
replies.append({**request, "sent": sent, "frame": frame,
"admin": 1 if format == 1 else 0,
"timestamp": None if format == 2 else 0x76543210,
"permissions": None if format == 2 else (3 if format else 1),
"firmware_level": None if format == 2 else 13})
result = self.app("logins", logins=replies)
self.assertEqual(result["login_cases"], 3)
self.assertTrue(result["guest_and_admin_distinct"])
self.assertTrue(result["wrong_server_ignored"])
if __name__ == "__main__":
unittest.main()
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""Execute the production room-login helper with the actual ACL role constants."""
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
PRELUDE = r'''
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <helpers/RoomLoginAuthorization.h>
@ROLES@
static mesh::RoomLoginAuthorization authorize(
const char* password, const char* admin, const char* guest,
bool known, uint8_t permissions, bool read_only) {
return mesh::authorizeRoomLogin(password, admin, guest, known, permissions,
read_only, PERM_ACL_ROLE_MASK, PERM_ACL_GUEST, PERM_ACL_READ_WRITE,
PERM_ACL_ADMIN);
}
'''
AUTH_MATRIX = r'''
struct Case {
const char* password;
const char* admin;
const char* guest;
bool read_only;
bool accepted;
uint8_t role;
};
int main() {
// Explicit expectations keep disabled credentials distinct from a valid
// blank-password ACL login. The same rows also detect partial/normalized
// comparisons and public-password elevation to Admin.
const Case cases[] = {
{"admin", "admin", "guest", false, true, PERM_ACL_ADMIN},
{"admin", "admin", "guest", true, true, PERM_ACL_ADMIN},
{"guest", "admin", "guest", false, true, PERM_ACL_READ_WRITE},
{"guest", "admin", "guest", true, true, PERM_ACL_READ_WRITE},
{"wrong", "admin", "guest", false, false, PERM_ACL_GUEST},
{"wrong", "admin", "guest", true, true, PERM_ACL_GUEST},
{"", "admin", "guest", false, false, PERM_ACL_GUEST},
{"", "admin", "guest", true, true, PERM_ACL_GUEST},
{nullptr, "admin", "guest", false, false, PERM_ACL_GUEST},
{nullptr, "admin", "guest", true, true, PERM_ACL_GUEST},
{"", "", "", false, false, PERM_ACL_GUEST},
{"", "", "", true, true, PERM_ACL_GUEST},
{nullptr, "", "", false, false, PERM_ACL_GUEST},
{nullptr, "", "", true, true, PERM_ACL_GUEST},
{"admin", "", "guest", false, false, PERM_ACL_GUEST},
{"admin", "", "guest", true, true, PERM_ACL_GUEST},
{"guest", "", "guest", false, true, PERM_ACL_READ_WRITE},
{"guest", nullptr, "guest", false, true, PERM_ACL_READ_WRITE},
{"admin", "admin", "", false, true, PERM_ACL_ADMIN},
{"admin", "admin", nullptr, false, true, PERM_ACL_ADMIN},
{"guest", "admin", "", false, false, PERM_ACL_GUEST},
{"guest", "admin", nullptr, true, true, PERM_ACL_GUEST},
{"anything", nullptr, nullptr, false, false, PERM_ACL_GUEST},
{"anything", nullptr, nullptr, true, true, PERM_ACL_GUEST},
{nullptr, nullptr, nullptr, false, false, PERM_ACL_GUEST},
{nullptr, nullptr, nullptr, true, true, PERM_ACL_GUEST},
{"same", "same", "same", false, true, PERM_ACL_ADMIN},
{"same", "same", "same", true, true, PERM_ACL_ADMIN},
{"admi", "admin", "guest", false, false, PERM_ACL_GUEST},
{"admin-more", "admin", "guest", false, false, PERM_ACL_GUEST},
{"ADMIN", "admin", "guest", false, false, PERM_ACL_GUEST},
{"admin ", "admin", "guest", false, false, PERM_ACL_GUEST},
{" admin", "admin", "guest", false, false, PERM_ACL_GUEST},
{"gues", "admin", "guest", false, false, PERM_ACL_GUEST},
{"guest-more", "admin", "guest", false, false, PERM_ACL_GUEST},
{"GUEST", "admin", "guest", false, false, PERM_ACL_GUEST},
{"guest ", "admin", "guest", false, false, PERM_ACL_GUEST},
{" guest", "admin", "guest", true, true, PERM_ACL_GUEST},
{" ", " ", "guest", false, true, PERM_ACL_ADMIN},
{" ", "admin", " ", false, true, PERM_ACL_READ_WRITE},
};
for (const auto& c : cases) {
// Even an irrelevant nonzero permissions argument cannot invent an ACL
// identity. This checks callers use identity presence independently.
for (const uint8_t stale : {uint8_t(0), uint8_t(3), uint8_t(255)}) {
const auto actual = authorize(c.password, c.admin, c.guest,
false, stale, c.read_only);
assert(actual.accepted == c.accepted && actual.role == c.role);
}
}
puts("120 explicit unknown-identity credential scenarios passed");
}
'''
NON_DEMOTION = r'''
int main() {
const char* passwords[] = {nullptr, "", "wrong", "guest", "old-admin"};
unsigned preserved = 0;
for (unsigned permissions = 0; permissions < 256; ++permissions) {
for (const auto* password : passwords) {
for (bool read_only : {false, true}) {
const auto actual = authorize(password, "admin", "guest", true,
permissions, read_only);
assert(actual.accepted);
assert(actual.role == (permissions & PERM_ACL_ROLE_MASK));
++preserved;
}
}
// Admin has explicit credential precedence even for roles 4/5/6/7. A
// numeric max() or >= comparison would retain the wrong delegated role.
const auto admin = authorize("admin", "admin", "guest", true,
permissions, false);
assert(admin.accepted && admin.role == PERM_ACL_ADMIN);
}
assert(preserved == 2560);
puts("2560 exact ACL-role preservation and 256 admin-upgrade scenarios passed");
}
'''
NULL_EMPTY = r'''
int main() {
const char* absent[] = {nullptr, ""};
const char* requests[] = {nullptr, "", "not-a-credential"};
unsigned disabled = 0;
for (const char* admin : absent) {
for (const char* guest : absent) {
for (const char* password : requests) {
assert(!mesh::roomLoginPasswordMatches(password, admin));
assert(!mesh::roomLoginPasswordMatches(password, guest));
const auto rejected = authorize(password, admin, guest, false, 255, false);
assert(!rejected.accepted && rejected.role == PERM_ACL_GUEST);
const auto public_reader = authorize(password, admin, guest, false, 255, true);
assert(public_reader.accepted && public_reader.role == PERM_ACL_GUEST);
for (uint8_t role = 0; role <= PERM_ACL_ROLE_MASK; ++role) {
const auto known = authorize(password, admin, guest, true, role, false);
assert(known.accepted && known.role == role);
}
++disabled;
}
}
}
assert(disabled == 12);
char request[] = "admin";
char admin[] = "admin";
char guest[] = "guest";
const auto authenticated = authorize(request, admin, guest, false, 0, false);
assert(authenticated.accepted && authenticated.role == PERM_ACL_ADMIN);
assert(!strcmp(request, "admin") && !strcmp(admin, "admin") && !strcmp(guest, "guest"));
puts("null/empty disabled credentials and read-only string access passed");
}
'''
class RoomLoginAuthorizationTests(unittest.TestCase):
def compile_and_run(self, checks, expected):
compiler = shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler)
# Keep role labels synchronized with production without pulling the
# Arduino/Mesh dependencies into this standalone authorization helper.
roles = "\n".join(line for line in (ROOT / "src/helpers/ClientACL.h").read_text().splitlines()
if line.startswith("#define PERM_ACL_"))
source = PRELUDE.replace("@ROLES@", roles) + checks
with tempfile.TemporaryDirectory(prefix="room-login-authorization-") as directory:
work = Path(directory)
cpp = work / "test.cpp"
cpp.write_text(source)
binary = work / "room-login"
command = [compiler, "-std=c++11", "-O1", "-Wall", "-Wextra", "-Werror",
"-include", "initializer_list", "-I" + str(ROOT / "src"),
str(cpp), "-o", str(binary)]
if sys.platform.startswith("linux"):
command[1:1] = ["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-omit-frame-pointer", "-fno-pie", "-no-pie"]
built = subprocess.run(command, capture_output=True, text=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
self.assertIn(expected, checked.stdout)
def test_unknown_identity_password_authorization_matrix(self):
self.compile_and_run(AUTH_MATRIX, "120 explicit unknown-identity credential scenarios passed")
def test_every_acl_role_is_preserved_without_numeric_privilege_ranking(self):
self.compile_and_run(NON_DEMOTION, "2560 exact ACL-role preservation and 256 admin-upgrade scenarios passed")
def test_null_empty_credentials_and_const_input_safety(self):
self.compile_and_run(NULL_EMPTY, "null/empty disabled credentials and read-only string access passed")
if __name__ == "__main__":
unittest.main()
+612
View File
@@ -0,0 +1,612 @@
#!/usr/bin/env python3
"""Execute the real room login/replay handlers against controlled storage faults."""
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <vector>
#include <Packet.h>
#include <helpers/RoomLoginAuthorization.h>
#include <helpers/ClientLoginPersistence.h>
#include <helpers/LazyPersistence.h>
#include <helpers/LogicalMessageCache.h>
@CONSTANTS@
#define MESH_CLIENT_REPEATER_ONLY 0
namespace mesh {
struct Identity { uint8_t pub_key[PUB_KEY_SIZE]; };
struct Utils {
static void sha256(uint8_t* out, size_t n, const uint8_t* key, size_t key_n,
const uint8_t* text, size_t text_n) {
// Posting authorization and replay are tested here, not the hash primitive.
memset(out, 0, n);
for (size_t i = 0; i < key_n; ++i) out[i % n] ^= key[i];
for (size_t i = 0; i < text_n; ++i) out[i % n] ^= text[i];
}
};
@PACKET_CONSTRUCTOR@
@PACKET_PATH_CHECK@
}
@CLIENT_INFO@;
struct ReplayStorage {
uint8_t key[PUB_KEY_SIZE] = {};
bool found = false, read_ok = true, write_ok = true;
uint32_t ceiling = 0;
unsigned reads = 0, writes = 0;
};
static bool readClientLoginReplayCeiling(
ReplayStorage* fs, const uint8_t* key, uint32_t* ceiling, bool* found) {
++fs->reads;
if (!fs->read_ok) return false;
*found = fs->found && memcmp(fs->key, key, PUB_KEY_SIZE) == 0;
*ceiling = *found ? fs->ceiling : 0;
return true;
}
static bool writeClientLoginReplayCeiling(
ReplayStorage* fs, const uint8_t* key, uint32_t ceiling,
mesh::ClientLoginReplayReservationAction action) {
++fs->writes;
assert(action != mesh::ClientLoginReplayReservationAction::None);
if (!fs->write_ok) return false;
memcpy(fs->key, key, PUB_KEY_SIZE);
fs->ceiling = ceiling;
fs->found = true;
return true;
}
struct ClientACL {
ReplayStorage storage;
ReplayStorage* _fs = &storage;
bool login_replay_store_available = true, acl_load_complete = true;
bool authorizeLoginTimestamp(const uint8_t*, uint32_t, uint32_t, uint8_t);
};
@REPLAY_HANDLER@
struct FakeACL : ClientACL {
ClientInfo client{};
bool known = false, allocate_ok = true;
unsigned lookups = 0, authorizations = 0, allocations = 0;
uint8_t authorized_role = 255;
ClientInfo* getClient(const uint8_t* key, size_t length) {
++lookups;
assert(length == PUB_KEY_SIZE);
return known && memcmp(client.id.pub_key, key, length) == 0 ? &client : nullptr;
}
bool authorizeLoginTimestamp(const uint8_t* key, uint32_t timestamp,
uint32_t floor, uint8_t role) {
++authorizations;
authorized_role = role;
return ClientACL::authorizeLoginTimestamp(key, timestamp, floor, role);
}
ClientInfo* putClient(const mesh::Identity& sender, uint8_t permissions) {
++allocations;
assert(permissions == 0 && !known);
if (!allocate_ok) return nullptr;
client = ClientInfo{};
client.id = sender;
client.permissions = permissions;
client.out_path_len = OUT_PATH_UNKNOWN;
client.out_path_is_persistable = true;
known = true;
return &client;
}
};
struct FakeClock {
uint32_t now = 1000;
unsigned reads = 0, unique_reads = 0;
uint32_t getCurrentTime() { ++reads; return now; }
uint32_t getCurrentTimeUnique() { ++unique_reads; return now + unique_reads; }
};
struct FakeRng {
unsigned calls = 0;
void random(uint8_t* out, size_t length) {
++calls;
memset(out, 0xB5, length);
}
};
struct MyMesh {
FakeACL acl;
struct Prefs {
char password[32] = "admin";
char guest_password[32] = "guest";
bool allow_read_only = false;
} _prefs;
FakeClock clock;
FakeRng rng;
unsigned long millis_now = 20000, dirty_contacts_expiry = 0, next_push = 0;
uint8_t contacts_save_failures = 0, reply_data[32] = {};
unsigned creations = 0, direct_sends = 0, flood_sends = 0, path_returns = 0;
bool create_ok = true;
mesh::Packet response;
uint8_t response_secret[PUB_KEY_SIZE] = {}, response_key[PUB_KEY_SIZE] = {};
uint8_t returned_path[MAX_PATH_SIZE] = {}, direct_path[MAX_PATH_SIZE] = {};
uint8_t returned_path_len = 0, direct_path_len = 0, reply_hash_size = 0;
uint8_t captured_reply[32] = {};
size_t captured_reply_length = 0;
mesh::LogicalMessageCache<ROOM_MESSAGE_CACHE_SIZE> recent_room_posts;
unsigned posts = 0, post_acks = 0;
FakeClock* getRTCClock() { return &clock; }
FakeRng* getRNG() { return &rng; }
unsigned long futureMillis(unsigned long delay) { return millis_now + delay; }
mesh::Packet* createDatagram(uint8_t type, const mesh::Identity& sender,
const uint8_t* secret, const uint8_t* data, size_t len) {
++creations;
assert(type == PAYLOAD_TYPE_RESPONSE && len == 13);
memcpy(response_key, sender.pub_key, PUB_KEY_SIZE);
memcpy(response_secret, secret, PUB_KEY_SIZE);
memcpy(captured_reply, data, len);
captured_reply_length = len;
return create_ok ? &response : nullptr;
}
mesh::Packet* createPathReturn(const mesh::Identity& sender, const uint8_t* secret,
const uint8_t* path, uint8_t path_len, uint8_t type, const uint8_t* data, size_t len) {
++path_returns;
returned_path_len = path_len;
memcpy(returned_path, path, (path_len & 63) * ((path_len >> 6) + 1));
return createDatagram(type, sender, secret, data, len);
}
void sendDirect(mesh::Packet* packet, const uint8_t* path, uint8_t path_len,
unsigned long delay) {
assert(packet == &response && delay == SERVER_RESPONSE_DELAY);
assert(mesh::Packet::isValidPathLen(path_len));
++direct_sends;
direct_path_len = path_len;
memcpy(direct_path, path, (path_len & 63) * ((path_len >> 6) + 1));
}
void sendFloodReply(mesh::Packet* packet, unsigned long delay, uint8_t hash_size) {
assert(packet == &response && delay == SERVER_RESPONSE_DELAY);
++flood_sends;
reply_hash_size = hash_size;
}
void addPost(ClientInfo*, const char*) { ++posts; }
void post(ClientInfo* client, const char* text, uint32_t sender_timestamp) {
const uint8_t flags = TXT_TYPE_PLAIN;
const size_t text_len = strlen(text);
bool send_ack = false;
@POST_GATE@
if (send_ack) ++post_acks;
}
void onAnonDataRecv(mesh::Packet*, const uint8_t*, const mesh::Identity&,
uint8_t*, size_t);
};
@LOGIN_HANDLER@
static mesh::Identity identity(uint8_t n = 1) {
mesh::Identity sender{};
memset(sender.pub_key, n, sizeof(sender.pub_key));
return sender;
}
static void seedKnown(MyMesh& mesh, uint8_t permissions = PERM_ACL_ADMIN) {
auto& client = mesh.acl.client;
// Initialize padding too, so byte-for-byte rejected-session checks are stable.
memset(&client, 0, sizeof(client));
client.id = identity();
client.permissions = permissions;
client.last_timestamp = 99;
client.last_activity = 777;
client.out_path_len = 2;
client.out_path[0] = 0xA1;
client.out_path[1] = 0xB2;
client.out_path_is_persistable = false;
memset(client.shared_secret, 0x44, sizeof(client.shared_secret));
client.extra.room.sync_since = 70;
client.extra.room.last_post_timestamp = 800;
client.extra.room.pending_ack = 0x12345678;
client.extra.room.push_failures = 2;
client.extra.room.topic_seen_revision = 11;
client.extra.room.pending_topic_revision = 12;
client.extra.room.topic_failures = 2;
mesh.acl.known = true;
}
static mesh::Packet incoming(bool flood = false) {
mesh::Packet packet;
packet.header = (PAYLOAD_TYPE_ANON_REQ << PH_TYPE_SHIFT)
| (flood ? ROUTE_TYPE_FLOOD : ROUTE_TYPE_DIRECT);
packet.path_len = 0x42; // Two 2-byte hashes, including non-default hash size.
packet.path[0] = 0x10;
packet.path[1] = 0x20;
packet.path[2] = 0x30;
packet.path[3] = 0x40;
return packet;
}
static void login(MyMesh& mesh, const char* password = "guest", uint32_t timestamp = 100,
uint32_t sync_since = 80, bool flood = false) {
// The real decrypt dispatcher provides one writable terminator byte.
const size_t password_len = strlen(password);
std::vector<uint8_t> data(8 + password_len + 1, 0xEE);
memcpy(data.data(), &timestamp, 4);
memcpy(data.data() + 4, &sync_since, 4);
memcpy(data.data() + 8, password, password_len);
uint8_t secret[PUB_KEY_SIZE];
memset(secret, 0x77, sizeof(secret));
auto packet = incoming(flood);
mesh.onAnonDataRecv(&packet, secret, identity(), data.data(), data.size() - 1);
assert(data.back() == 0);
}
static void checkReply(const MyMesh& mesh, uint8_t permissions) {
assert(mesh.creations == 1 && mesh.captured_reply_length == 13);
uint32_t timestamp;
memcpy(&timestamp, mesh.captured_reply, sizeof(timestamp));
assert(timestamp == mesh.clock.now + 1);
assert(mesh.captured_reply[4] == RESP_SERVER_LOGIN_OK);
assert(mesh.captured_reply[5] == 0);
const uint8_t role = permissions & PERM_ACL_ROLE_MASK;
assert(mesh.captured_reply[6] == (role == PERM_ACL_ADMIN ? 1 : role == PERM_ACL_GUEST ? 2 : 0));
assert(mesh.captured_reply[7] == permissions);
assert(mesh.captured_reply[12] == FIRMWARE_VER_LEVEL);
for (unsigned i = 0; i < 4; ++i) assert(mesh.captured_reply[8 + i] == 0xB5);
for (unsigned i = 0; i < PUB_KEY_SIZE; ++i) {
assert(mesh.response_key[i] == 1 && mesh.response_secret[i] == 0x77);
}
}
static void checkRejectedSession(const MyMesh& mesh, const ClientInfo& before) {
assert(memcmp(&mesh.acl.client, &before, sizeof(before)) == 0);
assert(mesh.creations == 0 && mesh.direct_sends == 0 && mesh.flood_sends == 0);
assert(mesh.clock.reads == 0 && mesh.clock.unique_reads == 0 && mesh.rng.calls == 0);
assert(mesh.next_push == 0 && mesh.dirty_contacts_expiry == 0);
}
static void authorizationCases() {
struct Case { const char* password; bool reader; bool accepted; uint8_t role; };
const Case cases[] = {
{"", false, false, PERM_ACL_GUEST}, {"", true, true, PERM_ACL_GUEST},
{"wrong", false, false, PERM_ACL_GUEST}, {"wrong", true, true, PERM_ACL_GUEST},
{"guest", false, true, PERM_ACL_READ_WRITE}, {"guest", true, true, PERM_ACL_READ_WRITE},
{"admin", false, true, PERM_ACL_ADMIN}, {"admin", true, true, PERM_ACL_ADMIN},
};
for (const auto& c : cases) {
MyMesh mesh;
mesh._prefs.allow_read_only = c.reader;
login(mesh, c.password);
assert(mesh.acl.known == c.accepted);
assert(mesh.acl.authorizations == (c.accepted ? 1U : 0U));
assert(mesh.acl.allocations == (c.accepted ? 1U : 0U));
assert(mesh.acl.storage.reads == (c.accepted ? 1U : 0U));
assert(mesh.acl.storage.writes == (c.accepted && c.role != PERM_ACL_GUEST ? 1U : 0U));
if (c.accepted) {
assert(mesh.acl.client.permissions == c.role);
checkReply(mesh, c.role);
} else {
assert(!mesh.acl.storage.found && mesh.acl.storage.ceiling == 0);
assert(mesh.creations == 0 && mesh.dirty_contacts_expiry == 0);
}
}
for (bool reader : {false, true}) {
MyMesh mesh;
mesh._prefs.password[0] = mesh._prefs.guest_password[0] = 0;
mesh._prefs.allow_read_only = reader;
login(mesh, "");
assert(mesh.acl.known == reader);
if (reader) {
assert(mesh.acl.client.permissions == PERM_ACL_GUEST);
assert(mesh.acl.storage.writes == 0);
checkReply(mesh, PERM_ACL_GUEST);
} else assert(mesh.acl.authorizations == 0);
}
// Parser validation occurs before lookup, replay reservation, or allocation.
for (size_t length = 0; length < 8; ++length) {
MyMesh mesh;
auto packet = incoming();
std::vector<uint8_t> data(length + 1, 0xEE);
uint8_t secret[PUB_KEY_SIZE] = {};
mesh.onAnonDataRecv(&packet, secret, identity(), data.data(), length);
assert(data.back() == 0xEE && mesh.acl.lookups == 0);
assert(mesh.acl.authorizations == 0 && mesh.acl.allocations == 0);
}
MyMesh other_payload;
auto packet = incoming();
packet.header = (PAYLOAD_TYPE_RESPONSE << PH_TYPE_SHIFT) | ROUTE_TYPE_DIRECT;
uint8_t data[9] = {}, secret[PUB_KEY_SIZE] = {};
other_payload.onAnonDataRecv(&packet, secret, identity(), data, 8);
assert(other_payload.acl.lookups == 0 && other_payload.acl.authorizations == 0);
puts("real-handler credentials, disabled passwords, and minimum payload cases passed");
}
static void preservedRoles() {
const char* passwords[] = {"guest", "wrong", "", "old-admin"};
unsigned preserved = 0;
for (unsigned permissions = 0; permissions < 256; ++permissions) {
for (const auto* password : passwords) {
for (bool reader : {false, true}) {
MyMesh mesh;
seedKnown(mesh, permissions);
mesh._prefs.allow_read_only = reader;
login(mesh, password);
assert(mesh.acl.client.permissions == permissions);
assert(mesh.acl.authorized_role == (permissions & PERM_ACL_ROLE_MASK));
assert(mesh.acl.allocations == 0 && mesh.acl.client.last_timestamp == 100);
checkReply(mesh, permissions);
// Repeat the exact accepted packet with a different public password.
const ClientInfo before = mesh.acl.client;
mesh.creations = mesh.direct_sends = mesh.flood_sends = 0;
mesh.clock.reads = mesh.clock.unique_reads = mesh.rng.calls = 0;
mesh.next_push = mesh.dirty_contacts_expiry = 0;
const unsigned writes_before = mesh.acl.storage.writes;
login(mesh, "guest", 100, 900, true);
checkRejectedSession(mesh, before);
assert(mesh.acl.storage.writes == writes_before);
++preserved;
}
}
// A valid explicit Admin credential replaces delegated labels 4/5, too.
MyMesh mesh;
seedKnown(mesh, permissions);
login(mesh, "admin");
const uint8_t expected = (permissions & ~PERM_ACL_ROLE_MASK) | PERM_ACL_ADMIN;
assert(mesh.acl.client.permissions == expected);
checkReply(mesh, expected);
}
assert(preserved == 2048);
puts("2048 real-handler preserved roles and replay rejects plus 256 admin upgrades passed");
}
static void storageFailures() {
for (bool known : {false, true}) {
for (unsigned fault = 0; fault < 5; ++fault) {
MyMesh mesh;
if (known) seedKnown(mesh, PERM_ACL_FILTER_MGR | 0x80);
const ClientInfo before = mesh.acl.client;
switch (fault) {
case 0: mesh.acl._fs = nullptr; break;
case 1: mesh.acl.acl_load_complete = false; break;
case 2: mesh.acl.login_replay_store_available = false; break;
case 3: mesh.acl.storage.read_ok = false; break;
case 4: mesh.acl.storage.write_ok = false; break;
}
login(mesh, known ? "guest" : "admin");
checkRejectedSession(mesh, before);
assert(mesh.acl.known == known && mesh.acl.allocations == 0);
assert(mesh.acl.authorizations == 1);
assert(!mesh.acl.storage.found && mesh.acl.storage.ceiling == 0);
assert(mesh.acl.storage.writes == (fault == 4 ? 1U : 0U));
}
}
// Denial from disabled/incorrect credentials never reaches replay storage.
for (bool write_ok : {false, true}) {
MyMesh mesh;
mesh.acl.storage.write_ok = write_ok;
login(mesh, "wrong");
assert(mesh.acl.authorizations == 0 && mesh.acl.allocations == 0);
assert(mesh.acl.storage.reads == 0 && mesh.acl.storage.writes == 0);
}
// A historical privileged tombstone is still enforced on a public reader.
MyMesh tombstone;
tombstone._prefs.allow_read_only = true;
tombstone.acl.storage.found = true;
memcpy(tombstone.acl.storage.key, identity().pub_key, PUB_KEY_SIZE);
tombstone.acl.storage.ceiling = 160;
login(tombstone, "", 160);
assert(!tombstone.acl.known && tombstone.acl.allocations == 0);
assert(tombstone.acl.storage.ceiling == 160 && tombstone.acl.storage.writes == 0);
tombstone.acl.storage.write_ok = false;
login(tombstone, "", 161);
assert(!tombstone.acl.known && tombstone.acl.allocations == 0);
assert(tombstone.acl.storage.ceiling == 160 && tombstone.acl.storage.writes == 1);
// A live downgraded Guest cannot bypass its historical privileged record.
MyMesh known_reader;
seedKnown(known_reader, PERM_ACL_GUEST | 0x80);
known_reader.acl.storage.found = true;
memcpy(known_reader.acl.storage.key, identity().pub_key, PUB_KEY_SIZE);
known_reader.acl.storage.ceiling = 159;
known_reader.acl.storage.write_ok = false;
const ClientInfo reader_before = known_reader.acl.client;
login(known_reader, "guest", 160, 900, true);
checkRejectedSession(known_reader, reader_before);
assert(known_reader.acl.authorized_role == PERM_ACL_GUEST);
assert(known_reader.acl.allocations == 0 && known_reader.acl.storage.ceiling == 159);
assert(known_reader.acl.storage.writes == 1);
// Durable reservation happens before allocation; a full ACL cannot roll it back.
MyMesh full;
full.acl.allocate_ok = false;
login(full, "admin");
assert(!full.acl.known && full.acl.allocations == 1 && full.creations == 0);
assert(full.acl.storage.found && full.acl.storage.ceiling == 160);
login(full, "admin", 100);
assert(full.acl.allocations == 1 && full.acl.storage.writes == 1);
// Zero timestamps are never accepted even with correct credentials.
MyMesh zero;
login(zero, "admin", 0);
assert(!zero.acl.known && zero.acl.allocations == 0 && zero.acl.storage.writes == 0);
puts("real replay admission rejects failed storage, historical replay, and failed allocation passed");
}
static void sessionRefresh() {
for (bool flood : {false, true}) {
for (uint8_t route : {uint8_t(2), uint8_t(OUT_PATH_UNKNOWN), uint8_t(OUT_PATH_FORCE_FLOOD)}) {
MyMesh mesh;
seedKnown(mesh, PERM_ACL_REGION_MGR | 0x80);
mesh.acl.client.out_path_len = route;
login(mesh, "guest", 100, 81, flood);
const auto& client = mesh.acl.client;
assert(client.permissions == (PERM_ACL_REGION_MGR | 0x80));
assert(client.last_timestamp == 100 && client.last_activity == mesh.clock.now);
assert(client.extra.room.sync_since == 81 && client.extra.room.last_post_timestamp == 800);
assert(client.extra.room.pending_ack == 0 && client.extra.room.push_failures == 0);
assert(client.extra.room.topic_seen_revision == 0 && client.extra.room.pending_topic_revision == 0);
assert(client.extra.room.topic_failures == 0);
for (uint8_t value : client.shared_secret) assert(value == 0x77);
assert(mesh.next_push == mesh.millis_now + PUSH_NOTIFY_DELAY_MILLIS);
assert(mesh.dirty_contacts_expiry == mesh.millis_now + LAZY_CONTACTS_WRITE_DELAY);
assert(mesh.acl.allocations == 0 && mesh.clock.reads == 1 && mesh.clock.unique_reads == 1);
const uint8_t expected_route = flood && route != OUT_PATH_FORCE_FLOOD ? OUT_PATH_UNKNOWN : route;
assert(client.out_path_len == expected_route);
assert(client.out_path[0] == 0xA1 && client.out_path[1] == 0xB2);
if (flood) {
assert(mesh.path_returns == 1 && mesh.flood_sends == 1 && mesh.direct_sends == 0);
assert(mesh.returned_path_len == 0x42 && mesh.reply_hash_size == 2);
const uint8_t expected_path[] = {0x10, 0x20, 0x30, 0x40};
assert(memcmp(mesh.returned_path, expected_path, sizeof(expected_path)) == 0);
if (route != OUT_PATH_FORCE_FLOOD) assert(client.out_path_is_persistable);
} else if (route == 2) {
assert(mesh.path_returns == 0 && mesh.direct_sends == 1 && mesh.flood_sends == 0);
assert(mesh.direct_path_len == 2 && mesh.direct_path[0] == 0xA1 && mesh.direct_path[1] == 0xB2);
} else assert(mesh.path_returns == 0 && mesh.flood_sends == 1 && mesh.direct_sends == 0);
checkReply(mesh, client.permissions);
}
}
// A login refresh must preserve an existing persistence fault's backoff.
MyMesh backoff;
seedKnown(backoff);
backoff.dirty_contacts_expiry = 400000;
backoff.contacts_save_failures = 5;
login(backoff, "guest");
assert(backoff.dirty_contacts_expiry == 400000 && backoff.contacts_save_failures == 5);
// Activity alone is transient; an unchanged persisted session needs no write.
MyMesh unchanged;
seedKnown(unchanged);
memset(unchanged.acl.client.shared_secret, 0x77, PUB_KEY_SIZE);
login(unchanged, "guest", 100, 70);
assert(unchanged.acl.client.last_activity == unchanged.clock.now);
assert(unchanged.dirty_contacts_expiry == 0);
// Packet allocation failure cannot undo a successfully authenticated session.
MyMesh response_failure;
seedKnown(response_failure);
response_failure.create_ok = false;
login(response_failure, "guest");
assert(response_failure.acl.client.last_timestamp == 100);
assert(response_failure.creations == 1 && response_failure.direct_sends == 0 && response_failure.flood_sends == 0);
puts("real-handler successful reconnect refreshes cursor, activity, secret, topic state, and route passed");
}
static void readOnlyPosting() {
for (const char* password : {"", "wrong"}) {
MyMesh mesh;
mesh._prefs.allow_read_only = true;
login(mesh, password);
assert(mesh.acl.known && mesh.acl.client.permissions == PERM_ACL_GUEST);
mesh.post(&mesh.acl.client, "reader must not write", 1000);
assert(mesh.posts == 0 && mesh.post_acks == 0);
assert(mesh.acl.client.extra.room.last_post_timestamp == 0);
// Existing Guest is an ACL role too; a public password cannot upgrade it.
login(mesh, "guest", 101);
assert(mesh.acl.client.permissions == PERM_ACL_GUEST && mesh.acl.storage.writes == 0);
mesh.post(&mesh.acl.client, "still not writable", 1001);
assert(mesh.posts == 0 && mesh.post_acks == 0);
}
for (uint8_t role : {uint8_t(PERM_ACL_READ_ONLY), uint8_t(PERM_ACL_REGION_MGR), uint8_t(PERM_ACL_FILTER_MGR)}) {
MyMesh mesh;
seedKnown(mesh, role);
login(mesh, "guest");
mesh.post(&mesh.acl.client, "not a posting role", 1000);
assert(mesh.acl.client.permissions == role && mesh.posts == 0 && mesh.post_acks == 0);
}
for (const char* password : {"guest", "admin"}) {
MyMesh mesh;
login(mesh, password);
mesh.post(&mesh.acl.client, "writable", 1000);
mesh.post(&mesh.acl.client, "writable", 1000);
assert(mesh.posts == 1 && mesh.post_acks == 2);
}
puts("actual room post gate blocks logged-in readers/managers and accepts RW/Admin once passed");
}
int main(int argc, char** argv) {
assert(argc == 2);
if (!strcmp(argv[1], "authorization")) authorizationCases();
else if (!strcmp(argv[1], "roles")) preservedRoles();
else if (!strcmp(argv[1], "storage")) storageFailures();
else if (!strcmp(argv[1], "refresh")) sessionRefresh();
else if (!strcmp(argv[1], "post")) readOnlyPosting();
else assert(false);
}
'''
class RoomLoginIntegrationTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
compiler = shutil.which("g++") or shutil.which("clang++")
if compiler is None:
raise AssertionError("a host C++ compiler is required")
room = (ROOT / "examples/simple_room_server/MyMesh.cpp").read_text()
room_header = (ROOT / "examples/simple_room_server/MyMesh.h").read_text()
acl_header = (ROOT / "src/helpers/ClientACL.h").read_text()
acl_source = (ROOT / "src/helpers/ClientACL.cpp").read_text()
packet = (ROOT / "src/Packet.cpp").read_text()
# Keep the executed bodies, ClientInfo layout, and protocol labels tied
# to production. Only filesystem operations and radio allocation are fake.
constants = "\n".join(line for line in acl_header.splitlines()
if line.startswith(("#define PERM_ACL_", "#define OUT_PATH_")))
for name in ("PUSH_NOTIFY_DELAY_MILLIS", "FIRMWARE_VER_LEVEL", "RESP_SERVER_LOGIN_OK",
"LAZY_CONTACTS_WRITE_DELAY", "ROOM_MESSAGE_CACHE_SIZE", "TXT_TYPE_PLAIN"):
match = re.search(r"^\s*#define\s+" + name + r"\s+.*$", room, re.MULTILINE)
if match is None:
for path in (ROOT / "examples/simple_room_server/MyMesh.h", ROOT / "src/Mesh.h",
ROOT / "src/helpers/TxtDataHelpers.h", ROOT / "src/Packet.h"):
if path.exists():
match = re.search(r"^\s*#define\s+" + name + r"\s+.*$", path.read_text(), re.MULTILINE)
if match is not None:
break
if match is None:
raise AssertionError("production constant not found: " + name)
constants += "\n" + match.group()
delay = re.search(r"^\s*#define\s+SERVER_RESPONSE_DELAY\s+.*$", room_header, re.MULTILINE)
if delay is None:
raise AssertionError("production SERVER_RESPONSE_DELAY not found")
constants += "\n" + delay.group()
replacements = {
"@CONSTANTS@": constants,
"@PACKET_CONSTRUCTOR@": extract_braced(packet, "Packet::Packet()"),
"@PACKET_PATH_CHECK@": extract_braced(packet, "bool Packet::isValidPathLen("),
"@CLIENT_INFO@": extract_braced(acl_header, "struct ClientInfo {"),
"@REPLAY_HANDLER@": extract_braced(acl_source, "bool ClientACL::authorizeLoginTimestamp("),
"@POST_GATE@": extract_braced(extract_braced(room, "void MyMesh::onPeerDataRecv("),
"if (flags == TXT_TYPE_PLAIN)"),
"@LOGIN_HANDLER@": extract_braced(room, "void MyMesh::onAnonDataRecv("),
}
generated = HARNESS
for marker, text in replacements.items():
generated = generated.replace(marker, text)
cls.directory = tempfile.TemporaryDirectory(prefix="room-login-integration-")
cls.addClassCleanup(cls.directory.cleanup)
work = Path(cls.directory.name)
source = work / "test.cpp"
source.write_text(generated)
cls.binary = work / "room-login"
command = [compiler, "-std=c++17", "-O1", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-parameter", "-I" + str(ROOT / "src"),
str(source), "-o", str(cls.binary)]
if sys.platform.startswith("linux"):
command[1:1] = ["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-omit-frame-pointer", "-fno-pie", "-no-pie"]
compiled = subprocess.run(command, capture_output=True, text=True, timeout=60)
if compiled.returncode != 0:
raise AssertionError(compiled.stdout + compiled.stderr)
def run_case(self, case, expected):
checked = subprocess.run([str(self.binary), case], capture_output=True, text=True, timeout=15)
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
self.assertIn(expected, checked.stdout)
def test_unknown_blank_read_only_disabled_passwords_and_payload_bounds(self):
self.run_case("authorization", "real-handler credentials, disabled passwords, and minimum payload cases passed")
def test_non_demotion_all_roles_and_exact_replay_preserve_session(self):
self.run_case("roles", "2048 real-handler preserved roles and replay rejects plus 256 admin upgrades passed")
def test_auth_or_durable_replay_failure_precedes_acl_allocation_and_mutation(self):
self.run_case("storage", "real replay admission rejects failed storage, historical replay, and failed allocation passed")
def test_successful_relogin_refreshes_session_and_routes_without_resetting_post_floor(self):
self.run_case("refresh", "real-handler successful reconnect refreshes cursor, activity, secret, topic state, and route passed")
def test_read_only_login_never_grants_post_permission(self):
self.run_case("post", "actual room post gate blocks logged-in readers/managers and accepts RW/Admin once passed")
if __name__ == "__main__":
unittest.main()
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Execute production room push/ACK/CLI code against loss and catch-up cases."""
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
FIXTURE = ROOT / "test/fixtures/room_topic_delivery/test.cpp"
class RoomTopicDeliveryTests(unittest.TestCase):
def test_production_delivery_and_commands(self):
compiler = shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler)
source = (ROOT / "examples/simple_room_server/MyMesh.cpp").read_text()
acl = (ROOT / "src/helpers/ClientACL.h").read_text()
header = (ROOT / "examples/simple_room_server/MyMesh.h").read_text()
generated = "\n".join(line for line in acl.splitlines()
if line.startswith(("#define PERM_ACL_", "#define OUT_PATH_")))
generated += "\n" + extract_braced(acl, "struct ClientInfo") + ";\n"
generated += extract_braced(header, "struct PostInfo") + ";\n"
clock = (ROOT / "src/MeshCore.h").read_text()
generated += "namespace mesh {\n" + extract_braced(clock, "class RTCClock") + ";\n}\n"
definitions = "\n".join(extract_braced(source, signature) for signature in (
"bool MyMesh::pushPostToClient(", "bool MyMesh::pushRoomTextToClient(",
"bool MyMesh::processAck(", "void MyMesh::activateRoomTopic(",
"bool MyMesh::handleRoomTopicCommand(", "void MyMesh::serviceRoomPush("))
with tempfile.TemporaryDirectory(prefix="room-topic-delivery-") as directory:
work = Path(directory)
(work / "state.inc").write_text(generated)
(work / "production.inc").write_text(definitions)
binary = work / "delivery"
command = [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-parameter", "-I" + str(work),
str(FIXTURE), "-o", str(binary)]
if sys.platform.startswith("linux"):
command[1:1] = ["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-pie", "-no-pie"]
built = subprocess.run(command, capture_output=True, text=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
tested = subprocess.run([str(binary)], capture_output=True, text=True, timeout=20)
self.assertEqual(tested.returncode, 0, tested.stdout + tested.stderr)
self.assertIn("room topic delivery regressions passed", tested.stdout)
if __name__ == "__main__":
unittest.main()
+343
View File
@@ -0,0 +1,343 @@
#!/usr/bin/env python3
"""Execute the actual room-topic transaction with filesystem faults and power cuts."""
from pathlib import Path
import os
import shutil
import struct
import subprocess
import sys
import tempfile
import unittest
import zlib
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <algorithm>
#include <cassert>
#include <cerrno>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <limits>
#include <map>
#include <memory>
#include <set>
#include <string>
#include <vector>
struct PowerCut {};
class MemoryFS;
static MemoryFS* metadata_filesystem = nullptr;
class File {
MemoryFS* fs = nullptr;
std::shared_ptr<std::vector<uint8_t>> bytes;
std::string path;
size_t cursor = 0;
bool writable = false, directory = false, valid = false;
public:
File() = default;
File(MemoryFS*, std::shared_ptr<std::vector<uint8_t>>, const char*, bool, bool);
operator bool() const { return valid; }
bool isDirectory() const { return directory; }
size_t size();
int read(uint8_t*, size_t);
size_t write(const uint8_t*, size_t);
void flush();
void close();
};
class MemoryFS {
public:
std::map<std::string, std::shared_ptr<std::vector<uint8_t>>> files;
std::set<std::string> faults, directories;
std::vector<std::string> trace;
size_t boundary = 0, cut_at = 0;
bool short_write = false, short_read = false;
std::vector<uint8_t> replacement_on_close;
unsigned write_opens = 0;
void point(const std::string& event) {
trace.push_back(event);
if (++boundary == cut_at) throw PowerCut{};
}
bool begin(const std::string& op) { point("before:" + op); return !faults.count(op); }
void end(const std::string& op) { point("after:" + op); }
bool exists(const char* path) {
const std::string op = std::string("stat:") + path;
const bool okay = begin(op);
const bool present = files.count(path) || directories.count(path);
end(op);
return okay && present;
}
bool remove(const char* path) {
const std::string op = std::string("remove:") + path;
if (!begin(op)) return false;
const bool removed = files.erase(path) + directories.erase(path) != 0;
end(op); return removed;
}
bool rename(const char* from, const char* to) {
const std::string op = std::string("rename:") + from + ":" + to;
if (!begin(op) || !files.count(from) || files.count(to) || directories.count(to)) return false;
files[to] = files[from]; files.erase(from); end(op); return true;
}
File open(const char* path, const char* mode = "r", bool create = false) {
(void)create;
const bool write = mode[0] == 'w';
const std::string op = std::string("open:") + (write ? "w:" : "r:") + path;
if (!begin(op)) return {};
if (write) {
++write_opens;
files[path] = std::make_shared<std::vector<uint8_t>>();
if (faults.count(std::string("directory:") + path)) directories.insert(path);
}
if (!files.count(path) && !directories.count(path)) { end(op); return {}; }
if (!files.count(path)) files[path] = std::make_shared<std::vector<uint8_t>>();
File file(this, files[path], path, write, directories.count(path));
end(op); return file;
}
File open(const char* path, int mode) { assert(mode == 1); return open(path, "w"); }
void put(const char* path, const std::vector<uint8_t>& bytes) {
files[path] = std::make_shared<std::vector<uint8_t>>(bytes);
}
std::vector<uint8_t> get(const char* path) const { return *files.at(path); }
void reset() { trace.clear(); boundary = cut_at = 0; faults.clear(); short_write = short_read = false; write_opens = 0; }
};
File::File(MemoryFS* owner, std::shared_ptr<std::vector<uint8_t>> data,
const char* name, bool write, bool dir)
: fs(owner), bytes(data), path(name), writable(write), directory(dir), valid(true) {}
size_t File::size() {
const std::string op = "size:" + path;
const bool okay = fs->begin(op); const size_t size = okay ? bytes->size() : 0;
fs->end(op); return size;
}
int File::read(uint8_t* output, size_t length) {
const std::string op = "read:" + path;
if (!valid || directory || !fs->begin(op)) return -1;
length = std::min(length, bytes->size() - cursor);
if (fs->short_read && length) --length;
if (length) memcpy(output, bytes->data() + cursor, length);
cursor += length; fs->end(op); return static_cast<int>(length);
}
size_t File::write(const uint8_t* data, size_t length) {
const std::string op = "write:" + path;
if (!valid || directory || !fs->begin(op)) return 0;
if (fs->short_write && length) --length;
bytes->insert(bytes->end(), data, data + length); fs->end(op); return length;
}
void File::flush() {
const std::string op = "flush:" + path;
if (!fs->begin(op) && !bytes->empty()) bytes->pop_back();
fs->end(op);
}
void File::close() {
const std::string op = std::string("close:") + (writable ? "w:" : "r:") + path;
if (!fs->begin(op) && writable && !bytes->empty()) (*bytes)[0] ^= 1;
if (writable && !fs->replacement_on_close.empty()) *bytes = fs->replacement_on_close;
valid = false; fs->end(op);
}
#define FILE_O_WRITE 1
#include <helpers/RoomTopicStore.h>
static const char* P = mesh::ROOM_TOPIC_PRIMARY_PATH;
static const char* T = mesh::ROOM_TOPIC_TEMP_PATH;
static const char* B = mesh::ROOM_TOPIC_BACKUP_PATH;
static const std::vector<uint8_t> OLD = { @OLD_VECTOR@ };
static const std::vector<uint8_t> NEW = { @NEW_VECTOR@ };
static unsigned scenarios = 0;
static bool save(MemoryFS& fs, const char* text) {
metadata_filesystem = &fs; return mesh::saveRoomTopic(&fs, text);
}
static bool load(MemoryFS& fs, char (&out)[152]) {
metadata_filesystem = &fs; return mesh::loadRoomTopic(&fs, out);
}
static void loaded(MemoryFS& fs, const char* expected) {
char text[152]; memset(text, 0x7f, sizeof(text));
assert(load(fs, text) && strcmp(text, expected) == 0);
}
static void checkFailedSave(MemoryFS& fs) {
assert(!save(fs, "New topic"));
fs.reset(); loaded(fs, "Old topic");
assert(fs.get(P) == OLD);
assert(!fs.files.count(T) && !fs.files.count(B)); ++scenarios;
}
static void crc(std::vector<uint8_t>& image) {
uint32_t sum = 0xffffffffu;
for (size_t i = 0; i < 156; ++i) {
sum ^= image[i];
for (unsigned bit = 0; bit < 8; ++bit) sum = (sum >> 1) ^ ((sum & 1) ? 0xedb88320u : 0);
}
sum ^= 0xffffffffu;
for (unsigned i = 0; i < 4; ++i) image[156 + i] = uint8_t(sum >> (8 * i));
}
int main() {
{ MemoryFS fs; loaded(fs, ""); assert(save(fs, "Old topic"));
assert(fs.get(P) == OLD); loaded(fs, "Old topic");
assert(save(fs, "New topic")); assert(fs.get(P) == NEW); loaded(fs, "New topic");
assert(save(fs, "")); loaded(fs, ""); assert(fs.files.count(P)); ++scenarios; }
{ MemoryFS fs; char maximum[152]; memset(maximum, 'x', 151); maximum[151] = 0;
assert(save(fs, maximum)); loaded(fs, maximum);
const auto before = fs.get(P); fs.reset(); char oversized[153]; memset(oversized, 'x', 152); oversized[152] = 0;
assert(!save(fs, oversized) && !save(fs, nullptr) && fs.trace.empty());
assert(fs.get(P) == before); ++scenarios; }
{ char output[152]; memset(output, 7, sizeof(output));
assert(!mesh::loadRoomTopic(static_cast<MemoryFS*>(nullptr), output));
assert(output[0] == 0 && !mesh::saveRoomTopic(static_cast<MemoryFS*>(nullptr), "x")); ++scenarios; }
std::vector<std::vector<uint8_t>> invalid = { {}, {1}, std::vector<uint8_t>(159), std::vector<uint8_t>(161), OLD };
invalid.back()[159] ^= 1;
auto future = OLD; future[3] = 2; crc(future); invalid.push_back(future);
auto magic = OLD; magic[0] = 'X'; crc(magic); invalid.push_back(magic);
auto unterminated = OLD; std::fill(unterminated.begin() + 4, unterminated.begin() + 156, 'x'); crc(unterminated); invalid.push_back(unterminated);
auto padding = OLD; padding[100] = 'x'; crc(padding); invalid.push_back(padding);
for (const auto& bytes : invalid) {
MemoryFS fs; fs.put(P, bytes); fs.put(B, OLD); fs.put(T, NEW);
char text[152]; assert(!load(fs, text) && text[0] == 0);
assert(!save(fs, "New topic")); assert(fs.get(P) == bytes && fs.get(B) == OLD && fs.get(T) == NEW);
assert(fs.write_opens == 0); ++scenarios;
MemoryFS only_backup; only_backup.put(B, bytes); only_backup.put(T, NEW);
assert(!load(only_backup, text) && !save(only_backup, "New topic"));
assert(!only_backup.files.count(P) && only_backup.get(B) == bytes && only_backup.get(T) == NEW); ++scenarios;
}
for (size_t position = 0; position < OLD.size(); ++position) {
MemoryFS fs; auto corrupted = OLD; corrupted[position] ^= 1;
fs.put(P, corrupted); fs.put(B, NEW); fs.put(T, NEW);
char text[152]; memset(text, 0x7f, sizeof(text));
assert(!load(fs, text));
assert(std::all_of(text, text + sizeof(text), [](char value) { return value == 0; }));
assert(!save(fs, "New topic") && fs.get(P) == corrupted);
assert(fs.get(B) == NEW && fs.get(T) == NEW && fs.write_opens == 0); ++scenarios;
}
{ MemoryFS fs; fs.put(P, NEW); fs.put(B, OLD); fs.put(T, OLD);
loaded(fs, "New topic"); assert(!fs.files.count(B) && !fs.files.count(T)); ++scenarios; }
{ MemoryFS fs; fs.put(B, OLD); fs.put(T, NEW);
loaded(fs, "Old topic"); assert(fs.get(P) == OLD && !fs.files.count(T)); ++scenarios; }
{ MemoryFS fs; fs.put(T, NEW); loaded(fs, ""); assert(!fs.files.count(P) && !fs.files.count(T)); ++scenarios; }
{ MemoryFS fs; fs.put(B, OLD); fs.faults.insert(std::string("rename:") + B + ":" + P);
char text[152]; assert(!load(fs, text) && !save(fs, "New topic"));
assert(fs.get(B) == OLD && !fs.files.count(P)); ++scenarios; }
for (const std::string& fault : {std::string("open:w:") + T, std::string("write:") + T,
std::string("flush:") + T, std::string("close:w:") + T,
std::string("open:r:") + T, std::string("read:") + T,
std::string("size:") + T, std::string("rename:") + P + ":" + B,
std::string("rename:") + T + ":" + P, std::string("directory:") + T}) {
MemoryFS fs; fs.put(P, OLD); fs.faults.insert(fault); checkFailedSave(fs);
}
{ MemoryFS fs; fs.put(P, OLD); fs.short_write = true; checkFailedSave(fs); }
{ MemoryFS fs; fs.put(P, OLD); fs.short_read = true; checkFailedSave(fs); }
{ MemoryFS fs; fs.put(P, OLD); fs.replacement_on_close = OLD;
// A valid but stale image must fail exact readback, not pass on CRC alone.
checkFailedSave(fs); }
{ MemoryFS fs; fs.put(P, OLD); fs.faults.insert(std::string("rename:") + T + ":" + P);
fs.faults.insert(std::string("rename:") + B + ":" + P);
fs.faults.insert(std::string("remove:") + T); assert(!save(fs, "New topic"));
assert(!fs.files.count(P) && fs.get(B) == OLD && fs.get(T) == NEW);
fs.reset(); loaded(fs, "Old topic"); assert(fs.get(P) == OLD); ++scenarios; }
{ MemoryFS fs; fs.faults.insert(std::string("rename:") + T + ":" + P);
fs.faults.insert(std::string("remove:") + T); assert(!save(fs, "New topic"));
fs.reset(); loaded(fs, ""); assert(!fs.files.count(P)); ++scenarios; }
for (const char* artifact : {B, T}) {
MemoryFS fs; fs.put(P, NEW); fs.put(artifact, OLD);
fs.faults.insert(std::string("remove:") + artifact);
loaded(fs, "New topic"); const unsigned writes = fs.write_opens;
assert(!save(fs, "Old topic") && fs.write_opens == writes && fs.get(P) == NEW);
fs.reset(); assert(save(fs, "Old topic")); loaded(fs, "Old topic"); ++scenarios;
}
{ MemoryFS fs; fs.put(P, OLD); fs.faults.insert(std::string("remove:") + B);
assert(save(fs, "New topic") && fs.get(P) == NEW && fs.get(B) == OLD);
loaded(fs, "New topic"); assert(!save(fs, "Old topic"));
fs.reset(); loaded(fs, "New topic"); ++scenarios; }
for (const char* path : {P, B}) {
MemoryFS fs; fs.put(path, OLD); fs.faults.insert(std::string("open:r:") + path);
char text[152]; assert(!load(fs, text) && !save(fs, "New topic"));
assert(fs.get(path) == OLD && fs.write_opens == 0); ++scenarios;
MemoryFS directory; directory.put(path, OLD); directory.directories.insert(path);
assert(!load(directory, text) && !save(directory, "New topic")); assert(directory.get(path) == OLD); ++scenarios;
}
#if defined(ESP32_PLATFORM)
for (bool present : {false, true}) {
MemoryFS fs; if (present) fs.put(P, OLD);
fs.faults.insert(std::string("stat:") + P); char text[152];
assert(!load(fs, text) && !save(fs, "New topic") && fs.write_opens == 0);
assert(fs.files.count(P) == size_t(present)); ++scenarios;
}
{ MemoryFS fs; fs.put(P, OLD); fs.put(B, NEW); fs.faults.insert(std::string("stat:") + B);
loaded(fs, "Old topic"); assert(!save(fs, "New topic") && fs.get(P) == OLD); ++scenarios; }
#endif
// Cut power before and after every filesystem boundary, including all read
// verification operations. Reboot must select exactly old or committed new.
for (bool existing : {false, true}) {
MemoryFS baseline; if (existing) baseline.put(P, OLD);
assert(save(baseline, "New topic")); const size_t boundaries = baseline.boundary;
for (size_t cut = 1; cut <= boundaries; ++cut) {
MemoryFS fs; if (existing) fs.put(P, OLD); fs.cut_at = cut;
try { save(fs, "New topic"); } catch (const PowerCut&) {}
const bool committed = fs.files.count(P) && fs.get(P) == NEW;
fs.reset(); loaded(fs, committed ? "New topic" : (existing ? "Old topic" : ""));
assert(!fs.files.count(T) && !fs.files.count(B));
if (committed || existing) assert(fs.get(P) == (committed ? NEW : OLD));
else assert(!fs.files.count(P));
++scenarios;
}
}
printf("PASS: %u room topic persistence and power-cut scenarios\n", scenarios);
}
'''
STAT_MOCK = r'''
#pragma once
struct stat {};
inline int stat(const char* absolute, struct stat*) {
if (!metadata_filesystem || strncmp(absolute, "/spiffs", 7) != 0) { errno = EIO; return -1; }
const char* path = absolute + 7;
const std::string op = std::string("stat:") + path;
const bool okay = metadata_filesystem->begin(op);
const bool present = metadata_filesystem->files.count(path) || metadata_filesystem->directories.count(path);
metadata_filesystem->end(op);
if (!okay) { errno = EIO; return -1; }
if (!present) { errno = ENOENT; return -1; }
return 0;
}
'''
def image(text):
payload = b"RTP\x01" + text.encode().ljust(152, b"\x00")
return payload + struct.pack("<I", zlib.crc32(payload))
class RoomTopicStoreTest(unittest.TestCase):
def test_real_store_with_all_backend_modes_and_faults(self):
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
if compiler is None:
self.skipTest("a host C++ compiler is required")
harness = HARNESS
for marker, text in (("@OLD_VECTOR@", "Old topic"), ("@NEW_VECTOR@", "New topic")):
harness = harness.replace(marker, ",".join(str(value) for value in image(text)))
sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else [])
with tempfile.TemporaryDirectory(prefix="meshcore-room-topic-") as temporary:
work = Path(temporary)
(work / "sys").mkdir()
(work / "sys/stat.h").write_text(STAT_MOCK)
source = work / "topic.cpp"
source.write_text(harness)
for platform in (None, "NRF52_PLATFORM", "STM32_PLATFORM", "RP2040_PLATFORM", "ESP32_PLATFORM"):
with self.subTest(platform=platform):
binary = work / (platform or "generic")
defines = ["-D" + platform + "=1"] if platform else []
built = subprocess.run([compiler, "-std=c++11", "-Wall", "-Wextra", "-Werror",
*sanitizers, *defines, "-I" + str(work), "-I" + str(ROOT / "src"),
str(source), "-o", str(binary)],
capture_output=True, text=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30)
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
self.assertIn("room topic persistence and power-cut scenarios", checked.stdout)
print(checked.stdout.strip())
if __name__ == "__main__":
unittest.main()