Improve room retention, moderation and local browser access

Add opt-in bounded chat history, persist explicit room ACL roles, enforce full-key bans and per-user quotas, and provide versioned information-board reads plus an ESP32 local room page. Preserve stored evidence on faults and withhold room delivery acknowledgments when a Companion cannot retain the message.

Exercise production handlers and filesystem fault recovery, pinned official-app JavaScript, real Chromium interactions, and representative ESP32, nRF52, RP2040 and STM32 builds. Add regression suites and bounded browser-workspace RAM reservations to CI.
This commit is contained in:
mikecarper committed 2026-10-09 23:44:00 -07:00
1 parent b6b6b4eaaa
commit 74bc91bf69
61 files changed
+6993 -91

No files matched your search

@@ -3,6 +3,9 @@
#include <cstring>
#include <iostream>
#include <vector>
#include "filesystem.h"
#include <helpers/RoomAccessPolicy.h>
#include <helpers/LogicalMessageCache.h>
#include <helpers/ClientACLResponse.h>
#include <helpers/RoutingPolicy.h>
@@ -87,11 +90,13 @@ struct RegionMap {
struct ClientInfo {
mesh::Identity id;
uint8_t permissions = 3;
bool permissions_are_explicit = false;
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, pending_topic_revision = 0;
uint8_t push_failures = 0;
uint16_t poll_quota_used = 0, post_quota_used = 0;
} room; } extra;
bool isAdmin() const { return (permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_ADMIN; }
};
@@ -106,6 +111,7 @@ struct Clock : mesh::RTCClock {
};
#ifdef TEST_SENSOR
#define TARGET_CLASS SensorMesh
struct MetadataFixtureUse { MetadataFixtureUse() { (void)metadata_filesystem; } } metadata_fixture_use;
#else
#define TARGET_CLASS MyMesh
#endif
@@ -130,6 +136,12 @@ public:
int handleRequest(ClientInfo*, uint32_t, uint8_t*, size_t,
size_t = mesh::CLIENT_ACL_DIRECT_REPLY_CAPACITY);
int telemetryPrefix(uint8_t*, size_t);
MemoryFS fs;
mesh::RoomAccessPolicy room_access;
mesh::LogicalMessageCache<8> recent_room_polls;
TARGET_CLASS() { metadata_filesystem = &fs; assert(room_access.load(&fs)); }
static bool saveFilter(ClientInfo*);
void serviceRoomQuotas() { room_access.serviceQuotaWindow(0, [] {}); }
#endif
void receive(mesh::Packet*, uint8_t*, size_t);
bool admit(mesh::Packet* packet) {
@@ -16,7 +16,7 @@ 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,
bool queueMessage(const RoomContact&, uint8_t, CompanionPacket*, uint32_t,
const uint8_t*, int, const char*, bool, uint32_t);
};
#include "presentation.inc"
+308
View File
@@ -0,0 +1,308 @@
// The production methods, plain-post gate, RTCClock and PostInfo are extracted
// unchanged. Only filesystem faults, radio ACK observation and identity hashing
// are host boundaries; the actual history store, quota and replay cache run here.
#include "../room_history_store/filesystem.h"
#include <helpers/RoomHistoryStore.h>
#include <helpers/RoomAccessPolicy.h>
#include <helpers/LogicalMessageCache.h>
#define PUB_KEY_SIZE 32
#define MAX_POST_TEXT_LEN 151
#define MAX_UNSYNCED_POSTS 32
#define MAX_HASH_SIZE 8
#define POST_SYNC_DELAY_SECS 6
#define PUSH_NOTIFY_DELAY_MILLIS 2000
#define TXT_TYPE_PLAIN 0
#undef MESH_DEBUG_PRINTLN
#define MESH_DEBUG_PRINTLN(...) ((void)0)
static uint32_t ticks = 100;
static unsigned long futureMillis(unsigned long delay) { return uint32_t(ticks + delay); }
namespace mesh {
struct Identity { uint8_t pub_key[32] = {}; };
struct Utils {
static void sha256(uint8_t* out, size_t size, const uint8_t* key, size_t key_size,
const uint8_t* text, size_t text_size) {
uint64_t value = UINT64_C(14695981039346656037);
for (size_t i = 0; i < key_size; ++i) value = (value ^ key[i]) * UINT64_C(1099511628211);
for (size_t i = 0; i < text_size; ++i) value = (value ^ text[i]) * UINT64_C(1099511628211);
for (size_t i = 0; i < size; ++i) out[i] = uint8_t(value >> (8 * (i % 8)));
}
};
}
#include "state.inc"
struct StrHelper { static void strncpy(char*, const char*, size_t); };
struct Clock : mesh::RTCClock {
uint32_t now = 1000;
unsigned set_calls = 0;
uint32_t getCurrentTime() override { return now; }
void setCurrentTime(uint32_t value) override { now = value; ++set_calls; }
};
struct ClientInfo {
mesh::Identity id;
uint8_t permissions = PERM_ACL_READ_WRITE;
struct { struct { uint32_t last_post_timestamp = 0; uint16_t post_quota_used = 0; } room; } extra;
ClientInfo() { id.pub_key[0] = 1; }
};
struct MyMesh {
MemoryFS* _fs;
Clock clock;
mesh::Identity self_id;
mesh::RoomAccessPolicy room_access;
mesh::LogicalMessageCache<8> recent_room_posts;
ClientInfo* active = nullptr;
PostInfo posts[MAX_UNSYNCED_POSTS] = {};
uint32_t post_ready_at[MAX_UNSYNCED_POSTS] = {}, post_ready_mask = UINT32_MAX;
uint32_t last_room_timestamp = 0;
mesh::RoomHistoryState room_history_state;
bool room_history_enabled = false, room_history_available = true;
int next_post_idx = 0;
unsigned long next_push = 0;
uint16_t _num_posted = 0;
unsigned acks = 0;
explicit MyMesh(MemoryFS* fs) : _fs(fs) {
metadata_filesystem = fs;
self_id.pub_key[0] = 77;
if (fs) assert(room_access.load(fs));
}
Clock* getRTCClock() { return &clock; }
bool addPost(ClientInfo*, const char*);
bool addSystemPost(const char*);
bool storePost(const mesh::Identity&, const char*);
bool snapshotRoomHistory();
void loadRoomHistory();
bool handleRoomHistoryCommand(const char*, char*);
void serviceRoomQuotas() {
room_access.serviceQuotaWindow(ticks, [this] { active->extra.room.post_quota_used = 0; });
}
void receive(ClientInfo* client, const char* text, uint32_t sender_timestamp) {
active = client;
const uint8_t flags = TXT_TYPE_PLAIN;
const size_t text_len = strlen(text);
bool send_ack = false;
#include "post_gate.inc"
if (send_ack) ++acks;
}
std::string command(const char* command) {
char reply[160] = {};
assert(handleRoomHistoryCommand(command, reply));
return reply;
}
std::vector<std::string> retained() const {
std::vector<std::string> result;
for (int k = 0, idx = next_post_idx; k < MAX_UNSYNCED_POSTS;
++k, idx = (idx + 1) % MAX_UNSYNCED_POSTS) {
if (posts[idx].post_timestamp) result.emplace_back(posts[idx].text);
}
return result;
}
};
#include "production.inc"
struct AdmissionState {
std::vector<std::string> text;
uint32_t floor, mask, ready[32], timestamps[32];
int index;
unsigned long next;
uint16_t stats, quota;
uint32_t replay;
unsigned acks;
AdmissionState(const MyMesh& m, const ClientInfo& c)
: text(m.retained()), floor(m.last_room_timestamp), mask(m.post_ready_mask),
index(m.next_post_idx), next(m.next_push), stats(m._num_posted),
quota(c.extra.room.post_quota_used), replay(c.extra.room.last_post_timestamp), acks(m.acks) {
memcpy(ready, m.post_ready_at, sizeof(ready));
for (unsigned i = 0; i < 32; ++i) timestamps[i] = m.posts[i].post_timestamp;
}
void unchanged(const MyMesh& m, const ClientInfo& c) const {
assert(text == m.retained() && floor == m.last_room_timestamp && mask == m.post_ready_mask);
assert(index == m.next_post_idx && next == m.next_push && stats == m._num_posted);
assert(quota == c.extra.room.post_quota_used && replay == c.extra.room.last_post_timestamp);
assert(acks == m.acks && memcmp(ready, m.post_ready_at, sizeof(ready)) == 0);
for (unsigned i = 0; i < 32; ++i) assert(timestamps[i] == m.posts[i].post_timestamp);
}
};
static void compareBoot(MemoryFS& fs, const MyMesh& running) {
const auto text = running.retained();
const uint32_t floor = running.last_room_timestamp;
MyMesh boot(&fs); boot.clock.now = 5; boot.loadRoomHistory();
assert(boot.room_history_available && boot.room_history_enabled);
assert(boot.retained() == text && boot.last_room_timestamp == floor);
assert(boot.clock.now == 5 && boot.clock.set_calls == 0 && boot.post_ready_mask == UINT32_MAX);
assert(boot._num_posted == 0);
}
int main() {
unsigned cases = 0;
{
MemoryFS fs; MyMesh m(&fs); m.loadRoomHistory();
assert(!m.room_history_enabled && m.room_history_available && m.retained().empty());
for (unsigned i = 0; i < 40; ++i) assert(m.addSystemPost(("volatile-" + std::to_string(i)).c_str()));
assert(!fs.files.count(mesh::ROOM_HISTORY_PATH) && fs.write_opens == 0);
assert(m.retained().size() == 32 && m.retained().front() == "volatile-8");
assert(m.command("set room.history on").find("enabled") != std::string::npos);
assert(m.room_history_enabled && m.room_history_state.count == 32 && m.room_history_state.total_records == 32);
compareBoot(fs, m);
for (unsigned i = 0; i < 180; ++i) {
assert(m.addSystemPost(("durable-" + std::to_string(i)).c_str()));
assert(m.room_history_state.count == 32 && m.room_history_state.total_records <= 64);
assert(fs.get(mesh::ROOM_HISTORY_PATH).size() <= mesh::ROOM_HISTORY_MAX_FILE_SIZE);
if (i % 13 == 0) compareBoot(fs, m);
++cases;
}
compareBoot(fs, m);
assert(m.retained().front() == "durable-148" && m.retained().back() == "durable-179");
assert(m.command("get room.history") == "> on; storage ready; retained 32/32");
++cases;
}
for (const std::string& text : {std::string(151, 'A'), std::string("x") + std::string(50, 'z')}) {
// Second text is replaced below with exactly fifty complete UTF-8 codepoints.
std::string exact = text;
if (exact.size() != 151) { exact = "x"; for (unsigned i = 0; i < 50; ++i) exact += "\xe2\x98\x83"; }
assert(exact.size() == 151);
MemoryFS fs; MyMesh m(&fs); assert(m.command("set room.history on").find("enabled") != std::string::npos);
ClientInfo client; m.receive(&client, exact.c_str(), 123);
assert(m.acks == 1 && m._num_posted == 1 && m.retained()[0] == exact);
assert(m.snapshotRoomHistory()); compareBoot(fs, m);
m.receive(&client, exact.c_str(), 123);
assert(m.acks == 2 && m._num_posted == 1);
AdmissionState before(m, client);
m.receive(&client, (exact + "!").c_str(), 124); before.unchanged(m, client);
assert(m.addSystemPost("short"));
for (unsigned i = 0; i < 31; ++i) assert(m.addSystemPost("tiny"));
assert(m.snapshotRoomHistory()); compareBoot(fs, m); // reused long slot has canonical padding
++cases;
}
for (const std::string failure : {"open:a:/room_history", "write:/room_history", "flush:/room_history",
"close:w:/room_history", "read:/room_history", "seek:/room_history"}) {
MemoryFS fs; MyMesh m(&fs); ClientInfo client;
assert(m.command("set room.history on").find("enabled") != std::string::npos);
m.receive(&client, "accepted", 100); assert(m.acks == 1 && m._num_posted == 1);
ticks = 100; fs.reset(); fs.faults.insert(failure);
AdmissionState before(m, client);
m.receive(&client, "not-yet-retained", 101); before.unchanged(m, client);
fs.reset(); m.receive(&client, "not-yet-retained", 101);
assert(m.acks == 2 && m._num_posted == 2 && m.retained().back() == "not-yet-retained");
assert(client.extra.room.last_post_timestamp == 101);
compareBoot(fs, m); ++cases;
}
{
MemoryFS fs; MyMesh m(&fs); ClientInfo client;
assert(m.command("set room.history on").find("enabled") != std::string::npos);
m.receive(&client, "trusted", 100); fs.short_write = true;
AdmissionState before(m, client); m.receive(&client, "partial", 101); before.unchanged(m, client);
assert(m.room_history_state.needs_repair);
fs.reset();
MyMesh reboot(&fs); reboot.loadRoomHistory();
assert(reboot.room_history_state.needs_repair && reboot.retained() == m.retained());
m.receive(&client, "partial", 101);
assert(!m.room_history_state.needs_repair && m.acks == 2);
assert(m.retained() == std::vector<std::string>({"trusted", "partial"}));
compareBoot(fs, m); ++cases;
}
{
MemoryFS fs; MyMesh m(&fs); ClientInfo client;
assert(m.command("set room.history on").find("enabled") != std::string::npos);
m.receive(&client, "durable-but-unconfirmed", 100); assert(m.acks == 1);
// Fail readback only after header+existing record validation. A whole disk
// append can survive without ACK, but trusted RAM repair must remove it.
fs.reset(); fs.fail_read_at = 3;
AdmissionState before(m, client); m.receive(&client, "readback-failed", 101); before.unchanged(m, client);
assert(m.room_history_state.needs_repair);
fs.reset(); m.receive(&client, "readback-failed", 101);
assert(m.acks == 2 && m._num_posted == 2 && m.retained().size() == 2);
compareBoot(fs, m); ++cases;
}
{
MemoryFS fs; MyMesh m(&fs);
assert(m.command("set room.history on").find("enabled") != std::string::npos);
assert(m.addSystemPost("archived")); const auto archive = fs.get(mesh::ROOM_HISTORY_PATH);
assert(m.command("set room.history off").find("archive retained") != std::string::npos);
assert(fs.get(mesh::ROOM_HISTORY_PATH) == archive);
assert(m.addSystemPost("volatile")); assert(fs.get(mesh::ROOM_HISTORY_PATH) == archive);
MyMesh boot(&fs); boot.loadRoomHistory();
assert(!boot.room_history_enabled && boot.retained().empty() && fs.get(mesh::ROOM_HISTORY_PATH) == archive);
assert(m.command("set room.history 1").find("enabled") != std::string::npos);
compareBoot(fs, m);
const auto floor = m.last_room_timestamp;
assert(m.command("room.history.clear").find("cleared") != std::string::npos);
assert(m.retained().empty() && m.last_room_timestamp == floor);
MyMesh cleared(&fs); cleared.loadRoomHistory();
assert(cleared.room_history_available && cleared.retained().empty() && cleared.last_room_timestamp == 0);
++cases;
}
{
MemoryFS fs; MyMesh m(&fs); ClientInfo client;
assert(m.command("set room.history on").find("enabled") != std::string::npos);
assert(m.addSystemPost("before-clear")); const auto archive = fs.get(mesh::ROOM_HISTORY_PATH);
fs.faults.insert("rename:/room_history.tmp:/room_history"); AdmissionState before(m, client);
assert(m.command("room.history.clear").find("failed") != std::string::npos);
before.unchanged(m, client); fs.reset(); assert(fs.get(mesh::ROOM_HISTORY_PATH) == archive);
++cases;
}
{
MemoryFS fs; MyMesh m(&fs); ClientInfo client; assert(m.addSystemPost("volatile-before-enable"));
fs.faults.insert("rename:/room_hist_cfg.tmp:/room_hist_cfg");
assert(m.command("set room.history on").find("failed") != std::string::npos && !m.room_history_enabled);
fs.reset(); assert(m.command("set room.history on").find("enabled") != std::string::npos);
const auto config = fs.get(mesh::ROOM_HISTORY_CONFIG_PATH);
fs.faults.insert("rename:/room_hist_cfg.tmp:/room_hist_cfg");
assert(m.command("set room.history 0").find("failed") != std::string::npos && m.room_history_enabled);
fs.reset(); assert(fs.get(mesh::ROOM_HISTORY_CONFIG_PATH) == config);
for (const char* invalid : {"set room.history yes", "set room.history on ", "set room.history 2"}) {
assert(m.command(invalid).find("requires on/off") != std::string::npos);
}
char reply[160] = {}; assert(!m.handleRoomHistoryCommand("get room.history.extra", reply));
++cases;
}
{
MemoryFS fs; MyMesh m(&fs); m.clock.now = 2000;
assert(m.command("set room.history on").find("enabled") != std::string::npos);
assert(m.addSystemPost("first")); const auto floor = m.last_room_timestamp;
m.clock.now = 5; m.clock.resetUniqueTime(5);
assert(m.addSystemPost("backward")); assert(m.last_room_timestamp == floor + 1 && m.clock.now == 5);
assert(m.clock.set_calls == 0 && m.post_ready_at[1] == ticks + 6000);
MyMesh boot(&fs); boot.clock.now = 1; boot.loadRoomHistory(); const auto boot_floor = boot.last_room_timestamp;
assert(boot.addSystemPost("after-reboot") && boot.last_room_timestamp == boot_floor + 1);
assert(boot.clock.now == 1 && boot.clock.set_calls == 0); ++cases;
}
{
MemoryFS fs; MyMesh m(&fs);
assert(m.command("set room.history on").find("enabled") != std::string::npos);
m.clock.now = UINT32_MAX; assert(m.addSystemPost("last representable"));
ClientInfo client; AdmissionState before(m, client); assert(!m.addSystemPost("overflow")); before.unchanged(m, client);
MyMesh boot(&fs); boot.loadRoomHistory(); assert(boot.last_room_timestamp == UINT32_MAX);
assert(!boot.addSystemPost("cannot wrap")); ++cases;
}
{
MemoryFS fs; MyMesh m(&fs); ClientInfo client;
assert(!m.addSystemPost(nullptr) && !m.addSystemPost("") && !m.storePost(client.id, nullptr));
assert(!m.addSystemPost(std::string(152, 'x').c_str()) && m._num_posted == 0);
MyMesh unavailable(nullptr); unavailable.loadRoomHistory();
assert(!unavailable.room_history_available && !unavailable.addSystemPost("no fs"));
assert(unavailable.command("get room.history").find("unavailable") != std::string::npos);
assert(unavailable.command("set room.history on").find("failed") != std::string::npos);
assert(unavailable.command("room.history.clear").find("failed") != std::string::npos); ++cases;
}
{
MemoryFS fs; MyMesh m(&fs);
assert(m.command("set room.history on").find("enabled") != std::string::npos);
assert(m.addSystemPost("kept")); const auto original = fs.get(mesh::ROOM_HISTORY_PATH);
fs.faults.insert("open:r:/room_history"); MyMesh boot(&fs); boot.loadRoomHistory();
assert(!boot.room_history_available && boot.retained().empty() && fs.get(mesh::ROOM_HISTORY_PATH) == original);
assert(!boot.addSystemPost("must not overwrite")); fs.reset();
auto corrupt = original; corrupt[3] = 2; fs.put(mesh::ROOM_HISTORY_PATH, corrupt);
MyMesh future(&fs); future.loadRoomHistory();
assert(!future.room_history_available && !future.addSystemPost("must preserve future"));
assert(fs.get(mesh::ROOM_HISTORY_PATH) == corrupt); ++cases;
}
{
MemoryFS fs; mesh::RoomHistoryState state;
metadata_filesystem = &fs;
assert(mesh::saveRoomHistoryConfig(&fs, true));
assert(mesh::saveRoomHistorySnapshot(&fs, state, 2, [](uint8_t ordinal, mesh::RoomHistoryRecord& record) {
memset(&record, 0, sizeof(record)); record.author[0] = 1;
record.timestamp = ordinal == 0 ? 100 : 99; strcpy(record.text, "out of order"); return true;
}));
const auto original = fs.get(mesh::ROOM_HISTORY_PATH); MyMesh boot(&fs); boot.loadRoomHistory();
assert(!boot.room_history_available && boot.retained().empty() && boot.last_room_timestamp == 0);
assert(fs.get(mesh::ROOM_HISTORY_PATH) == original && !boot.addSystemPost("no rewrite")); ++cases;
}
printf("%u production room history integration scenarios passed\n", cases);
}
+155
View File
@@ -0,0 +1,155 @@
#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, initial_size = 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();
bool seek(uint32_t position);
int read(uint8_t*, size_t);
size_t write(const uint8_t*, size_t);
void flush();
void close();
};
class MemoryFS {
public:
void _lockFS() {}
void _unlockFS() {}
MemoryFS* _getFS() { return this; }
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;
size_t capacity = std::numeric_limits<size_t>::max();
unsigned read_calls = 0, fail_read_at = 0;
std::string replacement_path;
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 append = mode[0] == 'a';
const bool write = mode[0] == 'w' || append;
const std::string op = std::string("open:") + (append ? "a:" : write ? "w:" : "r:") + path;
if (!begin(op)) return {};
if (write) {
++write_opens;
if (!append || !files.count(path)) 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, "a"); }
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; read_calls = fail_read_at = 0; replacement_on_close.clear(); replacement_path.clear(); capacity = std::numeric_limits<size_t>::max(); }
};
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) {
initial_size = bytes->size(); cursor = write ? initial_size : 0;
}
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;
if (++fs->read_calls == fs->fail_read_at) 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;
size_t occupied = 0;
for (const auto& entry : fs->files) occupied += entry.second->size();
const size_t free = occupied < fs->capacity ? fs->capacity - occupied : 0;
length = std::min(length, free);
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->size() > initial_size) 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->size() > initial_size) (*bytes)[initial_size] ^= 1;
if (writable && !fs->replacement_on_close.empty() && fs->replacement_path == path) *bytes = fs->replacement_on_close;
valid = false; fs->end(op);
}
bool File::seek(uint32_t position) {
const std::string op = "seek:" + path;
if (!valid || directory || !fs->begin(op) || position > bytes->size()) return false;
cursor = position; fs->end(op); return true;
}
#define FILE_O_WRITE 1
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
struct lfs_info {};
static constexpr int LFS_ERR_NOENT = -2;
static int lfs_stat(MemoryFS* fs, const char* path, lfs_info*) {
const std::string op = std::string("stat:") + path;
const bool okay = fs->begin(op);
const bool present = fs->files.count(path) || fs->directories.count(path);
fs->end(op);
return !okay ? -5 : present ? 0 : LFS_ERR_NOENT;
}
#endif
+385
View File
@@ -0,0 +1,385 @@
#include "filesystem.h"
#include <helpers/RoomHistoryStore.h>
#include "vectors.inc"
using Record = mesh::RoomHistoryRecord;
using State = mesh::RoomHistoryState;
static const char* P = mesh::ROOM_HISTORY_PATH;
static const char* T = mesh::ROOM_HISTORY_TEMP_PATH;
static const char* B = mesh::ROOM_HISTORY_BACKUP_PATH;
static const char* C = mesh::ROOM_HISTORY_CONFIG_PATH;
static const char* CT = mesh::ROOM_HISTORY_CONFIG_TEMP_PATH;
static const char* CB = mesh::ROOM_HISTORY_CONFIG_BACKUP_PATH;
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
static const char* WRITE_MODE = "a";
#else
static const char* WRITE_MODE = "w";
#endif
static unsigned scenarios = 0;
static Record record(unsigned number) {
Record result = {};
result.author[0] = 1; result.author[31] = uint8_t(number);
result.timestamp = 1000 + number;
snprintf(result.text, sizeof(result.text), "post %u", number);
return result;
}
static bool same(const Record& a, const Record& b) {
return a.timestamp == b.timestamp && strcmp(a.text, b.text) == 0
&& memcmp(a.author, b.author, sizeof(a.author)) == 0;
}
static bool sameState(const State& a, const State& b) {
return a.count == b.count && a.total_records == b.total_records
&& a.needs_repair == b.needs_repair && a.present == b.present;
}
static bool snapshot(MemoryFS& fs, State& state, const std::vector<Record>& records) {
metadata_filesystem = &fs;
return mesh::saveRoomHistorySnapshot(&fs, state, uint8_t(records.size()),
[&](uint8_t index, Record& output) {
if (index >= records.size()) return false;
output = records[index]; return true;
});
}
static bool append(MemoryFS& fs, State& state, const Record& input) {
metadata_filesystem = &fs;
return mesh::appendRoomHistory(&fs, state, input.author, input.timestamp, input.text);
}
static bool load(MemoryFS& fs, State& state, std::vector<Record>& records) {
metadata_filesystem = &fs; records.clear();
return mesh::loadRoomHistory(&fs, state,
[&](uint8_t index, const Record& input) {
assert(index == records.size()); records.push_back(input); return true;
});
}
static bool config(MemoryFS& fs, bool enabled) {
metadata_filesystem = &fs; return mesh::saveRoomHistoryConfig(&fs, enabled);
}
static bool loadConfig(MemoryFS& fs, bool& enabled) {
metadata_filesystem = &fs; return mesh::loadRoomHistoryConfig(&fs, enabled);
}
static void expect(MemoryFS& fs, const std::vector<Record>& expected, bool repair = false) {
State state; std::vector<Record> actual;
assert(load(fs, state, actual) && actual.size() == expected.size());
for (size_t index = 0; index < expected.size(); ++index) assert(same(actual[index], expected[index]));
assert(state.count == expected.size() && state.needs_repair == repair);
}
static std::vector<uint8_t> rendered(const Record& input) {
uint8_t image[mesh::ROOM_HISTORY_RECORD_SIZE];
assert(mesh::room_history_detail::encodeRecord(input, image));
return {image, image + sizeof(image)};
}
static void checkFailedSnapshot(MemoryFS& fs, State& state,
const std::vector<Record>& old) {
const State previous = state;
assert(!snapshot(fs, state, {record(10), record(11)}));
assert(sameState(state, previous)); fs.reset(); expect(fs, old);
assert(!fs.files.count(T) && !fs.files.count(B)); ++scenarios;
}
int main() {
const std::vector<Record> old = {record(1), record(2)};
{
MemoryFS fs; bool enabled = true; assert(loadConfig(fs, enabled) && !enabled);
assert(config(fs, true) && fs.get(C) == CONFIG_ON);
assert(loadConfig(fs, enabled) && enabled);
assert(config(fs, false) && fs.get(C) == CONFIG_OFF);
assert(loadConfig(fs, enabled) && !enabled); ++scenarios;
}
{
MemoryFS fs; State state; std::vector<Record> rows;
assert(load(fs, state, rows) && !state.present && rows.empty());
assert(append(fs, state, record(1)) && state.present);
const auto wire = fs.get(P);
assert(wire.size() == HEADER.size() + RECORD_ONE.size());
assert(std::equal(HEADER.begin(), HEADER.end(), wire.begin()));
assert(std::equal(RECORD_ONE.begin(), RECORD_ONE.end(), wire.begin() + HEADER.size()));
expect(fs, {record(1)}); ++scenarios;
}
{
MemoryFS fs; State loaded; assert(snapshot(fs, loaded, old));
const auto previous = fs.get(P); State uninitialized; fs.reset();
assert(!append(fs, uninitialized, record(3)) && uninitialized.needs_repair);
assert(fs.get(P) == previous && fs.write_opens == 0);
fs.files.erase(P); fs.put(B, previous); uninitialized = State{}; fs.reset();
assert(!append(fs, uninitialized, record(3)) && uninitialized.needs_repair);
assert(fs.get(P) == previous && fs.write_opens == 0); ++scenarios;
}
{
MemoryFS fs; State state; std::vector<Record> retained;
assert(snapshot(fs, state, {}));
for (unsigned number = 1; number <= 64; ++number) assert(append(fs, state, record(number)));
assert(state.count == 32 && state.total_records == 64);
assert(fs.get(P).size() == mesh::ROOM_HISTORY_MAX_FILE_SIZE);
for (unsigned number = 33; number <= 64; ++number) retained.push_back(record(number));
expect(fs, retained); const auto full = fs.get(P); fs.reset();
assert(!append(fs, state, record(65)) && fs.trace.empty() && fs.get(P) == full);
assert(snapshot(fs, state, retained) && state.total_records == 32);
assert(fs.get(P).size() == HEADER.size() + 32 * RECORD_ONE.size());
assert(append(fs, state, record(65))); retained.erase(retained.begin()); retained.push_back(record(65));
expect(fs, retained); metadata_filesystem = &fs;
assert(mesh::clearRoomHistory(&fs, state) && fs.get(P) == HEADER && state.count == 0);
expect(fs, {}); assert(!fs.files.count(T) && !fs.files.count(B)); ++scenarios;
}
{
MemoryFS fs; State state; assert(snapshot(fs, state, old)); const auto previous = fs.get(P);
Record maximum = record(3); memset(maximum.text, 'x', 151); maximum.text[151] = 0;
assert(append(fs, state, maximum)); expect(fs, {record(1), record(2), maximum});
fs.reset(); const auto accepted = fs.get(P);
Record bad = maximum; memset(bad.text, 'x', sizeof(bad.text));
assert(!append(fs, state, bad) && fs.trace.empty());
bad = record(4); bad.timestamp = 0; assert(!append(fs, state, bad) && fs.trace.empty());
bad = record(4); memset(bad.author, 0, sizeof(bad.author));
assert(!append(fs, state, bad) && fs.trace.empty() && fs.get(P) == accepted);
assert(previous.size() < accepted.size()); ++scenarios;
}
{
MemoryFS fs; State state; assert(snapshot(fs, state, old)); const auto previous = fs.get(P);
metadata_filesystem = &fs;
assert(!mesh::saveRoomHistorySnapshot(&fs, state, 33, mesh::room_history_detail::EmptyProvider{}));
assert(fs.get(P) == previous);
unsigned calls = 0;
assert(!mesh::saveRoomHistorySnapshot(&fs, state, 2, [&](uint8_t, Record& out) {
out = record(10); return ++calls == 1;
})); expect(fs, old);
calls = 0;
assert(!mesh::saveRoomHistorySnapshot(&fs, state, 2, [&](uint8_t index, Record& out) {
out = record(index + 10); return ++calls <= 2; // writer succeeds, verification provider fails
})); expect(fs, old); ++scenarios;
}
for (const std::string& fault : {std::string("open:") + WRITE_MODE + ":" + 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("directory:") + T, std::string("rename:") + P + ":" + B,
std::string("rename:") + T + ":" + P}) {
MemoryFS fs; State state; assert(snapshot(fs, state, old)); fs.reset();
fs.faults.insert(fault); checkFailedSnapshot(fs, state, old);
}
{
MemoryFS fs; State state; assert(snapshot(fs, state, old));
fs.capacity = fs.get(P).size() + 100; checkFailedSnapshot(fs, state, old);
}
{
MemoryFS fs; State state; assert(snapshot(fs, state, old));
fs.replacement_path = T; fs.replacement_on_close = fs.get(P);
checkFailedSnapshot(fs, state, old); // valid CRC stale image is not intended contents
}
{
MemoryFS fs; State state; assert(snapshot(fs, state, 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(!snapshot(fs, state, {record(10)})); assert(!fs.files.count(P) && fs.files.count(B));
fs.reset(); expect(fs, old); ++scenarios;
}
{
MemoryFS fs; State state; assert(snapshot(fs, state, old));
fs.faults.insert(std::string("remove:") + B);
assert(snapshot(fs, state, {record(10)}) && fs.files.count(B));
const unsigned writes = fs.write_opens;
assert(!snapshot(fs, state, old) && fs.write_opens == writes);
assert(!append(fs, state, record(11)) && fs.write_opens == writes && state.needs_repair);
fs.reset(); expect(fs, {record(10)}); assert(snapshot(fs, state, {record(10)}));
assert(append(fs, state, record(11))); ++scenarios;
}
for (const std::string& fault : {std::string("open:a:") + P, std::string("write:") + P,
std::string("flush:") + P, std::string("close:w:") + P,
std::string("seek:") + P}) {
MemoryFS fs; State state; assert(snapshot(fs, state, old)); fs.reset();
fs.faults.insert(fault); assert(!append(fs, state, record(3)) && state.needs_repair);
assert(state.count == 2 && state.total_records == 2);
const unsigned writes = fs.write_opens;
assert(!append(fs, state, record(3)) && fs.write_opens == writes);
fs.reset(); assert(snapshot(fs, state, old) && append(fs, state, record(3)));
expect(fs, {record(1), record(2), record(3)}); ++scenarios;
}
{
MemoryFS fs; State state; assert(snapshot(fs, state, old)); fs.reset(); fs.short_write = true;
assert(!append(fs, state, record(3)) && state.needs_repair); fs.reset(); expect(fs, old, true);
assert(snapshot(fs, state, old) && append(fs, state, record(3))); ++scenarios;
}
{
MemoryFS fs; State state; assert(snapshot(fs, state, old)); fs.reset();
fs.capacity = fs.get(P).size() + 100;
assert(!append(fs, state, record(3)) && state.needs_repair); fs.reset(); expect(fs, old, true); ++scenarios;
}
{
MemoryFS fs; State state; assert(snapshot(fs, state, old)); fs.reset();
// Three old scan reads; the fourth, post-write verification header, fails.
fs.fail_read_at = 4;
assert(!append(fs, state, record(3)) && state.needs_repair && state.count == 2);
fs.reset(); expect(fs, {record(1), record(2), record(3)});
// Runtime repair uses unchanged trusted RAM, discarding the unACKed append.
assert(snapshot(fs, state, old) && append(fs, state, record(3)));
expect(fs, {record(1), record(2), record(3)}); ++scenarios;
}
for (size_t tail_length = 1; tail_length < RECORD_ONE.size(); ++tail_length) {
MemoryFS fs; State state; assert(snapshot(fs, state, old)); auto bytes = fs.get(P);
const auto tail = rendered(record(3)); bytes.insert(bytes.end(), tail.begin(), tail.begin() + tail_length);
fs.put(P, bytes); expect(fs, old, true); std::vector<Record> rows;
assert(load(fs, state, rows) && state.needs_repair);
fs.reset(); assert(!append(fs, state, record(3)) && fs.trace.empty() && fs.get(P) == bytes);
assert(snapshot(fs, state, old) && append(fs, state, record(3))); ++scenarios;
}
for (size_t position = 0; position < RECORD_ONE.size(); ++position) {
MemoryFS fs; State state; assert(snapshot(fs, state, old)); assert(append(fs, state, record(3)));
auto bytes = fs.get(P); bytes[HEADER.size() + 2 * RECORD_ONE.size() + position] ^= 1;
fs.put(P, bytes); expect(fs, old, true); assert(fs.get(P) == bytes); ++scenarios;
}
// Corruption before another complete accepted record is never a torn
// append. Do not expose a shortened RAM ring that could replace the intact
// later records on the next post, clear, or snapshot attempt.
for (size_t damaged_record = 0; damaged_record < 2; ++damaged_record) {
for (size_t position = 0; position < RECORD_ONE.size(); ++position) {
MemoryFS fs; State state;
assert(snapshot(fs, state, {record(1), record(2), record(3)}));
auto bytes = fs.get(P);
bytes[HEADER.size() + damaged_record * RECORD_ONE.size() + position] ^= 1;
fs.put(P, bytes); fs.reset();
const State previous = state;
std::vector<Record> rows;
assert(!load(fs, state, rows) && sameState(state, previous) && rows.empty());
assert(fs.get(P) == bytes && fs.write_opens == 0);
// Both a truncated boot destination and the old trusted live ring must
// fail closed; administrative clear cannot discard the evidence either.
assert(!snapshot(fs, state, {record(1)}));
assert(!snapshot(fs, state, {record(1), record(2), record(3)}));
assert(!mesh::clearRoomHistory(&fs, state));
assert(fs.get(P) == bytes && fs.write_opens == 0 && sameState(state, previous));
assert(!append(fs, state, record(4)) && state.needs_repair);
assert(fs.get(P) == bytes && fs.write_opens == 0);
assert(!fs.files.count(T) && !fs.files.count(B)); ++scenarios;
}
}
for (int malformed = 0; malformed < 4; ++malformed) {
MemoryFS fs; State state; assert(snapshot(fs, state, old));
auto bytes = fs.get(P); auto tail = rendered(record(3));
if (malformed == 0) std::fill(tail.begin(), tail.begin() + 32, 0);
if (malformed == 1) std::fill(tail.begin() + 32, tail.begin() + 36, 0);
if (malformed == 2) std::fill(tail.begin() + 36, tail.begin() + 188, 'x');
if (malformed == 3) tail[150] = 'x';
mesh::room_history_detail::put32(tail.data() + 188,
mesh::room_history_detail::crcBytes(tail.data(), 188));
bytes.insert(bytes.end(), tail.begin(), tail.end()); fs.put(P, bytes);
expect(fs, old, true); assert(fs.get(P) == bytes); ++scenarios;
}
{
MemoryFS fs; State state; assert(snapshot(fs, state, old)); fs.reset(); fs.fail_read_at = 2;
const State before = state; std::vector<Record> rows;
assert(!load(fs, state, rows) && sameState(state, before) && rows.empty());
fs.reset(); fs.fail_read_at = 6;
assert(!load(fs, state, rows) && sameState(state, before) && rows.size() == 1);
rows.clear(); fs.reset(); expect(fs, old); ++scenarios;
}
std::vector<std::vector<uint8_t>> invalid_headers = {{}, {1}, std::vector<uint8_t>(11), HEADER,
std::vector<uint8_t>(mesh::ROOM_HISTORY_MAX_FILE_SIZE + 1)};
invalid_headers[3][3] = 2;
mesh::room_history_detail::put32(invalid_headers[3].data() + 8,
mesh::room_history_detail::crcBytes(invalid_headers[3].data(), 8));
for (const auto& bytes : invalid_headers) {
MemoryFS fs; State state; assert(snapshot(fs, state, old)); const auto good = fs.get(P);
fs.put(P, bytes); fs.put(B, good); fs.put(T, good);
State before = state; std::vector<Record> rows;
assert(!load(fs, state, rows) && sameState(state, before));
assert(!snapshot(fs, state, {}) && fs.get(P) == bytes && fs.get(B) == good && fs.get(T) == good);
MemoryFS backup; backup.put(B, bytes); backup.put(T, good);
assert(!load(backup, state, rows) && backup.get(B) == bytes && backup.get(T) == good); ++scenarios;
}
for (size_t position = 0; position < HEADER.size(); ++position) {
MemoryFS fs; State state; assert(snapshot(fs, state, old)); auto bytes = fs.get(P);
bytes[position] ^= 1; fs.put(P, bytes); std::vector<Record> rows;
assert(!load(fs, state, rows) && !snapshot(fs, state, {}));
assert(fs.get(P) == bytes); ++scenarios;
}
for (size_t position : {size_t(4), size_t(6), size_t(7)}) {
MemoryFS fs; State state; assert(snapshot(fs, state, old)); auto bytes = fs.get(P);
++bytes[position]; mesh::room_history_detail::put32(bytes.data() + 8,
mesh::room_history_detail::crcBytes(bytes.data(), 8)); fs.put(P, bytes);
std::vector<Record> rows; assert(!load(fs, state, rows) && !snapshot(fs, state, {}));
assert(fs.get(P) == bytes); ++scenarios;
}
{
MemoryFS fs; State state; assert(snapshot(fs, state, old)); const auto bytes = fs.get(P);
fs.files.erase(P); fs.put(B, bytes); fs.put(T, HEADER); expect(fs, old);
assert(fs.get(P) == bytes && !fs.files.count(T) && !fs.files.count(B));
MemoryFS temp; temp.put(T, bytes); expect(temp, {}); assert(!temp.files.count(P)); ++scenarios;
}
{
MemoryFS fs; State state; assert(snapshot(fs, state, old));
fs.directories.insert(P); std::vector<Record> rows;
assert(!load(fs, state, rows) && !snapshot(fs, state, {}) && fs.files.count(P)); ++scenarios;
}
for (size_t position = 0; position < CONFIG_ON.size(); ++position) {
MemoryFS fs; auto image = CONFIG_ON; image[position] ^= 1;
fs.put(C, image); fs.put(CB, CONFIG_OFF); fs.put(CT, CONFIG_OFF);
bool enabled = false;
assert(!loadConfig(fs, enabled) && !enabled && !config(fs, false));
assert(fs.get(C) == image && fs.get(CB) == CONFIG_OFF && fs.get(CT) == CONFIG_OFF); ++scenarios;
}
{
MemoryFS fs; auto image = CONFIG_ON; image[3] = 2;
mesh::room_history_detail::put32(image.data() + 12, mesh::room_history_detail::crcBytes(image.data(), 12));
fs.put(C, image); bool enabled = true;
assert(!loadConfig(fs, enabled) && enabled && !config(fs, false) && fs.get(C) == image);
MemoryFS backup; backup.put(CB, CONFIG_ON); backup.put(CT, CONFIG_OFF);
assert(loadConfig(backup, enabled) && enabled && backup.get(C) == CONFIG_ON);
MemoryFS temp; temp.put(CT, CONFIG_ON); assert(loadConfig(temp, enabled) && !enabled && !temp.files.count(C)); ++scenarios;
}
for (size_t position : {size_t(4), size_t(5)}) {
MemoryFS fs; auto image = CONFIG_ON; image[position] = 2;
mesh::room_history_detail::put32(image.data() + 12,
mesh::room_history_detail::crcBytes(image.data(), 12)); fs.put(C, image);
bool enabled = false; assert(!loadConfig(fs, enabled) && !config(fs, false));
assert(fs.get(C) == image); ++scenarios;
}
for (const std::string& fault : {std::string("open:") + WRITE_MODE + ":" + CT,
std::string("write:") + CT, std::string("flush:") + CT,
std::string("close:w:") + CT, std::string("open:r:") + CT,
std::string("read:") + CT, std::string("size:") + CT,
std::string("directory:") + CT, std::string("rename:") + C + ":" + CB,
std::string("rename:") + CT + ":" + C}) {
MemoryFS fs; assert(config(fs, false)); fs.reset(); fs.faults.insert(fault);
assert(!config(fs, true)); fs.reset(); bool enabled = true;
assert(loadConfig(fs, enabled) && !enabled); ++scenarios;
}
#if defined(ESP32_PLATFORM) || defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
for (const char* path : {P, C}) {
MemoryFS fs; State state; assert(snapshot(fs, state, old) && config(fs, false)); fs.reset();
fs.faults.insert(std::string("stat:") + path); bool enabled = false; std::vector<Record> rows;
if (path == P) assert(!load(fs, state, rows) && !snapshot(fs, state, {}));
else assert(!loadConfig(fs, enabled) && !config(fs, true));
assert(fs.write_opens == 0); ++scenarios;
}
#endif
// Simulate interruption before/after every filesystem operation, including
// verification and publication. Only temp->primary publication changes the
// selected snapshot/config; journal appends may recover a complete new row.
for (int action = 0; action < 3; ++action) {
MemoryFS baseline; State state; assert(snapshot(baseline, state, old) && config(baseline, false));
baseline.reset();
if (action == 0) assert(snapshot(baseline, state, {record(10), record(11)}));
if (action == 1) assert(append(baseline, state, record(3)));
if (action == 2) assert(config(baseline, true));
const size_t boundaries = baseline.boundary;
for (size_t cut = 1; cut <= boundaries; ++cut) {
MemoryFS fs; State current; assert(snapshot(fs, current, old) && config(fs, false));
fs.reset(); fs.cut_at = cut;
try {
if (action == 0) snapshot(fs, current, {record(10), record(11)});
if (action == 1) append(fs, current, record(3));
if (action == 2) config(fs, true);
} catch (const PowerCut&) {}
const bool new_snapshot = fs.files.count(P) && fs.get(P).size() > HEADER.size()
&& fs.get(P)[HEADER.size() + 31] == 10;
const bool appended = action == 1 && fs.files.count(P)
&& fs.get(P).size() == HEADER.size() + 3 * RECORD_ONE.size();
const bool new_config = fs.files.count(C) && fs.get(C) == CONFIG_ON;
fs.reset();
if (action == 0) expect(fs, new_snapshot ? std::vector<Record>{record(10), record(11)} : old);
if (action == 1) expect(fs, appended ? std::vector<Record>{record(1), record(2), record(3)} : old);
if (action == 2) { bool enabled; assert(loadConfig(fs, enabled) && enabled == new_config); expect(fs, old); }
assert(!fs.files.count(T) && !fs.files.count(B) && !fs.files.count(CT) && !fs.files.count(CB)); ++scenarios;
}
}
printf("PASS: %u bounded room history fault and recovery scenarios\n", scenarios);
}
+237
View File
@@ -0,0 +1,237 @@
// Actual receive dispatcher and Companion queue/callbacks are extracted into
// production.inc. Radio, crypto, UI and persistent-store boundaries are mocks.
#include <algorithm>
#include <cassert>
#include <cctype>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
#include <utility>
#include <vector>
#include "helpers/CompanionFrameLimits.h"
#define PUB_KEY_SIZE 32
#define PRV_KEY_SIZE 64
#define MAX_CONTACTS 2
#define OFFLINE_QUEUE_SIZE 4
#define ADV_TYPE_NONE 0
#define ADV_TYPE_ROOM 3
#define OUT_PATH_UNKNOWN 255
#define PAYLOAD_TYPE_TXT_MSG 2
#define PAYLOAD_TYPE_REQ 0
#define PAYLOAD_TYPE_RESPONSE 1
#define PAYLOAD_TYPE_ACK 3
#define TXT_ACK_DELAY 200
#define CLI_REPLY_DELAY_MILLIS 200
#define SERVER_RESPONSE_DELAY 200
#define COMPANION_FEATURE_TEXT_TERMINAL 0
#define COMPANION_FEATURE_NOTIFICATIONS 1
#define MESH_ENABLE_ONE_KEY_DM 0
#define ONE_KEY_DM_SHARED_OFFLINE_QUEUE 0
#define DISPLAY_CLASS TestDisplay
#define MESH_DEBUG_PRINTLN(...) ((void)0)
#include "constants.inc"
static uint32_t millis() { return 100; }
static bool millisHasNowPassed(unsigned long deadline) { return millis() > deadline; }
namespace mesh {
struct Identity { uint8_t pub_key[PUB_KEY_SIZE] = {}; };
struct Packet {
bool flood = false;
uint8_t path[2] = {7, 8}, path_len = 2;
float getSNR() const { return 10.75f; }
bool isRouteFlood() const { return flood; }
};
struct Utils {
static void sha256(uint8_t* out, size_t size, const uint8_t*, size_t,
const uint8_t*, size_t) { memset(out, 0x55, size); }
};
}
struct ContactInfo {
mesh::Identity id;
uint8_t type = ADV_TYPE_ROOM, out_path_len = 0;
uint32_t sync_since = 50, lastmod = 99;
char name[16] = "room";
const uint8_t* getPath() const { return id.pub_key; }
};
struct Clock {
uint32_t now = 1200;
uint32_t getCurrentTime() const { return now; }
uint32_t getCurrentTimeUnique() { return now++; }
};
struct Rng { void random(uint8_t* out, size_t length) { memset(out, 0, length); } };
struct BaseChatMesh {
ContactInfo contacts[MAX_CONTACTS];
int matching_peer_indexes[1] = {0}, num_contacts = MAX_CONTACTS;
bool mutable_contacts = true;
Clock clock;
Rng rng;
mesh::Identity self_id;
mesh::Packet response;
unsigned direct_acks = 0, flood_acks = 0;
std::vector<uint8_t> ack;
uint8_t temp_buf[184] = {};
bool canMutateContacts() const { return mutable_contacts; }
Clock* getRTCClock() { return &clock; }
Rng* getRNG() { return &rng; }
virtual bool onSignedMessageRecv(const ContactInfo&, mesh::Packet*, uint32_t, const uint8_t*, const char*) = 0;
virtual void onMessageRecv(const ContactInfo&, mesh::Packet*, uint32_t, const char*) = 0;
void onCommandDataRecv(const ContactInfo&, mesh::Packet*, uint32_t, const char*) {}
void onCLICommandRecv(const ContactInfo&, mesh::Packet*, uint32_t, const char*, char*) {}
uint8_t onContactRequest(const ContactInfo&, uint32_t, const uint8_t*, size_t, uint8_t*) { return 0; }
void onContactResponse(const ContactInfo&, const uint8_t*, size_t) {}
void handleReturnPathRetry(const ContactInfo&, const uint8_t*, uint8_t) {}
mesh::Packet* createDatagram(uint8_t, const mesh::Identity&, const uint8_t*, const uint8_t*, size_t) { return &response; }
mesh::Packet* createPathReturn(const mesh::Identity&, const uint8_t*, const uint8_t*, uint8_t,
uint8_t type, const uint8_t* bytes, size_t length) {
if (type == PAYLOAD_TYPE_ACK) { ++flood_acks; ack.assign(bytes, bytes + length); }
return &response;
}
void sendAckTo(const ContactInfo&, const uint8_t* bytes, size_t length = 4) {
++direct_acks; ack.assign(bytes, bytes + length);
}
void sendFloodScoped(const ContactInfo&, mesh::Packet*, unsigned long = 0) {}
void sendDirect(mesh::Packet*, const uint8_t*, uint8_t, unsigned long = 0) {}
void onPeerDataRecv(mesh::Packet*, uint8_t, int, const uint8_t*, uint8_t*, size_t);
};
enum class UIEventType { contactMessage };
struct UI {
unsigned queue_changes = 0, messages = 0, notifications = 0, overrides = 0;
void syncMessageQueue(int, int = -1) { ++queue_changes; }
void setMessageNotificationOverride(bool, bool = false) { ++overrides; }
template<typename... Args> void newMsg(Args...) { ++messages; }
void notify(UIEventType) { ++notifications; }
};
struct Notifications {
unsigned remote = 0, ordinary = 0;
bool remoteMessage(const uint8_t*, const char*, uint32_t, uint32_t) { ++remote; return false; }
bool message(const uint8_t*, const uint8_t*, bool, uint32_t) { ++ordinary; return false; }
bool overridesScreen() const { return false; }
};
struct Serial {
bool connected = false;
unsigned writes = 0;
bool isConnected() const { return connected; }
bool isReplyRouteAvailable(void*) const { return true; }
size_t writeFrameToRoute(void*, const uint8_t*, size_t length) { ++writes; return length; }
size_t writeFrame(const uint8_t*, size_t length) { ++writes; return length; }
};
struct MyMesh : BaseChatMesh {
#include "frame.inc"
Frame offline_queue[OFFLINE_QUEUE_SIZE] = {};
int offline_queue_len = 0, offline_queue_head = 0;
uint8_t out_frame[MAX_FRAME_SIZE + 1] = {};
uint8_t app_target_ver = 3;
UI ui, *_ui = &ui;
Serial serial, *_serial = &serial;
Notifications _notifications;
void* private_key_backup_route = nullptr;
unsigned long private_key_backup_deadline = 1000;
uint8_t private_key_backup_sender[6] = {};
char private_key_backup_nonce[17] = {};
unsigned active = 0;
std::vector<std::pair<uint32_t, uint32_t>> saved;
MyMesh() { contacts[0].id.pub_key[0] = 77; contacts[1].id.pub_key[0] = 77; contacts[1].id.pub_key[31] = 1; }
void markConnectionActive(const ContactInfo&) { ++active; }
bool scheduleContactWrite(const ContactInfo& from) { saved.emplace_back(from.sync_since, from.lastmod); return true; }
int getOfflineQueueCapacity() const;
Frame& offlineQueueFrameAt(int);
bool addToOfflineQueue(const uint8_t*, int);
void popOfflineQueue();
bool queueMessage(const ContactInfo&, uint8_t, mesh::Packet*, uint32_t, const uint8_t*, int, const char*, bool = false, uint32_t = 0);
bool onSignedMessageRecv(const ContactInfo&, mesh::Packet*, uint32_t, const uint8_t*, const char*) override;
void onMessageRecv(const ContactInfo&, mesh::Packet*, uint32_t, const char*) override;
void receive(mesh::Packet& packet, uint32_t timestamp, const char* text, uint8_t type = TXT_TYPE_SIGNED_PLAIN, int sender = 0) {
std::vector<uint8_t> payload(10 + strlen(text), 0);
memcpy(payload.data(), &timestamp, 4); payload[4] = uint8_t(type << 2);
const size_t offset = type == TXT_TYPE_SIGNED_PLAIN ? 9 : 5;
if (offset == 9) { payload[5] = 1; payload[6] = 2; payload[7] = 3; payload[8] = 4; }
memcpy(payload.data() + offset, text, strlen(text));
matching_peer_indexes[0] = sender;
const uint8_t secret[32] = {};
onPeerDataRecv(&packet, PAYLOAD_TYPE_TXT_MSG, 0, secret, payload.data(), offset + strlen(text));
}
void fill(bool channel = false) {
for (int at = 0; at < OFFLINE_QUEUE_SIZE; ++at) {
const uint8_t frame[] = {uint8_t(channel && at == 1 ? RESP_CODE_CHANNEL_MSG_RECV : RESP_CODE_CONTACT_MSG_RECV), uint8_t(at)};
assert(addToOfflineQueue(frame, sizeof(frame)));
}
}
};
#include "production.inc"
static unsigned effects(const MyMesh& m) {
return m.ui.messages + m.ui.notifications + m.ui.overrides + m.serial.writes
+ m._notifications.remote + m._notifications.ordinary + m.active + unsigned(m.saved.size());
}
int main() {
unsigned cases = 0;
for (int version : {2, 3}) for (bool flood : {false, true}) for (bool connected : {false, true}) {
MyMesh m; m.app_target_ver = version; m.serial.connected = connected; m.fill();
mesh::Packet packet; packet.flood = flood;
const unsigned before = effects(m);
m.receive(packet, 100, "retained-before-ack");
assert(m.contacts[0].sync_since == 50 && m.contacts[0].lastmod == 99);
assert(m.direct_acks == 0 && m.flood_acks == 0 && m.saved.empty());
assert(effects(m) == before && m.offline_queue_len == OFFLINE_QUEUE_SIZE);
m.popOfflineQueue(); m.receive(packet, 100, "retained-before-ack");
assert(m.contacts[0].sync_since == 100 && m.contacts[0].lastmod == 1200);
assert(m.saved.size() == 1 && m.saved[0] == std::make_pair(100U, 1200U));
assert(m.direct_acks == unsigned(!flood) && m.flood_acks == unsigned(flood));
assert(m.ack.size() == 4 && m.ui.messages == 1 && m._notifications.ordinary == 1);
const auto& frame = m.offlineQueueFrameAt(OFFLINE_QUEUE_SIZE - 1);
const size_t timestamp_offset = version >= 3 ? 12 : 9;
uint32_t stored; memcpy(&stored, frame.buf + timestamp_offset, 4); assert(stored == 100);
assert(frame.buf[timestamp_offset - 1] == TXT_TYPE_SIGNED_PLAIN);
assert(memcmp(frame.buf + timestamp_offset + 4, "\x01\x02\x03\x04", 4) == 0);
// Lost ACK with a now-full queue must not claim retention of another copy.
const unsigned accepted_effects = effects(m);
m.receive(packet, 100, "retained-before-ack");
assert(effects(m) == accepted_effects && m.saved.size() == 1);
assert(m.direct_acks + m.flood_acks == 1 && m.contacts[0].sync_since == 100);
// Once the app drains capacity, normal retry admission and ACK resume.
m.popOfflineQueue(); m.receive(packet, 100, "retained-before-ack");
assert(m.direct_acks + m.flood_acks == 2 && m.saved.size() == 2);
assert(m.contacts[0].sync_since == 100 && m.offline_queue_len == OFFLINE_QUEUE_SIZE);
++cases;
}
{
MyMesh m; m.fill(true); mesh::Packet packet;
m.receive(packet, 100, "replace-channel");
assert(m.offline_queue_len == OFFLINE_QUEUE_SIZE && m.direct_acks == 1);
assert(m.contacts[0].sync_since == 100 && m.saved.size() == 1); ++cases;
}
{
MyMesh m; mesh::Packet packet; m.mutable_contacts = false;
m.receive(packet, 100, "immutable-contact");
assert(m.contacts[0].sync_since == 50 && m.contacts[0].lastmod == 99);
assert(m.direct_acks == 1 && m.offline_queue_len == 1); ++cases;
}
{
MyMesh m; mesh::Packet packet;
m.receive(packet, 40, "earlier-topic");
assert(m.contacts[0].sync_since == 50 && m.direct_acks == 1);
m.receive(packet, 100, "same-text", TXT_TYPE_SIGNED_PLAIN, 0);
m.receive(packet, 100, "same-text", TXT_TYPE_SIGNED_PLAIN, 1);
assert(m.contacts[1].sync_since == 100 && m.offline_queue_len == 3);
// Distinct full room keys sharing the serialized prefix are both retained.
++cases;
}
{
MyMesh m; m.fill(); mesh::Packet packet;
m.receive(packet, 100, "direct-message", TXT_TYPE_PLAIN);
assert(m.direct_acks == 1 && m.ack.size() == 6);
assert(m.contacts[0].sync_since == 50 && m.saved.size() == 1);
assert(m.ui.messages == 1 && m.offline_queue_len == OFFLINE_QUEUE_SIZE); ++cases;
}
for (size_t length : {6U, 7U, 8U}) {
MyMesh m; mesh::Packet packet; std::vector<uint8_t> malformed(length + 1, 0);
malformed[4] = TXT_TYPE_SIGNED_PLAIN << 2;
const uint8_t secret[32] = {};
m.onPeerDataRecv(&packet, PAYLOAD_TYPE_TXT_MSG, 0, secret, malformed.data(), length);
assert(m.direct_acks == 0 && m.offline_queue_len == 0 && effects(m) == 0);
assert(m.contacts[0].sync_since == 50 && m.contacts[0].lastmod == 99); ++cases;
}
assert(cases == 15);
printf("PASS: production room receive admission (%u cases, direct/flood, legacy/V3, both app states)\n", cases);
return 0;
}
+36 -3
View File
@@ -4,6 +4,8 @@
#include <cstring>
#include <string>
#include <vector>
#include "../room_history_store/filesystem.h"
#include <helpers/RoomAccessPolicy.h>
#define PUB_KEY_SIZE 32
#define MAX_PATH_SIZE 64
@@ -55,7 +57,7 @@ static bool saveRoomTopic(Store* store, const char* text) {
}
}
#include "state.inc"
static_assert(sizeof(((ClientInfo*)0)->extra.room) <= sizeof(((ClientInfo*)0)->extra.sensor),
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;
@@ -79,6 +81,8 @@ struct Sent {
};
struct MyMesh {
ACL acl;
MemoryFS policy_fs;
mesh::RoomAccessPolicy room_access;
Clock clock;
Random rng;
mesh::Identity self_id;
@@ -95,9 +99,13 @@ struct MyMesh {
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; } _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;
@@ -200,9 +208,10 @@ int main() {
}
{
MyMesh m; m.topic("topic"); m.clock.now = 1003;
m.post(0, 1000, "unaged"); m.tick(6000);
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.clock.now = 1007; m.tick(); assert(m.sent.back().text == "unaged");
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}) {
@@ -266,5 +275,29 @@ int main() {
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);
}
puts("room topic delivery regressions passed");
}
+5
View File
@@ -0,0 +1,5 @@
{
"status": {"name":"Test room","topic":"Topic <img src=x onerror=attack()>","boot":71,"role":2,"web_key":"5f5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a","persistent_history":false},
"index": {"revision":1,"articles":[{"id":1,"version":1,"length":22,"title":"Instructions"}],"next":255},
"read": {"version":1,"offset":0,"next":22,"total":22,"data64":"UmVhZCBvbmx5IHRoaXMgYXJ0aWNsZQ=="}
}
+279
View File
@@ -0,0 +1,279 @@
#include "filesystem.h"
#include <cstdlib>
#include <new>
#include <array>
#include <ArduinoJson.h>
static bool deny_json_allocation = false;
static unsigned json_allocations = 0;
static void* trackedMalloc(size_t size) {
if (deny_json_allocation) return nullptr;
void* result = std::malloc(size); if (result) ++json_allocations; return result;
}
static void trackedFree(void* ptr) {
if (ptr) { assert(json_allocations != 0); --json_allocations; } std::free(ptr);
}
static void* trackedRealloc(void* ptr, size_t size) {
if (deny_json_allocation) return nullptr;
return std::realloc(ptr, size);
}
#define malloc trackedMalloc
#define free trackedFree
#define realloc trackedRealloc
#include <helpers/RoomWebJson.h>
#undef malloc
#undef free
#undef realloc
#include <helpers/RoomBoardStore.h>
#include <helpers/RoomHistoryStore.h>
#include <helpers/RoomAccessPolicy.h>
#include <helpers/RoomLoginAuthorization.h>
#define WITH_WEBCONFIG 1
#define PUB_KEY_SIZE 32
#define MAX_POST_TEXT_LEN 151
#define MAX_UNSYNCED_POSTS 32
#define POST_SYNC_DELAY_SECS 2
#define PUSH_NOTIFY_DELAY_MILLIS 200
#define MESH_DEBUG_PRINTLN(...) ((void)0)
#include "roles.inc"
static uint32_t ticks = 100;
static uint32_t millis() { return ticks; }
static uint32_t futureMillis(uint32_t duration) { return millis() + duration; }
namespace mesh {
struct Identity {
uint8_t pub_key[32] = {};
bool matches(const Identity& other) const { return memcmp(pub_key, other.pub_key, 32) == 0; }
};
struct Utils {
// Cryptography is a boundary mock: every fixture token has a distinct key.
static void sha256(uint8_t* out, size_t length, const uint8_t* token, size_t token_length,
const uint8_t* server, size_t server_length) {
assert(length == 32 && token_length == 32 && server_length == 32);
for (size_t i = 0; i < length; ++i) out[i] = token[i] ^ server[i] ^ 0x5a;
}
static void toHex(char* output, const uint8_t* key, size_t length) {
static const char digits[] = "0123456789abcdef";
for (size_t i = 0; i < length; ++i) { output[2 * i] = digits[key[i] >> 4]; output[2 * i + 1] = digits[key[i] & 15]; }
output[length * 2] = 0;
}
};
}
struct StrHelper {
static void strncpy(char* out, const char* text, size_t capacity) { std::snprintf(out, capacity, "%s", text); }
};
struct Preferences {
static bool value, fail_open, fail_write;
bool begin(const char* name, bool readonly) { assert(strcmp(name, "mesh-room") == 0 && !readonly); return !fail_open; }
size_t putBool(const char* key, bool next) { assert(strcmp(key, "web") == 0); if (fail_write) return 0; value = next; return sizeof(bool); }
bool getBool(const char* key, bool fallback) { (void)fallback; assert(strcmp(key, "web") == 0); return value; }
void end() {}
};
bool Preferences::value = false, Preferences::fail_open = false, Preferences::fail_write = false;
struct ClientInfo {
mesh::Identity id;
uint8_t permissions = PERM_ACL_ADMIN;
struct { struct { uint16_t post_quota_used = 0, poll_quota_used = 0; uint32_t last_post_timestamp = 0; } room; } extra;
};
struct ACL {
ClientInfo radio_admin;
unsigned lookups = 0;
int getNumClients() const { return 1; }
ClientInfo* getClientByIdx(int i) { assert(i == 0); return &radio_admin; }
ClientInfo* getClient(const mesh::Identity&) { ++lookups; return &radio_admin; }
};
struct Clock { uint32_t now = 1000; uint32_t getCurrentTimeUnique() { return now++; } };
#include "post.inc"
struct MyMesh {
MemoryFS storage; MemoryFS* _fs = &storage;
mesh::RoomAccessPolicy room_access;
mesh::Identity self_id;
struct { char password[65] = "admin-secret", guest_password[65] = "write-secret", node_name[64] = "Test room"; bool allow_read_only = true; } _prefs;
bool room_web_enabled = false;
bool room_web_request_authenticated = false;
uint32_t room_web_boot_id = 71;
#include "web_user.inc"
ACL acl;
Clock clock;
PostInfo posts[MAX_UNSYNCED_POSTS] = {};
int next_post_idx = 0;
uint32_t last_room_timestamp = 0, post_ready_at[MAX_UNSYNCED_POSTS] = {}, post_ready_mask = UINT32_MAX, next_push = 0;
uint16_t _num_posted = 0;
mesh::RoomHistoryState room_history_state;
bool room_history_enabled = false, room_history_available = true;
char room_topic[152] = "Topic <img src=x onerror=attack()>";
MyMesh() { self_id.pub_key[0] = 4; metadata_filesystem = &storage; assert(room_access.load(_fs)); }
Clock* getRTCClock() { return &clock; }
bool handleRoomWebCommand(const char*, char*);
void processRoomRequest(const uint8_t*, uint32_t, char*, char*, size_t);
void serviceRoomQuotas();
bool storePost(const mesh::Identity&, const char*);
bool snapshotRoomHistory();
};
#include "production.inc"
static std::array<uint8_t, 32> token(uint8_t id = 1) { std::array<uint8_t, 32> value = {}; value[0] = id; return value; }
static std::string execute(MyMesh& m, const std::string& input, uint32_t sequence,
uint8_t owner = 1, size_t capacity = 2048) {
metadata_filesystem = &m.storage;
std::vector<char> request(input.begin(), input.end()); request.push_back(0);
std::vector<char> reply(capacity + 2, 0x7f);
const auto key = token(owner);
m.processRoomRequest(key.data(), sequence, request.data(), reply.data() + 1, capacity);
assert(reply.front() == 0x7f && reply.back() == 0x7f && json_allocations == 0);
if (!capacity) return "";
assert(memchr(reply.data() + 1, 0, capacity)); return reply.data() + 1;
}
static JsonDocument result(MyMesh& m, const std::string& input, uint32_t sequence, uint8_t owner = 1) {
const auto text = execute(m, input, sequence, owner);
JsonDocument json; assert(deserializeJson(json, text) == DeserializationError::Ok); return json;
}
static std::string request(const char* op, const char* password = "write-secret", const std::string& fields = "") {
return std::string("{\"op\":\"") + op + "\",\"password\":\"" + password + "\"" + fields + "}";
}
static void error(MyMesh& m, const std::string& input, uint32_t sequence, const char* fragment, uint8_t owner = 1) {
const auto json = result(m, input, sequence, owner); const char* text = json["error"];
if (!text || !strstr(text, fragment)) fprintf(stderr, "Expected %s, got %s for %.120s\n", fragment, text ? text : "no error", input.c_str());
assert(text && strstr(text, fragment));
}
static std::string encoded(const std::string& body) {
std::vector<char> out(4 * ((body.size() + 2) / 3) + 1);
mesh::roomEncodeBase64(reinterpret_cast<const uint8_t*>(body.data()), body.size(), out.data()); return out.data();
}
static std::string saveRequest(uint8_t id, uint32_t version, const std::string& title, const std::string& body, const char* password = "admin-secret") {
return request("board.save", password, ",\"boot\":71,\"id\":" + std::to_string(id) + ",\"version\":" + std::to_string(version)
+ ",\"title\":\"" + title + "\",\"body64\":\"" + encoded(body) + "\"");
}
static void enable(MyMesh& m) { char reply[160]; assert(m.handleRoomWebCommand("set room.web on", reply)); assert(m.room_web_enabled); }
static unsigned floors(const MyMesh& m) { unsigned total = 0; for (const auto& user : m.room_web_users) total += user.last_sequence; return total; }
int main() {
unsigned scenarios = 0;
{
MyMesh m; assert(!m.room_web_enabled);
error(m, request("status"), 1, "disabled"); assert(floors(m) == 0);
char reply[160]; assert(m.handleRoomWebCommand("get room.web", reply) && strstr(reply, "off"));
Preferences::fail_write = true; assert(m.handleRoomWebCommand("set room.web on", reply) && !m.room_web_enabled);
Preferences::fail_write = false; enable(m);
assert(!m.handleRoomWebCommand("get room.webx", reply));
auto status = result(m, request("status"), 1); assert(status["role"] == PERM_ACL_READ_WRITE && status["boot"] == 71);
assert(strcmp(status["topic"], m.room_topic) == 0 && status["persistent_history"] == false);
status = result(m, request("status", "bad-password", ",\"key\":\"radio-admin\",\"permissions\":3"), 2);
assert(status["role"] == PERM_ACL_GUEST && m.acl.lookups == 0);
const std::string web_key = status["web_key"].as<std::string>(); assert(web_key.size() == 64);
error(m, request("post", "bad-password", ",\"boot\":71,\"name\":\"A\",\"text\":\"denied\""), 500, "read-only");
assert(floors(m) == 2 && m.last_room_timestamp == 0 && m._num_posted == 0);
assert(result(m, request("status", "admin-secret"), 3)["role"] == PERM_ACL_ADMIN); ++scenarios;
}
{
MyMesh m; enable(m); m._prefs.allow_read_only = false;
error(m, request("status", "wrong"), 100, "incorrect"); assert(floors(m) == 0 && !m.room_web_request_authenticated);
error(m, request("status"), 100, "invalid browser", 0); assert(floors(m) == 0 && !m.room_web_request_authenticated);
assert(result(m, request("status"), 1)["role"] == PERM_ACL_READ_WRITE);
error(m, request("post", "write-secret", ",\"boot\":70,\"name\":\"A\",\"text\":\"no\""), 200, "restarted");
assert(floors(m) == 1 && m._num_posted == 0);
error(m, saveRequest(1, 0, "Title", "body", "write-secret"), 300, "admin"); assert(floors(m) == 1);
const auto okay = result(m, request("post", "write-secret", ",\"boot\":71,\"name\":\"A\",\"text\":\"one\""), 2);
assert(okay["ok"] == true && m.last_room_timestamp == 1000 && m._num_posted == 1);
error(m, request("post", "write-secret", ",\"boot\":71,\"name\":\"A\",\"text\":\"one\""), 2, "completed");
assert(m._num_posted == 1 && m.acl.radio_admin.extra.room.last_post_timestamp == 0);
auto post = result(m, request("posts", "write-secret", ",\"after\":0"), 3);
assert(strcmp(post["post"]["text"], "A: one") == 0 && post["post"]["timestamp"] == 1000);
post = result(m, request("posts", "write-secret", ",\"after\":1000"), 4); assert(post["post"].isNull()); ++scenarios;
}
{
MyMesh m; enable(m); m.room_history_enabled = true;
const std::string text(148, 'x');
assert(result(m, request("post", "write-secret", ",\"boot\":71,\"name\":\"A\",\"text\":\"" + text + "\""), 1)["ok"] == true);
assert(strlen(m.posts[0].text) == 151 && m.room_history_state.count == 1);
error(m, request("post", "write-secret", ",\"boot\":71,\"name\":\"A\",\"text\":\"" + text + "x\""), 2, "151");
assert(m._num_posted == 1 && m.last_room_timestamp == 1000);
error(m, request("post", "write-secret", ",\"boot\":71,\"name\":\"A:B\",\"text\":\"x\""), 3, "display name");
m.storage.capacity = 0;
error(m, request("post", "write-secret", ",\"boot\":71,\"name\":\"A\",\"text\":\"retain\""), 4, "not retained");
assert(m._num_posted == 1 && m.last_room_timestamp == 1000 && m.room_history_state.count == 1);
m.storage.reset(); m.clock.now = 2;
assert(result(m, request("post", "write-secret", ",\"boot\":71,\"name\":\"A\",\"text\":\"retry with new sequence\""), 5)["timestamp"] == 1001);
assert(m._num_posted == 2 && m.room_history_state.count == 2); ++scenarios;
}
{
MyMesh m; enable(m); assert(m.room_access.setRates(m._fs, 1, 2));
assert(result(m, request("post", "write-secret", ",\"boot\":71,\"name\":\"A\",\"text\":\"one\""), 1)["ok"] == true);
error(m, request("post", "write-secret", ",\"boot\":71,\"name\":\"A\",\"text\":\"two\""), 2, "rate limit");
assert(m._num_posted == 1 && m.last_room_timestamp == 1000);
result(m, request("status"), 3); result(m, request("status"), 4); error(m, request("status"), 5, "rate limit");
ticks += 60000; result(m, request("status"), 6);
const auto key = token(); uint8_t author[32]; mesh::Utils::sha256(author, 32, key.data(), 32, m.self_id.pub_key, 32);
assert(m.room_access.addBan(m._fs, author) == mesh::RoomAccessPolicy::BanResult::Saved);
const unsigned floor = floors(m); error(m, request("status"), 1000, "denied"); assert(floors(m) == floor);
assert(m.room_access.removeBan(m._fs, author) == mesh::RoomAccessPolicy::BanResult::Saved);
result(m, request("status"), 7); ++scenarios;
}
{
MyMesh m; enable(m); const std::string title = u8"Café 地図 📡"; const std::string body(2048, 'b');
const auto saved = result(m, saveRequest(1, 0, title, body), 1);
if (saved["ok"] != true) fprintf(stderr, "Full board save error: %s\n", saved["error"].as<const char*>());
assert(saved["ok"] == true);
assert(saved["version"] == 1);
auto page = result(m, request("board.index", "", ",\"revision\":0,\"cursor\":0"), 2);
assert(page["revision"] == 1 && page["articles"].size() == 1 && page["next"] == 255);
assert(page["articles"][0]["length"] == 2048 && strcmp(page["articles"][0]["title"], title.c_str()) == 0);
std::string assembled;
for (unsigned offset = 0, sequence = 3; offset < 2048; ++sequence) {
auto chunk = result(m, request("board.read", "", ",\"id\":1,\"version\":1,\"offset\":" + std::to_string(offset)), sequence);
assert(chunk["version"] == 1 && chunk["offset"] == offset && chunk["total"] == 2048);
uint8_t bytes[128]; size_t length; assert(mesh::roomDecodeBase64(chunk["data64"], bytes, sizeof(bytes), length) && length > 0 && length <= 128);
assembled.append(reinterpret_cast<const char*>(bytes), length); offset += length; assert(chunk["next"] == offset);
}
assert(assembled == body);
const auto updated = result(m, saveRequest(1, 1, "Updated", "new body"), 30);
assert(updated["ok"] == true && updated["version"] == 2);
error(m, request("board.index", "", ",\"revision\":1,\"cursor\":0"), 31, "board changed");
error(m, request("board.read", "", ",\"id\":1,\"version\":1,\"offset\":128"), 32, "article changed");
error(m, saveRequest(1, 1, "Stale", "old"), 33, "article changed");
error(m, request("board.delete", "admin-secret", ",\"boot\":71,\"id\":1,\"version\":0"), 34, "current article version");
assert(result(m, request("board.delete", "admin-secret", ",\"boot\":71,\"id\":1,\"version\":2"), 35)["ok"] == true);
assert(result(m, saveRequest(1, 0, "Reused", "new"), 36)["ok"] == true);
error(m, request("board.read", "", ",\"id\":1,\"version\":2,\"offset\":0"), 37, "article changed"); ++scenarios;
}
{
MyMesh m; enable(m); uint32_t sequence = 1;
for (const std::string& input : {std::string("{"), std::string("[]"), std::string("null"), std::string("{\"op\":{\"a\":{\"b\":{\"c\":{\"d\":1}}}}}"),
std::string("{\"op\":\"status\",\"password\":\"write-secret\\u0000ignored\"}"), std::string("{\"op\":\"status\\u0000ignored\",\"password\":\"write-secret\"}"),
std::string("{\"op\":3,\"password\":[]}")}) {
const auto json = result(m, input, sequence++); assert(json["error"].is<const char*>() && floors(m) == 0);
}
error(m, std::string(4097, ' '), sequence++, "invalid request");
for (const std::string text : {"A===", "====", "Zg=A", "Zg==Zg==", "Zh==", "Zm9=", "*m9v", "Zg", "Zg== "}) {
error(m, request("board.save", "admin-secret", ",\"boot\":71,\"id\":1,\"version\":0,\"title\":\"T\",\"body64\":\"" + text + "\""), sequence++, "encoding");
}
error(m, request("post", "write-secret", ",\"boot\":71,\"name\":\"A\",\"text\":\"before\\u0000after\""), sequence++, "name and message");
error(m, request("post", "write-secret", ",\"boot\":71,\"name\":\"A\\u0000B\",\"text\":\"body\""), sequence++, "name and message");
error(m, request("board.save", "admin-secret", ",\"boot\":71,\"id\":1,\"version\":0,\"title\":\"T\\u0000X\",\"body64\":\"Zg==\""), sequence++, "article changed");
error(m, request("board.save", "admin-secret", ",\"boot\":71,\"id\":1,\"version\":0,\"title\":\"T\",\"body64\":\"Zg==\\u0000Zg==\""), sequence++, "article changed");
assert(m._num_posted == 0 && m.last_room_timestamp == 0);
error(m, saveRequest(1, 0, std::string(64, 't'), "body"), sequence++, "article changed");
error(m, saveRequest(1, 0, "Title", std::string(2049, 'b')), sequence++, "encoding");
assert(!m.storage.files.count(mesh::ROOM_BOARD_PRIMARY_PATH));
deny_json_allocation = true; error(m, request("status"), sequence++, "invalid JSON"); deny_json_allocation = false;
result(m, request("status"), sequence++);
// Excess unknown array members exhaust the actual bounded allocator.
std::string huge = "{\"op\":\"status\",\"password\":\"write-secret\",\"extra\":[";
for (unsigned i = 0; i < 1000; ++i) { if (i) huge += ','; huge += '0'; } huge += "]}";
error(m, huge, sequence++, "invalid JSON"); assert(json_allocations == 0); ++scenarios;
}
{
MyMesh m; enable(m);
for (uint8_t owner = 1; owner <= 8; ++owner) result(m, request("status"), 1, owner);
error(m, request("status"), 1, "busy", 9); ticks += 1200000;
result(m, request("status"), 1, 9);
// Every small response capacity stays within its provided buffer.
for (size_t capacity = 0; capacity < 200; ++capacity) execute(m, request("status"), uint32_t(capacity + 2), 9, capacity);
for (unsigned i = 0; i < 40; ++i) assert(result(m, request("post", "write-secret", ",\"boot\":71,\"name\":\"A\",\"text\":\"bounded ring\""), 300 + i, 9)["ok"] == true);
assert(m._num_posted == 40 && m.next_post_idx == 8 && m.last_room_timestamp == 1039); ++scenarios;
}
// Deliver concrete backend responses for the browser boundary fixture.
MyMesh boundary; enable(boundary);
printf("BOUNDARY_STATUS %s\n", execute(boundary, request("status"), 1).c_str());
assert(result(boundary, saveRequest(1, 0, "Instructions", "Read only this article"), 2)["ok"] == true);
printf("BOUNDARY_INDEX %s\n", execute(boundary, request("board.index", "write-secret", ",\"revision\":0,\"cursor\":0"), 3).c_str());
printf("BOUNDARY_READ %s\n", execute(boundary, request("board.read", "write-secret", ",\"id\":1,\"version\":1,\"offset\":0"), 4).c_str());
printf("PASS: %u actual room web service authorization, storage, quota, JSON and boundary scenarios\n", scenarios);
}
@@ -7,6 +7,8 @@
#include <vector>
#include <helpers/ClientACLResponse.h>
#include <helpers/sensors/LPPDataHelpers.h>
#include "../room_history_store/filesystem.h"
#include <helpers/RoomBoardProtocol.h>
#include "telemetry_capacity.inc"
#include "telemetry_access.inc"
@@ -127,7 +129,12 @@ public:
};
class MyMesh : public SensorMesh {
public:
MyMesh() { telemetry.max_len = ROOM_TELEMETRY_CAPACITY; telemetry.data.reserve(telemetry.max_len); }
MemoryFS room_fs;
MemoryFS* _fs = &room_fs;
MyMesh() {
telemetry.max_len = ROOM_TELEMETRY_CAPACITY; telemetry.data.reserve(telemetry.max_len);
metadata_filesystem = _fs;
}
int handleRequest(ClientInfo*, uint32_t, uint8_t*, size_t,
size_t = mesh::CLIENT_ACL_DIRECT_REPLY_CAPACITY);
};
@@ -245,6 +252,30 @@ static void room_telemetry_checks() {
assert(target.handleRequest(nullptr, 51, query, sizeof(query)) == 0);
}
static void room_board_dispatch_checks() {
ClientInfo client;
uint8_t malformed[] = {mesh::ROOM_BOARD_REQUEST_SUBTYPE, 0};
for (size_t capacity = 0; capacity <= MAX_PACKET_PAYLOAD; ++capacity) {
MyMesh target;
memset(target.reply_data, 0xee, sizeof(target.reply_data));
const int length = target.handleRequest(&client, 51, malformed, sizeof(malformed), capacity);
assert(length == (capacity < 7 ? 0 : 7));
assert(size_t(length) <= capacity);
for (size_t i = capacity; i < sizeof(target.reply_data); ++i) assert(target.reply_data[i] == 0xee);
if (length) {
uint32_t tag = 0; memcpy(&tag, target.reply_data, sizeof(tag));
assert(tag == 51 && target.reply_data[4] == mesh::ROOM_BOARD_REQUEST_SUBTYPE);
assert(target.reply_data[5] == 0 && target.reply_data[6] == 1);
}
}
uint8_t index[] = {mesh::ROOM_BOARD_REQUEST_SUBTYPE, 0, 0, 0, 0, 0, 0};
MyMesh target;
assert(target.handleRequest(&client, 51, index, sizeof(index), 13) == 7);
assert(target.reply_data[6] == 1);
assert(target.handleRequest(&client, 51, index, sizeof(index), 14) == 14);
assert(target.reply_data[6] == 0);
}
static void history_checks() {
ClientInfo client;
uint8_t query[10] = {};
@@ -313,6 +344,7 @@ static void history_checks() {
int main(int argc, char** argv) {
assert(argc == 2);
if (!strcmp(argv[1], "room")) room_telemetry_checks();
else if (!strcmp(argv[1], "room.board")) room_board_dispatch_checks();
else if (!strcmp(argv[1], "telemetry")) telemetry_checks();
else if (!strcmp(argv[1], "history")) history_checks();
else if (!strcmp(argv[1], "empty")) {
+5
View File
@@ -4,6 +4,7 @@
#include <cstdint>
#include <cstring>
#include <initializer_list>
#include <helpers/RoomWebMailbox.h>
static uint32_t millis() { return 123; }
struct WebConfigBatch {
@@ -34,6 +35,7 @@ struct WebConfigServer {
DNS* _dns = nullptr;
Callbacks callbacks;
Callbacks* _cb = &callbacks;
mesh::RoomWebMailbox* _room_mailbox = new mesh::RoomWebMailbox;
Mode _mode = MODE_LAN;
bool _stopping = false, _keep_wifi_on_stop = false, _was_setup_ap = false;
bool _owns_wifi = true, _stop_warned = false, _initial_setup = false;
@@ -54,6 +56,7 @@ struct WebConfigServer {
void requestStop();
bool stopForOTA(char*);
void finalizeTeardown();
~WebConfigServer() { delete _room_mailbox; }
};
@METHODS@
@@ -79,6 +82,7 @@ int main() {
WiFi.ap = true;
portal.requestStop(); // A duplicate stop cannot revoke the handoff.
portal.finalizeTeardown(); // Existing requests have finally drained.
assert(portal._room_mailbox == nullptr);
assert(WiFi.ap && WiFi.sta && WiFi.stops == 0);
assert(WiFi.ap_stops == unsigned(setup));
assert(portal.callbacks.stops == 1 && !portal._keep_wifi_on_stop);
@@ -88,6 +92,7 @@ int main() {
WiFi = WiFiFake{};
ordinary.requestStop();
ordinary.finalizeTeardown();
assert(ordinary._room_mailbox == nullptr);
assert(WiFi.stops == 1 && !WiFi.sta); // Normal stop keeps its old behavior.
WebConfigServer failed;
failed._was_setup_ap = true;
+5 -1
View File
@@ -2,7 +2,9 @@
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <new>
#include <string>
#include <helpers/RoomWebMailbox.h>
#include <helpers/WiFiReconnectPolicy.h>
static uint32_t clock_ms = 1000;
uint32_t millis() { return clock_ms; }
@@ -77,7 +79,7 @@ struct DNS { void stop() {} };
class WebConfigServer {
public:
enum Mode { MODE_OFF, MODE_SETUP, MODE_CONNECTING, MODE_LAN };
struct Callbacks {};
struct Callbacks { bool supportsRoomService() const { return false; } };
Callbacks* _cb;
void* _mqtt_prefs;
bool _owns_wifi;
@@ -86,6 +88,7 @@ public:
const uint8_t* _pub_key;
const char *_fw_ver, *_build_date, *_role, *_board_name;
void* _mux = nullptr;
mesh::RoomWebMailbox* _room_mailbox = nullptr;
bool _cli_enabled = false;
char _wifi_ssid[32] = {}, _wifi_password[65] = {};
uint8_t _wifi_power_save = 0;
@@ -130,6 +133,7 @@ public:
void createServer() { ++server_starts; }
void probeBoardCommands() { _board_cmds_probed = true; }
void serviceTerminal(uint32_t) {}
void serviceRoom(uint32_t) {}
bool bluetoothWiFiCoexistenceRequired() { return false; }
bool acceptWiFi(Entry& e, const char* value) {
@CREDENTIAL_BATCH@
+7 -1
View File
@@ -27,6 +27,11 @@ def production_methods(role, transform=None):
begin = handler.index(" if (req_type == REQ_TYPE_GET_TELEMETRY_DATA" if sensor
else " if (payload[0] == REQ_TYPE_GET_STATUS)")
prefix = handler[:begin]
# The new board protocol has its own admission/storage regression suite.
# Keep this legacy ACL/telemetry fixture on its original request families.
if not sensor and "if (payload[0] == mesh::ROOM_BOARD_REQUEST_SUBTYPE)" in prefix:
board = extract_braced(prefix, "if (payload[0] == mesh::ROOM_BOARD_REQUEST_SUBTYPE)")
prefix = prefix.replace(board, "", 1)
acl = extract_braced(handler, "if (req_type == REQ_TYPE_GET_ACCESS_LIST" if sensor
else "if (payload[0] == REQ_TYPE_GET_ACCESS_LIST")
generated = prefix + (" (void)perms;\n" if sensor else "") + acl + "\nreturn 0;\n}\n"
@@ -36,6 +41,7 @@ def production_methods(role, transform=None):
generated += "ClientInfo* " + ("from" if sensor else "client") + " = &sender;\n"
generated += "const uint8_t type = PAYLOAD_TYPE_REQ; uint8_t secret[PUB_KEY_SIZE] = {};\n" + receive + "\n}\n"
if not sensor:
generated += extract_braced(source, "bool MyMesh::saveFilter(") + "\n"
telemetry = extract_braced(handler, "if (payload[0] == REQ_TYPE_GET_TELEMETRY_DATA)")
telemetry_prefix = telemetry[:telemetry.index(" telemetry.reset();")]
generated += "int MyMesh::telemetryPrefix(uint8_t* payload, size_t payload_len) {\n"
@@ -67,7 +73,7 @@ class ClientAclInfrastructureTest(unittest.TestCase):
(work / "production.inc").write_text(production_methods(role, transform), encoding="ascii")
binary = work / "acl.exe"
command = [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter",
"-I" + str(work), "-I" + str(ROOT / "src"), str(FIXTURE), "-o", str(binary)]
"-I" + str(work), "-I" + str(ROOT / "test/fixtures/room_history_store"), "-I" + str(ROOT / "src"), str(FIXTURE), "-o", str(binary)]
if role == "sensor": command.insert(1, "-DTEST_SENSOR=1")
if sys.platform.startswith("linux"):
command[1:1] = ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
+1 -1
View File
@@ -104,7 +104,7 @@ 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),
static_assert(sizeof(void*) != 4 || sizeof(((ClientInfo*)0)->extra.room) <= sizeof(((ClientInfo*)0)->extra.sensor),
"topic delivery must fit the existing shared union");
#endif
int main() {
@@ -78,8 +78,8 @@ public:
void onMessageRecv(const ContactInfo&, mesh::Packet*, uint32_t, const char*) override {}
void onCommandDataRecv(const ContactInfo&, mesh::Packet*, uint32_t, const char*) override {}
void onCLICommandRecv(const ContactInfo&, mesh::Packet*, uint32_t, const char*, char*) override {}
void onSignedMessageRecv(const ContactInfo&, mesh::Packet*, uint32_t,
const uint8_t*, const char*) override {}
bool onSignedMessageRecv(const ContactInfo&, mesh::Packet*, uint32_t,
const uint8_t*, const char*) override { return true; }
uint32_t calcFloodTimeoutMillisFor(uint32_t) const override { return 100; }
uint32_t calcDirectTimeoutMillisFor(uint32_t, uint8_t) const override { return 100; }
void onSendTimeout() override {}
+13
View File
@@ -156,6 +156,19 @@ class FirmwareRamTest(unittest.TestCase):
self.assertEqual(local["components"]["browser_terminal_session"], 2048)
self.assertEqual(local["components"]["browser_terminal_scrollback"], 4096)
def test_room_browser_workspaces_are_bounded_and_only_budgeted_on_eligible_rooms(self):
flags = {"ADMIN_PASSWORD": "test"}
enabled = ram.requirements("ESP32_PLATFORM", flags, "v4_room_server")
disabled = ram.requirements("ESP32_PLATFORM", {**flags, "WEBCONFIG_DISABLED": 1}, "v4_room_server")
self.assertEqual(enabled["components"]["room_browser_mailbox"], 6656 + 16)
self.assertEqual(enabled["components"]["room_browser_request_workspace"], 8192 + 2048 + 16)
self.assertGreaterEqual(enabled["required_contiguous_bytes"], 8192)
for policy in (disabled, ram.requirements("ESP32_PLATFORM", flags, "v4_repeater"),
ram.requirements("ESP32_PLATFORM", flags, "v4_companion"),
ram.requirements("NRF52_PLATFORM", flags, "RAK_4631_room_server")):
self.assertNotIn("room_browser_mailbox", policy["components"])
self.assertNotIn("room_browser_request_workspace", policy["components"])
def test_longer_display_previews_reserve_heap_and_contiguous_history(self):
defines = {"DISPLAY_CLASS": "SSD1306Display", "UI_SMALL_MESSAGE_FONT": 0}
base = ram.requirements("ESP32_PLATFORM", defines, "v4_companion")
+5 -3
View File
@@ -50,11 +50,11 @@ def room_outputs(work, compiler):
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(")
presentation = extract_braced(companion, "bool 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 += " (void)path_len;\n frame.assign(out_frame, out_frame + i);\n return true;\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")
@@ -62,7 +62,9 @@ def room_outputs(work, compiler):
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)])
f"-I{work}", f"-I{ROOT / 'src'}",
f"-I{ROOT / 'test/fixtures/room_topic_delivery'}",
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(":")
+404
View File
@@ -0,0 +1,404 @@
#!/usr/bin/env python3
"""Run the room ban/quota policy and its real store against faults and reboots."""
from pathlib import Path
import os
import re
import shutil
import struct
import subprocess
import sys
import tempfile
import unittest
import zlib
from test_room_topic_store import HARNESS as FILESYSTEM_HARNESS, STAT_MOCK
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
LFS_MOCK = r'''
struct lfs_info {};
static constexpr int LFS_ERR_NOENT = -2;
static int lfs_stat(MemoryFS* fs, const char* path, struct lfs_info*) {
assert(fs && fs->lock_depth == 1);
const std::string op = std::string("stat:") + path;
const bool okay = fs->begin(op);
const bool present = fs->files.count(path) || fs->directories.count(path);
fs->end(op);
return !okay ? -5 : present ? 0 : LFS_ERR_NOENT;
}
'''
TESTS = r'''
#define FILE_O_WRITE 1
#include <array>
#define HEX 16 // Arduino's numeric-format macro must remain usable.
#include <helpers/RoomAccessPolicy.h>
static_assert(HEX == 16, "room policy must preserve Arduino HEX");
using Policy = mesh::RoomAccessPolicy;
using BanResult = Policy::BanResult;
static const char* P = mesh::ROOM_ACCESS_PRIMARY_PATH;
static const char* T = mesh::ROOM_ACCESS_TEMP_PATH;
static const char* B = mesh::ROOM_ACCESS_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 std::array<uint8_t, 32> key(uint8_t suffix) {
std::array<uint8_t, 32> result;
result.fill(0xab); result.back() = suffix; return result;
}
static std::string hex(const uint8_t* key) {
static const char digits[] = "0123456789abcdef";
std::string result;
for (size_t i = 0; i < 32; ++i) { result += digits[key[i] >> 4]; result += digits[key[i] & 15]; }
return result;
}
static bool load(Policy& policy, MemoryFS& fs) {
metadata_filesystem = &fs;
const bool okay = policy.load(&fs);
assert(fs.lock_depth == 0); return okay;
}
static void loaded(MemoryFS& fs, unsigned post, unsigned poll, bool banned = true) {
Policy rebooted; assert(load(rebooted, fs));
assert(rebooted.healthy() && rebooted.postRate() == post && rebooted.pollRate() == poll);
assert(rebooted.isBanned(key(1).data()) == banned);
assert(rebooted.allowsIdentity(key(1).data()) == !banned);
assert(rebooted.allowsIdentity(key(2).data()));
}
static void rechecksum(std::vector<uint8_t>& image) {
uint32_t sum = 0xffffffffu;
for (size_t i = 0; i + 4 < image.size(); ++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[image.size() - 4 + i] = uint8_t(sum >> (8 * i));
}
static mesh::RoomAccessSettings nextSettings() {
mesh::RoomAccessSettings result;
result.posts_per_minute = 5; result.polls_per_minute = 7; result.ban_count = 1;
memcpy(result.bans[0], key(1).data(), 32); return result;
}
static void ordinaryFault(MemoryFS& fs) {
Policy policy; assert(load(policy, fs));
assert(!policy.setRates(&fs, 5, 7));
assert(policy.healthy() && policy.postRate() == 2 && policy.pollRate() == 3);
assert(!policy.allowsIdentity(key(1).data()) && policy.allowsIdentity(key(2).data()));
fs.reset(); loaded(fs, 2, 3); assert(fs.get(P) == OLD);
assert(!fs.files.count(T) && !fs.files.count(B)); ++scenarios;
}
static void admissionAndBans() {
MemoryFS fs; Policy policy;
assert(!policy.healthy() && !policy.allowsIdentity(key(1).data()));
uint16_t count = 0; assert(!policy.consumePost(count) && count == 0);
assert(!policy.load(static_cast<MemoryFS*>(nullptr)));
assert(load(policy, fs) && policy.postRate() == 0 && policy.pollRate() == 0);
assert(!policy.allowsIdentity(nullptr)); uint8_t zero[32] = {};
assert(!policy.allowsIdentity(zero) && !policy.isBanned(nullptr));
assert(policy.addBan(&fs, nullptr) == BanResult::InvalidKey);
assert(policy.addBan(&fs, zero) == BanResult::InvalidKey && fs.write_opens == 0);
assert(policy.addBan(&fs, key(1).data()) == BanResult::Saved);
assert(!policy.allowsIdentity(key(1).data()) && policy.allowsIdentity(key(2).data()));
// Keys share their entire first31 bytes: prefixes cannot broaden a ban.
for (uint8_t suffix = 2; suffix < 34; ++suffix) assert(policy.allowsIdentity(key(suffix).data()));
const unsigned writes = fs.write_opens;
assert(policy.addBan(&fs, key(1).data()) == BanResult::Unchanged && fs.write_opens == writes);
assert(policy.removeBan(&fs, key(2).data()) == BanResult::Unchanged && fs.write_opens == writes);
Policy rebooted; assert(load(rebooted, fs)); assert(rebooted.banCount() == 1);
assert(!rebooted.allowsIdentity(key(1).data()));
for (uint8_t suffix = 2; suffix <= 32; ++suffix) {
assert(policy.addBan(&fs, key(suffix).data()) == BanResult::Saved);
}
assert(policy.banCount() == 32);
const auto full = fs.get(P); const unsigned full_writes = fs.write_opens;
assert(policy.addBan(&fs, key(33).data()) == BanResult::Full);
assert(fs.get(P) == full && fs.write_opens == full_writes && policy.allowsIdentity(key(33).data()));
for (uint8_t suffix : {uint8_t(32), uint8_t(1), uint8_t(16)}) {
assert(policy.removeBan(&fs, key(suffix).data()) == BanResult::Saved);
assert(policy.allowsIdentity(key(suffix).data()));
}
assert(policy.banCount() == 29);
assert(policy.banAt(29) == nullptr && policy.banAt(size_t(-1)) == nullptr);
assert(policy.addBan(&fs, key(33).data()) == BanResult::Saved);
Policy final; assert(load(final, fs));
assert(final.banCount() == 30 && !final.allowsIdentity(key(33).data()));
assert(final.allowsIdentity(key(1).data()) && final.allowsIdentity(key(16).data())); ++scenarios;
}
static void aclPersistenceAndQuotas() {
for (unsigned permissions = 0; permissions < 256; ++permissions) {
assert(mesh::roomAclShouldPersist(permissions, true, 7, 3));
assert(mesh::roomAclShouldPersist(permissions, false, 7, 3) == ((permissions & 7) == 3));
}
MemoryFS fs; Policy policy; assert(load(policy, fs));
struct Counts { uint16_t posts = 0, polls = 0; } users[2];
unsigned resets = 0;
const auto reset = [&] { ++resets; for (auto& u : users) u.posts = u.polls = 0; };
policy.serviceQuotaWindow(1000, reset); assert(resets == 1);
users[0].posts = users[0].polls = UINT16_MAX;
assert(policy.consumePost(users[0].posts) && policy.consumeKeepAlive(users[0].polls));
assert(users[0].posts == UINT16_MAX && users[0].polls == UINT16_MAX); // Disabled is free.
assert(policy.setRates(&fs, 2, 3));
policy.serviceQuotaWindow(2000, reset); assert(resets == 2);
for (unsigned n = 0; n < 2; ++n) assert(policy.consumePost(users[0].posts));
assert(!policy.consumePost(users[0].posts) && users[0].posts == 2);
for (unsigned n = 0; n < 3; ++n) assert(policy.consumeKeepAlive(users[0].polls));
assert(!policy.consumeKeepAlive(users[0].polls) && users[0].polls == 3);
assert(policy.consumePost(users[1].posts) && users[1].posts == 1); // Actual separate identity state.
policy.serviceQuotaWindow(61999, reset); assert(resets == 2 && users[0].posts == 2);
policy.serviceQuotaWindow(62000, reset); assert(resets == 3 && users[0].posts == 0);
assert(policy.addBan(&fs, key(1).data()) == BanResult::Saved);
assert(policy.consumePost(users[1].posts));
policy.serviceQuotaWindow(62001, reset); assert(resets == 3 && users[1].posts == 1); // Ban doesn't reset quotas.
assert(policy.setRates(&fs, 2, 3)); policy.serviceQuotaWindow(62002, reset);
assert(resets == 3); // Idempotent config cannot renew a user's budget.
assert(policy.setRates(&fs, UINT16_MAX, UINT16_MAX));
policy.serviceQuotaWindow(UINT32_MAX - 30000U, reset); const unsigned before_wrap = resets;
users[0].posts = users[0].polls = UINT16_MAX - 1;
assert(policy.consumePost(users[0].posts) && users[0].posts == UINT16_MAX);
assert(!policy.consumePost(users[0].posts));
assert(policy.consumeKeepAlive(users[0].polls) && users[0].polls == UINT16_MAX);
assert(!policy.consumeKeepAlive(users[0].polls));
policy.serviceQuotaWindow(29998, reset); assert(resets == before_wrap);
policy.serviceQuotaWindow(29999, reset); assert(resets == before_wrap + 1);
assert(users[0].posts == 0 && users[0].polls == 0);
policy.serviceQuotaWindow(UINT32_C(0x80007530), reset); assert(resets == before_wrap + 2);
loaded(fs, UINT16_MAX, UINT16_MAX);
++scenarios;
}
static void cliCases() {
MemoryFS fs; Policy policy; assert(load(policy, fs)); char reply[160];
const auto run = [&](const std::string& command) {
memset(reply, 0xa5, sizeof(reply));
assert(policy.handleConfig(&fs, command.c_str(), reply, 157));
assert(uint8_t(reply[157]) == 0xa5);
};
run("get room.post.rate"); assert(!strcmp(reply, "> 0/min (0=off)"));
run("set room.post.rate 12"); assert(!strncmp(reply, "OK", 2));
run("set room.poll.rate 7"); assert(policy.postRate() == 12 && policy.pollRate() == 7);
run("get room.post.rate "); assert(!strcmp(reply, "> 12/min (0=off)"));
run("get room.poll.rate"); assert(!strcmp(reply, "> 7/min (0=off)"));
const unsigned saved = fs.write_opens;
for (const char* rate : {"", "-1", "+1", "65536", "9999999999999999999999", "1/min", "1 2", "1.0", "1;2"}) {
run(std::string("set room.post.rate ") + rate); assert(!strncmp(reply, "Err", 3));
assert(policy.postRate() == 12 && fs.write_opens == saved);
}
uint8_t parsed[32]; memset(parsed, 0xcc, sizeof(parsed));
const std::string first = hex(key(1).data()), second = hex(key(2).data());
for (const std::string& bad : {first.substr(0,8), first.substr(0,12), first.substr(0,63), first + "0", std::string("all"), std::string(64,'0'), std::string(64,'g')}) {
assert(!Policy::parseFullKey(bad.c_str(), parsed));
for (uint8_t value : parsed) assert(value == 0xcc);
run("room.ban " + bad); assert(!strncmp(reply, "Err", 3) && policy.banCount() == 0);
}
assert(!Policy::parseFullKey(nullptr, parsed));
std::string uppercase = first; for (char& c : uppercase) if (c >= 'a' && c <= 'f') c -= 'a' - 'A';
assert(Policy::parseFullKey(uppercase.c_str(), parsed) && memcmp(parsed,key(1).data(),32) == 0);
run("room.ban " + first); assert(policy.banCount() == 1);
run("room.ban " + second); assert(policy.banCount() == 2);
run("room.ban " + hex(key(3).data())); assert(policy.banCount() == 3);
run("get room.bans"); assert(std::string(reply) == "Room bans 1/2\n" + first + "\n" + second);
run("get room.bans 2"); assert(std::string(reply) == "Room bans 2/2\n" + hex(key(3).data()));
for (const char* page : {"0", "3", "65536", "-1", "1 2", "abc"}) {
run(std::string("get room.bans ") + page); assert(!strncmp(reply, "Err", 3));
}
char tiny[65]; memset(tiny,0xa5,sizeof(tiny)); policy.formatBanPage(1,tiny,64);
assert(!strncmp(tiny,"Err",3) && uint8_t(tiny[64]) == 0xa5);
assert(!policy.handleConfig(&fs,"room.ban-other",reply,157));
assert(!policy.handleConfig(&fs,"get room.bans-other",reply,157));
assert(!policy.handleConfig(&fs,"set room.poll.rate-other",reply,157));
assert(!policy.handleConfig(&fs,nullptr,reply,157));
assert(!policy.handleConfig(&fs,"get room.bans",nullptr,157));
run("room.unban " + first); assert(policy.allowsIdentity(key(1).data()));
run("room.unban " + first); assert(!strncmp(reply,"OK",2));
++scenarios;
}
static void persistenceAndFaults() {
{ MemoryFS fs; fs.put(P,OLD); loaded(fs,2,3);
Policy policy; assert(load(policy,fs)); assert(policy.setRates(&fs,5,7));
assert(fs.get(P) == NEW); loaded(fs,5,7); ++scenarios; }
{ mesh::RoomAccessSettings bad; bad.ban_count = 33; MemoryFS fs;
assert(mesh::room_access_detail::save(&fs,bad) == mesh::room_access_detail::SaveResult::WriteFailure);
bad.ban_count = 1;
assert(mesh::room_access_detail::save(&fs,bad) == mesh::room_access_detail::SaveResult::WriteFailure);
memcpy(bad.bans[0],key(1).data(),32); memcpy(bad.bans[1],key(1).data(),32); bad.ban_count = 2;
assert(mesh::room_access_detail::save(&fs,bad) == mesh::room_access_detail::SaveResult::WriteFailure);
assert(fs.trace.empty()); ++scenarios; }
std::vector<std::vector<uint8_t>> invalid = {{}, {1}, std::vector<uint8_t>(15), OLD};
invalid.back().push_back(0);
auto future = OLD; future[3] = 2; rechecksum(future); invalid.push_back(future);
auto reserved = OLD; reserved[10] = 1; rechecksum(reserved); invalid.push_back(reserved);
auto too_many = OLD; too_many[8] = 33; rechecksum(too_many); invalid.push_back(too_many);
auto zero_key = OLD; std::fill(zero_key.begin()+12,zero_key.end()-4,0); rechecksum(zero_key); invalid.push_back(zero_key);
auto duplicate = OLD; duplicate.insert(duplicate.end()-4,OLD.begin()+12,OLD.end()-4);
duplicate[8] = 2; rechecksum(duplicate); invalid.push_back(duplicate);
for (size_t byte = 0; byte < OLD.size(); ++byte) {
auto corrupt = OLD; corrupt[byte] ^= 1; invalid.push_back(corrupt);
}
for (const auto& bytes : invalid) {
MemoryFS fs; fs.put(P,bytes); fs.put(B,OLD); fs.put(T,NEW); Policy policy;
assert(!load(policy,fs) && !policy.healthy());
assert(!policy.allowsIdentity(key(2).data())); uint16_t count = 0;
assert(!policy.consumePost(count) && !policy.consumeKeepAlive(count) && count == 0);
assert(!policy.setRates(&fs,5,7) && policy.addBan(&fs,key(2).data()) == BanResult::StorageFailure);
assert(fs.get(P) == bytes && fs.get(B) == OLD && fs.get(T) == NEW && fs.write_opens == 0); ++scenarios;
}
{ MemoryFS fs; fs.put(B,OLD); fs.put(T,NEW); loaded(fs,2,3);
assert(fs.get(P) == OLD && !fs.files.count(T)); ++scenarios; }
{ MemoryFS fs; fs.put(T,NEW); loaded(fs,0,0,false);
assert(!fs.files.count(P) && !fs.files.count(T)); ++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); ordinaryFault(fs);
}
{ MemoryFS fs; fs.put(P,OLD); Policy policy; assert(load(policy,fs)); fs.short_write = true;
assert(!policy.setRates(&fs,5,7) && policy.postRate() == 2); fs.reset(); loaded(fs,2,3); ++scenarios; }
{ MemoryFS fs; fs.put(P,OLD); Policy policy; assert(load(policy,fs)); fs.replacement_on_close = OLD;
assert(!policy.setRates(&fs,5,7) && policy.postRate() == 2); fs.reset(); loaded(fs,2,3); ++scenarios; }
{ MemoryFS fs; fs.put(P,OLD); Policy policy; assert(load(policy,fs));
fs.faults.insert(std::string("open:r:")+P);
assert(!policy.setRates(&fs,5,7) && !policy.healthy() && !policy.allowsIdentity(key(2).data()));
assert(fs.write_opens == 0 && fs.get(P) == OLD); fs.reset(); assert(load(policy,fs)); ++scenarios; }
{ MemoryFS fs; fs.put(P,OLD); Policy policy; assert(load(policy,fs));
fs.faults.insert(std::string("rename:")+T+":"+P);
fs.faults.insert(std::string("rename:")+B+":"+P);
fs.faults.insert(std::string("remove:")+T);
assert(!policy.setRates(&fs,5,7) && policy.postRate() == 2);
assert(!fs.files.count(P) && fs.get(B) == OLD && fs.get(T) == NEW);
fs.reset(); loaded(fs,2,3); ++scenarios; }
{ MemoryFS fs; fs.put(P,OLD); Policy policy; assert(load(policy,fs));
fs.faults.insert(std::string("remove:")+B);
assert(policy.setRates(&fs,5,7) && policy.postRate() == 5 && fs.get(P) == NEW);
loaded(fs,5,7); assert(!policy.setRates(&fs,2,3));
fs.reset(); loaded(fs,5,7); ++scenarios; }
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) || defined(ESP32_PLATFORM)
for (bool present : {false,true}) {
MemoryFS fs; if (present) fs.put(P,OLD);
fs.faults.insert(std::string("stat:")+P); Policy policy;
assert(!load(policy,fs) && !policy.allowsIdentity(key(1).data()));
assert(!policy.setRates(&fs,5,7) && fs.write_opens == 0);
assert(fs.files.count(P) == size_t(present)); ++scenarios;
}
#endif
for (bool existing : {false,true}) {
MemoryFS baseline; if (existing) baseline.put(P,OLD); metadata_filesystem = &baseline;
assert(mesh::room_access_detail::save(&baseline,nextSettings()) == mesh::room_access_detail::SaveResult::Saved);
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; metadata_filesystem = &fs;
try { mesh::room_access_detail::save(&fs,nextSettings()); } catch (const PowerCut&) {}
const bool committed = fs.files.count(P) && fs.get(P) == NEW;
fs.reset(); fs.lock_depth = 0; // Power loss also releases the abandoned mount lock.
loaded(fs,committed ? 5 : existing ? 2 : 0, committed ? 7 : existing ? 3 : 0,committed || existing);
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;
}
}
}
int main() {
admissionAndBans(); aclPersistenceAndQuotas(); cliCases(); persistenceAndFaults();
printf("PASS: %u room access identity/quota/storage/power-cut scenarios\n",scenarios);
}
'''
def image(post, poll, bans):
payload = b"RAP\x01" + struct.pack("<HHB3x", post, poll, len(bans)) + b"".join(bans)
return payload + struct.pack("<I", zlib.crc32(payload))
class RoomAccessPolicyTests(unittest.TestCase):
def test_actual_client_layout_has_no_32bit_ram_growth(self):
compiler = shutil.which("arm-none-eabi-g++")
if compiler is None:
packages = Path.home() / ".platformio/packages"
compiler = next((str(path) for path in sorted(packages.glob("toolchain-gccarmnoneeabi*/bin/arm-none-eabi-g++"))), None)
if compiler is None:
if os.environ.get("MESHCORE_REQUIRE_ARM_COMPILER") == "1":
self.fail("ARM compiler required for actual 32-bit room client layout proof")
self.skipTest("ARM compiler not installed")
header = (ROOT / "src/helpers/ClientACL.h").read_text()
current = extract_braced(header, "struct ClientInfo {")
self.assertIn("bool permissions_are_explicit;", current)
self.assertIn("uint16_t post_quota_used;", current)
self.assertIn("uint16_t poll_quota_used;", current)
prior = current.replace("struct ClientInfo {", "struct PriorClientInfo {")
prior = re.sub(r"^\s*(?:bool permissions_are_explicit|uint16_t (?:post|poll)_quota_used);[^\n]*$",
"", prior, flags=re.MULTILINE)
roles = "\n".join(line for line in header.splitlines() if line.startswith("#define PERM_ACL_"))
source = """
#include <stdint.h>
#include <stddef.h>
#define PUB_KEY_SIZE 32
#define MAX_PATH_SIZE 64
namespace mesh { struct Identity { uint8_t pub_key[PUB_KEY_SIZE]; }; }
""" + roles + "\n" + prior + ";\n" + current + ";\n" + """
static_assert(sizeof(void*) == 4 && sizeof(unsigned long) == 4, "target ABI must be 32-bit");
static_assert(sizeof(ClientInfo) == sizeof(PriorClientInfo), "room counters/explicit marker must use existing padding");
static_assert(sizeof(ClientInfo) <= (MESH_CLIENT_REPEATER_ONLY ? 284 : 320), "existing client RAM bound must hold");
#if !MESH_CLIENT_REPEATER_ONLY
static_assert(sizeof(((ClientInfo*)0)->extra.room) <= sizeof(((ClientInfo*)0)->extra.sensor), "room counters must fit existing sensor union");
#endif
"""
with tempfile.TemporaryDirectory(prefix="room-access-layout-") as temporary:
work = Path(temporary)
cpp = work / "layout.cpp"
cpp.write_text(source)
for repeater_only in (0, 1):
with self.subTest(repeater_only=repeater_only):
compiled = subprocess.run([compiler, "-std=c++11", "-Wall", "-Wextra", "-Werror",
"-DMESH_CLIENT_REPEATER_ONLY=" + str(repeater_only),
"-fsyntax-only", str(cpp)], capture_output=True, text=True, timeout=30)
self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr)
def test_production_policy_store_cli_and_faults_on_all_backends(self):
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler)
filesystem = FILESYSTEM_HARNESS[:FILESYSTEM_HARNESS.index("#define FILE_O_WRITE")]
# Reuse the existing power-cut filesystem without changing that suite.
# Native LittleFS metadata probes also prove the lock is released.
default_lock = (" void _lockFS() {}\n"
" void _unlockFS() {}\n"
" MemoryFS* _getFS() { return this; }\n")
self.assertIn(default_lock, filesystem)
filesystem = filesystem.replace(default_lock,
" int lock_depth = 0;\n"
" void _lockFS() { assert(lock_depth++ == 0); }\n"
" void _unlockFS() { assert(--lock_depth == 0); }\n"
" MemoryFS* _getFS() { return this; }\n", 1)
tests = TESTS
key = bytes([0xAB] * 31 + [1])
for marker, settings in (("@OLD_VECTOR@", (2, 3, [key])), ("@NEW_VECTOR@", (5, 7, [key]))):
tests = tests.replace(marker, ",".join(str(byte) for byte in image(*settings)))
sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
if sys.platform.startswith("linux") else [])
with tempfile.TemporaryDirectory(prefix="room-access-policy-") as temporary:
work = Path(temporary)
(work / "sys").mkdir()
(work / "sys/stat.h").write_text(STAT_MOCK)
source = work / "access.cpp"
for platform in (None, "NRF52_PLATFORM", "STM32_PLATFORM", "RP2040_PLATFORM", "ESP32_PLATFORM"):
with self.subTest(platform=platform):
source.write_text(filesystem + (LFS_MOCK if platform in ("NRF52_PLATFORM", "STM32_PLATFORM") else "") + tests)
binary = work / (platform or "generic")
defines = ["-D" + platform + "=1"] if platform else []
built = subprocess.run([compiler, "-std=c++11", "-O1", "-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 access identity/quota/storage/power-cut scenarios", checked.stdout)
print((platform or "generic") + ": " + checked.stdout.strip())
if __name__ == "__main__":
unittest.main()
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""Run the actual room ACL filter and ClientACL storage/eviction policy."""
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/client_acl_spiffs"
HARNESS = r'''
#define main existing_acl_fixture_main
#include "test_client_acl_spiffs.cpp"
#undef main
#include <helpers/RoomAccessPolicy.h>
struct MyMesh { static bool saveFilter(ClientInfo*); };
@SAVE_FILTER@
static mesh::Identity keyed(unsigned n) {
uint8_t key[PUB_KEY_SIZE] = {};
key[0] = 0x49; key[29] = uint8_t(n >> 16); key[30] = uint8_t(n >> 8); key[31] = uint8_t(n);
return mesh::Identity(key);
}
static void roomAssignedAclSurvivesReboot() {
FakeFilesystem fs;
ClientACL acl; acl.load(&fs, SELF); acl.protectExplicitPermissions();
for (unsigned role = 1; role <= 7; ++role) {
const auto id = keyed(role);
CHECK(acl.applyPermissions(SELF, id.pub_key, PUB_KEY_SIZE, uint8_t(role | 0x80)));
auto* client = acl.getClient(id.pub_key, PUB_KEY_SIZE);
CHECK(client && client->permissions_are_explicit && MyMesh::saveFilter(client));
client->last_activity = 1000 + role;
CHECK(acl.authorizeLoginTimestamp(id.pub_key, 100 + role, 0, uint8_t(role)));
client->last_timestamp = 100 + role;
client->extra.room.sync_since = 40 + role;
#if !MESH_CLIENT_REPEATER_ONLY
client->extra.room.post_quota_used = 6; client->extra.room.poll_quota_used = 5;
#endif
}
const auto guest = keyed(20), writer = keyed(21), admin = keyed(22);
CHECK(acl.putClient(guest, PERM_ACL_GUEST));
auto* auto_rw = acl.putClient(writer, PERM_ACL_READ_WRITE);
auto* auto_admin = acl.putClient(admin, PERM_ACL_ADMIN);
CHECK(auto_rw && auto_admin);
CHECK(!auto_rw->permissions_are_explicit && !MyMesh::saveFilter(auto_rw));
CHECK(!auto_admin->permissions_are_explicit && MyMesh::saveFilter(auto_admin));
SELF.calcSharedSecret(auto_admin->shared_secret, admin.pub_key);
CHECK(acl.save(&fs, MyMesh::saveFilter));
const auto contacts = fs.files["/s_contacts"];
ClientACL restored; restored.load(&fs, SELF); restored.protectExplicitPermissions();
CHECK(restored.getNumClients() == 8);
CHECK(!restored.getClient(guest.pub_key, PUB_KEY_SIZE));
CHECK(!restored.getClient(writer.pub_key, PUB_KEY_SIZE));
for (unsigned role = 1; role <= 7; ++role) {
const auto id = keyed(role);
auto* client = restored.getClient(id.pub_key, PUB_KEY_SIZE);
CHECK(client && client->permissions == uint8_t(role | 0x80));
CHECK(client->permissions_are_explicit && client->extra.room.sync_since == 40 + role);
if (role == PERM_ACL_READ_ONLY) {
// Existing RO-only sessions deliberately do not allocate durable login ceilings.
CHECK(client->last_timestamp == 0);
CHECK(!restored.authorizeLoginTimestamp(id.pub_key, 100 + role, 100 + role, uint8_t(role)));
} else {
CHECK(client->last_timestamp >= 160 + role);
CHECK(!restored.authorizeLoginTimestamp(id.pub_key, 100 + role, client->last_timestamp, uint8_t(role)));
}
#if !MESH_CLIENT_REPEATER_ONLY
CHECK(client->extra.room.post_quota_used == 0 && client->extra.room.poll_quota_used == 0);
#endif
}
CHECK(restored.getClient(admin.pub_key, PUB_KEY_SIZE)->permissions_are_explicit);
// The marker is not a new wire/storage flag and an unchanged save is byte-identical.
CHECK(restored.save(&fs, MyMesh::saveFilter));
CHECK(fs.files["/s_contacts"] == contacts);
}
static void roomPinsOnlyExplicitAssignments() {
FakeFilesystem fs; ClientACL acl; acl.load(&fs, SELF); acl.protectExplicitPermissions();
for (unsigned i = 0; i < MAX_CLIENTS; ++i) {
const auto id = keyed(i + 1);
CHECK(acl.applyPermissions(SELF, id.pub_key, PUB_KEY_SIZE, i % 2 ? PERM_ACL_READ_ONLY : PERM_ACL_READ_WRITE));
acl.getClient(id.pub_key, PUB_KEY_SIZE)->last_activity = i + 1;
}
CHECK(!acl.putClient(keyed(100), PERM_ACL_GUEST));
CHECK(acl.getNumClients() == MAX_CLIENTS);
// setperm0 stays removal, creating one ephemeral login-cache slot.
const auto removed = keyed(1);
CHECK(acl.applyPermissions(SELF, removed.pub_key, PUB_KEY_SIZE, 0));
CHECK(!acl.getClient(removed.pub_key, PUB_KEY_SIZE));
auto* ephemeral = acl.putClient(keyed(100), PERM_ACL_READ_WRITE);
CHECK(ephemeral && !ephemeral->permissions_are_explicit);
#if !MESH_CLIENT_REPEATER_ONLY
ephemeral->extra.room.post_quota_used = ephemeral->extra.room.poll_quota_used = 9;
#endif
CHECK(acl.putClient(keyed(101), PERM_ACL_GUEST));
CHECK(!acl.getClient(keyed(100).pub_key, PUB_KEY_SIZE));
for (unsigned i = 2; i <= MAX_CLIENTS; ++i) CHECK(acl.getClient(keyed(i).pub_key, PUB_KEY_SIZE));
#if !MESH_CLIENT_REPEATER_ONLY
CHECK(acl.getClient(keyed(101).pub_key, PUB_KEY_SIZE)->extra.room.post_quota_used == 0);
#endif
// Other roles keep the existing default policy: explicit RO is still evictable.
FakeFilesystem other_fs; ClientACL other; other.load(&other_fs, SELF);
for (unsigned i = 0; i < MAX_CLIENTS; ++i) {
const auto id = keyed(i + 1);
CHECK(other.applyPermissions(SELF, id.pub_key, PUB_KEY_SIZE, PERM_ACL_READ_ONLY));
other.getClient(id.pub_key, PUB_KEY_SIZE)->last_activity = i + 1;
}
CHECK(other.putClient(keyed(999), PERM_ACL_GUEST));
CHECK(!other.getClient(keyed(1).pub_key, PUB_KEY_SIZE));
// Opt-out is deliberate and reversible; no persistence-format migration.
acl.protectExplicitPermissions(false);
CHECK(acl.putClient(keyed(102), PERM_ACL_GUEST));
}
int main() {
roomAssignedAclSurvivesReboot(); roomPinsOnlyExplicitAssignments();
puts("actual room assigned ACL persistence, replay, eviction, and other-role policy passed");
}
'''
class RoomAclPersistenceTests(unittest.TestCase):
def test_actual_acl_filter_storage_and_opt_in_pressure_protection(self):
compiler = shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler)
room = (ROOT / "examples/simple_room_server/MyMesh.cpp").read_text()
generated = HARNESS.replace("@SAVE_FILTER@", extract_braced(room, "bool MyMesh::saveFilter("))
with tempfile.TemporaryDirectory(prefix="room-assigned-acl-") as directory:
work = Path(directory)
(work / "test.cpp").write_text(generated)
for compact in (0, 1):
with self.subTest(repeater_only=compact):
binary = work / ("acl-" + str(compact))
command = [compiler, "-std=c++17", "-O1", "-Wall", "-Wextra", "-Werror",
"-DESP32=1", "-DESP32_PLATFORM=1", f"-DMESH_CLIENT_REPEATER_ONLY={compact}",
f"-I{FIXTURE / 'mocks'}", f"-I{FIXTURE}", f"-I{ROOT / 'src'}",
str(work / "test.cpp"), "-o", str(binary)]
if "g++" in Path(compiler).name:
# The existing fixture identity has a test-only nontrivial constructor.
command.insert(1, "-Wno-class-memaccess")
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)
checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=20)
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
self.assertIn("actual room assigned ACL persistence, replay, eviction, and other-role policy passed", checked.stdout)
if __name__ == "__main__":
unittest.main()
+241
View File
@@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""Run actual bounded board LoRa/CLI wrappers against their real file store."""
from pathlib import Path
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from test_room_board_store import FILESYSTEM_HARNESS, LFS_MOCK, STAT_MOCK
ROOT = Path(__file__).resolve().parents[1]
TESTS = r'''
#define FILE_O_WRITE 1
#include <helpers/RoomBoardProtocol.h>
using Result = mesh::RoomBoardResult;
static unsigned scenarios = 0;
static Result save(MemoryFS& fs, uint8_t id, const std::string& title, const std::string& body) {
metadata_filesystem = &fs;
return mesh::saveRoomBoardArticle(&fs, id, title.c_str(), body.size(),
[&body](size_t offset, uint8_t* out, size_t length) -> size_t {
memcpy(out, body.data() + offset, length); return length;
});
}
static std::vector<uint8_t> indexRequest(uint32_t revision = 0, uint8_t start = 0) {
std::vector<uint8_t> request(7); request[0] = 10;
mesh::room_board_detail::put32(request.data() + 2, revision); request[6] = start; return request;
}
static std::vector<uint8_t> readRequest(uint8_t id, uint32_t version, uint16_t offset = 0) {
std::vector<uint8_t> request(9); request[0] = 10; request[1] = 1; request[2] = id;
mesh::room_board_detail::put32(request.data() + 3, version);
mesh::room_board_detail::put16(request.data() + 7, offset); return request;
}
static std::vector<uint8_t> request(MemoryFS& fs, const std::vector<uint8_t>& payload, size_t capacity = 160) {
metadata_filesystem = &fs;
std::vector<uint8_t> out(capacity + 2, 0x7f);
const size_t used = mesh::handleRoomBoardRequest(&fs, payload.data(), payload.size(), out.data() + 1, capacity);
assert(used <= capacity && out.front() == 0x7f && out.back() == 0x7f);
++scenarios; return std::vector<uint8_t>(out.begin() + 1, out.begin() + 1 + used);
}
static std::string command(MemoryFS& fs, const std::string& text, size_t capacity = 160, bool handled = true) {
metadata_filesystem = &fs;
std::vector<char> out(capacity + 2, 0x7f);
assert(mesh::handleRoomBoardCommand(&fs, text.c_str(), out.data() + 1, capacity) == handled);
assert(out.front() == 0x7f && out.back() == 0x7f);
++scenarios;
if (!capacity || !handled) return "";
const void* terminated = memchr(out.data() + 1, 0, capacity); assert(terminated);
return out.data() + 1;
}
static std::string decode(const std::string& encoded) {
static const std::string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
unsigned bits = 0, held = 0; std::string output;
for (char byte : encoded) {
if (byte == '=') break;
const size_t digit = alphabet.find(byte); assert(digit != std::string::npos);
bits = (bits << 6) | unsigned(digit); held += 6;
if (held >= 8) { held -= 8; output += char((bits >> held) & 255); }
}
return output;
}
static void checkIndex(const std::vector<uint8_t>& reply, uint8_t start, uint8_t count) {
assert(reply.size() >= 10 && reply[0] == 10 && reply[1] == 0 && reply[2] == 0);
assert(reply[8] == start && reply[9] >= start && reply[9] <= start + 2 && reply[7] == count);
size_t cursor = 10;
for (uint8_t i = start; i < reply[9]; ++i) {
assert(cursor + 8 <= reply.size());
assert(reply[cursor] == i + 1 && reply[cursor + 7] <= 63);
const size_t title_length = reply[cursor + 7];
assert(cursor + 8 + title_length <= reply.size()); cursor += 8 + title_length;
}
assert(cursor == reply.size());
}
int main() {
MemoryFS fs;
auto packet = request(fs, indexRequest()); checkIndex(packet, 0, 0);
assert(mesh::room_board_detail::get32(packet.data() + 3) == 0 && packet[9] == 0);
assert(command(fs, "get room.board") == "rev=0 total=0 start=0 next=0");
assert(command(fs, "room.board.put 1 Instructions|Original body") == "OK rev=1");
assert(command(fs, "room.board.put 2 Cafe |Unicode café 📡") == "OK rev=2");
for (uint8_t id = 3; id <= 8; ++id) assert(save(fs, id, "Article " + std::to_string(id), std::string(2048, char('a' + id))) == Result::Success);
packet = request(fs, indexRequest()); checkIndex(packet, 0, 8); assert(packet[9] == 2);
assert(mesh::room_board_detail::get32(packet.data() + 3) == 8);
for (uint8_t start = 0; start <= 8; start += 2) checkIndex(request(fs, indexRequest(8, start)), start, 8);
packet = request(fs, indexRequest(0, 2)); assert(packet[2] == 5 && packet.size() == 10 && packet[9] == 2);
packet = request(fs, indexRequest(7)); assert(packet[2] == 5 && packet.size() == 10);
packet = request(fs, indexRequest(8, 9)); assert(packet[2] == 1 && packet.size() == 10);
assert(command(fs, "get room.board 2").find("start=2 next=4 3@") != std::string::npos);
// Every possible transport capacity checks canaries and complete entries.
for (size_t capacity = 0; capacity <= 256; ++capacity) {
packet = request(fs, indexRequest(), capacity);
if (capacity < 3) assert(packet.empty());
else if (capacity < 10) assert(packet.size() == 3 && packet[2] == 1);
else if (packet[2] == 0) checkIndex(packet, 0, 8);
else assert(packet.size() == 10 && packet[2] == 1 && packet[9] == 0);
packet = request(fs, readRequest(3, 3), capacity);
if (capacity < 3) assert(packet.empty());
else if (capacity < 13) assert(packet.size() == 3 && packet[2] == 1);
else if (capacity == 13) assert(packet.size() == 13 && packet[2] == 1 && packet[12] == 0);
else {
const size_t wanted = std::min(size_t(128), capacity - 13);
assert(packet.size() == 13 + wanted && packet[2] == 0 && packet[12] == wanted);
assert(std::all_of(packet.begin() + 13, packet.end(), [](uint8_t byte) { return byte == 'd'; }));
}
}
for (size_t capacity = 0; capacity <= 160; ++capacity) {
const std::string listed = command(fs, "get room.board", capacity);
if (listed.find("rev=") == 0) assert(listed.find("next=") != std::string::npos);
const std::string part = command(fs, "get room.board.read 3 3 0", capacity);
if (part.find("id=") == 0) {
unsigned id, version, offset, total, length;
assert(sscanf(part.c_str(), "id=%u v=%u off=%u total=%u n=%u data=", &id, &version, &offset, &total, &length) == 5);
const size_t data = part.find("data="); assert(data != std::string::npos);
assert(id == 3 && version == 3 && offset == 0 && total == 2048 && length > 0 && length <= 80);
assert(decode(part.substr(data + 5)) == std::string(length, 'd'));
}
}
// Maximum titles still page by the emitted cursor, without dropping an item.
MemoryFS long_titles;
assert(save(long_titles, 1, std::string(63, 'x'), "a") == Result::Success);
assert(save(long_titles, 2, std::string(63, 'y'), "b") == Result::Success);
packet = request(long_titles, indexRequest(), 81); checkIndex(packet, 0, 2); assert(packet[9] == 1 && packet.size() == 81);
checkIndex(request(long_titles, indexRequest(2, 1), 81), 1, 2);
packet = request(long_titles, indexRequest(), 151); assert(packet[9] == 1);
packet = request(long_titles, indexRequest(), 152); assert(packet[9] == 2 && packet.size() == 152);
std::string listed = command(long_titles, "get room.board"); assert(listed.find("next=1") != std::string::npos);
listed = command(long_titles, "get room.board 1"); assert(listed.find("next=2") != std::string::npos && listed.find(std::string(63, 'y')) != std::string::npos);
// Both binary and CLI paging reconstruct a full 2-KiB article exactly.
std::string assembled;
for (uint16_t offset = 0; offset < 2048;) {
packet = request(fs, readRequest(3, 3, offset), 103);
assert(packet[2] == 0 && packet[3] == 3 && mesh::room_board_detail::get32(packet.data() + 4) == 3);
assert(mesh::room_board_detail::get16(packet.data() + 8) == offset && mesh::room_board_detail::get16(packet.data() + 10) == 2048);
assembled.append(reinterpret_cast<const char*>(packet.data() + 13), packet[12]); offset += packet[12];
}
assert(assembled == std::string(2048, 'd'));
assembled.clear();
for (size_t offset = 0; offset < 2048;) {
const std::string part = command(fs, "get room.board.read 3 3 " + std::to_string(offset));
const size_t data = part.find("data="); assert(data != std::string::npos);
const std::string bytes = decode(part.substr(data + 5)); assert(!bytes.empty() && bytes.size() <= 80);
assembled += bytes; offset += bytes.size();
}
assert(assembled == std::string(2048, 'd'));
packet = request(fs, readRequest(3, 3, 2048), 13); assert(packet[2] == 0 && packet.size() == 13 && packet[12] == 0);
packet = request(fs, readRequest(3, 3, 2049)); assert(packet[2] == 1 && packet.size() == 13);
assert(command(fs, "get room.board.read 3 3 2048").find("n=0 data=") != std::string::npos);
assert(command(fs, "get room.board.read 2 2 0").find("data=VW5pY29kZSBjYWbDqSDwn5Oh") != std::string::npos);
// A mutation between index pages or article chunks cannot mix snapshots.
assert(save(fs, 3, "Changed", "replacement") == Result::Success);
packet = request(fs, indexRequest(8, 2)); assert(packet[2] == 5 && mesh::room_board_detail::get32(packet.data() + 3) == 9);
packet = request(fs, readRequest(3, 3, 90)); assert(packet[2] == 5 && packet[12] == 0 && mesh::room_board_detail::get32(packet.data() + 4) == 9);
assert(command(fs, "get room.board.read 3 3 90") == "Error stale version");
assert(command(fs, "room.board.del 3 3") == "Error stale version");
assert(command(fs, "room.board.del 3 9") == "OK rev=10");
packet = request(fs, readRequest(3, 9)); assert(packet[2] == 4);
assert(command(fs, "room.board.put 3 Reused|new") == "OK rev=11");
packet = request(fs, readRequest(3, 9)); assert(packet[2] == 5);
const auto malformed = readRequest(1, 1);
for (size_t length = 0; length < malformed.size(); ++length) {
packet = request(fs, std::vector<uint8_t>(malformed.begin(), malformed.begin() + length));
assert(packet.size() == 3 && packet[2] == 1);
}
packet = request(fs, std::vector<uint8_t>{10, 2}); assert(packet[2] == 1);
packet = request(fs, std::vector<uint8_t>{9, 0}); assert(packet[2] == 1);
packet = request(fs, readRequest(0, 1)); assert(packet[2] == 1);
packet = request(fs, readRequest(1, 0)); assert(packet[2] == 1);
const unsigned writes = fs.write_opens;
for (const std::string text : {"room.board.put", "room.board.put 0 Title|body", "room.board.put 9 Title|body", "room.board.put 1 No separator",
"room.board.put 1 |body", "room.board.del 1", "room.board.del 1 0", "room.board.del 1 -1", "room.board.del 1 4294967296",
"room.board.del 1 1 trailing", "get room.board -1", "get room.board 9", "get room.board.read 1 1",
"get room.board.read 1 1 -1", "get room.board.read 1 1 2049", "get room.board.read 1 0 0", "get room.board.read 1 1 0 trailing"}) {
assert(command(fs, text) == "Error invalid");
}
assert(command(fs, "room.board.put 1 " + std::string(64, 't') + "|body") == "Error invalid");
assert(command(fs, "room.board.put 1 Title|" + std::string(513, 'b')) == "Error invalid");
assert(command(fs, "room.board.put 1 " + std::string("\xed\xa0\x80") + "|body") == "Error invalid");
assert(fs.write_opens == writes);
command(fs, "unrelated", 160, false); command(fs, "get room.boardwalk", 160, false);
command(fs, "room.board.putaway 1 Title|body", 160, false);
// Fail-closed CRC checks apply before protocol reads and command writes.
mesh::RoomBoardIndex index; metadata_filesystem = &fs; assert(mesh::loadRoomBoard(&fs, index));
char path[24]; mesh::room_board_detail::articlePath(index.articles[0], path);
auto damaged = fs.get(path); damaged.back() ^= 1; fs.put(path, damaged);
const auto primary = fs.get(mesh::ROOM_BOARD_PRIMARY_PATH);
packet = request(fs, indexRequest()); assert(packet[2] == 2 && packet.size() == 10);
packet = request(fs, readRequest(1, 1)); assert(packet[2] == 2 && packet[12] == 0);
assert(command(fs, "get room.board") == "Error unavailable");
assert(command(fs, "get room.board.read 1 1 0") == "Error unavailable");
assert(command(fs, "room.board.put 1 Changed|body") == "Error unavailable");
assert(command(fs, "room.board.del 1 1") == "Error unavailable");
assert(fs.get(mesh::ROOM_BOARD_PRIMARY_PATH) == primary && fs.get(path) == damaged && fs.write_opens == writes);
printf("PASS: %u actual room board LoRa/CLI protocol, bounds and stale-snapshot scenarios\n", scenarios);
}
'''
class RoomBoardProtocolTest(unittest.TestCase):
def test_real_protocol_and_store_all_backend_modes(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")
filesystem = FILESYSTEM_HARNESS.split("#define FILE_O_WRITE 1")[0]
filesystem = filesystem.replace(" void _lockFS() {}\n void _unlockFS() {}\n MemoryFS* _getFS() { return this; }\n", "", 1)
filesystem = filesystem.replace(" void point(const std::string& event) {", """ int lock_depth = 0;
void _lockFS() { assert(lock_depth++ == 0); }
void _unlockFS() { assert(--lock_depth == 0); }
MemoryFS* _getFS() { return this; }
void point(const std::string& event) {""")
sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
if sys.platform.startswith("linux") else [])
with tempfile.TemporaryDirectory(prefix="meshcore-room-board-protocol-") as temporary:
work = Path(temporary)
(work / "sys").mkdir()
(work / "sys/stat.h").write_text(STAT_MOCK)
source = work / "protocol.cpp"
for platform in (None, "NRF52_PLATFORM", "STM32_PLATFORM", "RP2040_PLATFORM", "ESP32_PLATFORM"):
with self.subTest(platform=platform):
source.write_text(filesystem + (LFS_MOCK if platform in ("NRF52_PLATFORM", "STM32_PLATFORM") else "") + TESTS)
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=60)
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
self.assertIn("actual room board LoRa/CLI protocol", checked.stdout)
print(checked.stdout.strip())
if __name__ == "__main__":
unittest.main()
+322
View File
@@ -0,0 +1,322 @@
#!/usr/bin/env python3
"""Exercise the real bounded room board with faults, paging, and power cuts."""
from pathlib import Path
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from test_room_topic_store import HARNESS as FILESYSTEM_HARNESS, STAT_MOCK
ROOT = Path(__file__).resolve().parents[1]
LFS_MOCK = r'''
struct lfs_info {};
static constexpr int LFS_ERR_NOENT = -2;
static int lfs_stat(MemoryFS* fs, const char* path, struct lfs_info*) {
assert(fs && fs->lock_depth == 1);
const std::string op = std::string("stat:") + path;
const bool okay = fs->begin(op);
const bool present = fs->files.count(path) || fs->directories.count(path);
fs->end(op);
return !okay ? -5 : present ? 0 : LFS_ERR_NOENT;
}
'''
TESTS = r'''
#define FILE_O_WRITE 1
#include <helpers/RoomBoardStore.h>
using Result = mesh::RoomBoardResult;
using Index = mesh::RoomBoardIndex;
static const char* P = mesh::ROOM_BOARD_PRIMARY_PATH;
static const char* T = mesh::ROOM_BOARD_TEMP_PATH;
static const char* B = mesh::ROOM_BOARD_BACKUP_PATH;
static unsigned scenarios = 0;
static const std::string OLD_BODY = "Original room directions";
static const std::string NEW_BODY = std::string(2048, 'n');
static bool load(MemoryFS& fs, Index& index) {
metadata_filesystem = &fs; return mesh::loadRoomBoard(&fs, index);
}
static Result save(MemoryFS& fs, uint8_t id, const char* title,
const std::string& body, uint32_t version = 0, bool short_reader = false) {
metadata_filesystem = &fs;
return mesh::saveRoomBoardArticle(&fs, id, title, body.size(),
[&](size_t offset, uint8_t* out, size_t length) -> size_t {
assert(length <= 64 && offset + length <= body.size());
memcpy(out, body.data() + offset, length);
return short_reader && length ? length - 1 : length;
}, version);
}
static Result erase(MemoryFS& fs, uint8_t id, uint32_t version = 0) {
metadata_filesystem = &fs; return mesh::deleteRoomBoardArticle(&fs, id, version);
}
static Result read(MemoryFS& fs, uint8_t id, uint32_t version, size_t offset,
uint8_t* out, size_t capacity, size_t& count) {
metadata_filesystem = &fs;
return mesh::readRoomBoardArticle(&fs, id, version, offset, out, capacity, count);
}
static MemoryFS clone(const MemoryFS& source) {
MemoryFS fs; fs.directories = source.directories;
for (const auto& file : source.files) fs.put(file.first.c_str(), *file.second);
return fs;
}
static std::map<std::string, std::vector<uint8_t>> snapshot(const MemoryFS& fs) {
std::map<std::string, std::vector<uint8_t>> result;
for (const auto& file : fs.files) result[file.first] = *file.second;
return result;
}
static Index loaded(MemoryFS& fs, uint32_t revision, uint8_t count) {
Index index; assert(load(fs, index));
assert(index.revision == revision && index.count == count); return index;
}
static std::string bodyPath(const mesh::RoomBoardArticle& article) {
char path[24]; mesh::room_board_detail::articlePath(article, path); return path;
}
static void bodyIs(MemoryFS& fs, const mesh::RoomBoardArticle& article, const std::string& expected) {
assert(article.body_length == expected.size());
std::string assembled;
for (size_t offset = 0; offset <= expected.size();) {
uint8_t out[39]; memset(out, 0x7f, sizeof(out)); size_t length = 999;
assert(read(fs, article.id, article.version, offset, out + 1, 37, length) == Result::Success);
assert(out[0] == 0x7f && out[38] == 0x7f && length <= 37);
assembled.append(reinterpret_cast<const char*>(out + 1), length);
offset += length;
if (length == 0) break;
}
assert(assembled == expected);
}
static void checksum(std::vector<uint8_t>& image) {
const uint32_t value = mesh::room_board_detail::crc(0xffffffffu, image.data(), image.size() - 4) ^ 0xffffffffu;
mesh::room_board_detail::put32(image.data() + image.size() - 4, value);
}
static void preserveUnavailable(MemoryFS& fs) {
const auto before = snapshot(fs); const auto directories = fs.directories;
Index index; index.count = 7; index.revision = 99;
assert(!load(fs, index) && index.count == 0 && index.revision == 0);
assert(save(fs, 1, "Changed", NEW_BODY) == Result::Unavailable);
assert(erase(fs, 1) == Result::Unavailable);
uint8_t out[128]; size_t count = 99;
assert(read(fs, 1, 1, 0, out, sizeof(out), count) == Result::Unavailable && count == 0);
assert(snapshot(fs) == before && fs.directories == directories && fs.write_opens == 0);
++scenarios;
}
static void failedUpdate(MemoryFS& fs) {
assert(save(fs, 1, "New title", NEW_BODY) != Result::Success);
fs.reset(); Index index = loaded(fs, 1, 1); bodyIs(fs, index.articles[0], OLD_BODY); ++scenarios;
}
int main() {
MemoryFS initial; Index empty = loaded(initial, 0, 0); (void)empty;
assert(save(initial, 1, "Old title", OLD_BODY) == Result::Success);
Index old = loaded(initial, 1, 1); bodyIs(initial, old.articles[0], OLD_BODY);
initial.reset();
const auto old_index = initial.get(P);
const std::string old_path = bodyPath(old.articles[0]);
const std::string new_path = "/room_board_1.b";
{ MemoryFS fs = clone(initial);
assert(save(fs, 1, "New title", NEW_BODY, 1) == Result::Success);
Index next = loaded(fs, 2, 1); assert(next.articles[0].version == 2 && next.articles[0].bank == 1);
bodyIs(fs, next.articles[0], NEW_BODY);
assert(save(fs, 1, "Again", "third", 2) == Result::Success);
next = loaded(fs, 3, 1); assert(next.articles[0].bank == 0); bodyIs(fs, next.articles[0], "third");
assert(erase(fs, 1, 3) == Result::Success); loaded(fs, 4, 0);
assert(save(fs, 1, "Replacement", "", 0) == Result::Success);
next = loaded(fs, 5, 1); bodyIs(fs, next.articles[0], ""); ++scenarios;
}
{ MemoryFS fs;
for (uint8_t id = 8; id != 0; --id) assert(save(fs, id, "Directions", std::string(2048, char('a' + id))) == Result::Success);
Index index = loaded(fs, 8, 8);
for (uint8_t i = 0; i < 8; ++i) { assert(index.articles[i].id == i + 1); bodyIs(fs, index.articles[i], std::string(2048, char('a' + i + 1))); }
assert(fs.files.size() == 9); // One metadata image and one streamed body per article.
assert(erase(fs, 4, index.articles[3].version) == Result::Success);
index = loaded(fs, 9, 7); assert(index.articles[3].id == 5);
assert(save(fs, 4, "Returned", "body") == Result::Success);
index = loaded(fs, 10, 8); assert(index.articles[3].id == 4 && index.articles[3].version == 10); ++scenarios;
}
{ MemoryFS fs = clone(initial);
const std::string maximum_title(63, 't'); assert(save(fs, 2, maximum_title.c_str(), "body") == Result::Success);
assert(save(fs, 3, u8"Café 地図 📡", "body") == Result::Success);
fs.reset(); const auto before = snapshot(fs);
for (const std::string& title : {std::string(""), std::string(64, 't'), std::string("\xc0\xaf"), std::string("\xed\xa0\x80"), std::string("\xf4\x90\x80\x80"), std::string("\xe2\x82"), std::string("line\nfeed")}) {
assert(save(fs, 1, title.c_str(), "body") == Result::Invalid);
}
assert(save(fs, 0, "Title", "body") == Result::Invalid);
assert(save(fs, 9, "Title", "body") == Result::Invalid);
assert(save(fs, 1, nullptr, "body") == Result::Invalid);
assert(save(fs, 1, "Title", std::string(2049, 'x')) == Result::Invalid);
assert(fs.trace.empty() && snapshot(fs) == before); ++scenarios;
}
{ MemoryFS fs = clone(initial); uint8_t out[130]; size_t count;
assert(read(fs, 1, 2, 0, out, 128, count) == Result::StaleVersion && count == 0);
assert(read(fs, 2, 1, 0, out, 128, count) == Result::NotFound && count == 0);
assert(read(fs, 1, 0, 0, out, 128, count) == Result::Invalid && count == 0);
assert(read(fs, 1, 1, OLD_BODY.size() + 1, out, 128, count) == Result::Invalid && count == 0);
assert(read(fs, 1, 1, 0, out, 129, count) == Result::Invalid && count == 0);
assert(read(fs, 1, 1, 0, out, 0, count) == Result::Invalid && count == 0);
assert(read(fs, 1, 1, 0, nullptr, 128, count) == Result::Invalid && count == 0);
const auto before = snapshot(fs);
assert(save(fs, 1, "Changed", NEW_BODY, 2) == Result::StaleVersion);
assert(save(fs, 2, "Changed", NEW_BODY, 2) == Result::StaleVersion);
assert(erase(fs, 1, 2) == Result::StaleVersion && erase(fs, 2) == Result::NotFound);
assert(snapshot(fs) == before);
assert(save(fs, 1, "Changed", NEW_BODY, 1) == Result::Success);
assert(read(fs, 1, 1, 0, out, 128, count) == Result::StaleVersion && count == 0);
assert(erase(fs, 1, 2) == Result::Success);
assert(read(fs, 1, 1, 0, out, 128, count) == Result::NotFound && count == 0);
assert(save(fs, 1, "Reused", "new") == Result::Success);
assert(read(fs, 1, 1, 0, out, 128, count) == Result::StaleVersion && count == 0); ++scenarios;
}
{ MemoryFS fs = clone(initial); auto image = fs.get(P);
mesh::room_board_detail::put32(image.data() + 4, UINT32_MAX); checksum(image); fs.put(P, image);
loaded(fs, UINT32_MAX, 1); const auto before = snapshot(fs); fs.reset();
assert(save(fs, 1, "Changed", NEW_BODY) == Result::Unavailable);
assert(erase(fs, 1) == Result::Unavailable && fs.write_opens == 0 && snapshot(fs) == before); ++scenarios;
}
{ Index index; assert(!mesh::loadRoomBoard(static_cast<MemoryFS*>(nullptr), index));
uint8_t out[8]; size_t count;
assert(mesh::readRoomBoardArticle(static_cast<MemoryFS*>(nullptr), 1, 1, 0, out, 8, count) == Result::Unavailable);
assert(mesh::deleteRoomBoardArticle(static_cast<MemoryFS*>(nullptr), 1) == Result::Unavailable); ++scenarios;
}
// Every single-byte corruption in the real index/body must fail closed and
// preserve the primary plus its backup/temp evidence without any writes.
for (const std::string& path : {std::string(P), old_path}) {
const auto original = initial.get(path.c_str());
for (size_t byte = 0; byte < original.size(); ++byte) {
MemoryFS fs = clone(initial); auto corrupted = original; corrupted[byte] ^= 1;
fs.put(path.c_str(), corrupted); fs.put(B, old_index); fs.put(T, old_index); preserveUnavailable(fs);
}
for (const auto& invalid : {std::vector<uint8_t>{}, std::vector<uint8_t>{1}, std::vector<uint8_t>(original.begin(), original.end() - 1)}) {
MemoryFS fs = clone(initial); fs.put(path.c_str(), invalid); fs.put(B, old_index); fs.put(T, old_index); preserveUnavailable(fs);
}
auto trailing = original; trailing.push_back(0);
MemoryFS fs = clone(initial); fs.put(path.c_str(), trailing); fs.put(B, old_index); preserveUnavailable(fs);
auto future = original; future[3] = 2; checksum(future);
fs = clone(initial); fs.put(path.c_str(), future); fs.put(B, old_index); preserveUnavailable(fs);
fs = clone(initial); fs.directories.insert(path); preserveUnavailable(fs);
for (const std::string operation : {"open:r:", "read:", "size:"}) {
fs = clone(initial); fs.faults.insert(operation + path); preserveUnavailable(fs);
}
}
for (const auto& mutation : {std::make_pair(size_t(9), uint8_t(1)), std::make_pair(size_t(8), uint8_t(9)),
std::make_pair(size_t(16), uint8_t(0)), std::make_pair(size_t(17), uint8_t(2)),
std::make_pair(size_t(19), uint8_t(9)), std::make_pair(size_t(20), uint8_t(0)),
std::make_pair(size_t(24), uint8_t(0)), std::make_pair(size_t(70), uint8_t('x'))}) {
MemoryFS fs = clone(initial); auto image = old_index; image[mutation.first] = mutation.second; checksum(image);
fs.put(P, image); preserveUnavailable(fs);
}
{ MemoryFS fs = clone(initial); auto image = fs.get(old_path.c_str()); image[12] = 1; checksum(image);
fs.put(old_path.c_str(), image); preserveUnavailable(fs); }
{ MemoryFS fs = clone(initial); fs.put(B, old_index); fs.files.erase(P);
Index index = loaded(fs, 1, 1); bodyIs(fs, index.articles[0], OLD_BODY); assert(!fs.files.count(B)); ++scenarios; }
{ MemoryFS fs; fs.put(T, old_index); fs.put(old_path.c_str(), initial.get(old_path.c_str()));
loaded(fs, 0, 0); assert(!fs.files.count(P) && !fs.files.count(T)); ++scenarios; }
{ MemoryFS fs = clone(initial); fs.files.erase(P); auto future = old_index; future[3] = 2; checksum(future);
fs.put(B, future); fs.put(T, old_index); preserveUnavailable(fs); }
for (const std::string& fault : {std::string("open:w:") + new_path, std::string("write:") + new_path,
std::string("flush:") + new_path, std::string("close:w:") + new_path,
std::string("open:r:") + new_path, std::string("read:") + new_path, std::string("size:") + new_path,
std::string("directory:") + new_path, 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}) {
MemoryFS fs = clone(initial); fs.faults.insert(fault); failedUpdate(fs);
}
{ MemoryFS fs = clone(initial); fs.short_write = true; failedUpdate(fs); }
{ MemoryFS fs = clone(initial); fs.short_read = true; failedUpdate(fs); }
{ MemoryFS fs = clone(initial); assert(save(fs, 1, "Changed", NEW_BODY, 0, true) == Result::WriteFailure);
fs.reset(); loaded(fs, 1, 1); ++scenarios; }
{ MemoryFS fs = clone(initial); fs.replacement_on_close = initial.get(old_path.c_str()); failedUpdate(fs); }
// A full filesystem can truncate any streamed body or metadata boundary.
for (size_t budget : {size_t(0), size_t(15), size_t(16), size_t(100), size_t(2067), size_t(2068), size_t(2100), size_t(2159)}) {
MemoryFS fs = clone(initial); fs.write_budget = budget; failedUpdate(fs);
}
{ MemoryFS fs = clone(initial); fs.put(new_path.c_str(), std::vector<uint8_t>{1});
fs.faults.insert("remove:" + new_path); failedUpdate(fs); }
for (const char* artifact : {T, B}) {
MemoryFS fs = clone(initial); fs.put(artifact, old_index); fs.faults.insert(std::string("remove:") + artifact);
loaded(fs, 1, 1); fs.write_opens = 0; assert(save(fs, 1, "Changed", NEW_BODY) == Result::Unavailable && fs.write_opens == 0);
fs.reset(); loaded(fs, 1, 1); ++scenarios;
}
{ MemoryFS fs = clone(initial); fs.faults.insert(std::string("remove:") + B);
assert(save(fs, 1, "Changed", NEW_BODY) == Result::Success);
loaded(fs, 2, 1); assert(fs.files.count(B));
assert(save(fs, 1, "Changed again", "x") == Result::Unavailable);
fs.reset(); loaded(fs, 2, 1); ++scenarios; }
{ MemoryFS fs = clone(initial); 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, 1, "Changed", NEW_BODY) == Result::WriteFailure && !fs.files.count(P) && fs.files.count(B));
fs.reset(); Index index = loaded(fs, 1, 1); bodyIs(fs, index.articles[0], OLD_BODY); ++scenarios; }
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) || defined(ESP32_PLATFORM)
{ MemoryFS fs = clone(initial); fs.faults.insert(std::string("stat:") + P); preserveUnavailable(fs); }
{ MemoryFS fs = clone(initial); fs.files.erase(P); fs.put(B, old_index);
fs.faults.insert(std::string("stat:") + B); preserveUnavailable(fs); }
#endif
// Cut before and after every filesystem operation in publish, update, and
// delete. Reboot may expose only the preceding image or the committed image.
for (unsigned mode = 0; mode < 3; ++mode) {
MemoryFS baseline = mode == 0 ? MemoryFS{} : clone(initial);
assert((mode == 2 ? erase(baseline, 1) : save(baseline, 1, "New title", NEW_BODY)) == Result::Success);
const auto committed_image = baseline.get(P); const size_t boundaries = baseline.boundary;
for (size_t cut = 1; cut <= boundaries; ++cut) {
MemoryFS fs = mode == 0 ? MemoryFS{} : clone(initial); fs.cut_at = cut;
try { if (mode == 2) erase(fs, 1); else save(fs, 1, "New title", NEW_BODY); } catch (const PowerCut&) {}
const bool committed = fs.files.count(P) && fs.get(P) == committed_image;
fs.reset();
Index index = loaded(fs, committed ? (mode == 0 ? 1 : 2) : (mode == 0 ? 0 : 1),
committed && mode == 2 ? 0 : (mode == 0 && !committed ? 0 : 1));
if (index.count) bodyIs(fs, index.articles[0], committed ? NEW_BODY : OLD_BODY);
assert(!fs.files.count(T) && !fs.files.count(B)); ++scenarios;
}
}
printf("PASS: %u room board persistence, version/paging, fault and power-cut scenarios\n", scenarios);
}
'''
class RoomBoardStoreTest(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")
filesystem = FILESYSTEM_HARNESS.split("#define FILE_O_WRITE 1")[0]
filesystem = filesystem.replace(" void _lockFS() {}\n void _unlockFS() {}\n MemoryFS* _getFS() { return this; }\n", "", 1)
filesystem = filesystem.replace(" void point(const std::string& event) {", """ int lock_depth = 0;
void _lockFS() { assert(lock_depth++ == 0); }
void _unlockFS() { assert(--lock_depth == 0); }
MemoryFS* _getFS() { return this; }
size_t write_budget = std::numeric_limits<size_t>::max();
void point(const std::string& event) {""")
filesystem = filesystem.replace(" bytes->insert(bytes->end(), data, data + length);", """ length = std::min(length, fs->write_budget);
fs->write_budget -= length;
bytes->insert(bytes->end(), data, data + length);""")
filesystem = filesystem.replace("void reset() { trace.clear();", "void reset() { lock_depth = 0; write_budget = std::numeric_limits<size_t>::max(); trace.clear();")
sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
if sys.platform.startswith("linux") else [])
with tempfile.TemporaryDirectory(prefix="meshcore-room-board-") as temporary:
work = Path(temporary)
(work / "sys").mkdir()
(work / "sys/stat.h").write_text(STAT_MOCK)
source = work / "board.cpp"
for platform in (None, "NRF52_PLATFORM", "STM32_PLATFORM", "RP2040_PLATFORM", "ESP32_PLATFORM"):
with self.subTest(platform=platform):
source.write_text(filesystem + (LFS_MOCK if platform in ("NRF52_PLATFORM", "STM32_PLATFORM") else "") + TESTS)
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=60)
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
self.assertIn("room board persistence, version/paging, fault and power-cut scenarios", checked.stdout)
print(checked.stdout.strip())
if __name__ == "__main__":
unittest.main()
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Execute actual room history admission, commands and boot loading with the real store."""
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
from test_room_history_store import STAT_MOCK
ROOT = Path(__file__).resolve().parents[1]
FIXTURE = ROOT / "test/fixtures/room_history_integration/test.cpp"
class RoomHistoryIntegrationTests(unittest.TestCase):
def test_actual_post_admission_persistence_commands_and_boot(self):
compiler = shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler, "a host C++ compiler is required")
source = (ROOT / "examples/simple_room_server/MyMesh.cpp").read_text()
header = (ROOT / "examples/simple_room_server/MyMesh.h").read_text()
acl = (ROOT / "src/helpers/ClientACL.h").read_text()
text = (ROOT / "src/helpers/TxtDataHelpers.cpp").read_text()
state = "\n".join(line for line in acl.splitlines() if line.startswith("#define PERM_ACL_"))
state += "\n" + extract_braced(header, "struct PostInfo") + ";\n"
definitions = "\n".join(extract_braced(source, signature) for signature in (
"bool MyMesh::addPost(", "bool MyMesh::addSystemPost(", "bool MyMesh::storePost(",
"bool MyMesh::snapshotRoomHistory(", "void MyMesh::loadRoomHistory(",
"bool MyMesh::handleRoomHistoryCommand("))
definitions += "\n" + extract_braced(text, "void StrHelper::strncpy(")
receive = extract_braced(source, "void MyMesh::onPeerDataRecv(")
gate = extract_braced(receive, "if (flags == TXT_TYPE_PLAIN)")
sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else [])
with tempfile.TemporaryDirectory(prefix="room-history-integration-") as directory:
work = Path(directory)
(work / "sys").mkdir()
(work / "sys/stat.h").write_text(STAT_MOCK)
(work / "state.inc").write_text(state)
(work / "production.inc").write_text(definitions)
(work / "post_gate.inc").write_text(gate)
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++17", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-parameter", *sanitizers, *defines, f"-I{work}", f"-I{ROOT / 'src'}",
f"-I{ROOT / 'test/mocks'}", str(FIXTURE), "-o", str(binary)],
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=30)
self.assertEqual(tested.returncode, 0, tested.stdout + tested.stderr)
self.assertIn("production room history integration scenarios passed", tested.stdout)
print((platform or "generic") + ": " + tested.stdout.strip())
if __name__ == "__main__":
unittest.main()
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Compile the actual bounded journal with fault injection on five FS APIs."""
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]
FIXTURES = ROOT / "test/fixtures/room_history_store"
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 crc_image(payload):
return payload + struct.pack("<I", zlib.crc32(payload))
class RoomHistoryStoreTest(unittest.TestCase):
def test_actual_bounded_journal_backend_modes_faults_and_powercuts(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")
vectors = {
"HEADER": crc_image(b"RHJ\x01" + struct.pack("<HBB", 192, 32, 64)),
"CONFIG_ON": crc_image(b"RHC\x01\x01" + bytes(7)),
"CONFIG_OFF": crc_image(b"RHC\x01\x00" + bytes(7)),
"RECORD_ONE": crc_image(b"\x01" + bytes(30) + b"\x01" + struct.pack("<I", 1001)
+ b"post 1".ljust(152, b"\x00")),
}
rendered = "\n".join("static const std::vector<uint8_t> " + name + " = {"
+ ",".join(str(byte) for byte in image) + "};"
for name, image in vectors.items())
sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else [])
with tempfile.TemporaryDirectory(prefix="meshcore-room-history-") as temporary:
work = Path(temporary)
(work / "sys").mkdir()
(work / "sys/stat.h").write_text(STAT_MOCK)
(work / "vectors.inc").write_text(rendered)
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"),
"-I" + str(FIXTURES), str(FIXTURES / "test.cpp"), "-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("bounded room history fault and recovery scenarios", checked.stdout)
print(checked.stdout.strip())
if __name__ == "__main__":
unittest.main()
+203 -15
View File
@@ -19,6 +19,12 @@ HARNESS = r'''
#include <cstdio>
#include <cstring>
#include <vector>
#include "filesystem.h"
#include <helpers/RoomAccessPolicy.h>
#include <helpers/RemoteCliReplyCache.h>
#include <helpers/RemoteCliRequest.h>
#include <helpers/ClientACLResponse.h>
#include <helpers/ClientPathPersistence.h>
#include <Packet.h>
#include <helpers/RoomLoginAuthorization.h>
#include <helpers/ClientLoginPersistence.h>
@@ -80,6 +86,8 @@ struct FakeACL : ClientACL {
bool known = false, allocate_ok = true;
unsigned lookups = 0, authorizations = 0, allocations = 0;
uint8_t authorized_role = 255;
int getNumClients() const { return known ? 1 : 0; }
ClientInfo* getClientByIdx(int i) { assert(i == 0 && known); return &client; }
ClientInfo* getClient(const uint8_t* key, size_t length) {
++lookups;
assert(length == PUB_KEY_SIZE);
@@ -117,8 +125,28 @@ struct FakeRng {
memset(out, 0xB5, length);
}
};
static uint32_t ticks = 0;
static uint32_t millis() { return ticks; }
struct MyMesh {
FakeACL acl;
MemoryFS policy_fs;
mesh::RoomAccessPolicy room_access;
MyMesh() { metadata_filesystem = &policy_fs; assert(room_access.load(&policy_fs)); }
int matching_peer_indexes[1] = {0};
mesh::RemoteCliReplyCache remote_cli_reply_cache;
mesh::LogicalMessageCache<ROOM_MESSAGE_CACHE_SIZE> recent_room_polls;
void serviceRoomQuotas();
static bool saveFilter(ClientInfo*);
unsigned path_acks = 0;
bool processAck(const uint8_t*) { ++path_acks; return true; }
uint8_t getUnsyncedCount(ClientInfo*) { return 2; }
int getExtraAckTransmitCount() { return 0; }
unsigned requests = 0;
uint8_t last_request_type = 0;
int handleRequest(ClientInfo*, uint32_t, uint8_t* payload, size_t, size_t) { ++requests; last_request_type = payload[0]; return 0; }
void handleCommand(uint32_t, char*, char* reply, int, uint8_t) { strcpy(reply, "OK"); }
mesh::Packet* createAck(uint32_t ack) { ++post_acks; response.payload_len = 4; memcpy(response.payload, &ack, 4); return &response; }
mesh::Packet* createMultiAck(uint32_t ack, uint8_t) { return createAck(ack); }
struct Prefs {
char password[32] = "admin";
char guest_password[32] = "guest";
@@ -145,7 +173,8 @@ struct MyMesh {
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);
assert(type == PAYLOAD_TYPE_RESPONSE || type == PAYLOAD_TYPE_TXT_MSG);
assert(len <= sizeof(captured_reply));
memcpy(response_key, sender.pub_key, PUB_KEY_SIZE);
memcpy(response_secret, secret, PUB_KEY_SIZE);
memcpy(captured_reply, data, len);
@@ -161,29 +190,48 @@ struct MyMesh {
}
void sendDirect(mesh::Packet* packet, const uint8_t* path, uint8_t path_len,
unsigned long delay) {
assert(packet == &response && delay == SERVER_RESPONSE_DELAY);
assert(packet == &response && (delay == SERVER_RESPONSE_DELAY || delay == TXT_ACK_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);
assert(packet == &response && (delay == SERVER_RESPONSE_DELAY || delay == TXT_ACK_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;
bool store_ok = true;
bool addPost(ClientInfo*, const char*) { if (!store_ok) return false; ++posts; return true; }
void post(ClientInfo*, const char* text, uint32_t sender_timestamp) {
std::vector<uint8_t> data(5 + strlen(text) + 1);
memcpy(data.data(), &sender_timestamp, 4); data[4] = TXT_TYPE_PLAIN << 2;
memcpy(data.data() + 5, text, strlen(text));
mesh::Packet packet; packet.header = ROUTE_TYPE_DIRECT;
onPeerDataRecv(&packet, PAYLOAD_TYPE_TXT_MSG, 0, acl.client.shared_secret, data.data(), data.size() - 1);
}
void poll(uint32_t timestamp, uint32_t since = 0, bool flood = false) {
uint8_t data[10] = {}; memcpy(data, &timestamp, 4); data[4] = REQ_TYPE_KEEP_ALIVE;
memcpy(data + 5, &since, 4);
mesh::Packet packet; packet.header = flood ? ROUTE_TYPE_FLOOD : ROUTE_TYPE_DIRECT;
onPeerDataRecv(&packet, PAYLOAD_TYPE_REQ, 0, acl.client.shared_secret, data, 9);
}
void request(uint32_t timestamp, uint8_t subtype, uint8_t argument = 0) {
uint8_t data[8] = {}; memcpy(data, &timestamp, 4); data[4] = subtype;
data[5] = argument;
mesh::Packet packet; packet.header = ROUTE_TYPE_DIRECT;
onPeerDataRecv(&packet, PAYLOAD_TYPE_REQ, 0, acl.client.shared_secret, data, sizeof(data));
}
void onPeerDataRecv(mesh::Packet*, uint8_t, int, const uint8_t*, uint8_t*, size_t);
bool onPeerPathRecv(mesh::Packet*, int, const uint8_t*, uint8_t*, uint8_t, uint8_t, uint8_t*, uint8_t);
void onAnonDataRecv(mesh::Packet*, const uint8_t*, const mesh::Identity&,
uint8_t*, size_t);
};
@LOGIN_HANDLER@
@PEER_HANDLER@
@PATH_HANDLER@
@QUOTA_SERVICE@
@SAVE_FILTER@
static mesh::Identity identity(uint8_t n = 1) {
mesh::Identity sender{};
@@ -210,6 +258,8 @@ static void seedKnown(MyMesh& mesh, uint8_t permissions = PERM_ACL_ADMIN) {
client.extra.room.topic_seen_revision = 11;
client.extra.room.pending_topic_revision = 12;
client.extra.room.topic_failures = 2;
client.extra.room.post_quota_used = 9;
client.extra.room.poll_quota_used = 8;
mesh.acl.known = true;
}
static mesh::Packet incoming(bool flood = false) {
@@ -235,7 +285,7 @@ static void login(MyMesh& mesh, const char* password = "guest", uint32_t timesta
memset(secret, 0x77, sizeof(secret));
auto packet = incoming(flood);
mesh.onAnonDataRecv(&packet, secret, identity(), data.data(), data.size() - 1);
assert(data.back() == 0);
if (mesh.room_access.allowsIdentity(identity().pub_key)) assert(data.back() == 0);
}
static void checkReply(const MyMesh& mesh, uint8_t permissions) {
assert(mesh.creations == 1 && mesh.captured_reply_length == 13);
@@ -325,8 +375,10 @@ static void preservedRoles() {
MyMesh mesh;
seedKnown(mesh, permissions);
mesh._prefs.allow_read_only = reader;
mesh.acl.client.permissions_are_explicit = reader;
login(mesh, password);
assert(mesh.acl.client.permissions == permissions);
assert(mesh.acl.client.permissions_are_explicit == reader);
assert(mesh.acl.authorized_role == (permissions & PERM_ACL_ROLE_MASK));
assert(mesh.acl.allocations == 0 && mesh.acl.client.last_timestamp == 100);
checkReply(mesh, permissions);
@@ -345,9 +397,11 @@ static void preservedRoles() {
// A valid explicit Admin credential replaces delegated labels 4/5, too.
MyMesh mesh;
seedKnown(mesh, permissions);
mesh.acl.client.permissions_are_explicit = true;
login(mesh, "admin");
const uint8_t expected = (permissions & ~PERM_ACL_ROLE_MASK) | PERM_ACL_ADMIN;
assert(mesh.acl.client.permissions == expected);
assert(mesh.acl.client.permissions_are_explicit);
checkReply(mesh, expected);
}
assert(preserved == 2048);
@@ -438,6 +492,7 @@ static void sessionRefresh() {
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);
assert(client.extra.room.post_quota_used == 9 && client.extra.room.poll_quota_used == 8);
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);
@@ -514,6 +569,119 @@ static void readOnlyPosting() {
puts("actual room post gate blocks logged-in readers/managers and accepts RW/Admin once passed");
}
static void moderationCases() {
for (bool known : {false, true}) for (const char* password : {"", "guest", "admin"}) {
MyMesh mesh;
if (known) seedKnown(mesh, PERM_ACL_ADMIN);
mesh._prefs.allow_read_only = true;
assert(mesh.room_access.addBan(&mesh.policy_fs, identity().pub_key) == mesh::RoomAccessPolicy::BanResult::Saved);
const ClientInfo before = mesh.acl.client;
login(mesh, password);
checkRejectedSession(mesh, before);
assert(mesh.acl.authorizations == 0 && mesh.acl.allocations == 0 && mesh.acl.lookups == 0);
assert(mesh.acl.storage.reads == 0 && mesh.acl.storage.writes == 0);
if (known) {
mesh.post(&mesh.acl.client, "banned", 1000);
mesh.poll(1000, 900);
assert(mesh.posts == 0 && mesh.post_acks == 0);
assert(memcmp(&mesh.acl.client, &before, sizeof(before)) == 0);
auto packet = incoming(); uint8_t path[] = {0x11, 0x22}, ack[4] = {};
assert(!mesh.onPeerPathRecv(&packet, 0, mesh.acl.client.shared_secret,
path, 2, PAYLOAD_TYPE_ACK, ack, sizeof(ack)));
assert(mesh.path_acks == 0 && memcmp(&mesh.acl.client, &before, sizeof(before)) == 0);
}
assert(mesh.room_access.removeBan(&mesh.policy_fs, identity().pub_key) == mesh::RoomAccessPolicy::BanResult::Saved);
login(mesh, password);
assert(mesh.acl.known && mesh.creations == 1);
}
// A 31-byte collision is a different identity; bans always use the full key.
MyMesh collision; auto other = identity(); other.pub_key[31] ^= 1;
assert(collision.room_access.addBan(&collision.policy_fs, other.pub_key) == mesh::RoomAccessPolicy::BanResult::Saved);
login(collision, "admin"); assert(collision.acl.known && collision.creations == 1);
// A bad policy image must not silently admit an Admin or public reader.
MyMesh unavailable;
unavailable.policy_fs.put(mesh::ROOM_ACCESS_PRIMARY_PATH, {1,2,3});
assert(!unavailable.room_access.load(&unavailable.policy_fs));
unavailable._prefs.allow_read_only = true;
login(unavailable, "admin");
assert(!unavailable.acl.known && unavailable.acl.lookups == 0 && unavailable.creations == 0);
puts("actual room gates deny full-key bans on login, posts, polls, and paths passed");
}
static void quotaCases() {
MyMesh mesh; ticks = 100; seedKnown(mesh, PERM_ACL_ADMIN);
assert(mesh.room_access.setRates(&mesh.policy_fs, 1, 1));
mesh.post(&mesh.acl.client, "one", 1000);
assert(mesh.posts == 1 && mesh.post_acks == 1 && mesh.acl.client.extra.room.post_quota_used == 1);
assert(mesh.acl.client.extra.room.poll_quota_used == 0);
login(mesh, "guest", 100);
assert(mesh.acl.client.extra.room.post_quota_used == 1); // Re-login never bypasses a posting quota.
mesh.post(&mesh.acl.client, "two", 1001);
assert(mesh.posts == 1 && mesh.post_acks == 1 && mesh.acl.client.extra.room.last_post_timestamp == 1000);
mesh.post(&mesh.acl.client, "one", 1000);
assert(mesh.posts == 1 && mesh.post_acks == 2 && mesh.acl.client.extra.room.post_quota_used == 1);
mesh.poll(1001, 50);
assert(mesh.post_acks == 3 && mesh.acl.client.extra.room.poll_quota_used == 1);
assert(mesh.acl.client.last_timestamp == 1001 && mesh.acl.client.extra.room.sync_since == 50);
mesh.acl.client.extra.room.pending_ack = 0x12345678;
mesh.acl.client.extra.room.pending_topic_revision = 55;
const auto poll_retry_before = mesh.acl.client;
mesh.poll(1001, 50); // Even equal-time retries cannot cancel a newer outstanding delivery.
assert(mesh.post_acks == 4 && mesh.acl.client.extra.room.poll_quota_used == 1);
assert(memcmp(&poll_retry_before, &mesh.acl.client, sizeof(poll_retry_before)) == 0);
mesh.poll(1002, 60);
assert(mesh.post_acks == 4 && mesh.acl.client.last_timestamp == 1001 && mesh.acl.client.extra.room.sync_since == 50);
login(mesh, "guest", 1002, 80);
assert(mesh.acl.client.extra.room.post_quota_used == 1 && mesh.acl.client.extra.room.poll_quota_used == 1);
mesh.poll(1001, 50); // Old exact retry ACK cannot rewind a refreshed/newer cursor.
assert(mesh.post_acks == 5 && mesh.acl.client.extra.room.sync_since == 80);
mesh.poll(1001, 99); // Changed content at an old timestamp isn't a cached retry.
assert(mesh.post_acks == 5 && mesh.acl.client.extra.room.sync_since == 80);
ticks = 60100;
mesh.post(&mesh.acl.client, "two", 1001);
assert(mesh.posts == 2 && mesh.post_acks == 6 && mesh.acl.client.extra.room.last_post_timestamp == 1001);
assert(mesh.acl.client.extra.room.post_quota_used == 1 && mesh.acl.client.extra.room.poll_quota_used == 0);
mesh.poll(1002, 60); // Earlier quota rejection must not have entered the retry cache.
assert(mesh.post_acks == 7 && mesh.acl.client.extra.room.poll_quota_used == 1);
assert(mesh.acl.client.extra.room.sync_since == 60);
ticks = 120100; mesh.store_ok = false;
const auto post_floor = mesh.acl.client.extra.room.last_post_timestamp;
const auto last_activity = mesh.acl.client.last_activity;
mesh.post(&mesh.acl.client, "storage-fails", 1003);
assert(mesh.posts == 2 && mesh.post_acks == 7 && mesh.acl.client.extra.room.post_quota_used == 0);
assert(mesh.acl.client.extra.room.last_post_timestamp == post_floor && mesh.acl.client.last_activity == last_activity);
mesh.store_ok = true; mesh.post(&mesh.acl.client, "storage-fails", 1003);
assert(mesh.posts == 3 && mesh.post_acks == 8 && mesh.acl.client.extra.room.post_quota_used == 1);
const auto before = mesh.acl.client;
mesh.poll(1004, 1000, true); // Flood polls do not consume allowance or refresh replay/session state.
assert(mesh.post_acks == 8 && memcmp(&before, &mesh.acl.client, sizeof(before)) == 0);
puts("actual room quotas survive relogin, exempt exact retries, and reject without replay or ACK passed");
}
static void allRequestQuotaCases() {
for (uint8_t subtype : {uint8_t(REQ_TYPE_GET_STATUS), uint8_t(REQ_TYPE_GET_TELEMETRY_DATA),
uint8_t(REQ_TYPE_GET_ACCESS_LIST), uint8_t(ROOM_BOARD_REQUEST_SUBTYPE)}) {
MyMesh mesh; ticks = 100; seedKnown(mesh, PERM_ACL_ADMIN);
assert(mesh.room_access.setRates(&mesh.policy_fs, 0, 1));
mesh.request(1000, subtype, 8);
assert(mesh.requests == 1 && mesh.last_request_type == subtype && mesh.acl.client.extra.room.poll_quota_used == 1);
assert(mesh.acl.client.last_timestamp == 1000);
mesh.request(1000, subtype, 8); // Exact accepted request is re-readable without a second charge.
assert(mesh.requests == 2 && mesh.acl.client.extra.room.poll_quota_used == 1);
const auto before_reject = mesh.acl.client;
mesh.request(1001, subtype, 8); // New timestamp consumes a new allowance and is denied.
mesh.request(1000, subtype, 9); // Changed payload cannot masquerade as an exact retry.
assert(mesh.requests == 2 && memcmp(&before_reject, &mesh.acl.client, sizeof(before_reject)) == 0);
ticks = 60100; mesh.request(1001, subtype, 8);
assert(mesh.requests == 3 && mesh.acl.client.last_timestamp == 1001 && mesh.acl.client.extra.room.poll_quota_used == 1);
mesh.request(1000, subtype, 8); // Older accepted read may repeat without lowering the session replay floor.
assert(mesh.requests == 4 && mesh.acl.client.last_timestamp == 1001 && mesh.acl.client.extra.room.poll_quota_used == 1);
mesh.request(1000, subtype, 9);
assert(mesh.requests == 4 && mesh.acl.client.last_timestamp == 1001);
}
puts("actual request quota covers status, telemetry, ACL, board, and exact payload retries passed");
}
int main(int argc, char** argv) {
assert(argc == 2);
if (!strcmp(argv[1], "authorization")) authorizationCases();
@@ -521,6 +689,9 @@ int main(int argc, char** argv) {
else if (!strcmp(argv[1], "storage")) storageFailures();
else if (!strcmp(argv[1], "refresh")) sessionRefresh();
else if (!strcmp(argv[1], "post")) readOnlyPosting();
else if (!strcmp(argv[1], "moderation")) moderationCases();
else if (!strcmp(argv[1], "quotas")) quotaCases();
else if (!strcmp(argv[1], "requests")) allRequestQuotaCases();
else assert(false);
}
'''
@@ -542,7 +713,8 @@ class RoomLoginIntegrationTests(unittest.TestCase):
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"):
"LAZY_CONTACTS_WRITE_DELAY", "ROOM_MESSAGE_CACHE_SIZE", "TXT_TYPE_PLAIN",
"TXT_TYPE_CLI_DATA", "TXT_TYPE_CLI_COMMAND", "TXT_ACK_DELAY", "REPLY_DELAY_MILLIS", "REQ_TYPE_KEEP_ALIVE", "REQ_TYPE_GET_STATUS", "REQ_TYPE_GET_TELEMETRY_DATA", "REQ_TYPE_GET_ACCESS_LIST"):
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",
@@ -554,6 +726,11 @@ class RoomLoginIntegrationTests(unittest.TestCase):
if match is None:
raise AssertionError("production constant not found: " + name)
constants += "\n" + match.group()
board = (ROOT / "src/helpers/RoomBoardProtocol.h").read_text()
board_subtype = re.search(r"static constexpr uint8_t ROOM_BOARD_REQUEST_SUBTYPE = ([^;]+);", board)
if board_subtype is None:
raise AssertionError("production board request subtype not found")
constants += "\n#define ROOM_BOARD_REQUEST_SUBTYPE " + board_subtype[1]
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")
@@ -564,8 +741,10 @@ class RoomLoginIntegrationTests(unittest.TestCase):
"@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)"),
"@PEER_HANDLER@": extract_braced(room, "void MyMesh::onPeerDataRecv("),
"@PATH_HANDLER@": extract_braced(room, "bool MyMesh::onPeerPathRecv("),
"@QUOTA_SERVICE@": extract_braced(room, "void MyMesh::serviceRoomQuotas("),
"@SAVE_FILTER@": extract_braced(room, "bool MyMesh::saveFilter("),
"@LOGIN_HANDLER@": extract_braced(room, "void MyMesh::onAnonDataRecv("),
}
generated = HARNESS
@@ -578,7 +757,7 @@ class RoomLoginIntegrationTests(unittest.TestCase):
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"),
"-Wno-unused-parameter", "-I" + str(ROOT / "test/fixtures/room_history_store"), "-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",
@@ -604,6 +783,15 @@ class RoomLoginIntegrationTests(unittest.TestCase):
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_all_room_request_types_consume_poll_budget_and_preserve_exact_retries(self):
self.run_case("requests", "actual request quota covers status, telemetry, ACL, board, and exact payload retries passed")
def test_full_identity_bans_apply_to_new_and_existing_sessions(self):
self.run_case("moderation", "actual room gates deny full-key bans on login, posts, polls, and paths passed")
def test_actual_post_and_poll_quotas_retries_and_relogin(self):
self.run_case("quotas", "actual room quotas survive relogin, exempt exact retries, and reject without replay or ACK 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")
+41 -4
View File
@@ -16,6 +16,8 @@ HARNESS = r'''
#include <cstdint>
#include <cstring>
#include <cstdio>
#include "filesystem.h"
#include <helpers/RoomAccessPolicy.h>
#include <helpers/LogicalMessageCache.h>
#define TXT_TYPE_PLAIN 0
#define ROOM_MESSAGE_CACHE_SIZE 8
@@ -35,13 +37,22 @@ struct Utils {
struct ClientInfo {
struct { uint8_t pub_key[PUB_KEY_SIZE] = {1}; } id;
uint8_t permissions = 0;
struct { struct { uint32_t last_post_timestamp = 0; } room; } extra;
struct { struct { uint32_t last_post_timestamp = 0; uint16_t post_quota_used = 0, poll_quota_used = 0; } room; } extra;
};
struct MyMesh {
MemoryFS fs;
mesh::RoomAccessPolicy room_access;
uint32_t ticks = 0;
ClientInfo* active = nullptr;
MyMesh() { metadata_filesystem = &fs; assert(room_access.load(&fs)); }
void serviceRoomQuotas() { room_access.serviceQuotaWindow(ticks, [this] { active->extra.room.post_quota_used = active->extra.room.poll_quota_used = 0; }); }
mesh::LogicalMessageCache<ROOM_MESSAGE_CACHE_SIZE> recent_room_posts;
unsigned posts = 0, acks = 0;
void addPost(ClientInfo*, const char*) { ++posts; }
bool store_ok = true;
bool addPost(ClientInfo*, const char*) { if (!store_ok) return false; ++posts; return true; }
void receive(ClientInfo* client, const char* text, uint32_t sender_timestamp) {
active = client;
@IDENTITY_GATE@
const uint8_t flags = TXT_TYPE_PLAIN;
const size_t text_len = strlen(text);
bool send_ack = false;
@@ -77,6 +88,31 @@ int main() {
assert(mesh.posts == 2 && mesh.acks == 4);
}
}
{
MyMesh mesh; ClientInfo client; client.permissions = PERM_ACL_READ_WRITE;
assert(mesh.room_access.setRates(&mesh.fs, 1, 2));
mesh.receive(&client, "quota-one", 100);
assert(mesh.posts == 1 && mesh.acks == 1 && client.extra.room.post_quota_used == 1);
mesh.receive(&client, "quota-one", 100);
assert(mesh.posts == 1 && mesh.acks == 2 && client.extra.room.post_quota_used == 1);
mesh.receive(&client, "quota-two", 101);
assert(mesh.posts == 1 && mesh.acks == 2 && client.extra.room.last_post_timestamp == 100);
mesh.ticks = 60000; mesh.receive(&client, "quota-two", 101);
assert(mesh.posts == 2 && mesh.acks == 3 && client.extra.room.last_post_timestamp == 101);
assert(client.extra.room.post_quota_used == 1); // Rejected post was not remembered.
mesh.ticks = 120000; mesh.store_ok = false; mesh.receive(&client, "failed-store", 102);
assert(mesh.posts == 2 && mesh.acks == 3 && client.extra.room.post_quota_used == 0);
assert(client.extra.room.last_post_timestamp == 101);
mesh.store_ok = true; mesh.receive(&client, "failed-store", 102);
assert(mesh.posts == 3 && mesh.acks == 4 && client.extra.room.post_quota_used == 1);
assert(mesh.room_access.addBan(&mesh.fs, client.id.pub_key) == mesh::RoomAccessPolicy::BanResult::Saved);
mesh.receive(&client, "failed-store", 102); // Ban blocks an already cached exact retry.
mesh.receive(&client, "banned-new", 103);
assert(mesh.posts == 3 && mesh.acks == 4 && client.extra.room.last_post_timestamp == 102);
assert(mesh.room_access.removeBan(&mesh.fs, client.id.pub_key) == mesh::RoomAccessPolicy::BanResult::Saved);
mesh.ticks = 180000; mesh.receive(&client, "banned-new", 103);
assert(mesh.posts == 4 && mesh.acks == 5);
}
puts("256 room permission and replay scenarios passed");
}
'''
@@ -92,13 +128,14 @@ class RoomPostPermissionTests(unittest.TestCase):
header = (ROOT / "src/helpers/ClientACL.h").read_text()
roles = "\n".join(line for line in header.splitlines()
if line.startswith("#define PERM_ACL_"))
generated = HARNESS.replace("@POST_GATE@", gate).replace("@ROLES@", roles)
identity_gate = next(line.strip() for line in receive.splitlines() if "if (!room_access.allowsIdentity(" in line)
generated = HARNESS.replace("@POST_GATE@", gate).replace("@ROLES@", roles).replace("@IDENTITY_GATE@", identity_gate)
with tempfile.TemporaryDirectory(prefix="room-post-permissions-") as directory:
work = Path(directory)
(work / "test.cpp").write_text(generated)
binary = work / "room-posts"
cmd = [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter",
"-I" + str(ROOT / "test/mocks"), "-I" + str(ROOT / "src"),
"-I" + str(ROOT / "test/fixtures/room_history_store"), "-I" + str(ROOT / "test/mocks"), "-I" + str(ROOT / "src"),
str(work / "test.cpp"), "-o", str(binary)]
if sys.platform.startswith("linux"):
cmd[1:1] = ["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Execute room reception, inbox admission and notifications under sanitizers."""
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]
FIXTURE = ROOT / "test/fixtures/room_receive_admission/test.cpp"
SANITIZERS = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else [])
class RoomReceiveAdmissionTests(unittest.TestCase):
def test_actual_receive_queue_retry_and_notifications(self):
compiler = shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler, "a host C++17 compiler is required")
base = (ROOT / "src/helpers/BaseChatMesh.cpp").read_text()
source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
header = (ROOT / "examples/companion_radio/MyMesh.h").read_text()
receive = extract_braced(base, "void BaseChatMesh::onPeerDataRecv(")
queue = extract_braced(source, "bool MyMesh::queueMessage(")
common = "\n".join(extract_braced(source, signature) for signature in (
"bool MyMesh::Frame::isChannelMsg(", "int MyMesh::getOfflineQueueCapacity(",
"MyMesh::Frame& MyMesh::offlineQueueFrameAt(", "bool MyMesh::addToOfflineQueue(",
"void MyMesh::popOfflineQueue(", "bool MyMesh::onSignedMessageRecv(",
"void MyMesh::onMessageRecv("))
constants = "\n".join(re.findall(
r"^#define (?:RESP_CODE_(?:CONTACT_MSG_RECV(?:_V3)?|CHANNEL_MSG_RECV(?:_V3)?|CHANNEL_DATA_RECV)|PUSH_CODE_MSG_WAITING)\s+.+$",
source, re.M))
constants += "\n" + "\n".join(line for line in (
ROOT / "src/helpers/TxtDataHelpers.h").read_text().splitlines()
if line.startswith("#define TXT_TYPE_"))
rejected = extract_braced(receive, "if (!onSignedMessageRecv(")
bypass = "(void)previous_sync_since; (void)previous_lastmod;\n" + \
"onSignedMessageRecv(from, packet, sender_timestamp, &data[5], (const char *) &data[9]);"
variants = (
("production", receive, queue, True),
("ack-without-admission", receive.replace(rejected, bypass), queue, False),
("notify-without-admission", receive, queue.replace(
"if (txt_type == TXT_TYPE_SIGNED_PLAIN && !queued) return false;", ""), False),
)
with tempfile.TemporaryDirectory(prefix="room-receive-admission-") as directory:
work = Path(directory)
(work / "frame.inc").write_text(extract_braced(header, "struct Frame") + ";\n", encoding="ascii")
(work / "constants.inc").write_text(constants, encoding="ascii")
for name, actual_receive, actual_queue, passes in variants:
with self.subTest(variant=name):
(work / "production.inc").write_text(
actual_receive + "\n" + actual_queue + "\n" + common, encoding="ascii")
binary = work / name
built = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-parameter", *SANITIZERS, f"-I{work}", f"-I{ROOT / 'src'}",
str(FIXTURE), "-o", str(binary)], 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)
if passes:
self.assertEqual(tested.returncode, 0, tested.stdout + tested.stderr)
self.assertIn("PASS: production room receive admission", tested.stdout)
else:
self.assertNotEqual(tested.returncode, 0, "negative control escaped the delivery checks")
self.assertIn("Assertion", tested.stderr)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -36,7 +36,7 @@ class RoomTopicDeliveryTests(unittest.TestCase):
(work / "production.inc").write_text(definitions)
binary = work / "delivery"
command = [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-parameter", "-I" + str(work),
"-Wno-unused-parameter", "-I" + str(work), "-I" + str(ROOT / "src"),
str(FIXTURE), "-o", str(binary)]
if sys.platform.startswith("linux"):
command[1:1] = ["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
+15 -1
View File
@@ -49,6 +49,9 @@ public:
};
class MemoryFS {
public:
void _lockFS() {}
void _unlockFS() {}
MemoryFS* _getFS() { return this; }
std::map<std::string, std::shared_ptr<std::vector<uint8_t>>> files;
std::set<std::string> faults, directories;
std::vector<std::string> trace;
@@ -136,6 +139,17 @@ void File::close() {
valid = false; fs->end(op);
}
#define FILE_O_WRITE 1
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
struct lfs_info {};
static constexpr int LFS_ERR_NOENT = -2;
static int lfs_stat(MemoryFS* fs, const char* path, lfs_info*) {
const std::string op = std::string("stat:") + path;
const bool okay = fs->begin(op);
const bool present = fs->files.count(path) || fs->directories.count(path);
fs->end(op);
return !okay ? -5 : present ? 0 : LFS_ERR_NOENT;
}
#endif
#include <helpers/RoomTopicStore.h>
static const char* P = mesh::ROOM_TOPIC_PRIMARY_PATH;
@@ -255,7 +269,7 @@ int main() {
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)
#if defined(ESP32_PLATFORM) || defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
for (bool present : {false, true}) {
MemoryFS fs; if (present) fs.put(P, OLD);
fs.faults.insert(std::string("stat:") + P); char text[152];
+507
View File
@@ -0,0 +1,507 @@
#!/usr/bin/env python3
"""Execute actual room HTTP/loop handlers and mailbox under adversarial retries."""
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 <cstdlib>
#include <cstring>
#include <functional>
#include <map>
#include <mutex>
#include <new>
#include <string>
#include <thread>
#include <vector>
#include <helpers/RoomWebMailbox.h>
#include <helpers/CLICommandUtils.h>
#include <helpers/WebConfigBatch.h>
using String = std::string;
#define PROGMEM
@HTML@
static uint32_t ticks = 100;
static uint32_t millis() { return ticks; }
using SemaphoreHandle_t = std::mutex*;
static thread_local unsigned lock_depth = 0;
static constexpr unsigned portMAX_DELAY = 0xffffffffu;
static void xSemaphoreTake(SemaphoreHandle_t h, unsigned) { h->lock(); ++lock_depth; }
static void xSemaphoreGive(SemaphoreHandle_t h) { assert(lock_depth == 1); --lock_depth; h->unlock(); }
@LOCK@
static std::mutex s_wc_route_mux;
#define portENTER_CRITICAL(h) (h)->lock()
#define portEXIT_CRITICAL(h) (h)->unlock()
namespace mesh {
struct Debug { void printf(const char*, ...) {} };
static Debug& usbDebugPort() { static Debug value; return value; }
struct Utils {
static void sha256(uint8_t* out, size_t count, const uint8_t* data, size_t length) {
// Only callback ordering/ownership is under test; production still uses SHA256.
uint32_t hash = 2166136261u;
for (size_t i = 0; i < length; ++i) hash = (hash ^ data[i]) * 16777619u;
for (size_t i = 0; i < count; ++i) out[i] = uint8_t(hash >> ((i & 3) * 8));
}
};
}
struct Header { String text; const String& value() const { return text; } };
struct AsyncWebServerResponse {
int status;
String type, body;
std::map<String, String> headers;
AsyncWebServerResponse(int code, String content_type, String text) : status(code), type(content_type), body(text) {}
virtual ~AsyncWebServerResponse() = default;
void addHeader(const char* key, const char* value) { headers[key] = value; }
};
static bool fail_page_allocation = false;
struct WebConfigPacedProgmemResponse : AsyncWebServerResponse {
WebConfigPacedProgmemResponse(const char* type, const uint8_t* bytes, size_t length)
: AsyncWebServerResponse(200, type, String(reinterpret_cast<const char*>(bytes), length)) {}
static void* operator new(size_t n, const std::nothrow_t&) noexcept {
if (fail_page_allocation) return nullptr;
try { return ::operator new(n); } catch (...) { return nullptr; }
}
static void operator delete(void* p) { ::operator delete(p); }
static void operator delete(void* p, const std::nothrow_t&) { ::operator delete(p); }
};
struct AsyncWebServerRequest {
std::map<String, Header> headers;
void* _tempObject = nullptr;
size_t length = 0;
String content_type = "application/json";
int status = 0;
String reply, response_type;
std::map<String, String> response_headers;
bool fail_response = false;
std::function<void()> on_send;
~AsyncWebServerRequest() { free(_tempObject); }
bool hasHeader(const char* key) const { return headers.count(key) != 0; }
Header* getHeader(const char* key) { return &headers.at(key); }
const String& contentType() const { return content_type; }
size_t contentLength() const { return length; }
AsyncWebServerResponse* beginResponse(int code, const char* type, const String& text) {
if (fail_response) return nullptr;
return new AsyncWebServerResponse(code, type, text);
}
void send(int code, const char* type = "", const char* text = "") {
status = code; response_type = type; reply = text;
if (on_send) on_send();
}
void send(AsyncWebServerResponse* response) {
assert(response);
status = response->status; response_type = response->type;
reply = response->body; response_headers = response->headers;
delete response;
if (on_send) on_send();
}
};
static void wcLogReq(AsyncWebServerRequest*) {}
static constexpr int HTTP_GET = 1, HTTP_POST = 2;
struct AsyncWebServer {
using Handler = std::function<void(AsyncWebServerRequest*)>;
using Collector = void(*)(AsyncWebServerRequest*, uint8_t*, size_t, size_t, size_t);
struct Route { String path; int method; Handler handler; Collector collector; };
std::vector<Route> routes;
Handler missing;
bool ended = false;
void on(const char* path, int method, Handler handler, void* = nullptr, Collector body = nullptr) {
routes.push_back({path, method, handler, body});
}
void onNotFound(Handler handler) { missing = handler; }
void end() { ended = true; }
void invoke(const char* path, int method, AsyncWebServerRequest& request) {
for (const auto& route : routes) {
// Model AsyncWebServer's ordered prefix matching: /result must precede /room.
if (route.method == method && String(path).compare(0, route.path.size(), route.path) == 0) {
route.handler(&request); return;
}
}
assert(missing); missing(&request);
}
};
struct DNS { unsigned stops = 0; void stop() { ++stops; } };
struct Callbacks {
bool supported = true, authenticated = true;
bool roomRequestAuthenticated() const { return authenticated; }
unsigned calls = 0, stops = 0;
String last_body;
uint8_t last_token[32] = {};
uint32_t last_sequence = 0;
size_t last_capacity = 0;
std::function<void(char*, char*)> in_callback;
bool supportsRoomService() const { return supported; }
void processRoomRequest(const uint8_t* token, uint32_t sequence, char* body, char* result, size_t capacity) {
assert(lock_depth == 0); // Mutating callback runs only on the loop, outside the HTTP mutex.
++calls; last_body = body; memcpy(last_token, token, 32);
last_sequence = sequence; last_capacity = capacity;
strcpy(result, "{\"owner_secret\":\"result-one\"}");
if (in_callback) in_callback(body, result);
}
void onWebConfigStopped() { ++stops; }
};
struct WebConfigServer {
static constexpr size_t MAX_BODY = @MAX_BODY@;
static constexpr uint32_t STOP_WARN_MS = WebConfigBatch::kStopWarnMs;
enum Mode { MODE_OFF, MODE_SETUP, MODE_LAN };
Mode _mode = MODE_LAN;
bool _stopping = false, _stop_warned = false, _board_cmds_probed = true;
uint32_t _handler_refs = 0, _stop_warn_at = 0, _last_activity = 0;
std::mutex mutex;
SemaphoreHandle_t _mux = &mutex;
mesh::RoomWebMailbox* _room_mailbox = nullptr;
Callbacks* _cb;
AsyncWebServer host;
AsyncWebServer* _server = &host;
DNS* _dns = nullptr;
static WebConfigServer* _active;
typedef void (WebConfigServer::*RequestHandler)(AsyncWebServerRequest*);
unsigned finalizations = 0;
explicit WebConfigServer(Callbacks& callbacks, bool allocate = true) : _cb(&callbacks) {
if (allocate) _room_mailbox = new mesh::RoomWebMailbox;
registerRoutes(); _active = this;
}
~WebConfigServer() { if (_active == this) _active = nullptr; delete _room_mailbox; }
void finalizeTeardown() { // Dependency stub: production tick decides when reclaim is safe.
assert(_handler_refs == 0); ++finalizations;
delete _room_mailbox; _room_mailbox = nullptr; _server = nullptr;
_stopping = false; _cb->onWebConfigStopped();
}
void probeBoardCommands() { _board_cmds_probed = true; }
void serviceTerminal(uint32_t) {}
void serviceRoom(uint32_t);
void tick(uint32_t);
void requestStop();
void detachRoutes();
uint32_t handlerRefCount() const;
void registerRoutes();
static void dispatchRequest(AsyncWebServerRequest*, RequestHandler);
static void collectBody(AsyncWebServerRequest*, uint8_t*, size_t, size_t, size_t);
void handleRoomPage(AsyncWebServerRequest*);
void handleRoomPost(AsyncWebServerRequest*);
void handleRoomResult(AsyncWebServerRequest*);
@UNRELATED_HANDLERS@
};
WebConfigServer* WebConfigServer::_active = nullptr;
@PRODUCTION@
static String token(unsigned value = 1) {
static const char digits[] = "0123456789abcdef";
String text(64, '0'); text[62] = digits[(value >> 4) & 15]; text[63] = digits[value & 15]; return text;
}
static void identity(AsyncWebServerRequest& request, unsigned owner = 1, const char* sequence = "1") {
request.headers["X-Room-Token"].text = token(owner);
request.headers["X-Room-Seq"].text = sequence;
request.headers["Host"].text = "192.168.4.1";
request.headers["Origin"].text = "http://192.168.4.1";
}
static void body(AsyncWebServerRequest& request, const String& text) {
request.length = text.size();
const size_t first = text.size() / 2;
WebConfigServer::collectBody(&request, reinterpret_cast<uint8_t*>(const_cast<char*>(text.data())), first, 0, text.size());
WebConfigServer::collectBody(&request, reinterpret_cast<uint8_t*>(const_cast<char*>(text.data() + first)), text.size() - first, first, text.size());
}
static void submit(WebConfigServer& server, AsyncWebServerRequest& request) { server.host.invoke("/api/room", HTTP_POST, request); }
static void result(WebConfigServer& server, AsyncWebServerRequest& request) { server.host.invoke("/api/room/result", HTTP_POST, request); }
static void validation() {
for (unsigned fault = 0; fault < 19; ++fault) {
Callbacks cb; WebConfigServer server(cb); AsyncWebServerRequest request;
identity(request); body(request, "{\"op\":\"post\"}");
switch (fault) {
case 0: request.headers.erase("X-Room-Token"); break;
case 1: request.headers.erase("X-Room-Seq"); break;
case 2: request.headers["X-Room-Token"].text = String(64, '0'); break;
case 3: request.headers["X-Room-Token"].text = token().substr(1); break;
case 4: request.headers["X-Room-Token"].text[0] = 'g'; break;
case 5: request.headers["X-Room-Seq"].text = "0"; break;
case 6: request.headers["X-Room-Seq"].text = "-1"; break;
case 7: request.headers["X-Room-Seq"].text = "4294967296"; break;
case 8: request.headers["X-Room-Seq"].text = "1junk"; break;
case 9: request.headers["X-Room-Seq"].text = "\n1"; break;
case 10: request.headers["Origin"].text = "http://attacker.invalid"; break;
case 11: request.headers.erase("Host"); break;
case 12: request.headers["Origin"].text = "https://192.168.4.1"; break;
case 13: request.content_type = "text/plain"; break;
case 14: request.content_type = "application/json; charset=utf-8"; break;
case 15: free(request._tempObject); request._tempObject = nullptr; break;
case 16: request.length = 0; break;
case 17: request.length = WebConfigServer::MAX_BODY + 1; break;
case 18: static_cast<char*>(request._tempObject)[2] = 0; break;
}
submit(server, request);
if (request.status != (fault == 18 ? 409 : 400)) fprintf(stderr, "validation fault=%u status=%d\n", fault, request.status);
assert(request.status == (fault == 18 ? 409 : 400));
assert(cb.calls == 0 && server._room_mailbox->state() == mesh::RoomWebMailbox::State::Idle);
server.tick(ticks); assert(cb.calls == 0);
}
for (unsigned fault = 0; fault < 5; ++fault) {
Callbacks cb; WebConfigServer server(cb); AsyncWebServerRequest request; identity(request);
if (fault == 0) request.headers.erase("X-Room-Token");
if (fault == 1) request.headers["X-Room-Seq"].text = "0";
if (fault == 2) request.headers["Origin"].text = "null";
if (fault == 3) request.headers["X-Room-Seq"].text = "1.0";
if (fault == 4) request.headers["X-Room-Seq"].text = "+-1";
result(server, request); assert(request.status == 400 && cb.calls == 0);
}
Callbacks cb; WebConfigServer server(cb); AsyncWebServerRequest allowed;
identity(allowed); allowed.headers.erase("Origin"); body(allowed, "{}"); submit(server, allowed);
assert(allowed.status == 202 && cb.calls == 0); server.tick(ticks); assert(cb.calls == 1);
for (const char* sequence : {"+1", " \t1\t ", "0001"}) {
Callbacks canonical_cb; WebConfigServer canonical(canonical_cb); AsyncWebServerRequest request;
identity(request, 10, sequence); request.headers["X-Room-Token"].text[63] = 'A'; body(request, "{}");
submit(canonical, request); assert(request.status == 202); canonical.tick(ticks);
assert(canonical_cb.calls == 1 && canonical_cb.last_sequence == 1 && canonical_cb.last_token[31] == 10);
}
puts("actual HTTP identity, origin, media type, and body validation passed");
}
static void deferredAndOwnership() {
Callbacks cb; WebConfigServer server(cb); AsyncWebServerRequest post;
identity(post); body(post, "{\"op\":\"post\",\"text\":\"secret\"}"); submit(server, post);
assert(post.status == 202 && cb.calls == 0 && server._room_mailbox->state() == mesh::RoomWebMailbox::State::Pending);
AsyncWebServerRequest wrong, wrong_seq, pending;
identity(wrong, 2); result(server, wrong); assert(wrong.status == 404 && wrong.reply.find("secret") == String::npos);
identity(wrong_seq, 1, "2"); result(server, wrong_seq); assert(wrong_seq.status == 404);
identity(pending); result(server, pending); assert(pending.status == 202 && pending.reply.find("secret") == String::npos);
cb.in_callback = [&](char* input, char* output) {
assert(server._room_mailbox->state() == mesh::RoomWebMailbox::State::Running);
const String before = input;
const auto async_actor = [&] {
AsyncWebServerRequest competing, duplicate, changed, running, stranger;
identity(competing, 2); body(competing, "{\"op\":\"other\"}"); submit(server, competing); assert(competing.status == 429);
identity(duplicate); body(duplicate, before); submit(server, duplicate); assert(duplicate.status == 202);
identity(changed); body(changed, before + " "); submit(server, changed); assert(changed.status == 409);
identity(running); result(server, running); assert(running.status == 202);
identity(stranger, 2); result(server, stranger); assert(stranger.status == 404);
};
std::thread first(async_actor), second(async_actor);
first.join(); second.join(); // Real concurrent HTTP handlers share the actual extracted lock.
assert(String(input) == before && String(output) == "{\"owner_secret\":\"result-one\"}");
};
server.tick(ticks);
assert(cb.calls == 1 && cb.last_sequence == 1 && cb.last_token[31] == 1);
assert(cb.last_capacity == mesh::RoomWebMailbox::OUTPUT_CAPACITY && cb.last_body == "{\"op\":\"post\",\"text\":\"secret\"}");
assert(server._room_mailbox->state() == mesh::RoomWebMailbox::State::Done);
for (size_t i = 0; i < mesh::RoomWebMailbox::INPUT_CAPACITY; ++i) assert(server._room_mailbox->input()[i] == 0);
AsyncWebServerRequest competitor; identity(competitor, 2); body(competitor, "{}"); submit(server, competitor); assert(competitor.status == 429);
AsyncWebServerRequest done; identity(done); result(server, done); assert(done.status == 200);
assert(done.reply == "{\"owner_secret\":\"result-one\"}" && done.response_headers["Cache-Control"] == "no-store");
AsyncWebServerRequest retry; identity(retry); body(retry, cb.last_body); submit(server, retry); assert(retry.status == 202);
server.tick(ticks + 1); assert(cb.calls == 1);
AsyncWebServerRequest mutated; identity(mutated); body(mutated, "{\"changed\":true}"); submit(server, mutated); assert(mutated.status == 409);
cb.in_callback = {}; body(competitor, "{}"); submit(server, competitor); assert(competitor.status == 202); server.tick(ticks + 2); assert(cb.calls == 2);
AsyncWebServerRequest old; identity(old); body(old, cb.last_body); submit(server, old); assert(old.status == 409);
AsyncWebServerRequest no_leak; identity(no_leak); result(server, no_leak); assert(no_leak.status == 404);
puts("actual deferred callback, running input, owner isolation, and duplicate sequence checks passed");
}
static void boundsAndFloors() {
static_assert(sizeof(mesh::RoomWebMailbox) <= 6656, "bounded room heap");
static_assert(WebConfigServer::MAX_BODY + 1 == mesh::RoomWebMailbox::INPUT_CAPACITY, "body cap agrees with mailbox");
Callbacks cb; WebConfigServer server(cb); AsyncWebServerRequest full;
identity(full, 1, "4294967295"); body(full, String(WebConfigServer::MAX_BODY, 'x'));
assert(static_cast<char*>(full._tempObject)[WebConfigServer::MAX_BODY] == 0);
submit(server, full); assert(full.status == 202 && cb.calls == 0 && full._tempObject == nullptr);
server.tick(ticks); assert(cb.last_body.size() == WebConfigServer::MAX_BODY);
AsyncWebServerRequest full_result; identity(full_result, 1, "4294967295"); result(server, full_result); assert(full_result.status == 200);
AsyncWebServerRequest wrapped; identity(wrapped, 1, "1"); body(wrapped, "{}"); submit(server, wrapped); assert(wrapped.status == 409);
for (unsigned owner = 2; owner <= 8; ++owner) {
AsyncWebServerRequest post, reply; identity(post, owner); body(post, "{}"); submit(server, post); assert(post.status == 202);
server.tick(ticks); identity(reply, owner); result(server, reply); assert(reply.status == 200);
}
AsyncWebServerRequest ninth; identity(ninth, 9); body(ninth, "{}"); submit(server, ninth); assert(ninth.status == 429 && cb.calls == 8);
ticks += mesh::RoomWebMailbox::OWNER_TTL_MS; body(ninth, "{}"); submit(server, ninth); assert(ninth.status == 202); server.tick(ticks); assert(cb.calls == 9);
Callbacks ttl_cb; WebConfigServer ttl(ttl_cb); AsyncWebServerRequest first; identity(first); body(first, "{}"); submit(ttl, first); ttl.tick(ticks);
AsyncWebServerRequest second; identity(second, 2); body(second, "{}"); ticks += mesh::RoomWebMailbox::RESULT_TTL_MS - 1; submit(ttl, second); assert(second.status == 429);
++ticks; body(second, "{}"); submit(ttl, second); assert(second.status == 202); ttl.tick(ticks); assert(ttl_cb.calls == 2);
{
Callbacks output_cb; WebConfigServer output(output_cb);
output_cb.in_callback = [](char*, char* bytes) { memset(bytes, 'x', mesh::RoomWebMailbox::OUTPUT_CAPACITY); };
AsyncWebServerRequest post, response; identity(post); body(post, "{}"); submit(output, post); output.tick(ticks);
identity(response); result(output, response); assert(response.status == 200);
assert(response.reply.size() == mesh::RoomWebMailbox::OUTPUT_CAPACITY - 1);
}
{
ticks = UINT32_MAX - 100;
Callbacks wrap_cb; WebConfigServer wrap(wrap_cb);
AsyncWebServerRequest first, second; identity(first); body(first, "{}"); submit(wrap, first); wrap.tick(ticks);
identity(second, 2); body(second, "{}"); ticks += mesh::RoomWebMailbox::RESULT_TTL_MS - 1;
submit(wrap, second); assert(second.status == 429);
++ticks; body(second, "{}"); submit(wrap, second); assert(second.status == 202); wrap.tick(ticks);
assert(wrap_cb.calls == 2);
}
puts("actual full-size body, owner capacity, sequence overflow, and result expiry checks passed");
}
static void pagesAndAvailability() {
Callbacks cb; WebConfigServer server(cb); AsyncWebServerRequest page;
server.host.invoke("/room", HTTP_GET, page); assert(page.status == 200);
assert(page.reply.find("MeshCore Room") != String::npos && page.response_type == "text/html; charset=utf-8");
assert(page.response_headers["Cache-Control"] == "no-store");
assert(page.response_headers["X-Content-Type-Options"] == "nosniff" && page.response_headers["Referrer-Policy"] == "no-referrer");
assert(cb.calls == 0);
fail_page_allocation = true; server.host.invoke("/room", HTTP_GET, page); assert(page.status == 503); fail_page_allocation = false;
cb.supported = false; server.host.invoke("/room", HTTP_GET, page); assert(page.status == 404); cb.supported = true;
server._mode = WebConfigServer::MODE_OFF; server.host.invoke("/room", HTTP_GET, page); assert(page.status == 404);
server._mode = WebConfigServer::MODE_LAN;
Callbacks lazy_cb; WebConfigServer lazy(lazy_cb, false); AsyncWebServerRequest before; identity(before); body(before, "{}"); submit(lazy, before); assert(before.status == 503);
lazy.tick(ticks); assert(lazy._room_mailbox && lazy_cb.calls == 0); submit(lazy, before); assert(before.status == 202); lazy.tick(ticks); assert(lazy_cb.calls == 1);
puts("actual page headers, bounded allocation failure, and service availability checks passed");
}
static void lifecycle() {
Callbacks cb; WebConfigServer server(cb); AsyncWebServerRequest pending; identity(pending); body(pending, "{}");
pending.on_send = [&] {
assert(server.handlerRefCount() == 1);
server.requestStop(); server.tick(ticks);
assert(server._stopping && server._room_mailbox && server.finalizations == 0);
assert(cb.calls == 0);
};
submit(server, pending); assert(pending.status == 202 && server.host.ended);
assert(server.handlerRefCount() == 0 && server._stopping && cb.calls == 0);
AsyncWebServerRequest unavailable; identity(unavailable); result(server, unavailable); assert(unavailable.status == 503);
server.tick(ticks); assert(!server._room_mailbox && !server._stopping && cb.stops == 1 && server.finalizations == 1);
assert(cb.calls == 0); // Pending work is canceled, never executed after stop.
Callbacks off_cb; WebConfigServer off(off_cb); AsyncWebServerRequest post; identity(post); body(post, "{}"); submit(off, post);
off._mode = WebConfigServer::MODE_OFF; off.tick(ticks); assert(off_cb.calls == 0);
body(post, "{}"); off.handleRoomPost(&post); assert(post.status == 503);
AsyncWebServerRequest poll; identity(poll); off.handleRoomResult(&poll); assert(poll.status == 503);
puts("actual stop reference retention, route detach, pending cancellation, and OFF gating passed");
}
static void responseFailure() {
Callbacks cb; WebConfigServer server(cb); AsyncWebServerRequest post; identity(post); body(post, "{}"); submit(server, post); server.tick(ticks);
AsyncWebServerRequest result_failure; identity(result_failure); result_failure.fail_response = true;
result(server, result_failure); assert(result_failure.status == 503 && cb.calls == 1);
AsyncWebServerRequest competing; identity(competing, 2); body(competing, "{}");
submit(server, competing); assert(competing.status == 429); // OOM is not delivery and cannot release the result.
AsyncWebServerRequest retry; identity(retry); result(server, retry); assert(retry.status == 200 && cb.calls == 1);
puts("actual room result allocation failure remains retryable without reexecution passed");
}
static void authenticationFloors() {
Callbacks cb; WebConfigServer server(cb); cb.authenticated = false;
for (unsigned owner = 1; owner <= 12; ++owner) {
AsyncWebServerRequest post, response;
identity(post, owner); body(post, "{\"password\":\"wrong\"}"); submit(server, post); assert(post.status == 202);
server.tick(ticks); identity(response, owner); result(server, response); assert(response.status == 200);
}
assert(cb.calls == 12); // Failed authentication cannot reserve all eight identities for twenty minutes.
cb.authenticated = true;
AsyncWebServerRequest good, first_result; identity(good, 99); body(good, "{}"); submit(server, good); assert(good.status == 202);
server.tick(ticks); identity(first_result, 99); result(server, first_result); assert(first_result.status == 200);
cb.authenticated = false;
AsyncWebServerRequest failed, failed_result; identity(failed, 99, "2"); body(failed, "{\"password\":\"wrong\"}"); submit(server, failed); assert(failed.status == 202);
server.tick(ticks); identity(failed_result, 99, "2"); result(server, failed_result); assert(failed_result.status == 200);
cb.authenticated = true;
AsyncWebServerRequest different, different_result; identity(different, 100); body(different, "{}"); submit(server, different); server.tick(ticks);
identity(different_result, 100); result(server, different_result); assert(different_result.status == 200);
AsyncWebServerRequest replay; identity(replay, 99); body(replay, "{}"); submit(server, replay); assert(replay.status == 409);
assert(cb.calls == 15);
puts("actual unauthenticated identities release capacity while authenticated replay floors remain passed");
}
int main(int argc, char** argv) {
assert(argc == 2);
if (!strcmp(argv[1], "validation")) validation();
else if (!strcmp(argv[1], "ownership")) deferredAndOwnership();
else if (!strcmp(argv[1], "bounds")) boundsAndFloors();
else if (!strcmp(argv[1], "pages")) pagesAndAvailability();
else if (!strcmp(argv[1], "lifecycle")) lifecycle();
else if (!strcmp(argv[1], "allocation")) responseFailure();
else if (!strcmp(argv[1], "authentication")) authenticationFloors();
else assert(false);
}
'''
class RoomWebHttpTests(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")
source = (ROOT / "src/helpers/esp32/WebConfigServer.cpp").read_text()
header = (ROOT / "src/helpers/esp32/WebConfigServer.h").read_text()
html = (ROOT / "src/helpers/esp32/RoomWebHtml.h").read_text()
html_constant = re.search(r'static const char ROOM_WEB_HTML\[\] PROGMEM = R"roomhtml\([\s\S]*?\)roomhtml";', html)
if html_constant is None:
raise AssertionError("production room HTML constant not found")
methods = [extract_braced(source, signature) for signature in (
"void WebConfigServer::serviceRoom(", "static bool roomRequestIdentity(",
"void WebConfigServer::handleRoomPage(", "void WebConfigServer::handleRoomPost(",
"void WebConfigServer::handleRoomResult(", "void WebConfigServer::collectBody(",
"void WebConfigServer::dispatchRequest(", "void WebConfigServer::detachRoutes(",
"uint32_t WebConfigServer::handlerRefCount(", "void WebConfigServer::requestStop(",
"void WebConfigServer::registerRoutes(")]
tick = extract_braced(source, "void WebConfigServer::tick(")
# Execute the exact stop/OFF guards and loop service calls; SDK Wi-Fi,
# display and later batch handling are outside this HTTP/mailbox fixture.
endpoint = tick.index(" serviceRoom(now);") + len(" serviceRoom(now);")
methods.append(tick[:endpoint] + "\n}")
routes = methods[-2]
unrelated = sorted(set(re.findall(r"&WebConfigServer::(handle\w+)", routes))
- {"handleRoomPage", "handleRoomPost", "handleRoomResult"})
stubs = "\n".join("void " + name + "(AsyncWebServerRequest* req) { req->send(418); }" for name in unrelated)
capacity = re.search(r"static const size_t MAX_BODY = ([0-9]+);", header)
if capacity is None:
raise AssertionError("production MAX_BODY not found")
generated = HARNESS.replace("@HTML@", html_constant.group()).replace("@LOCK@", extract_braced(source, "struct WCLock") + ";")
generated = generated.replace("@MAX_BODY@", capacity[1]).replace("@UNRELATED_HANDLERS@", stubs).replace("@PRODUCTION@", "\n".join(methods))
cls.directory = tempfile.TemporaryDirectory(prefix="room-web-http-")
cls.addClassCleanup(cls.directory.cleanup)
work = Path(cls.directory.name)
(work / "test.cpp").write_text(generated)
cls.binary = work / "http"
command = [compiler, "-std=c++17", "-O1", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter",
"-pthread", "-I" + str(ROOT / "src"), str(work / "test.cpp"), "-o", str(cls.binary)]
if sys.platform.startswith("linux"):
command[1:1] = ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-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, name, expected):
checked = subprocess.run([str(self.binary), name], capture_output=True, text=True, timeout=15)
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
self.assertIn(expected, checked.stdout)
def test_request_validation_never_runs_mutating_callback(self):
self.run_case("validation", "actual HTTP identity, origin, media type, and body validation passed")
def test_loop_only_mutation_running_ownership_and_duplicate_retries(self):
self.run_case("ownership", "actual deferred callback, running input, owner isolation, and duplicate sequence checks passed")
def test_full_body_bounds_owner_capacity_and_expiry(self):
self.run_case("bounds", "actual full-size body, owner capacity, sequence overflow, and result expiry checks passed")
def test_page_headers_allocation_and_lazy_service_start(self):
self.run_case("pages", "actual page headers, bounded allocation failure, and service availability checks passed")
def test_stop_lifecycle_retains_requests_and_never_executes_off(self):
self.run_case("lifecycle", "actual stop reference retention, route detach, pending cancellation, and OFF gating passed")
def test_result_allocation_failure_does_not_reexecute(self):
self.run_case("allocation", "actual room result allocation failure remains retryable without reexecution passed")
def test_failed_authentication_cannot_hold_owner_capacity_or_erase_good_floors(self):
self.run_case("authentication", "actual unauthenticated identities release capacity while authenticated replay floors remain passed")
def test_actual_route_registration_and_loop_order_are_not_shadowed(self):
source = (ROOT / "src/helpers/esp32/WebConfigServer.cpp").read_text()
routes = extract_braced(source, "void WebConfigServer::registerRoutes(")
self.assertLess(routes.index('_server->on("/api/room/result"'), routes.index('_server->on("/api/room"'))
self.assertIn('dispatchRequest(r, &WebConfigServer::handleRoomResult)', routes)
self.assertIn('dispatchRequest(r, &WebConfigServer::handleRoomPost)', routes)
tick = extract_braced(source, "void WebConfigServer::tick(")
self.assertLess(tick.index("if (_stopping)"), tick.index("serviceRoom(now)"))
self.assertLess(tick.index("if (_mode == MODE_OFF)"), tick.index("serviceRoom(now)"))
teardown = extract_braced(source, "void WebConfigServer::finalizeTeardown(")
self.assertIn("delete _room_mailbox;", teardown)
self.assertIn("_room_mailbox = nullptr;", teardown)
if __name__ == "__main__":
unittest.main()
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env python3
"""Execute production browser mailbox ownership and retry handshakes under sanitizers."""
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <helpers/RoomWebMailbox.h>
#include <cassert>
#include <cstdio>
#include <cstring>
#include <string>
#include <algorithm>
using Mailbox = mesh::RoomWebMailbox;
using Result = Mailbox::Result;
using State = Mailbox::State;
static unsigned cases = 0;
struct Client {
uint8_t token[32] = {}, digest[32] = {};
explicit Client(uint8_t value) { token[0] = value; digest[0] = uint8_t(value + 1); }
};
static Result submit(Mailbox& m, Client& c, uint32_t sequence, uint32_t now,
const char* body = "write-once") {
return m.submit(c.token, sequence, c.digest, body, strlen(body), now);
}
static void execute(Mailbox& m, uint32_t now, const char* output = "success") {
assert(m.begin()); assert(!m.begin()); strcpy(m.output(), output); m.finish(now);
assert(m.state() == State::Done);
}
static const char* read(Mailbox& m, Client& c, uint32_t sequence) {
bool pending = true; const char* result = m.read(c.token, sequence, pending);
assert(!pending);
if (result) m.delivered(c.token, sequence); // A successfully constructed HTTP response owns a copy.
return result;
}
int main() {
{
const std::string lower = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
std::string upper = lower; std::transform(upper.begin(), upper.end(), upper.begin(),
[](unsigned char c) { return c >= 'a' && c <= 'f' ? char(c - 'a' + 'A') : char(c); });
uint8_t decoded[32], equivalent[32];
assert(Mailbox::decodeToken(lower.c_str(), decoded));
assert(Mailbox::decodeToken(upper.c_str(), equivalent)); assert(memcmp(decoded, equivalent, 32) == 0);
for (unsigned position = 0; position < 64; ++position) {
std::string invalid = lower; invalid[position] = position % 2 ? ':' : 'g';
memset(decoded, 0xa5, sizeof(decoded)); assert(!Mailbox::decodeToken(invalid.c_str(), decoded));
for (uint8_t byte : decoded) { assert(byte == 0xa5); } ++cases;
}
for (const char* invalid : {static_cast<const char*>(nullptr), "", "a", "0000000000000000000000000000000000000000000000000000000000000000"}) {
memset(decoded, 0xa5, sizeof(decoded)); assert(!Mailbox::decodeToken(invalid, decoded));
for (uint8_t byte : decoded) { assert(byte == 0xa5); } ++cases;
}
assert(!Mailbox::decodeToken((lower + "0").c_str(), decoded)); ++cases;
}
for (const uint32_t start : {0U, UINT32_MAX - 15U}) {
Mailbox m; Client owner(1), stranger(2); bool pending = true;
assert(m.state() == State::Idle && !m.begin()); m.finish(start);
assert(m.read(owner.token, 1, pending) == nullptr && !pending);
assert(submit(m, owner, 1, start) == Result::Accepted && m.state() == State::Pending);
const std::string input(m.input()); strcpy(m.output(), "running-output");
m.finish(start); assert(m.state() == State::Pending && std::string(m.input()) == input);
assert(m.read(owner.token, 1, pending) == nullptr && pending);
assert(m.read(stranger.token, 1, pending) == nullptr && !pending);
assert(m.read(owner.token, 2, pending) == nullptr && !pending);
for (const uint32_t elapsed : {0U, 1U, Mailbox::OWNER_TTL_MS, UINT32_C(0x80000000)}) {
assert(submit(m, stranger, 1, start + elapsed) == Result::Busy);
assert(submit(m, owner, 2, start + elapsed) == Result::Busy);
assert(submit(m, owner, 1, start + elapsed) == Result::Accepted);
Client changed = owner; changed.digest[31] ^= 1;
assert(submit(m, changed, 1, start + elapsed, "different-body") == Result::Mismatch);
assert(std::string(m.input()) == input && std::string(m.output()) == "running-output"); ++cases;
}
assert(m.begin()); char* original_input = m.input(); char* original_output = m.output();
for (const uint32_t elapsed : {1U, Mailbox::OWNER_TTL_MS + 1U, UINT32_C(0x90000000)}) {
assert(submit(m, stranger, 99, start + elapsed, "replace") == Result::Busy);
assert(submit(m, owner, 2, start + elapsed, "replace") == Result::Busy);
assert(submit(m, owner, 1, start + elapsed) == Result::Accepted && !m.begin());
assert(m.input() == original_input && m.output() == original_output);
assert(std::string(m.input()) == input && memcmp(m.token(), owner.token, 32) == 0); ++cases;
}
memset(m.output(), 'R', Mailbox::OUTPUT_CAPACITY); m.finish(start + 10);
for (size_t i = 0; i < Mailbox::INPUT_CAPACITY; ++i) assert(m.input()[i] == 0);
assert(m.output()[Mailbox::OUTPUT_CAPACITY - 1] == 0 && strlen(m.output()) == Mailbox::OUTPUT_CAPACITY - 1);
assert(submit(m, owner, 1, start + 11) == Result::Accepted && !m.begin());
Client changed = owner; changed.digest[0] ^= 1;
assert(submit(m, changed, 1, start + 12, "new body") == Result::Mismatch);
assert(submit(m, stranger, 1, start + 10 + Mailbox::RESULT_TTL_MS - 1) == Result::Busy);
assert(m.read(stranger.token, 1, pending) == nullptr && !pending);
assert(submit(m, stranger, 1, start + 10 + Mailbox::RESULT_TTL_MS - 1) == Result::Busy);
const char* result = read(m, owner, 1); assert(result == m.output());
assert(read(m, owner, 1) == result && !m.begin());
assert(submit(m, stranger, 1, start + 13) == Result::Accepted);
execute(m, start + 14); assert(std::string(read(m, stranger, 1)) == "success");
assert(submit(m, owner, 1, start + 15) == Result::Completed);
assert(submit(m, owner, 2, start + 16) == Result::Accepted);
execute(m, start + 17, "second"); assert(std::string(read(m, owner, 2)) == "second");
assert(submit(m, owner, 1, start + 18) == Result::Completed);
assert(submit(m, stranger, 1, start + 18) == Result::Completed); ++cases;
}
{
Mailbox m; Client c(1); std::string maximum(Mailbox::INPUT_CAPACITY - 1, 'P');
const std::string too_large(Mailbox::INPUT_CAPACITY, 'X'); const char embedded[] = {'a', 0, 'b'};
for (unsigned state = 0; state < 4; ++state) {
const std::string prior(m.input()); const State prior_state = m.state();
assert(m.submit(c.token, 1, c.digest, nullptr, 10, state) == Result::Invalid);
assert(m.submit(c.token, 0, c.digest, "valid", 5, state) == Result::Invalid);
assert(m.submit(c.token, 1, c.digest, "", 0, state) == Result::Invalid);
assert(m.submit(c.token, 1, c.digest, too_large.data(), too_large.size(), state) == Result::Invalid);
assert(m.submit(c.token, 1, c.digest, embedded, sizeof(embedded), state) == Result::Invalid);
assert(m.state() == prior_state && std::string(m.input()) == prior);
if (state == 0) {
assert(m.submit(c.token, 1, c.digest, maximum.data(), maximum.size(), state) == Result::Accepted);
assert(strlen(m.input()) == 4096 && m.input()[4096] == 0);
} else if (state == 1) assert(m.begin());
else if (state == 2) m.finish(state);
++cases;
}
for (size_t i = 0; i < Mailbox::INPUT_CAPACITY; ++i) assert(m.input()[i] == 0);
}
for (const uint32_t start : {100U, UINT32_MAX - 100U}) {
Mailbox m; Client a(1), b(2);
assert(submit(m, a, 10, start) == Result::Accepted); execute(m, start + 5);
assert(submit(m, b, 1, start + 5 + Mailbox::RESULT_TTL_MS - 1) == Result::Busy);
assert(submit(m, b, 1, start + 5 + Mailbox::RESULT_TTL_MS) == Result::Accepted);
assert(read(m, a, 10) == nullptr); execute(m, start + 5 + Mailbox::RESULT_TTL_MS); assert(read(m, b, 1));
assert(submit(m, a, 9, start + Mailbox::OWNER_TTL_MS - 1) == Result::Completed);
assert(submit(m, a, 9, start + Mailbox::OWNER_TTL_MS) == Result::Accepted);
execute(m, start + Mailbox::OWNER_TTL_MS); assert(read(m, a, 9)); ++cases;
}
{
Mailbox m;
for (uint8_t i = 1; i <= 8; ++i) {
Client owner(i); assert(submit(m, owner, 7, i) == Result::Accepted);
execute(m, i); assert(read(m, owner, 7)); ++cases;
}
Client ninth(9), first(1);
assert(submit(m, ninth, 1, 10) == Result::Busy);
assert(submit(m, first, 6, 10) == Result::Completed);
assert(submit(m, first, 8, 10) == Result::Accepted); execute(m, 10); assert(read(m, first, 8));
assert(submit(m, ninth, 1, 2 + Mailbox::OWNER_TTL_MS - 1) == Result::Busy);
assert(submit(m, ninth, 1, 2 + Mailbox::OWNER_TTL_MS) == Result::Accepted); ++cases;
}
{
Mailbox m; Client owner(1), other(2); bool pending;
assert(submit(m, owner, 1, 100) == Result::Accepted); execute(m, 100);
assert(std::string(m.read(owner.token, 1, pending)) == "success" && !pending);
// Copy/allocation failure must leave the cached result protected for retry.
assert(submit(m, other, 1, 101) == Result::Busy);
m.delivered(other.token, 1); m.delivered(owner.token, 2);
assert(submit(m, other, 1, 102) == Result::Busy);
assert(std::string(m.read(owner.token, 1, pending)) == "success" && !pending);
m.delivered(owner.token, 1);
assert(submit(m, other, 1, 103) == Result::Accepted); ++cases;
}
{
Mailbox m;
for (uint8_t i = 1; i <= 100; ++i) {
Client unauthenticated(i);
assert(submit(m, unauthenticated, 1, i) == Result::Accepted);
assert(m.begin()); strcpy(m.output(), "bad password"); m.finish(i, false);
assert(std::string(read(m, unauthenticated, 1)) == "bad password");
assert(!m.begin()); ++cases;
}
// Bad-password requests never exhaust the eight durable in-boot floors.
for (uint8_t i = 101; i <= 108; ++i) {
Client authenticated(i); assert(submit(m, authenticated, 7, i) == Result::Accepted);
execute(m, i); assert(read(m, authenticated, 7)); ++cases;
}
Client ninth(109); assert(submit(m, ninth, 1, 109) == Result::Busy); ++cases;
}
{
Mailbox m; Client owner(1), other(2);
assert(submit(m, owner, 7, 100) == Result::Accepted); execute(m, 100); assert(read(m, owner, 7));
assert(submit(m, owner, 8, 101) == Result::Accepted); assert(m.begin()); m.finish(101, false);
assert(read(m, owner, 8));
assert(submit(m, other, 1, 102) == Result::Accepted); execute(m, 102); assert(read(m, other, 1));
assert(submit(m, owner, 7, 103) == Result::Completed);
assert(submit(m, owner, 8, 103) == Result::Completed); ++cases;
}
{
Mailbox m; Client c(1), other(2);
assert(submit(m, c, UINT32_MAX, 10) == Result::Accepted); execute(m, 10); assert(read(m, c, UINT32_MAX));
assert(submit(m, c, 0, 11) == Result::Invalid);
assert(submit(m, c, 1, 11) == Result::Completed);
assert(submit(m, other, 1, 11) == Result::Accepted); execute(m, 11); assert(read(m, other, 1));
assert(submit(m, c, UINT32_MAX, 12) == Result::Completed); ++cases;
}
printf("%u production room web mailbox handshake scenarios passed\n", cases);
}
'''
class RoomWebMailboxTests(unittest.TestCase):
def test_actual_bounded_mailbox_ownership_retry_and_expiry(self):
compiler = shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler, "a host C++ compiler is required")
sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else [])
with tempfile.TemporaryDirectory(prefix="room-web-mailbox-") as directory:
work = Path(directory)
(work / "test.cpp").write_text(HARNESS)
binary = work / "mailbox"
built = subprocess.run([compiler, "-std=c++11", "-Wall", "-Wextra", "-Werror",
*sanitizers, f"-I{ROOT / 'src'}", str(work / "test.cpp"), "-o", str(binary)],
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("production room web mailbox handshake scenarios passed", tested.stdout)
print(tested.stdout.strip())
if __name__ == "__main__":
unittest.main()
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Execute production room-browser callbacks with real JSON, stores and gates."""
import json
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
from test_room_history_store import STAT_MOCK
ROOT = Path(__file__).resolve().parents[1]
FIXTURES = ROOT / "test/fixtures/room_web_service"
class RoomWebServiceTest(unittest.TestCase):
def test_actual_callbacks_with_real_arduinojson_and_stores(self):
compiler = shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler, "a host C++ compiler is required")
json_header = next((ROOT / ".pio/libdeps").glob("*/ArduinoJson/src/ArduinoJson.h"), None)
self.assertIsNotNone(json_header, "actual ArduinoJson headers from the checkout are required")
version = (json_header.parent / "ArduinoJson/version.hpp").read_text()
self.assertRegex(version, r'ARDUINOJSON_VERSION "7\.4\.\d+"')
source = (ROOT / "examples/simple_room_server/MyMesh.cpp").read_text()
header = (ROOT / "examples/simple_room_server/MyMesh.h").read_text()
production = "\n".join(extract_braced(source, signature) for signature in (
"bool MyMesh::handleRoomWebCommand(", "static const char* roomWebString(",
"void MyMesh::processRoomRequest(", "void MyMesh::serviceRoomQuotas(",
"bool MyMesh::storePost(", "bool MyMesh::snapshotRoomHistory("))
roles = "\n".join(line for line in (ROOT / "src/helpers/ClientACL.h").read_text().splitlines()
if line.startswith("#define PERM_ACL_"))
sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
if sys.platform.startswith("linux") else [])
# 64-bit host slots are twice ESP32's size. This reproduces the real
# ESP32 default 1024-byte pool, while testing its unchanged 8192-byte
# allocator budget and the actual ArduinoJson parser/temporary strings.
with tempfile.TemporaryDirectory(prefix="meshcore-room-web-service-") as temporary:
work = Path(temporary)
(work / "sys").mkdir()
(work / "sys/stat.h").write_text(STAT_MOCK)
(work / "filesystem.h").write_text((ROOT / "test/fixtures/room_history_store/filesystem.h").read_text())
(work / "production.inc").write_text(production)
(work / "roles.inc").write_text(roles)
(work / "post.inc").write_text(extract_braced(header, "struct PostInfo {") + ";")
web_user = extract_braced(header, "struct RoomWebUser {")
(work / "web_user.inc").write_text(web_user + " room_web_users[8] = {};")
binary = work / "service"
built = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-Wno-misleading-indentation", *sanitizers, "-DESP32_PLATFORM=1",
"-DARDUINOJSON_POOL_CAPACITY=64",
"-I" + str(work), "-I" + str(ROOT / "src"), "-I" + str(json_header.parent),
str(FIXTURES / "service.cpp"), "-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=60)
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
self.assertIn("actual room web service", checked.stdout)
for kind in ("STATUS", "INDEX", "READ"):
match = re.search(r"^BOUNDARY_" + kind + r" (.+)$", checked.stdout, re.M)
self.assertIsNotNone(match)
actual = json.loads(match.group(1))
expected = json.loads((FIXTURES / "boundary.json").read_text())[kind.lower()]
self.assertEqual(actual, expected)
print(checked.stdout.splitlines()[-1])
if __name__ == "__main__":
unittest.main()
+292
View File
@@ -0,0 +1,292 @@
#!/usr/bin/env python3
"""Execute the actual offline room page in Chromium with bounded HTTP mocks."""
import html
import json
import os
from pathlib import Path
import re
import subprocess
import tempfile
import unittest
from test_webconfig_ui_runtime import BROWSER, run_browser
ROOT = Path(__file__).resolve().parents[1]
HEADER = ROOT / "src/helpers/esp32/RoomWebHtml.h"
BOUNDARY = ROOT / "test/fixtures/room_web_service/boundary.json"
COMMON = r'''
const boundary=__BOUNDARY__,calls=[];
localStorage.setItem('mc-room-token','01'.repeat(32));localStorage.setItem('mc-room-sequence','0');
window.attack=()=>document.body.dataset.testAttacked='yes';
window.addEventListener('error',e=>document.body.dataset.testJsError=e.message);
window.addEventListener('unhandledrejection',e=>document.body.dataset.testJsError=String(e.reason));
const response=(value,status=200)=>Promise.resolve({ok:status>=200&&status<300,status:status,json:()=>Promise.resolve(value)});
const until=async predicate=>{for(let i=0;i<500;i++){if(predicate())return;await new Promise(r=>setTimeout(r,10))}throw Error('test condition timed out')};
const submit=id=>document.getElementById(id).dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));
const record=(url,options)=>{let p=options.body?JSON.parse(options.body):null;calls.push({url:url,body:options.body||'',seq:options.headers['X-Room-Seq'],token:options.headers['X-Room-Token'],op:p?p.op:'result'});return p};
const mark=(key,value)=>document.body.setAttribute('data-test-'+key,String(value));
'''
class RoomWebUiTest(unittest.TestCase):
def run_page(self, scenario, virtual_time=7000):
if not BROWSER:
if os.environ.get("MESHCORE_REQUIRE_ROOM_BROWSER") == "1":
self.fail("Chromium is required for room browser validation")
self.skipTest("Chromium-family browser is unavailable")
source = HEADER.read_text().split('R"roomhtml(', 1)[1].split(')roomhtml";', 1)[0]
fixture = json.loads(BOUNDARY.read_text())
prelude = "<script>\n" + COMMON.replace("__BOUNDARY__", json.dumps(fixture)) + scenario + "\n</script>"
self.assertIn("<script>\n'use strict';", source)
source = source.replace("<script>\n'use strict';", prelude + "<script>\n'use strict';", 1)
# Snap Chromium uses a private /tmp; workspace files are visible to
# both the test process and the real browser.
cache_root = ROOT / ".pio"
cache_root.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(prefix="meshcore-room-ui-", dir=cache_root, ignore_cleanup_errors=True) as temporary:
work = Path(temporary)
page = work / "index.html"
page.write_text(source)
args = [BROWSER, "--headless=new", "--disable-gpu", "--no-first-run", "--no-default-browser-check",
"--user-data-dir=" + str(work / "profile"), "--virtual-time-budget=" + str(virtual_time),
"--dump-dom", page.as_uri()]
if hasattr(os, "geteuid") and os.geteuid() == 0:
args.insert(1, "--no-sandbox")
checked = run_browser(args)
self.assertEqual(checked.returncode, 0, checked.stderr.decode("utf-8", "replace"))
dom = checked.stdout.decode("utf-8", "replace")
self.assertTrue(dom, checked.stderr.decode("utf-8", "replace"))
self.assertNotIn("data-test-js-error=", dom)
self.assertNotIn('data-test-attacked="yes"', dom)
self.assertIn('data-test-done="true"', dom)
return dom
def attribute(self, dom, name):
match = re.search(r'data-test-' + re.escape(name) + r'="([^"]*)"', dom)
self.assertIsNotNone(match, dom)
return html.unescape(match.group(1))
def test_shared_backend_contract_xss_and_selected_article_only(self):
dom = self.run_page(r'''
window.fetch=(url,options)=>{
const p=record(url,options);
if(p.op==='status')return response(boundary.status);
if(p.op==='posts')return response({post:p.after===0?{timestamp:1001,author:'ab'.repeat(32),text:'<img src=x onerror=attack()> chat'}:null});
if(p.op==='board.index')return response({revision:1,articles:[boundary.index.articles[0],{id:2,version:1,length:2048,title:'<img src=x onerror=attack()> title'}],next:255});
if(p.op==='board.read'){if(p.id!==1)throw Error('unselected article fetched');return response(boundary.read)}
throw Error('unexpected operation '+p.op);
};
window.addEventListener('load',()=>{(async()=>{
submit('join');await until(()=>$('notices').childElementCount===2);
mark('before-reads',calls.filter(c=>c.op==='board.read').length);
mark('topic',$('topic').textContent);mark('post',$('posts').textContent);mark('title',$('notices').lastElementChild.textContent);
mark('image-count',document.querySelectorAll('img').length);mark('role',$('state').textContent);mark('editor-hidden',$('editor').hidden);
await openArticle(notices.get(1));await openArticle(notices.get(1));
mark('article',$('article').textContent);mark('reads',calls.filter(c=>c.op==='board.read').length);
mark('tokens',calls.every(c=>c.token==='01'.repeat(32)));mark('calls',JSON.stringify(calls));mark('done',true);
})().catch(e=>mark('js-error',e.message))});
''')
self.assertEqual(self.attribute(dom, "before-reads"), "0")
self.assertEqual(self.attribute(dom, "reads"), "1")
self.assertEqual(self.attribute(dom, "image-count"), "0")
self.assertIn("<img src=x onerror=attack()>", self.attribute(dom, "topic"))
self.assertIn("<img src=x onerror=attack()>", self.attribute(dom, "post"))
self.assertIn("<img src=x onerror=attack()>", self.attribute(dom, "title"))
self.assertEqual(self.attribute(dom, "article"), "Read only this article")
self.assertEqual(self.attribute(dom, "editor-hidden"), "true")
self.assertEqual(self.attribute(dom, "tokens"), "true")
requests = json.loads(self.attribute(dom, "calls"))
self.assertEqual(sorted({json.loads(item["body"])["id"] for item in requests if item["op"] == "board.read"}), [1])
def test_identical_sequence_retry_failed_post_retention_and_utf8_budget(self):
dom = self.run_page(r'''
let postAttempts=0,effects=0,pending=null,first=null;
window.fetch=(url,options)=>{
const p=record(url,options);
if(url==='/api/room/result')return response(pending);
if(p.op==='status')return response(boundary.status);
if(p.op==='posts')return response({post:null});
if(p.op==='board.index')return response({revision:0,articles:[],next:255});
if(p.op==='post'){
postAttempts++;
if(postAttempts===1){first=calls[calls.length-1];effects++;pending={ok:true,timestamp:1001};return Promise.reject(Error('lost submission response'))}
if(postAttempts===2){const current=calls[calls.length-1];mark('same-seq',current.seq===first.seq);mark('same-body',current.body===first.body);return response({},202)}
return response({error:'post not retained; storage unavailable'});
}
throw Error('unexpected operation');
};
window.addEventListener('load',()=>{(async()=>{
submit('join');await until(()=>!$('room').hidden&&$('notices').textContent==='No notices published.');
$('name').value='A';$('name').dispatchEvent(new Event('input'));$('message').value='x'.repeat(148);$('message').dispatchEvent(new Event('input'));
mark('maximum-disabled',$('send').disabled);mark('maximum-budget',$('budget').textContent);
$('message').value='📡'.repeat(38);$('message').dispatchEvent(new Event('input'));mark('utf8-disabled',$('send').disabled);mark('utf8-budget',$('budget').textContent);
$('message').value='first';$('message').dispatchEvent(new Event('input'));submit('compose');
await until(()=>postAttempts===2&&$('message').value==='');mark('first-cleared',$('message').value==='');
$('message').value='keep this draft';$('message').dispatchEvent(new Event('input'));submit('compose');
await until(()=>postAttempts===3&&$('error').textContent.includes('not retained'));
mark('draft',$('message').value);mark('effects',effects);mark('attempts',postAttempts);mark('done',true);
})().catch(e=>mark('js-error',e.message))});
''')
self.assertEqual(self.attribute(dom, "maximum-disabled"), "false")
self.assertIn("151 / 151", self.attribute(dom, "maximum-budget"))
self.assertEqual(self.attribute(dom, "utf8-disabled"), "true")
self.assertIn("155 / 151", self.attribute(dom, "utf8-budget"))
self.assertEqual(self.attribute(dom, "same-seq"), "true")
self.assertEqual(self.attribute(dom, "same-body"), "true")
self.assertEqual(self.attribute(dom, "effects"), "1")
self.assertEqual(self.attribute(dom, "attempts"), "3")
self.assertEqual(self.attribute(dom, "draft"), "keep this draft")
def test_chunk_resume_version_restart_and_full_utf8_publication(self):
dom = self.run_page(r'''
let currentVersion=1,lostChunk=true,oldOffsets=[],newOffsets=[],publications=[],indexCount=0;
const oldText='a'.repeat(256),newText='📡'.repeat(64),oldBytes=new TextEncoder().encode(oldText),newBytes=new TextEncoder().encode(newText);
window.fetch=(url,options)=>{
const p=record(url,options);
if(p.op==='status')return response(Object.assign({},boundary.status,{role:3}));
if(p.op==='posts')return response({post:null});
if(p.op==='board.index'){indexCount++;return response({revision:currentVersion,articles:[{id:1,version:currentVersion,length:256,title:'Article v'+currentVersion}],next:255})}
if(p.op==='board.read'){
(p.version===1?oldOffsets:newOffsets).push(p.offset);
if(p.version===1&&p.offset===128&&lostChunk){lostChunk=false;return response({error:'lost link; retry selected article'})}
const bytes=(p.version===1?oldBytes:newBytes).slice(p.offset,p.offset+128),data64=btoa(Array.from(bytes,b=>String.fromCharCode(b)).join(''));
return response({version:p.version,offset:p.offset,next:p.offset+bytes.length,total:256,data64:data64});
}
if(p.op==='board.save'){publications.push(p);return response({ok:true,version:3})}
throw Error('unexpected operation');
};
window.addEventListener('load',()=>{(async()=>{
submit('join');await until(()=>$('notices').childElementCount===1);
try{await openArticle(notices.get(1))}catch(e){}
mark('resume-next',transfers.get(1).next);await openArticle(notices.get(1));mark('old-article',$('article').textContent===oldText);
currentVersion=2;await refreshBoard();await openArticle(notices.get(1));
mark('new-article',$('article').textContent===newText);mark('old-offsets',JSON.stringify(oldOffsets));mark('new-offsets',JSON.stringify(newOffsets));
$('title').value='📡'.repeat(15)+'abc';$('body').value='📡'.repeat(512);submit('publish');
await until(()=>publications.length===1&&indexCount>=3);
mark('title-bytes',new TextEncoder().encode(publications[0].title).length);
mark('body-bytes',atob(publications[0].body64).length);mark('publish-version',publications[0].version);mark('publish-boot',publications[0].boot);
$('body').value='📡'.repeat(513);submit('publish');await until(()=>$('error').textContent.includes('2,048'));
mark('oversized-publications',publications.length);
$('body').value='ok';$('title').value='📡'.repeat(16);submit('publish');await new Promise(r=>setTimeout(r,20));
mark('oversized-title-publications',publications.length);mark('editor-hidden',$('editor').hidden);mark('done',true);
})().catch(e=>mark('js-error',e.message))});
''')
self.assertEqual(self.attribute(dom, "resume-next"), "128")
self.assertEqual(self.attribute(dom, "old-article"), "true")
self.assertEqual(self.attribute(dom, "new-article"), "true")
self.assertEqual(json.loads(self.attribute(dom, "old-offsets")), [0, 128, 128])
self.assertEqual(json.loads(self.attribute(dom, "new-offsets")), [0, 128])
self.assertEqual(self.attribute(dom, "title-bytes"), "63")
self.assertEqual(self.attribute(dom, "body-bytes"), "2048")
self.assertEqual(self.attribute(dom, "publish-version"), "2")
self.assertEqual(self.attribute(dom, "publish-boot"), "71")
self.assertEqual(self.attribute(dom, "oversized-publications"), "1")
self.assertEqual(self.attribute(dom, "oversized-title-publications"), "1")
self.assertEqual(self.attribute(dom, "editor-hidden"), "false")
def test_empty_article_version_handshake_and_reboot_draft_preservation(self):
dom = self.run_page(r'''
let serverBoot=71,validEmpty=false,emptyReads=0,postEffects=0;
window.fetch=(url,options)=>{
const p=record(url,options);
if(p.op==='status')return response(Object.assign({},boundary.status,{boot:serverBoot}));
if(p.op==='posts')return response({post:null});
if(p.op==='board.index')return response({revision:1,articles:[{id:1,version:1,length:0,title:'Empty notice'}],next:255});
if(p.op==='board.read'){emptyReads++;return response(validEmpty?{version:1,offset:0,next:0,total:0,data64:''}:{error:'article changed; refresh notices'})}
if(p.op==='post'){
if(p.boot!==serverBoot)return response({error:'radio restarted; refresh before sending'});
postEffects++;return response({ok:true,timestamp:1001});
}
throw Error('unexpected operation');
};
window.addEventListener('load',()=>{(async()=>{
submit('join');await until(()=>$('notices').childElementCount===1);
try{await openArticle(notices.get(1))}catch(e){}
mark('stale-empty-hidden',$('article').hidden);mark('stale-empty-reads',emptyReads);
validEmpty=true;await openArticle(notices.get(1));mark('valid-empty-hidden',$('article').hidden);mark('valid-empty-reads',emptyReads);
$('name').value='A';$('message').value='draft across reboot';$('message').dispatchEvent(new Event('input'));
serverBoot=72;submit('compose');await until(()=>$('error').textContent.includes('restarted'));
mark('reboot-draft',$('message').value);mark('reboot-effects',postEffects);
await status();mark('boot',boot);mark('transfer-count',transfers.size);mark('notice-count',notices.size);mark('done',true);
})().catch(e=>mark('js-error',e.message))});
''')
self.assertEqual(self.attribute(dom, "stale-empty-hidden"), "true")
self.assertEqual(self.attribute(dom, "stale-empty-reads"), "1")
self.assertEqual(self.attribute(dom, "valid-empty-hidden"), "false")
self.assertEqual(self.attribute(dom, "valid-empty-reads"), "2")
self.assertEqual(self.attribute(dom, "reboot-draft"), "draft across reboot")
self.assertEqual(self.attribute(dom, "reboot-effects"), "0")
self.assertEqual(self.attribute(dom, "boot"), "72")
self.assertEqual(self.attribute(dom, "transfer-count"), "0")
self.assertEqual(self.attribute(dom, "notice-count"), "0")
def test_write_queued_behind_reboot_status_keeps_original_boot(self):
dom = self.run_page(r'''
let serverBoot=71,delayedStatus=false,effects=0,writeBoot=0;
window.fetch=(url,options)=>{
const p=record(url,options);
if(p.op==='status'){
const value=Object.assign({},boundary.status,{boot:serverBoot});
return delayedStatus?new Promise(resolve=>setTimeout(()=>resolve({ok:true,status:200,json:()=>Promise.resolve(value)}),80)):response(value);
}
if(p.op==='posts')return response({post:null});
if(p.op==='board.index')return response(boundary.index);
if(p.op==='post'){writeBoot=p.boot;if(p.boot!==serverBoot)return response({error:'radio restarted; refresh before sending'});effects++;return response({ok:true,timestamp:1001})}
throw Error('unexpected operation');
};
window.addEventListener('load',()=>{(async()=>{
submit('join');await until(()=>$('notices').childElementCount===1);
$('name').value='A';$('message').value='queued before reboot';$('message').dispatchEvent(new Event('input'));
serverBoot=72;delayedStatus=true;const updating=status();submit('compose');
await updating;await until(()=>$('error').textContent.includes('restarted'));
mark('write-boot',writeBoot);mark('current-boot',boot);mark('effects',effects);mark('draft',$('message').value);mark('done',true);
})().catch(e=>mark('js-error',e.message))});
''')
self.assertEqual(self.attribute(dom, "write-boot"), "71")
self.assertEqual(self.attribute(dom, "current-boot"), "72")
self.assertEqual(self.attribute(dom, "effects"), "0")
self.assertEqual(self.attribute(dom, "draft"), "queued before reboot")
def test_editor_uses_opened_version_and_then_exact_save_response_version(self):
dom = self.run_page(r'''
let currentVersion=1,body='original version one',publications=[],effects=0;
window.fetch=(url,options)=>{
const p=record(url,options);
if(p.op==='status')return response(Object.assign({},boundary.status,{role:3}));
if(p.op==='posts')return response({post:null});
if(p.op==='board.index')return response({revision:currentVersion,articles:[{id:1,version:currentVersion,length:new TextEncoder().encode(body).length,title:'Article '+currentVersion}],next:255});
if(p.op==='board.read'){const data=new TextEncoder().encode(body);return response({version:currentVersion,offset:0,next:data.length,total:data.length,data64:btoa(Array.from(data,b=>String.fromCharCode(b)).join(''))})}
if(p.op==='board.save'){
publications.push(p);
if(p.version!==currentVersion)return response({error:'article changed; reload index before editing'});
effects++;currentVersion=7;body=atob(p.body64);return response({ok:true,version:7});
}
throw Error('unexpected operation');
};
window.addEventListener('load',()=>{(async()=>{
submit('join');await until(()=>$('notices').childElementCount===1);await openArticle(notices.get(1));
currentVersion=2;body='another admin updated this';await refreshBoard();submit('publish');
await until(()=>publications.length===1&&$('error').textContent.includes('changed'));
mark('stale-version',publications[0].version);mark('stale-draft',$('body').value);mark('stale-effects',effects);
await openArticle(notices.get(1));$('body').value='my new draft';submit('publish');
await until(()=>publications.length===2&&notices.get(1).version===7);
mark('valid-version',publications[1].version);mark('save-effects',effects);
currentVersion=9;body='later external edit';await refreshBoard();submit('publish');
await until(()=>publications.length===3&&$('error').textContent.includes('changed'));
mark('after-refresh-version',publications[2].version);mark('retained-draft',$('body').value);mark('final-effects',effects);mark('done',true);
})().catch(e=>mark('js-error',e.message))});
''')
self.assertEqual(self.attribute(dom, "stale-version"), "1")
self.assertEqual(self.attribute(dom, "stale-draft"), "original version one")
self.assertEqual(self.attribute(dom, "stale-effects"), "0")
self.assertEqual(self.attribute(dom, "valid-version"), "2")
self.assertEqual(self.attribute(dom, "save-effects"), "1")
self.assertEqual(self.attribute(dom, "after-refresh-version"), "7")
self.assertEqual(self.attribute(dom, "retained-draft"), "my new draft")
self.assertEqual(self.attribute(dom, "final-effects"), "1")
if __name__ == "__main__":
unittest.main()
+8
View File
@@ -108,6 +108,14 @@ class SensorReplyBoundsTest(unittest.TestCase):
def test_room_telemetry_entries_fit_all_routes_and_preserve_composites(self):
self.check_case("room")
def test_room_board_short_response_never_returns_a_tag_only_reply(self):
self.check_case("room.board")
def test_old_room_board_tag_only_response_is_rejected(self):
def old(source):
return source.replace("return length ? 4 + length : 0;", "return 4 + length;", 1)
self.assertNotEqual(self.execute("room.board", room_transform=old).returncode, 0)
def test_old_room_unbounded_copy_is_rejected(self):
def old(source):
begin = source.index(" const uint8_t* tbuf = telemetry.getBuffer();")
+1 -1
View File
@@ -142,7 +142,7 @@ int main() {
# Actual Mesh::createMultiAck has a pre-existing signed-size comparison.
# Keep other fixture warnings fatal without changing production code.
args = [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", "-Wno-sign-compare",
"-I" + str(work), "-I" + str(ROOT / "src"), str(work / "test.cpp"), "-o", str(binary)]
"-I" + str(work), "-I" + str(ROOT / "test/fixtures/room_history_store"), "-I" + str(ROOT / "src"), str(work / "test.cpp"), "-o", str(binary)]
if sensor: args.insert(1, "-DTEST_SENSOR=1")
if sys.platform.startswith("linux"):
args[1:1] = ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]