diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml
index 638cb81f0..ab361c2ed 100644
--- a/.github/workflows/run-unit-tests.yml
+++ b/.github/workflows/run-unit-tests.yml
@@ -111,12 +111,22 @@ jobs:
run: |
python3 -B test/test_protocol_parser_bounds.py -v
python3 -B test/test_companion_offline_delivery.py -v
+ python3 -B test/test_room_receive_admission.py -v
python3 -B test/test_companion_lfs_traversal.py -v
python3 -B test/test_room_post_permissions.py -v
python3 -B test/test_room_login_authorization.py -v
python3 -B test/test_room_login_integration.py -v
python3 -B test/test_room_topic_store.py -v
python3 -B test/test_room_topic_delivery.py -v
+ python3 -B test/test_room_acl_persistence.py -v
+ python3 -B test/test_room_access_policy.py -v
+ python3 -B test/test_room_history_store.py -v
+ python3 -B test/test_room_history_integration.py -v
+ python3 -B test/test_room_board_store.py -v
+ python3 -B test/test_room_board_protocol.py -v
+ python3 -B test/test_room_web_mailbox.py -v
+ python3 -B test/test_room_web_http.py -v
+ python3 -B test/test_room_web_service.py -v
python3 -B test/test_contact_clock_bootstrap.py -v
python3 -B test/test_rtc_discovery.py -v
python3 -B test/test_radio_interrupt_recovery.py -v
@@ -537,6 +547,7 @@ jobs:
python3 -B test/test_heltec_v4_wifi_setup_page.py -v
python3 -B test/test_sensor_webconfig_capabilities.py -v
python3 -B test/test_webconfig_ui_runtime.py
+ MESHCORE_REQUIRE_ROOM_BROWSER=1 python3 -B test/test_room_web_ui.py -v
- name: Upload Test Results
# Upload test results even if the test step failed.
diff --git a/README.md b/README.md
index b3eed4508..cd31e6013 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
* **[Flood retries that account for network topology](https://mikecarper.github.io/MeshCore/halo_keymind_settings/#flood-and-advert-settings).** Configure retry counts, path limits, packet-type limits, target prefixes, ignored repeaters, and channel-specific eligibility. Bridge buckets keep retrying until traffic is heard on the intended sides of a relay or the retry budget expires. Qualifying echoes cancel unnecessary attempts.
* **[Enhanced direct messages and direct-route retries](https://mikecarper.github.io/MeshCore/halo_keymind_settings/#direct-retry-settings).** Adapt the coding rate across bounded retry attempts and cancel unnecessary retries when a qualifying echo is heard. [Single Key DMs](https://mikecarper.github.io/MeshCore/companion_protocol/#private-text-when-only-the-sender-knows-the-recipient) let compatible Companions send private text when only the sender knows the recipient's key, using a signed, encrypted introduction. The recipient accepts the sender through the app or opts into automatic acceptance; compact STM32WL builds omit this feature.
* **[Enhanced telemetry history](https://mikecarper.github.io/MeshCore/telemetry_decoder/).** Send compact temperature and battery-voltage snapshots of up to 165 samples per packet, plus external-voltage history in chunks. Read retained GPS history through the CLI; GPS history is not included in the automatic telemetry packets. Decode snapshots and CLI history in the browser.
-* **[Room topics and access controls](https://mikecarper.github.io/MeshCore/cli_commands/#view-or-change-the-room-topic).** Save a room announcement across reboots and deliver it through the existing app message format while preserving unread chat catch-up. Public read-only login and explicit ACL roles are respected; guest-password login cannot demote an admin.
+* **[Room topics, history, and access controls](https://mikecarper.github.io/MeshCore/room_services/).** Save an announcement and optionally retain the newest 32 chat posts across reboots. Explicit ACL roles persist; full-key bans and per-user post/request limits apply to room sessions. ESP32 images with WebConfig provide local Wi-Fi chat and a persistent information board with selected, resumable article downloads. The existing app's chat and catch-up formats remain supported.
* **[Full firmware profiles, Companion transports, and ESP32 bridges](https://mikecarper.github.io/MeshCore/full_profiles/).** Full combines the exact board and role's supported features with runtime controls: Companion transports and firmware serving, or qualified ESP32 infrastructure services and MQTT/ESP-NOW/RS-232 bridges. Includes capacity exceptions, matching application slots, exact-board/role partition migration packages, and nRF52 Full/Reduced supported-sensor packages with LoRa OTA. The picker links supported migration routes; USB layout changes use the matching merged image, since an app-only image cannot change the partition table.
* **[Operational reporting and alerts](https://mikecarper.github.io/MeshCore/management_reports/).** Adds optional management reports with firmware version, bootloader version, LoRa update capabilities, uptime, and historical voltage and temperature ranges.
* **[Set time using the LoRa network](https://mikecarper.github.io/MeshCore/cli_commands/#estimate-and-correct-infrastructure-node-time-after-startup).** Infrastructure nodes can estimate time from fresh mesh evidence and correct their clocks when a consensus is available. Successful manual, GPS, or NTP synchronization takes precedence until reboot.
diff --git a/docs/cli_commands.md b/docs/cli_commands.md
index c7888c3d0..e22e0e1c1 100644
--- a/docs/cli_commands.md
+++ b/docs/cli_commands.md
@@ -3813,8 +3813,12 @@ a packet already queued for transmission can still arrive.
Topic saves are verified before publication. A failed save reports an error
and retains the previous live topic. Corrupt, unreadable, or unsupported stored
topic files are preserved and block replacement until their storage issue is
-repaired. Topics do not make ordinary room history persistent: retained posts
-still use the existing bounded RAM ring and are lost on reboot.
+repaired. Ordinary room history uses the bounded RAM ring by default. Enable
+optional retention of the newest 32 posts with `set room.history on`.
+
+See [Room history, moderation, and information board](room_services.md) for
+persistent history, saved ACL roles, full-key bans, per-user post/request
+limits, the ESP32 `/room` browser page, and versioned article downloads.
---
diff --git a/docs/index.md b/docs/index.md
index acc635ed5..907cc58e8 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -17,6 +17,7 @@ Choose firmware with the [firmware picker](firmware_picker.md). For help with a
- [Firmware update guide](ota_user_guide.md), [easy LoRa OTA](ota_easy.md), and [scripted LoRa OTA](lora_ota_automation.md)
- [CLI commands](cli_commands.md), [availability by build](cli_build_matrix.md), and [radio profiles](radio_profiles.md)
- [Private fleet management](fleet_management.md): signed filtering and secondary-radio commands for a fleet or one node
+- [Room history, moderation, and information board](room_services.md): optional persistent chat and local Wi-Fi room access
- [Companion protocol](companion_protocol.md), [packet format](packet_format.md), and [QR codes](qr_codes.md)
The navigation groups the remaining guides under Documentation, including board-specific updates, networking, power, and protocol references.
diff --git a/docs/room_services.md b/docs/room_services.md
new file mode 100644
index 000000000..974ff4590
--- /dev/null
+++ b/docs/room_services.md
@@ -0,0 +1,220 @@
+
+
View this page on MeshCore Docs .
+
+
+# Room history, moderation, and information board
+
+Room Server firmware keeps one room per radio. Existing app login, chat,
+catch-up, and Access Control formats remain supported. The information board
+and local Wi-Fi chat use the browser page below; the stock mobile app does not
+have an information-board screen.
+
+## Optional persistent chat history
+
+History uses RAM by default. From a local console or authenticated remote
+administrator session:
+
+~~~text
+get room.history
+set room.history on
+set room.history off
+room.history.clear
+~~~
+
+Enabling persistence first saves the current RAM ring. The newest **32 posts**
+then survive reboot. Disabling retains the archive and stops disk writes; the
+next enable replaces it with the current RAM ring. Clearing removes disk and
+RAM posts, leaving the topic and information board alone.
+
+Accepted posts append 192-byte records. At 64 records the journal compacts to
+the newest 32. Its maximum size is 12,300 bytes; compaction temporarily also
+needs a verified snapshot of about 6.2 KB. This is bounded recent history,
+and its filesystem records are not encrypted at rest.
+
+A post is saved before the server sends its ACK or advances the live ring,
+retry cache, or sender replay timestamp. Full storage and unsuccessful writes
+retain the previous chat and withhold the ACK. A recoverable incomplete or
+corrupt final append keeps its verified earlier records. Corruption before
+later complete records preserves the entire journal and blocks replacement.
+Unreadable, unsupported, or corrupt primary metadata is also preserved until
+storage is repaired.
+
+A reboot after storage completes but before the sender receives the ACK can
+recover a valid post without confirming it to the sender. Exactly-once delivery
+across that crash boundary is not guaranteed.
+
+Catch-up timestamps stay increasing across restored history and backward clock
+corrections without setting the radio's RTC from archived posts. New posts use
+a six-second monotonic delivery delay; restored posts can be delivered
+immediately. Set the clock correctly for useful displayed dates.
+
+The Companion also withholds a room-message ACK when its receive queue cannot
+retain the message. Rejected delivery does not advance catch-up, schedule
+receipt persistence, or emit a new-message notification.
+
+## Saved ACL roles and moderation
+
+An operator's nonzero `setperm` assignment survives reboot and is protected
+from automatic room-client eviction. Assigned read-only and read/write users
+appear in Access Control alongside administrators. Automatic guest-password
+read/write sessions remain transient; password-created administrators remain
+retained. The existing permission byte is unchanged.
+
+ACL updates use the existing delayed settings save. Allow at least five seconds
+after `setperm` before removing power so the assignment reaches storage.
+
+~~~text
+setperm 1
+setperm 2
+setperm 3
+setperm 0
+room.ban
+room.unban
+get room.bans
+get room.bans 2
+set room.post.rate 12
+set room.poll.rate 60
+get room.post.rate
+get room.poll.rate
+~~~
+
+Roles are `1` read-only, `2` read/write, and `3` administrator; `0` removes
+the assignment. Guest-password login cannot demote an explicit administrator
+or upgrade an explicit read-only user. Empty passwords are not enabled
+credentials; public passwordless read-only login still requires its existing
+setting.
+
+The persistent ban list accepts up to 32 complete identities, never names or
+prefixes. It excludes new logins and existing sessions, including posts,
+requests, path updates, ACKs, and pushes. It leaves existing posts and ACL
+assignments intact. Use another authorized administrator or a local console
+to undo a ban on your own identity.
+
+Rates are per identity per shared monotonic minute. `0` disables a limit
+(the default); the maximum is 65,535. The post limit covers new chat posts.
+The poll limit covers room requests, including keep-alive, status, telemetry,
+ACL and board reads, and authenticated browser operations.
+Login does not reset counts. Exact accepted retries are not charged again.
+Old keep-alive retries cannot rewind catch-up or cancel a newer pending push.
+Rejected posts do not enter the retry cache.
+
+Changing to a different identity creates a different user. These controls
+complement forwarding and airtime policies.
+
+## Local Wi-Fi chat on ESP32
+
+ESP32 Room Server images with WebConfig have an offline browser room page.
+It is **disabled by default** and uses the existing WebConfig listener:
+
+~~~text
+set room.web on
+start webconfig
+~~~
+
+Open `http:///room`. Without a usable router, use
+`start webconfig ap`, connect the phone to its access point, and append
+`/room` to the reported address. Existing setup AP time limits and Wi-Fi/OTA
+ownership rules apply. Starting OTA shuts down this listener. Compact images
+and boards without WebConfig retain LoRa chat and CLI access.
+
+Enter the guest or administrator room password, or leave it empty for
+explicitly enabled public read-only access. Readers cannot post or edit
+notices; only administrators can publish or delete them. Each operation
+rechecks credentials and bans. Use private Wi-Fi: the local page uses HTTP,
+and network access alone does not grant administrator permission.
+
+The browser generates a secret token and its own moderation identity. It
+does not import a radio private key or inherit a claimed radio key's ACL role.
+The Access and history panel shows the complete browser identity for banning.
+Posts share the LoRa chat history and include the display name; combined name
+and text must fit 151 UTF-8 bytes.
+
+Passwords stay in page memory. The token, sequence and display name are saved
+locally; use one active room tab per browser identity. Interrupted submissions
+retry the same sequence and body, then poll the owned result. Retry floors
+are bounded to twenty minutes and the current boot. The page stops retrying
+after twenty seconds and preserves an unconfirmed draft. After a restart or
+uncertain result, refresh and check before submitting again.
+
+Authentication, storage and radio work run on the mesh loop. HTTP callbacks
+only admit a bounded request and return its owned result. No arbitrary CLI
+operation is exposed. Disable room access with `set room.web off`; stop the
+portal with `stop webconfig`.
+
+## Information board
+
+Eight persistent article slots have titles up to 63 UTF-8 bytes and bodies up
+to 2,048 bytes. Index revisions and article versions survive reboot. Bodies
+use two bounded file banks per slot, streamed verification, and atomic index
+publication.
+
+Available filesystem space determines how much fits. Small internal nRF52
+filesystems cannot hold every article at maximum size alongside persistent
+history and settings. Leave space for the history snapshot and article/index
+replacement; a full filesystem rejects the change and retains existing data.
+
+In `/room`, refresh the notice index and select an article to download.
+Unselected bodies are not pushed or downloaded. Chunks are at most 128 bytes;
+completed chunks remain available within the page after a lost connection.
+A new article version starts a new transfer. The administrator editor checks
+the version of the loaded article before saving to prevent lost edits.
+
+Small notices also support administrator/local CLI management:
+
+~~~text
+room.board.put 1 Trail conditions|Bridge closed until Saturday.
+get room.board
+get room.board 1
+get room.board.read 1 0
+room.board.del 1
+~~~
+
+Index arguments are zero-based entry cursors; follow the returned `next=`.
+Long titles can reduce a reply to one entry. Put uses `TITLE|BODY` and still
+obeys the command transport's total length limit; use the browser for a full
+2 KB article. Read replies contain bounded Base64 and ID, version, offset,
+total length and byte count. Continue at offset plus count. Deletion requires
+a current nonzero version; reusing a deleted slot assigns a newer version.
+
+### LoRa board read protocol
+
+Logged-in readers, including read-only guests, can use encrypted
+`PAYLOAD_TYPE_REQ` requests to the existing single room. The usual four-byte
+tag precedes these payloads and is echoed before responses. Multi-byte
+integers are little-endian.
+
+| Operation | Payload after tag |
+| --- | --- |
+| Index | `0a 00` + expected revision `u32` + entry cursor `u8` |
+| Article | `0a 01` + article ID `u8` + expected version `u32` + offset `u16` |
+
+First index page: revision `0`, cursor `0`. Subsequent pages require the
+returned revision. Response: subtype, operation, status, revision `u32`,
+total count, start cursor, next cursor, then up to two complete entries.
+Each entry is ID `u8`, version `u32`, body length `u16`, title length `u8`
+and title bytes. Next cursor `255` means complete.
+
+Article response: subtype, operation, status, ID `u8`, version `u32`,
+offset `u16`, total body length `u16`, chunk length `u8`, then raw bytes.
+The route's encrypted reply capacity may reduce the chunk. Continue at offset
+plus length; offset equal to total returns successful EOF. Never combine
+different versions.
+
+Statuses: `0` success, `1` invalid, `2` storage unavailable, `3` write
+failure, `4` not found, `5` stale version. A capacity too small for a full
+header may return only subtype, operation, and invalid status. On stale
+revision/version, reload the index and restart the affected transfer.
+
+## Regression coverage
+
+Production handlers and stores are tested across ESP32, nRF52, STM32, Pico
+and generic filesystem APIs, including full storage, corruption, interrupted
+publication, queue rejection, ACL reboot/eviction, quotas, clock correction
+and allocation failure. ARM checks verify the new counters fit the existing
+client union.
+
+The pinned official app JavaScript consumes real firmware room frames and
+login responses. Chromium executes the bundled browser page with real backend
+response fixtures, testing untrusted text, lost replies, draft preservation,
+selective reads, resume and UTF-8 limits. These browser fixtures do not claim
+a physical LoRa/Wi-Fi transfer.
diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp
index 4f0f02522..4ec99ecab 100644
--- a/examples/companion_radio/MyMesh.cpp
+++ b/examples/companion_radio/MyMesh.cpp
@@ -1517,7 +1517,7 @@ void MyMesh::releaseHeldOneKeyDMs() {
}
#endif // MESH_ENABLE_ONE_KEY_DM
-void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packet *pkt,
+bool MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packet *pkt,
uint32_t sender_timestamp, const uint8_t *extra,
int extra_len, const char *text,
bool terminal_command_reply,
@@ -1570,9 +1570,12 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe
memset(private_key_backup_nonce, 0, sizeof(private_key_backup_nonce));
memset(private_key_backup_sender, 0, sizeof(private_key_backup_sender));
memset(out_frame, 0, i);
- return;
+ return false;
}
const bool queued = addToOfflineQueue(out_frame, i);
+ // Signed room posts are retried by the server until retained. Do not make
+ // an unretained post visible as delivered or notify the user about it.
+ if (txt_type == TXT_TYPE_SIGNED_PLAIN && !queued) return false;
if (_serial->isConnected()) {
uint8_t frame[1];
@@ -1618,6 +1621,7 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe
}
#endif
if (_ui) _ui->setMessageNotificationOverride(false);
+ return queued;
}
static uint16_t emergencyClientRepeatKey(const mesh::Packet* packet) {
@@ -1845,12 +1849,13 @@ void MyMesh::onCLICommandRecv(const ContactInfo &from, mesh::Packet *pkt, uint32
}
}
-void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
+bool MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
const uint8_t *sender_prefix, const char *text) {
+ if (!queueMessage(from, TXT_TYPE_SIGNED_PLAIN, pkt, sender_timestamp, sender_prefix, 4, text)) return false;
markConnectionActive(from);
// from.sync_since change needs to be persisted
scheduleContactWrite(from);
- queueMessage(from, TXT_TYPE_SIGNED_PLAIN, pkt, sender_timestamp, sender_prefix, 4, text);
+ return true;
}
void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t timestamp,
diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h
index da497eb5d..329ad9807 100644
--- a/examples/companion_radio/MyMesh.h
+++ b/examples/companion_radio/MyMesh.h
@@ -395,7 +395,7 @@ protected:
bool onAddressedTextPacket(mesh::Packet* packet, uint8_t src_hash,
const uint8_t* mac_and_data, size_t len) override;
#endif
- void queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packet *pkt, uint32_t sender_timestamp,
+ bool queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packet *pkt, uint32_t sender_timestamp,
const uint8_t *extra, int extra_len, const char *text,
bool terminal_command_reply=false,
uint32_t terminal_command_elapsed_millis=0);
@@ -406,7 +406,7 @@ protected:
const char *text) override;
void onCLICommandRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
const char *text, char* reply) override;
- void onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
+ bool onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
const uint8_t *sender_prefix, const char *text) override;
void onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t timestamp,
const char *text) override;
diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp
index 9d9c67f74..bf6c5e8b3 100644
--- a/examples/simple_room_server/MyMesh.cpp
+++ b/examples/simple_room_server/MyMesh.cpp
@@ -23,6 +23,7 @@
#include
#include
#include
+#include
#include
#include
#if MESH_PACKET_LOGGING
@@ -36,6 +37,9 @@
#include
#ifdef WITH_WEBCONFIG
#include
+#include
+#include
+#include
#endif
#include
@@ -97,25 +101,42 @@ struct ServerStats {
uint16_t n_posted, n_post_push;
};
-void MyMesh::addPost(ClientInfo *client, const char *postData) {
- storePost(client->id, postData);
+bool MyMesh::addPost(ClientInfo *client, const char *postData) {
+ return storePost(client->id, postData);
}
-void MyMesh::addSystemPost(const char *postData) {
- if (!postData || postData[0] == 0) return;
+bool MyMesh::addSystemPost(const char *postData) {
+ if (!postData || postData[0] == 0) return false;
MESH_DEBUG_PRINTLN("room.post: addSystemPost: %s", postData);
- storePost(self_id, postData);
+ return storePost(self_id, postData);
}
-void MyMesh::storePost(const mesh::Identity &author, const char *postData) {
+bool MyMesh::storePost(const mesh::Identity &author, const char *postData) {
+ if (!postData || !postData[0] || strlen(postData) > MAX_POST_TEXT_LEN
+ || !room_history_available || last_room_timestamp == UINT32_MAX) return false;
+ uint32_t timestamp = getRTCClock()->getCurrentTimeUnique();
+ // Keep catch-up cursors increasing after reboot and backward RTC corrections,
+ // without setting the radio's wall clock from archived messages.
+ if (timestamp <= last_room_timestamp) timestamp = last_room_timestamp + 1;
+ if (timestamp == 0) return false;
+ if (room_history_enabled) {
+ if ((room_history_state.needs_repair
+ || room_history_state.total_records >= mesh::ROOM_HISTORY_JOURNAL_RECORDS)
+ && !snapshotRoomHistory()) return false;
+ if (!mesh::appendRoomHistory(_fs, room_history_state, author.pub_key,
+ timestamp, postData)) return false;
+ }
int idx = next_post_idx;
// TODO: suggested postData format: /
posts[idx].author = author; // add to cyclic queue
- StrHelper::strncpy(posts[idx].text, postData, MAX_POST_TEXT_LEN);
+ StrHelper::strncpy(posts[idx].text, postData, sizeof(posts[idx].text));
- posts[idx].post_timestamp = getRTCClock()->getCurrentTimeUnique();
+ posts[idx].post_timestamp = timestamp;
+ last_room_timestamp = timestamp;
+ post_ready_at[idx] = futureMillis(POST_SYNC_DELAY_SECS * 1000UL);
+ post_ready_mask &= ~(UINT32_C(1) << idx);
MESH_DEBUG_PRINTLN("room.post: storePost idx=%d text=%s", idx, posts[idx].text);
MESH_DEBUG_PRINTLN("room.post: timestamp=%u", posts[idx].post_timestamp);
next_post_idx = (next_post_idx + 1) % MAX_UNSYNCED_POSTS;
@@ -123,6 +144,94 @@ void MyMesh::storePost(const mesh::Identity &author, const char *postData) {
next_push = futureMillis(PUSH_NOTIFY_DELAY_MILLIS);
_num_posted++; // stats
MESH_DEBUG_PRINTLN("room.post: next_post_idx=%d num_posted=%d push scheduled", next_post_idx, _num_posted);
+ return true;
+}
+
+bool MyMesh::snapshotRoomHistory() {
+ uint8_t count = 0;
+ for (const auto& post : posts) if (post.post_timestamp != 0) ++count;
+ return mesh::saveRoomHistorySnapshot(_fs, room_history_state, count,
+ [this](uint8_t ordinal, mesh::RoomHistoryRecord& record) {
+ for (int k = 0, idx = next_post_idx; k < MAX_UNSYNCED_POSTS; ++k,
+ idx = (idx + 1) % MAX_UNSYNCED_POSTS) {
+ if (posts[idx].post_timestamp == 0) continue;
+ if (ordinal-- != 0) continue;
+ memcpy(record.author, posts[idx].author.pub_key, PUB_KEY_SIZE);
+ record.timestamp = posts[idx].post_timestamp;
+ memcpy(record.text, posts[idx].text, sizeof(record.text));
+ return true;
+ }
+ return false;
+ });
+}
+
+void MyMesh::loadRoomHistory() {
+ room_history_available = mesh::loadRoomHistoryConfig(_fs, room_history_enabled);
+ if (!room_history_available || !room_history_enabled) return;
+ room_history_available = mesh::loadRoomHistory(_fs, room_history_state,
+ [this](uint8_t ordinal, const mesh::RoomHistoryRecord& record) {
+ if (ordinal >= MAX_UNSYNCED_POSTS || record.timestamp <= last_room_timestamp) return false;
+ auto& post = posts[ordinal];
+ memcpy(post.author.pub_key, record.author, PUB_KEY_SIZE);
+ post.post_timestamp = record.timestamp;
+ memcpy(post.text, record.text, sizeof(post.text));
+ last_room_timestamp = record.timestamp;
+ next_post_idx = (ordinal + 1) % MAX_UNSYNCED_POSTS;
+ return true;
+ });
+ if (!room_history_available) {
+ for (auto& post : posts) post.post_timestamp = 0;
+ next_post_idx = 0;
+ last_room_timestamp = 0;
+ MESH_DEBUG_PRINTLN("Room history unavailable; stored journal preserved");
+ }
+}
+
+bool MyMesh::handleRoomHistoryCommand(const char* command, char* reply) {
+ if (strcmp(command, "get room.history") == 0) {
+ snprintf(reply, 157, "> %s; storage %s; retained %u/32",
+ room_history_enabled ? "on" : "off", room_history_available ? "ready" : "unavailable",
+ unsigned(room_history_state.count));
+ return true;
+ }
+ if (strcmp(command, "room.history.clear") == 0) {
+ if (!room_history_available || !mesh::clearRoomHistory(_fs, room_history_state)) {
+ strcpy(reply, "Err - history clear failed; previous history retained"); return true;
+ }
+ for (auto& post : posts) post.post_timestamp = 0;
+ next_post_idx = 0;
+ // Do not lower cursors already sent to companions in this boot.
+ recent_room_posts.clear();
+ strcpy(reply, "OK - room history cleared");
+ return true;
+ }
+ if (strncmp(command, "set room.history ", 17) != 0) return false;
+ const char* value = command + 17;
+ bool enabled = strcmp(value, "on") == 0 || strcmp(value, "1") == 0;
+ if (!enabled && strcmp(value, "off") != 0 && strcmp(value, "0") != 0) {
+ strcpy(reply, "Err - room.history requires on/off"); return true;
+ }
+ if (!room_history_available || (enabled && !snapshotRoomHistory())
+ || !mesh::saveRoomHistoryConfig(_fs, enabled)) {
+ strcpy(reply, "Err - history save failed; previous setting retained"); return true;
+ }
+ room_history_enabled = enabled;
+ strcpy(reply, enabled ? "OK - persistent room history enabled (32 posts)"
+ : "OK - persistent room history disabled; archive retained");
+ return true;
+}
+
+void MyMesh::serviceRoomQuotas() {
+ room_access.serviceQuotaWindow(millis(), [this]() {
+ for (int i = 0; i < acl.getNumClients(); ++i) {
+ auto client = acl.getClientByIdx(i);
+ client->extra.room.post_quota_used = 0;
+ client->extra.room.poll_quota_used = 0;
+ }
+#ifdef WITH_WEBCONFIG
+ for (auto& user : room_web_users) { user.posts = 0; user.polls = 0; }
+#endif
+ });
}
bool MyMesh::pushPostToClient(ClientInfo *client, PostInfo &post) {
@@ -130,6 +239,219 @@ bool MyMesh::pushPostToClient(ClientInfo *client, PostInfo &post) {
return pushRoomTextToClient(client, post.post_timestamp, post.author, post.text);
}
+#ifdef WITH_WEBCONFIG
+bool MyMesh::handleRoomWebCommand(const char* command, char* reply) {
+ if (strcmp(command, "get room.web") == 0) {
+ snprintf(reply, 157, "> %s; browser path /room", room_web_enabled ? "on" : "off");
+ return true;
+ }
+ if (strncmp(command, "set room.web ", 13) != 0) return false;
+ const char* value = command + 13;
+ const bool enabled = strcmp(value, "on") == 0 || strcmp(value, "1") == 0;
+ if (!enabled && strcmp(value, "off") != 0 && strcmp(value, "0") != 0) {
+ strcpy(reply, "Err - room.web requires on/off"); return true;
+ }
+ Preferences settings;
+ if (!settings.begin("mesh-room", false)) {
+ strcpy(reply, "Err - room browser setting unavailable"); return true;
+ }
+ const bool saved = settings.putBool("web", enabled) == sizeof(bool)
+ && settings.getBool("web", !enabled) == enabled;
+ settings.end();
+ if (!saved) { strcpy(reply, "Err - room browser setting save failed"); return true; }
+ room_web_enabled = enabled;
+ strcpy(reply, enabled ? "OK - room browser enabled; start webconfig, then open /room"
+ : "OK - room browser disabled");
+ return true;
+}
+
+static const char* roomWebString(JsonVariantConst value, size_t maximum) {
+ if (!value.is()) return nullptr;
+ const JsonString text = value.as();
+ if (text.size() > maximum || strlen(text.c_str()) != text.size()) return nullptr;
+ return text.c_str();
+}
+
+void MyMesh::processRoomRequest(const uint8_t* token, uint32_t sequence, char* request,
+ char* response, size_t capacity) {
+ room_web_request_authenticated = false;
+ auto error = [response, capacity](const char* message) {
+ mesh::RoomJsonWriter json(response, capacity);
+ json.raw("{\"error\":"); json.string(message); json.raw("}"); json.finish();
+ };
+ if (!room_web_enabled) { error("room browser disabled; use set room.web on"); return; }
+ if (!token || !sequence || !request || strlen(request) > 4096) { error("invalid request"); return; }
+ uint8_t nonzero = 0;
+ for (size_t i = 0; i < 32; ++i) nonzero |= token[i];
+ if (!nonzero) { error("invalid browser identity"); return; }
+ mesh::RoomJsonAllocator allocator;
+ JsonDocument input(&allocator);
+ if (deserializeJson(input, request, DeserializationOption::NestingLimit(4))
+ != DeserializationError::Ok || !input.is()) { error("invalid JSON"); return; }
+ const char* operation = roomWebString(input["op"], 20);
+ const char* password = roomWebString(input["password"], 64);
+ if (!operation || !password) { error("missing operation or password"); return; }
+
+ // A browser proves possession of its own random web token. It cannot claim
+ // a radio public key or inherit that key's ACL permission without its secret.
+ mesh::Identity author;
+ mesh::Utils::sha256(author.pub_key, PUB_KEY_SIZE, token, 32, self_id.pub_key, PUB_KEY_SIZE);
+ if (!room_access.allowsIdentity(author.pub_key)) { error("room access denied"); return; }
+ const auto authorization = mesh::authorizeRoomLogin(password, _prefs.password,
+ _prefs.guest_password, false, 0, _prefs.allow_read_only, PERM_ACL_ROLE_MASK,
+ PERM_ACL_GUEST, PERM_ACL_READ_WRITE, PERM_ACL_ADMIN);
+ if (!authorization.accepted) { error("incorrect room password"); return; }
+ room_web_request_authenticated = true;
+ const uint8_t role = authorization.role & PERM_ACL_ROLE_MASK;
+ const bool writing = strcmp(operation, "post") == 0
+ || strcmp(operation, "board.save") == 0 || strcmp(operation, "board.delete") == 0;
+ if (writing && (!input["boot"].is()
+ || input["boot"].as() != room_web_boot_id)) {
+ error("radio restarted; refresh before sending"); return;
+ }
+ if (strcmp(operation, "post") == 0 && role != PERM_ACL_READ_WRITE && role != PERM_ACL_ADMIN) {
+ error("read-only room access"); return;
+ }
+ if ((strcmp(operation, "board.save") == 0 || strcmp(operation, "board.delete") == 0)
+ && role != PERM_ACL_ADMIN) { error("admin permission required"); return; }
+ if (strcmp(operation, "status") != 0 && strcmp(operation, "posts") != 0
+ && strcmp(operation, "post") != 0 && strcmp(operation, "board.index") != 0
+ && strcmp(operation, "board.read") != 0 && strcmp(operation, "board.save") != 0
+ && strcmp(operation, "board.delete") != 0) { error("unknown room operation"); return; }
+
+ const uint32_t now = millis();
+ RoomWebUser* user = nullptr;
+ RoomWebUser* available = nullptr;
+ for (auto& entry : room_web_users) {
+ if (entry.id.matches(author) && entry.last_sequence != 0) user = &entry;
+ if (entry.last_sequence == 0 || uint32_t(now - entry.last_seen) >= 1200000UL) available = &entry;
+ }
+ if (!user) {
+ if (!available) { error("room browser busy; try later"); return; }
+ user = available; *user = RoomWebUser{}; user->id = author;
+ } else if (sequence <= user->last_sequence) { error("request already completed; refresh to check its result"); return; }
+ // Preserve the floor even if WebConfig is stopped and restarted. Failed
+ // requests also have a result; a retry must reuse the mailbox's cached result.
+ user->last_sequence = sequence; user->last_seen = now;
+ serviceRoomQuotas();
+ const uint16_t prior_posts = user->posts;
+ if (!(strcmp(operation, "post") == 0 ? room_access.consumePost(user->posts)
+ : room_access.consumeKeepAlive(user->polls))) {
+ error("room rate limit reached; try after the next minute"); return;
+ }
+ char key[65]; mesh::Utils::toHex(key, author.pub_key, PUB_KEY_SIZE);
+ mesh::RoomJsonWriter json(response, capacity);
+ if (strcmp(operation, "status") == 0) {
+ json.raw("{\"name\":"); json.string(_prefs.node_name);
+ json.raw(",\"topic\":"); json.string(room_topic);
+ json.raw(",\"boot\":"); json.number(room_web_boot_id);
+ json.raw(",\"role\":"); json.number(role);
+ json.raw(",\"web_key\":"); json.string(key);
+ json.raw(",\"persistent_history\":"); json.raw(room_history_enabled ? "true" : "false");
+ json.raw("}"); json.finish(); return;
+ }
+ if (strcmp(operation, "post") == 0) {
+ const char* name = roomWebString(input["name"], 24);
+ const char* text = roomWebString(input["text"], MAX_POST_TEXT_LEN);
+ if (!name || !*name || !text || !*text) { user->posts = prior_posts; error("name and message required"); return; }
+ for (const unsigned char* p = (const unsigned char*)name; *p; ++p) {
+ if (*p < 0x20 || *p == 0x7f || *p == ':') { user->posts = prior_posts; error("invalid display name"); return; }
+ }
+ char post[MAX_POST_TEXT_LEN + 1];
+ const int length = snprintf(post, sizeof(post), "%s: %s", name, text);
+ if (length < 0 || size_t(length) >= sizeof(post)) { user->posts = prior_posts; error("message with name exceeds 151 UTF-8 bytes"); return; }
+ if (!storePost(author, post)) { user->posts = prior_posts; error("post not retained; storage unavailable"); return; }
+ json.raw("{\"ok\":true,\"timestamp\":"); json.number(last_room_timestamp);
+ json.raw("}"); json.finish(); return;
+ }
+ if (strcmp(operation, "posts") == 0) {
+ if (!input["after"].is()) { error("invalid message cursor"); return; }
+ const uint32_t after = input["after"].as();
+ const PostInfo* selected = nullptr;
+ for (int k = 0, idx = next_post_idx; k < MAX_UNSYNCED_POSTS; ++k, idx = (idx + 1) % MAX_UNSYNCED_POSTS) {
+ if (posts[idx].post_timestamp > after) { selected = &posts[idx]; break; }
+ }
+ json.raw("{\"post\":");
+ if (!selected) json.raw("null");
+ else {
+ mesh::Utils::toHex(key, selected->author.pub_key, PUB_KEY_SIZE);
+ json.raw("{\"timestamp\":"); json.number(selected->post_timestamp);
+ json.raw(",\"author\":"); json.string(key);
+ json.raw(",\"text\":"); json.string(selected->text); json.raw("}");
+ }
+ json.raw("}"); json.finish(); return;
+ }
+
+ mesh::RoomBoardIndex board;
+ if (!mesh::loadRoomBoard(_fs, board)) { error("information board storage unavailable"); return; }
+ if (strcmp(operation, "board.index") == 0) {
+ if (!input["revision"].is() || !input["cursor"].is()) { error("invalid board cursor"); return; }
+ const uint32_t revision = input["revision"].as();
+ const uint8_t cursor = input["cursor"].as();
+ if (cursor > board.count || (revision != board.revision && (revision != 0 || cursor != 0))) {
+ error("board changed; reload index"); return;
+ }
+ json.raw("{\"revision\":"); json.number(board.revision);
+ json.raw(",\"articles\":[");
+ uint8_t next = cursor;
+ for (; next < board.count && next < cursor + 2; ++next) {
+ if (next != cursor) json.raw(",");
+ const auto& article = board.articles[next];
+ json.raw("{\"id\":"); json.number(article.id);
+ json.raw(",\"version\":"); json.number(article.version);
+ json.raw(",\"length\":"); json.number(article.body_length);
+ json.raw(",\"title\":"); json.string(article.title); json.raw("}");
+ }
+ json.raw("],\"next\":"); json.number(next < board.count ? next : 255);
+ json.raw("}"); json.finish(); return;
+ }
+ if (!input["id"].is() || !input["version"].is()) { error("invalid article identity/version"); return; }
+ const uint8_t id = input["id"].as();
+ const uint32_t version = input["version"].as();
+ const mesh::RoomBoardArticle* article = nullptr;
+ for (uint8_t i = 0; i < board.count; ++i) if (board.articles[i].id == id) article = &board.articles[i];
+ if (strcmp(operation, "board.read") == 0) {
+ if (!input["offset"].is()) { error("invalid article offset"); return; }
+ const uint16_t offset = input["offset"].as();
+ uint8_t chunk[mesh::ROOM_BOARD_READ_MAX_CHUNK];
+ size_t length = 0;
+ const auto result = mesh::readRoomBoardArticle(_fs, id, version, offset, chunk, sizeof(chunk), length);
+ if (result != mesh::RoomBoardResult::Success || !article) { error("article changed or unavailable; reload index"); return; }
+ char encoded[4 * ((sizeof(chunk) + 2) / 3) + 1]; mesh::roomEncodeBase64(chunk, length, encoded);
+ json.raw("{\"version\":"); json.number(version);
+ json.raw(",\"offset\":"); json.number(offset);
+ json.raw(",\"next\":"); json.number(uint32_t(offset) + length);
+ json.raw(",\"total\":"); json.number(article->body_length);
+ json.raw(",\"data64\":"); json.string(encoded); json.raw("}"); json.finish(); return;
+ }
+ mesh::RoomBoardResult result;
+ if (strcmp(operation, "board.delete") == 0) {
+ if (version == 0) { error("deletion requires the current article version"); return; }
+ result = mesh::deleteRoomBoardArticle(_fs, id, version);
+ } else {
+ const char* title = roomWebString(input["title"], 63);
+ const char* encoded = roomWebString(input["body64"], 2732);
+ if (!title || !encoded || (article ? article->version != version : version != 0)) {
+ error("article changed; reload index before editing"); return;
+ }
+ auto* body = new (std::nothrow) uint8_t[mesh::ROOM_BOARD_MAX_BODY_LENGTH];
+ if (!body) { error("insufficient memory; article retained"); return; }
+ size_t length = 0;
+ if (!mesh::roomDecodeBase64(encoded, body, mesh::ROOM_BOARD_MAX_BODY_LENGTH, length)) {
+ delete[] body; error("invalid article encoding"); return;
+ }
+ result = mesh::saveRoomBoardArticle(_fs, id, title, length,
+ [body](size_t offset, uint8_t* out, size_t count) {
+ memcpy(out, body + offset, count); return count;
+ }, version);
+ delete[] body;
+ }
+ if (result != mesh::RoomBoardResult::Success) { error("article save failed or version changed; previous article retained"); return; }
+ json.raw("{\"ok\":true,\"version\":"); json.number(board.revision + 1);
+ json.raw("}"); json.finish();
+}
+#endif
+
bool MyMesh::pushRoomTextToClient(ClientInfo *client, uint32_t timestamp,
const mesh::Identity& author, const char* text,
uint32_t topic_revision) {
@@ -209,6 +531,7 @@ uint8_t MyMesh::getUnsyncedCount(ClientInfo *client) {
bool MyMesh::processAck(const uint8_t *data) {
for (int i = 0; i < acl.getNumClients(); i++) {
auto client = acl.getClientByIdx(i);
+ if (!room_access.allowsIdentity(client->id.pub_key)) continue;
if (client->extra.room.pending_ack && memcmp(data, &client->extra.room.pending_ack, 4) == 0) { // got an ACK from Client!
client->extra.room.pending_ack = 0; // clear this, so next push can happen
if (client->extra.room.pending_topic_revision != 0) {
@@ -268,6 +591,10 @@ bool MyMesh::handleRoomTopicCommand(const char* command, char* reply) {
}
void MyMesh::serviceRoomPush() {
+ for (int idx = 0; idx < MAX_UNSYNCED_POSTS; ++idx) {
+ if (!(post_ready_mask & (UINT32_C(1) << idx))
+ && millisHasNowPassed(post_ready_at[idx])) post_ready_mask |= UINT32_C(1) << idx;
+ }
// Latch this even without subscribers. An unchanged topic remains ready
// beyond the signed timer comparison's half-range (~25 days).
if (!room_topic_ready && millisHasNowPassed(room_topic_ready_at)) room_topic_ready = true;
@@ -298,7 +625,8 @@ void MyMesh::serviceRoomPush() {
auto client = acl.getClientByIdx(next_client_idx);
bool did_push = false;
if (client->extra.room.pending_ack == 0 && client->last_activity != 0
- && client->extra.room.push_failures < 3) {
+ && client->extra.room.push_failures < 3
+ && room_access.allowsIdentity(client->id.pub_key)) {
const uint32_t now = getRTCClock()->getCurrentTime();
if (room_topic[0] != 0 && room_topic_ready
&& client->extra.room.topic_seen_revision != room_topic_revision
@@ -316,8 +644,7 @@ void MyMesh::serviceRoomPush() {
// Companions echo the latest received timestamp as keep-alive forceSince.
// Even an unaged older post must precede the topic on the wire.
if (post->post_timestamp <= room_topic_timestamp) has_older_topic_posts = true;
- if (next_post == NULL && now >= post->post_timestamp
- && now - post->post_timestamp >= POST_SYNC_DELAY_SECS) next_post = post;
+ if (next_post == NULL && (post_ready_mask & (UINT32_C(1) << idx))) next_post = post;
}
idx = (idx + 1) % MAX_UNSYNCED_POSTS;
}
@@ -360,6 +687,11 @@ int MyMesh::handleRequest(ClientInfo *sender, uint32_t sender_timestamp, uint8_t
// uint32_t now = getRTCClock()->getCurrentTimeUnique();
// memcpy(reply_data, &now, 4); // response packets always prefixed with timestamp
memcpy(reply_data, &sender_timestamp, 4); // reflect sender_timestamp back in response packet (kind of like a 'tag')
+ if (payload[0] == mesh::ROOM_BOARD_REQUEST_SUBTYPE) {
+ const size_t length = mesh::handleRoomBoardRequest(_fs, payload, payload_len,
+ reply_data + 4, reply_capacity - 4);
+ return length ? 4 + length : 0;
+ }
if (payload[0] == REQ_TYPE_GET_STATUS) {
ServerStats stats;
@@ -417,7 +749,7 @@ int MyMesh::handleRequest(ClientInfo *sender, uint32_t sender_timestamp, uint8_t
// Preserve the legacy seven-byte entries within the encrypted route budget.
for (int i = 0; i < acl.getNumClients() && ofs + 7 <= reply_capacity; i++) {
auto c = acl.getClientByIdx(i);
- if (!c->isAdmin()) continue; // skip non-Admin entries
+ if (!saveFilter(c)) continue; // operator assignments plus password-created Admins
memcpy(&reply_data[ofs], c->id.pub_key, 6); ofs += 6; // just 6-byte pub_key prefix
reply_data[ofs++] = c->permissions;
}
@@ -831,6 +1163,7 @@ mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) {
void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const mesh::Identity &sender,
uint8_t *data, size_t len) {
+ if (!room_access.allowsIdentity(sender.pub_key)) return;
if (packet->getPayloadType() == PAYLOAD_TYPE_ANON_REQ) { // received an initial request by a possible admin
// client (unknown at this stage)
if (len < 8) {
@@ -996,6 +1329,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx,
return;
}
auto client = acl.getClientByIdx(i);
+ if (!room_access.allowsIdentity(client->id.pub_key)) return;
#if defined(WITH_MQTT_NEIGHBORS)
// A neighbour that IS an ACL client resolves to a normal index above, so a
// scope-query response from it lands here -- match it against the overlay.
@@ -1037,18 +1371,17 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx,
mesh::Utils::sha256(message_fingerprint, sizeof(message_fingerprint),
client->id.pub_key, PUB_KEY_SIZE,
(const uint8_t*)text, text_len);
- const auto replay_decision = recent_room_posts.classifyAndRemember(
- message_fingerprint, sender_timestamp,
- client->extra.room.last_post_timestamp);
-
- using ReplayDecision =
- mesh::LogicalMessageCache::ReplayDecision;
- if (replay_decision == ReplayDecision::StaleOrMismatched) {
- MESH_DEBUG_PRINTLN("onPeerDataRecv: stale or mismatched room post detected");
- return;
- }
- if (replay_decision == ReplayDecision::NewMessage) {
- addPost(client, text);
+ if (!recent_room_posts.find(message_fingerprint, sender_timestamp)) {
+ if (sender_timestamp <= client->extra.room.last_post_timestamp) return;
+ serviceRoomQuotas();
+ const uint16_t prior_quota = client->extra.room.post_quota_used;
+ if (!room_access.consumePost(client->extra.room.post_quota_used)) return;
+ if (!addPost(client, text)) {
+ client->extra.room.post_quota_used = prior_quota;
+ return;
+ }
+ client->extra.room.last_post_timestamp = sender_timestamp;
+ recent_room_posts.remember(message_fingerprint, sender_timestamp);
}
// Exact retries are ACKed again regardless of whether newer posts have
// advanced this client's room-post replay timestamp.
@@ -1161,10 +1494,45 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx,
} else if (type == PAYLOAD_TYPE_REQ && len >= 5) {
uint32_t sender_timestamp;
memcpy(&sender_timestamp, data, 4); // timestamp (by sender's RTC clock - which could be wrong)
- if (sender_timestamp < client->last_timestamp) { // prevent replay attacks
+ const bool keep_alive = data[4] == REQ_TYPE_KEEP_ALIVE;
+ uint32_t since = 0;
+ uint8_t canonical_poll[5];
+ const uint8_t* request_bytes = data + 4;
+ size_t request_length = len - 4;
+ if (keep_alive) {
+ if (!packet->isRouteDirect()) return;
+ if (len >= 9) memcpy(&since, data + 5, 4);
+ canonical_poll[0] = data[4]; memcpy(canonical_poll + 1, &since, 4);
+ request_bytes = canonical_poll; request_length = sizeof(canonical_poll);
+ }
+ uint8_t fingerprint[MAX_HASH_SIZE];
+ mesh::Utils::sha256(fingerprint, sizeof(fingerprint), client->id.pub_key, PUB_KEY_SIZE,
+ request_bytes, request_length);
+ const bool request_retry = recent_room_polls.find(fingerprint, sender_timestamp);
+ if (sender_timestamp < client->last_timestamp && !request_retry) return;
+ serviceRoomQuotas();
+ if (!request_retry && !room_access.consumeKeepAlive(client->extra.room.poll_quota_used)) return;
+ recent_room_polls.remember(fingerprint, sender_timestamp);
+ // All accepted keep-alive retries get an ACK without rewinding catch-up or
+ // cancelling a newer pending delivery, even if no new request advanced RTC.
+ if (keep_alive && request_retry) {
+ uint32_t ack_hash;
+ uint8_t ack_data[9];
+ memcpy(ack_data, data, 5); memcpy(ack_data + 5, &since, 4);
+ mesh::Utils::sha256((uint8_t*)&ack_hash, 4, ack_data, 9, client->id.pub_key, PUB_KEY_SIZE);
+ if (mesh::Packet::isValidPathLen(client->out_path_len)) {
+ auto reply = createAck(ack_hash);
+ if (reply) {
+ reply->payload[reply->payload_len++] = getUnsyncedCount(client);
+ sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY);
+ }
+ }
+ return;
+ }
+ if (sender_timestamp < client->last_timestamp && !request_retry) { // prevent replay attacks
MESH_DEBUG_PRINTLN("onPeerDataRecv: possible replay attack detected");
} else {
- client->last_timestamp = sender_timestamp;
+ if (sender_timestamp > client->last_timestamp) client->last_timestamp = sender_timestamp;
uint32_t now = getRTCClock()->getCurrentTime();
client->last_activity = now; // <-- THIS will keep client connection alive
@@ -1184,9 +1552,6 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx,
client->extra.room.pending_ack = 0;
client->extra.room.pending_topic_revision = 0;
- // TODO: Throttle KEEP_ALIVE requests!
- // if client sends too quickly, evict()
-
// RULE: only send keep_alive response DIRECT!
if (mesh::Packet::isValidPathLen(client->out_path_len)) {
uint32_t ack_hash; // calc ACK to prove to sender that we got request
@@ -1232,6 +1597,7 @@ bool MyMesh::onPeerPathRecv(mesh::Packet *packet, int sender_idx, const uint8_t
if (i >= 0 && i < acl.getNumClients()) { // get from our known_clients table (sender SHOULD already be known in this context)
MESH_DEBUG_PRINTLN("PATH to client, path_len=%d", (uint32_t)path_len);
auto client = acl.getClientByIdx(i);
+ if (!room_access.allowsIdentity(client->id.pub_key)) return false;
// PATH has no durable replay freshness signal. Keep it RAM-only and do not
// let a replay replace an operator-selected force-flood route.
const bool persistence_allowed = mesh::clientPathPersistenceAllowed(
@@ -1529,6 +1895,18 @@ void MyMesh::begin(FILESYSTEM *fs) {
_cli.beginManagement(*this, _fs);
acl.load(_fs, self_id);
+ acl.protectExplicitPermissions();
+ room_access.load(_fs);
+ loadRoomHistory();
+#ifdef WITH_WEBCONFIG
+ Preferences room_settings;
+ if (room_settings.begin("mesh-room", true)) {
+ room_web_enabled = room_settings.getBool("web", false);
+ room_settings.end();
+ }
+ getRNG()->random((uint8_t*)&room_web_boot_id, sizeof(room_web_boot_id));
+ if (room_web_boot_id == 0) room_web_boot_id = 1;
+#endif
region_map.load(_fs);
_clock_sync.begin(_fs);
if (mesh::loadRoomTopic(_fs, room_topic)) {
@@ -2689,6 +3067,12 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
mesh::cli::normalizeCommandVerb(command);
if (handleRoomTopicCommand(command, reply)) return;
+ if (handleRoomHistoryCommand(command, reply)) return;
+ if (room_access.handleConfig(_fs, command, reply, 157)) return;
+ if (mesh::handleRoomBoardCommand(_fs, command, reply, 157)) return;
+#ifdef WITH_WEBCONFIG
+ if (handleRoomWebCommand(command, reply)) return;
+#endif
#if MESH_ENABLE_TELEMETRY_HISTORY
if (handleTelemetryHistoryCommand(command, reply)) return;
@@ -2791,8 +3175,7 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
if (*msg == 0) {
snprintf(reply, MAX_POST_TEXT_LEN, "ERR empty message");
} else {
- addSystemPost(msg);
- snprintf(reply, MAX_POST_TEXT_LEN, "OK");
+ snprintf(reply, MAX_POST_TEXT_LEN, addSystemPost(msg) ? "OK" : "Err - post not retained");
}
}
#if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE
@@ -2815,7 +3198,8 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
}
bool MyMesh::saveFilter(ClientInfo* client) {
- return client->isAdmin(); // only save Admins
+ return mesh::roomAclShouldPersist(client->permissions, client->permissions_are_explicit,
+ PERM_ACL_ROLE_MASK, PERM_ACL_ADMIN);
}
void MyMesh::loop() {
diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h
index c177f4d37..d942d4bde 100644
--- a/examples/simple_room_server/MyMesh.h
+++ b/examples/simple_room_server/MyMesh.h
@@ -30,6 +30,9 @@
#include
#include
#include
+#include
+#include
+#include
#include
#include
#include
@@ -211,6 +214,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks,
#endif
ClientACL acl;
mesh::LogicalMessageCache recent_room_posts;
+ mesh::LogicalMessageCache<16> recent_room_polls;
+ mesh::RoomAccessPolicy room_access;
mesh::RemoteCliReplyCache remote_cli_reply_cache;
CommonCLI _cli;
mesh::MeshClockSync _clock_sync;
@@ -225,6 +230,13 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks,
int next_client_idx; // for round-robin polling
int next_post_idx;
PostInfo posts[MAX_UNSYNCED_POSTS]; // cyclic queue
+ static_assert(MAX_UNSYNCED_POSTS <= 32, "Room readiness bitmap holds 32 posts");
+ uint32_t post_ready_at[MAX_UNSYNCED_POSTS] = {};
+ uint32_t post_ready_mask = UINT32_MAX;
+ uint32_t last_room_timestamp = 0;
+ mesh::RoomHistoryState room_history_state;
+ bool room_history_enabled = false;
+ bool room_history_available = true;
char room_topic[MAX_POST_TEXT_LEN + 1] = {};
uint32_t room_topic_revision = 0;
uint32_t room_topic_timestamp = 0;
@@ -325,6 +337,15 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks,
#endif
#ifdef WITH_WEBCONFIG
WebConfigServer* _webconfig = nullptr;
+ bool room_web_enabled = false;
+ bool room_web_request_authenticated = false;
+ uint32_t room_web_boot_id = 0;
+ struct RoomWebUser {
+ mesh::Identity id;
+ uint32_t last_seen = 0, last_sequence = 0;
+ uint16_t posts = 0, polls = 0;
+ } room_web_users[8] = {};
+ bool handleRoomWebCommand(const char* command, char* reply);
bool _unconfigured_setup_espnow_suspended = false;
void suspendUnconfiguredSetupBridges();
bool startWebConfigImpl(bool force_ap, char* reply, bool automatic_setup);
@@ -336,9 +357,13 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks,
void serviceIdleWiFi();
#endif
- void addPost(ClientInfo* client, const char* postData);
+ bool addPost(ClientInfo* client, const char* postData);
bool applySavedRadioParams();
- void storePost(const mesh::Identity& author, const char* postData);
+ bool storePost(const mesh::Identity& author, const char* postData);
+ bool snapshotRoomHistory();
+ void loadRoomHistory();
+ bool handleRoomHistoryCommand(const char* command, char* reply);
+ void serviceRoomQuotas();
bool pushPostToClient(ClientInfo* client, PostInfo& post);
bool pushRoomTextToClient(ClientInfo* client, uint32_t timestamp,
const mesh::Identity& author, const char* text,
@@ -455,7 +480,7 @@ public:
MyMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables);
void begin(FILESYSTEM* fs);
- void addSystemPost(const char* postData);
+ bool addSystemPost(const char* postData);
const char* getFirmwareVer() override { return FIRMWARE_VERSION; }
const char* getBuildDate() override { return FIRMWARE_BUILD_DATE; }
@@ -950,6 +975,10 @@ public:
}
void getNodeSnapshot(WebConfigServer::NodeSnapshot& snapshot) override;
void execCommand(char* cmd, char* reply) override { handleCommand(0, cmd, reply); }
+ bool supportsRoomService() const override { return true; }
+ bool roomRequestAuthenticated() const override { return room_web_request_authenticated; }
+ void processRoomRequest(const uint8_t* token, uint32_t sequence, char* request,
+ char* response, size_t capacity) override;
bool supportsCliTerminal() const override { return true; }
void execAdminCommand(char* cmd, char* reply) override {
if (strcmp(cmd, "get acl") == 0 || strcmp(cmd, "log") == 0
diff --git a/examples/simple_secure_chat/main.cpp b/examples/simple_secure_chat/main.cpp
index e5937e429..781361938 100644
--- a/examples/simple_secure_chat/main.cpp
+++ b/examples/simple_secure_chat/main.cpp
@@ -271,7 +271,8 @@ protected:
void onCLICommandRecv(const ContactInfo& contact, mesh::Packet* pkt, uint32_t sender_timestamp, const char *text, char* reply) override {
}
- void onSignedMessageRecv(const ContactInfo& from, mesh::Packet* pkt, uint32_t sender_timestamp, const uint8_t *sender_prefix, const char *text) override {
+ bool onSignedMessageRecv(const ContactInfo& from, mesh::Packet* pkt, uint32_t sender_timestamp, const uint8_t *sender_prefix, const char *text) override {
+ return false; // This minimal example does not retain room posts.
}
void onChannelMessageRecv(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t timestamp, const char *text) override {
diff --git a/mkdocs.yml b/mkdocs.yml
index d9d843355..1ed5d851e 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -75,6 +75,7 @@ nav:
- Radio, networking, and power:
- radio_profiles.md
- fleet_management.md
+ - room_services.md
- carrier_wave.md
- espnow_bridge_setup.md
- WiFi.md
diff --git a/scripts/check_firmware_ram.py b/scripts/check_firmware_ram.py
index 3eaad6f2b..bc4586a05 100644
--- a/scripts/check_firmware_ram.py
+++ b/scripts/check_firmware_ram.py
@@ -112,6 +112,12 @@ def requirements(platform, defines, target):
# Larger replies grow on demand only while preserving 32 KiB
# of free internal heap, and shrink after the browser reads.
parts["browser_terminal_scrollback"] = 4096
+ if "room_server" in target.lower():
+ # The mailbox is exclusive to Room Server callbacks. JSON's
+ # real allocator cap and one decoded maximum article coexist;
+ # ordinary HTTP bodies/responses use the transient margin.
+ parts["room_browser_mailbox"] = 6656 + 16
+ parts["room_browser_request_workspace"] = 8192 + 2048 + 16
if repeater:
# Match MyMesh's platform defaults. The post-hook supplies the
# MCU-specific classic ESP32 default before sdkconfig is included.
@@ -232,6 +238,8 @@ def requirements(platform, defines, target):
"ota_context", "screen_objects_and_history", "ota_encoder_workspace",
"ota_generic_source_buffers", "ethernet_or_external_ota_workspaces"):
largest = max(largest, parts.get(name, 0))
+ if "room_browser_mailbox" in parts:
+ largest = max(largest, 8192, parts["room_browser_mailbox"])
return {"required_heap_bytes": required, "required_contiguous_bytes": largest,
"components": parts, "display": display, "full_companion": full}
diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp
index 0f9d42489..571ffca22 100644
--- a/src/helpers/BaseChatMesh.cpp
+++ b/src/helpers/BaseChatMesh.cpp
@@ -428,13 +428,26 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender
}
}
} else if (flags == TXT_TYPE_SIGNED_PLAIN) {
- if (canMutateContacts()) {
+ if (len < 9) return; // timestamp, flags and complete author prefix
+ const uint32_t previous_sync_since = from.sync_since;
+ const uint32_t previous_lastmod = from.lastmod;
+ const bool mutate_contact = canMutateContacts();
+ if (mutate_contact) {
if (sender_timestamp > from.sync_since) { // make sure 'sync_since' is up-to-date
from.sync_since = sender_timestamp;
}
from.lastmod = getRTCClock()->getCurrentTime(); // update last heard time
}
- onSignedMessageRecv(from, packet, sender_timestamp, &data[5], (const char *) &data[9]); // let UI know
+ // The accepted callback can persist the updated live contact. Rejected
+ // admission must not persist it or report delivery; restore its cursor
+ // so a subsequent keep-alive still requests this post.
+ if (!onSignedMessageRecv(from, packet, sender_timestamp, &data[5], (const char *) &data[9])) {
+ if (mutate_contact) {
+ from.sync_since = previous_sync_since;
+ from.lastmod = previous_lastmod;
+ }
+ return;
+ }
uint32_t ack_hash; // calc truncated hash of the message timestamp + text + OUR pub_key, to prove to sender that we got it
mesh::Utils::sha256((uint8_t *) &ack_hash, 4, data, 9 + strlen((char *)&data[9]), self_id.pub_key, PUB_KEY_SIZE);
diff --git a/src/helpers/BaseChatMesh.h b/src/helpers/BaseChatMesh.h
index 64cc0c16f..7cb8f5645 100644
--- a/src/helpers/BaseChatMesh.h
+++ b/src/helpers/BaseChatMesh.h
@@ -171,7 +171,9 @@ protected:
virtual void onMessageRecv(const ContactInfo& contact, mesh::Packet* pkt, uint32_t sender_timestamp, const char *text) = 0;
virtual void onCommandDataRecv(const ContactInfo& contact, mesh::Packet* pkt, uint32_t sender_timestamp, const char *text) = 0;
virtual void onCLICommandRecv(const ContactInfo& contact, mesh::Packet* pkt, uint32_t sender_timestamp, const char *text, char* reply) = 0;
- virtual void onSignedMessageRecv(const ContactInfo& contact, mesh::Packet* pkt, uint32_t sender_timestamp, const uint8_t *sender_prefix, const char *text) = 0;
+ // Return true only after retaining/delivering the room post. A false result
+ // leaves the catch-up cursor unchanged and suppresses the delivery ACK.
+ virtual bool onSignedMessageRecv(const ContactInfo& contact, mesh::Packet* pkt, uint32_t sender_timestamp, const uint8_t *sender_prefix, const char *text) = 0;
virtual uint32_t calcFloodTimeoutMillisFor(uint32_t pkt_airtime_millis) const = 0;
virtual uint32_t calcDirectTimeoutMillisFor(uint32_t pkt_airtime_millis, uint8_t path_len) const = 0;
virtual void onSendTimeout() = 0;
diff --git a/src/helpers/ClientACL.cpp b/src/helpers/ClientACL.cpp
index 4bdecf37e..6b42f3b2e 100644
--- a/src/helpers/ClientACL.cpp
+++ b/src/helpers/ClientACL.cpp
@@ -715,6 +715,7 @@ void ClientACL::load(FILESYSTEM* fs, const mesh::LocalIdentity& self_id) {
c.last_timestamp = UINT32_MAX;
}
self_id.calcSharedSecret(c.shared_secret, pub_key); // recalculate shared secrets in case our private key changed
+ c.permissions_are_explicit = true; // this record was deliberately retained by the role's save policy
clients[num_clients++] = c;
}
file.close();
@@ -908,7 +909,9 @@ ClientInfo* ClientACL::putClient(const mesh::Identity& id, uint8_t init_perms) {
ClientInfo* oldest = NULL;
for (int i = 0; i < num_clients; i++) {
if (id.matches(clients[i].id)) return &clients[i]; // already known
- if (!clients[i].isProtectedManager() && clients[i].last_activity < min_time) {
+ if (!clients[i].isProtectedManager()
+ && !(protect_explicit_permissions && clients[i].permissions_are_explicit)
+ && clients[i].last_activity < min_time) {
oldest = &clients[i];
min_time = oldest->last_activity;
}
@@ -953,6 +956,7 @@ bool ClientACL::applyPermissions(const mesh::LocalIdentity& self_id, const uint8
if (c == NULL) return false;
c->permissions = perms; // update their permissions
+ c->permissions_are_explicit = true;
self_id.calcSharedSecret(c->shared_secret, pubkey);
}
return true;
diff --git a/src/helpers/ClientACL.h b/src/helpers/ClientACL.h
index 85a1b7e74..dc64e71bd 100644
--- a/src/helpers/ClientACL.h
+++ b/src/helpers/ClientACL.h
@@ -32,6 +32,7 @@ struct ClientInfo {
uint8_t out_path_len;
uint8_t out_path[MAX_PATH_SIZE];
bool out_path_is_persistable; // live route may replace, but not erase, durable route
+ bool permissions_are_explicit; // operator assignment; room login caches remain transient
uint8_t alt_path_len;
uint8_t alt_path[MAX_PATH_SIZE];
uint8_t observed_path_len; // reciprocal login PATH (transient)
@@ -55,6 +56,8 @@ struct ClientInfo {
uint32_t topic_seen_revision;
uint32_t pending_topic_revision;
uint8_t topic_failures;
+ uint16_t post_quota_used;
+ uint16_t poll_quota_used;
#endif
} room;
#if !MESH_CLIENT_REPEATER_ONLY
@@ -96,6 +99,7 @@ class ClientACL {
int num_clients;
bool login_replay_store_available;
bool acl_load_complete;
+ bool protect_explicit_permissions = false;
public:
// MAX_CLIENTS entries run to several kilobytes. Classic ESP32's link-time
@@ -117,6 +121,7 @@ public:
ClientACL(const ClientACL&) = delete;
ClientACL& operator=(const ClientACL&) = delete;
void load(FILESYSTEM* _fs, const mesh::LocalIdentity& self_id);
+ void protectExplicitPermissions(bool protect = true) { protect_explicit_permissions = protect; }
bool save(FILESYSTEM* _fs, bool (*filter)(ClientInfo*)=NULL);
bool clear();
diff --git a/src/helpers/RoomAccessPolicy.h b/src/helpers/RoomAccessPolicy.h
new file mode 100644
index 000000000..3dae58e6c
--- /dev/null
+++ b/src/helpers/RoomAccessPolicy.h
@@ -0,0 +1,226 @@
+#pragma once
+
+#include
+#include "RoomAccessPolicyStore.h"
+
+namespace mesh {
+
+inline bool roomAclShouldPersist(uint8_t permissions, bool explicitly_assigned,
+ uint8_t role_mask, uint8_t admin_role) {
+ return explicitly_assigned || (permissions & role_mask) == (admin_role & role_mask);
+}
+
+// The two counts live beside their actual ClientInfo identity, not in a second
+// table keyed by a short hash. The role owns resetting all counts when the
+// policy's one shared monotonic minute expires. Re-login must not reset them.
+class RoomAccessPolicy {
+ RoomAccessSettings settings_;
+ uint32_t quota_window_started_ = 0;
+ bool quota_window_active_ = false;
+ bool healthy_ = false;
+
+ int findBan(const uint8_t* key) const {
+ if (!key) return -1;
+ for (uint8_t i = 0; i < settings_.ban_count; ++i) {
+ if (memcmp(settings_.bans[i], key, ROOM_ACCESS_KEY_SIZE) == 0) return i;
+ }
+ return -1;
+ }
+ template
+ bool commit(Filesystem* fs, const RoomAccessSettings& next) {
+ if (!healthy_) return false;
+ const auto result = room_access_detail::save(fs, next);
+ if (result == room_access_detail::SaveResult::Unavailable) healthy_ = false;
+ if (result != room_access_detail::SaveResult::Saved) return false;
+ if (settings_.posts_per_minute != next.posts_per_minute
+ || settings_.polls_per_minute != next.polls_per_minute) quota_window_active_ = false;
+ settings_ = next;
+ return true;
+ }
+ bool consume(uint16_t& used, uint16_t limit) const {
+ if (!healthy_) return false;
+ if (limit == 0) return true;
+ if (used >= limit) return false;
+ ++used;
+ return true;
+ }
+ static bool prefix(const char* command, const char* expected, const char*& args) {
+ const size_t length = strlen(expected);
+ if (strncmp(command, expected, length) != 0
+ || (command[length] != 0 && command[length] != ' ')) return false;
+ args = command + length;
+ while (*args == ' ') ++args;
+ return true;
+ }
+ static bool parseUnsigned(const char* value, uint16_t& result) {
+ if (!value || *value < '0' || *value > '9') return false;
+ uint32_t number = 0;
+ do {
+ number = number * 10 + unsigned(*value++ - '0');
+ if (number > UINT16_MAX) return false;
+ } while (*value >= '0' && *value <= '9');
+ if (*value != 0) return false;
+ result = static_cast(number);
+ return true;
+ }
+
+public:
+ enum class BanResult : uint8_t { Saved, Unchanged, InvalidKey, Full, StorageFailure };
+
+ bool healthy() const { return healthy_; }
+ uint16_t postRate() const { return settings_.posts_per_minute; }
+ uint16_t pollRate() const { return settings_.polls_per_minute; }
+ uint8_t banCount() const { return settings_.ban_count; }
+ const uint8_t* banAt(size_t index) const {
+ return index < settings_.ban_count ? settings_.bans[index] : nullptr;
+ }
+ bool isBanned(const uint8_t* key) const { return findBan(key) >= 0; }
+ // Apply before password checks and to existing sessions too. Unknown policy
+ // storage cannot silently turn a banned identity into an admitted identity.
+ bool allowsIdentity(const uint8_t* key) const {
+ return healthy_ && room_access_detail::nonzeroKey(key) && !isBanned(key);
+ }
+ template
+ bool load(Filesystem* fs) {
+ RoomAccessSettings staged;
+ healthy_ = room_access_detail::recover(fs, staged, false);
+ if (healthy_) settings_ = staged;
+ quota_window_active_ = false;
+ return healthy_;
+ }
+ template
+ BanResult addBan(Filesystem* fs, const uint8_t* key) {
+ if (!room_access_detail::nonzeroKey(key)) return BanResult::InvalidKey;
+ if (!healthy_) return BanResult::StorageFailure;
+ if (isBanned(key)) return BanResult::Unchanged;
+ if (settings_.ban_count == ROOM_ACCESS_MAX_BANS) return BanResult::Full;
+ RoomAccessSettings next = settings_;
+ memcpy(next.bans[next.ban_count++], key, ROOM_ACCESS_KEY_SIZE);
+ return commit(fs, next) ? BanResult::Saved : BanResult::StorageFailure;
+ }
+ template
+ BanResult removeBan(Filesystem* fs, const uint8_t* key) {
+ if (!room_access_detail::nonzeroKey(key)) return BanResult::InvalidKey;
+ if (!healthy_) return BanResult::StorageFailure;
+ const int at = findBan(key);
+ if (at < 0) return BanResult::Unchanged;
+ RoomAccessSettings next = settings_;
+ --next.ban_count;
+ if (at < next.ban_count) {
+ memmove(next.bans[at], next.bans[at + 1], (next.ban_count - at) * ROOM_ACCESS_KEY_SIZE);
+ }
+ memset(next.bans[next.ban_count], 0, ROOM_ACCESS_KEY_SIZE);
+ return commit(fs, next) ? BanResult::Saved : BanResult::StorageFailure;
+ }
+ template
+ bool setRates(Filesystem* fs, uint16_t post_rate, uint16_t poll_rate) {
+ if (!healthy_) return false;
+ if (post_rate == postRate() && poll_rate == pollRate()) return true;
+ RoomAccessSettings next = settings_;
+ next.posts_per_minute = post_rate;
+ next.polls_per_minute = poll_rate;
+ return commit(fs, next);
+ }
+ template
+ void serviceQuotaWindow(uint32_t now, ResetCounts reset_counts) {
+ if (!quota_window_active_ || uint32_t(now - quota_window_started_) >= 60000UL) {
+ reset_counts();
+ quota_window_started_ = now;
+ quota_window_active_ = true;
+ }
+ }
+ bool consumePost(uint16_t& used) const { return consume(used, postRate()); }
+ bool consumeKeepAlive(uint16_t& used) const { return consume(used, pollRate()); }
+
+ static bool parseFullKey(const char* text, uint8_t (&key)[ROOM_ACCESS_KEY_SIZE]) {
+ if (!text || strlen(text) != ROOM_ACCESS_KEY_SIZE * 2) return false;
+ uint8_t parsed[ROOM_ACCESS_KEY_SIZE];
+ for (size_t i = 0; i < sizeof(parsed); ++i) {
+ uint8_t byte = 0;
+ for (unsigned nibble = 0; nibble < 2; ++nibble) {
+ const char digit = text[i * 2 + nibble];
+ unsigned value;
+ if (digit >= '0' && digit <= '9') value = digit - '0';
+ else if (digit >= 'a' && digit <= 'f') value = digit - 'a' + 10;
+ else if (digit >= 'A' && digit <= 'F') value = digit - 'A' + 10;
+ else return false;
+ byte = static_cast((byte << 4) | value);
+ }
+ parsed[i] = byte;
+ }
+ if (!room_access_detail::nonzeroKey(parsed)) return false;
+ memcpy(key, parsed, sizeof(parsed));
+ return true;
+ }
+ void formatBanPage(unsigned page, char* reply, size_t capacity) const {
+ if (!reply || capacity == 0) return;
+ if (!healthy_) { snprintf(reply, capacity, "Err - room access storage unavailable"); return; }
+ const unsigned pages = settings_.ban_count == 0 ? 1 : (settings_.ban_count + 1) / 2;
+ if (page == 0 || page > pages) {
+ snprintf(reply, capacity, "Err - ban page range: 1-%u", pages); return;
+ }
+ if (settings_.ban_count == 0) { snprintf(reply, capacity, "Room bans: empty"); return; }
+ const unsigned offset = (page - 1) * 2;
+ const unsigned rows = settings_.ban_count - offset < 2 ? settings_.ban_count - offset : 2;
+ const int header = snprintf(reply, capacity, "Room bans %u/%u", page, pages);
+ if (header < 0 || size_t(header) >= capacity
+ || rows > (capacity - size_t(header) - 1) / (ROOM_ACCESS_KEY_SIZE * 2 + 1)) {
+ snprintf(reply, capacity, "Err - ban reply too small"); return;
+ }
+ static const char hex_digits[] = "0123456789abcdef";
+ size_t used = static_cast(header);
+ for (unsigned row = 0; row < rows; ++row) {
+ reply[used++] = '\n';
+ const uint8_t* key = settings_.bans[offset + row];
+ for (size_t i = 0; i < ROOM_ACCESS_KEY_SIZE; ++i) {
+ reply[used++] = hex_digits[key[i] >> 4];
+ reply[used++] = hex_digits[key[i] & 15];
+ }
+ }
+ reply[used] = 0;
+ }
+
+ // The role must authorize Admin/local access before invoking this backend.
+ template
+ bool handleConfig(Filesystem* fs, const char* command, char* reply, size_t capacity) {
+ if (!command || !reply || capacity == 0) return false;
+ const char* args;
+ bool remove = false;
+ if (prefix(command, "room.ban", args) || (remove = prefix(command, "room.unban", args))) {
+ uint8_t key[ROOM_ACCESS_KEY_SIZE];
+ if (!parseFullKey(args, key)) {
+ snprintf(reply, capacity, "Err - use a complete 64-hex public key"); return true;
+ }
+ const BanResult result = remove ? removeBan(fs, key) : addBan(fs, key);
+ if (result == BanResult::Full) snprintf(reply, capacity, "Err - room ban list full (32)");
+ else if (result == BanResult::StorageFailure) snprintf(reply, capacity, "Err - room access save failed");
+ else snprintf(reply, capacity, remove ? "OK - room ban removed" : "OK - room ban saved");
+ return true;
+ }
+ if (prefix(command, "get room.bans", args)) {
+ uint16_t page = 1;
+ if (*args && !parseUnsigned(args, page)) snprintf(reply, capacity, "Err - usage: get room.bans [page]");
+ else formatBanPage(page, reply, capacity);
+ return true;
+ }
+ bool post = false;
+ if ((post = prefix(command, "get room.post.rate", args)) || prefix(command, "get room.poll.rate", args)) {
+ if (*args) return false;
+ if (!healthy_) snprintf(reply, capacity, "Err - room access storage unavailable");
+ else snprintf(reply, capacity, "> %u/min (0=off)", unsigned(post ? postRate() : pollRate()));
+ return true;
+ }
+ if ((post = prefix(command, "set room.post.rate", args)) || prefix(command, "set room.poll.rate", args)) {
+ uint16_t value;
+ if (!parseUnsigned(args, value)) snprintf(reply, capacity, "Err - rate must be 0-65535/min");
+ else if (!setRates(fs, post ? value : postRate(), post ? pollRate() : value))
+ snprintf(reply, capacity, "Err - room access save failed");
+ else snprintf(reply, capacity, "OK - room rate saved");
+ return true;
+ }
+ return false;
+ }
+};
+static_assert(sizeof(RoomAccessPolicy) <= 1040,
+ "Room access policy must not allocate a duplicate identity table");
+} // namespace mesh
diff --git a/src/helpers/RoomAccessPolicyStore.h b/src/helpers/RoomAccessPolicyStore.h
new file mode 100644
index 000000000..ecf046a15
--- /dev/null
+++ b/src/helpers/RoomAccessPolicyStore.h
@@ -0,0 +1,204 @@
+#pragma once
+
+#include
+#include
+#include
+#include "FilePresence.h"
+
+namespace mesh {
+
+static constexpr size_t ROOM_ACCESS_KEY_SIZE = 32;
+static constexpr size_t ROOM_ACCESS_MAX_BANS = 32;
+static const char ROOM_ACCESS_PRIMARY_PATH[] = "/room_access";
+static const char ROOM_ACCESS_TEMP_PATH[] = "/room_access.tmp";
+static const char ROOM_ACCESS_BACKUP_PATH[] = "/room_access.bak";
+
+struct RoomAccessSettings {
+ uint16_t posts_per_minute = 0;
+ uint16_t polls_per_minute = 0;
+ uint8_t ban_count = 0;
+ uint8_t bans[ROOM_ACCESS_MAX_BANS][ROOM_ACCESS_KEY_SIZE] = {};
+};
+static_assert(sizeof(RoomAccessSettings) <= 1032,
+ "Room access settings must remain bounded");
+
+namespace room_access_detail {
+static const uint8_t MAGIC[4] = {'R', 'A', 'P', 1};
+static constexpr size_t HEADER_SIZE = 12;
+static constexpr size_t TRAILER_SIZE = 4;
+
+inline uint32_t crc(uint32_t value, const uint8_t* data, size_t length) {
+ while (length-- != 0) {
+ value ^= *data++;
+ for (uint8_t bit = 0; bit < 8; ++bit) {
+ value = (value >> 1) ^ ((value & 1) ? UINT32_C(0xedb88320) : 0);
+ }
+ }
+ return value;
+}
+
+inline bool nonzeroKey(const uint8_t* key) {
+ if (!key) return false;
+ uint8_t any = 0;
+ for (size_t i = 0; i < ROOM_ACCESS_KEY_SIZE; ++i) any |= key[i];
+ return any != 0;
+}
+
+inline bool validSettings(const RoomAccessSettings& settings) {
+ if (settings.ban_count > ROOM_ACCESS_MAX_BANS) return false;
+ for (size_t i = 0; i < settings.ban_count; ++i) {
+ if (!nonzeroKey(settings.bans[i])) return false;
+ for (size_t prior = 0; prior < i; ++prior) {
+ if (memcmp(settings.bans[prior], settings.bans[i], ROOM_ACCESS_KEY_SIZE) == 0) return false;
+ }
+ }
+ return true;
+}
+
+template
+bool removeArtifact(Filesystem* fs, const char* path) {
+ bool present;
+ if (!filePresence(fs, path, present)) return false;
+ if (!present) return true;
+ fs->remove(path);
+ return filePresence(fs, path, present) && !present;
+}
+
+// Decode into a staging object. Callers publish it only after full validation;
+// a failed read can never replace the active policy with a partial ban list.
+template
+bool readState(Filesystem* fs, const char* path, RoomAccessSettings& staged,
+ const RoomAccessSettings* expected = nullptr) {
+#if defined(RP2040_PLATFORM)
+ auto file = fs->open(path, "r");
+#else
+ auto file = fs->open(path);
+#endif
+ if (!file) return false;
+ uint8_t header[HEADER_SIZE];
+ bool valid = !file.isDirectory()
+ && static_cast(file.read(header, sizeof(header))) == sizeof(header);
+ if (valid) {
+ const size_t count = header[8];
+ valid = memcmp(header, MAGIC, sizeof(MAGIC)) == 0
+ && header[9] == 0 && header[10] == 0 && header[11] == 0
+ && count <= ROOM_ACCESS_MAX_BANS
+ && file.size() == HEADER_SIZE + count * ROOM_ACCESS_KEY_SIZE + TRAILER_SIZE;
+ if (valid) {
+ staged.posts_per_minute = uint16_t(header[4]) | (uint16_t(header[5]) << 8);
+ staged.polls_per_minute = uint16_t(header[6]) | (uint16_t(header[7]) << 8);
+ staged.ban_count = static_cast(count);
+ memset(staged.bans, 0, sizeof(staged.bans));
+ }
+ }
+ uint32_t checksum = valid ? crc(UINT32_C(0xffffffff), header, sizeof(header)) : 0;
+ for (size_t i = 0; valid && i < staged.ban_count; ++i) {
+ uint8_t* key = staged.bans[i];
+ valid = static_cast(file.read(key, ROOM_ACCESS_KEY_SIZE)) == ROOM_ACCESS_KEY_SIZE
+ && nonzeroKey(key);
+ if (valid) {
+ checksum = crc(checksum, key, ROOM_ACCESS_KEY_SIZE);
+ for (size_t prior = 0; prior < i; ++prior) {
+ if (memcmp(staged.bans[prior], key, ROOM_ACCESS_KEY_SIZE) == 0) valid = false;
+ }
+ }
+ }
+ uint8_t trailer[TRAILER_SIZE] = {};
+ if (valid) {
+ valid = static_cast(file.read(trailer, sizeof(trailer))) == sizeof(trailer);
+ const uint32_t stored = uint32_t(trailer[0]) | (uint32_t(trailer[1]) << 8)
+ | (uint32_t(trailer[2]) << 16) | (uint32_t(trailer[3]) << 24);
+ valid = valid && (checksum ^ UINT32_C(0xffffffff)) == stored;
+ }
+ file.close();
+ if (valid && expected) {
+ valid = staged.posts_per_minute == expected->posts_per_minute
+ && staged.polls_per_minute == expected->polls_per_minute
+ && staged.ban_count == expected->ban_count
+ && memcmp(staged.bans, expected->bans,
+ staged.ban_count * ROOM_ACCESS_KEY_SIZE) == 0;
+ }
+ return valid;
+}
+
+// A present primary is authoritative even when corrupt/newer/unreadable.
+// Restore only a verified backup when the primary is genuinely absent. A temp
+// file is uncommitted and can never be promoted after reboot.
+template
+bool recover(Filesystem* fs, RoomAccessSettings& staged, bool require_cleanup) {
+ bool present;
+ if (!fs || !filePresence(fs, ROOM_ACCESS_PRIMARY_PATH, present)) return false;
+ if (present) {
+ if (!readState(fs, ROOM_ACCESS_PRIMARY_PATH, staged)) return false;
+ const bool removed_temp = removeArtifact(fs, ROOM_ACCESS_TEMP_PATH);
+ const bool removed_backup = removeArtifact(fs, ROOM_ACCESS_BACKUP_PATH);
+ return !require_cleanup || (removed_temp && removed_backup);
+ }
+ if (!filePresence(fs, ROOM_ACCESS_BACKUP_PATH, present)) return false;
+ if (present) {
+ if (!readState(fs, ROOM_ACCESS_BACKUP_PATH, staged)
+ || !fs->rename(ROOM_ACCESS_BACKUP_PATH, ROOM_ACCESS_PRIMARY_PATH)) return false;
+ } else {
+ staged = RoomAccessSettings{};
+ }
+ const bool removed_temp = removeArtifact(fs, ROOM_ACCESS_TEMP_PATH);
+ return !require_cleanup || removed_temp;
+}
+
+enum class SaveResult : uint8_t { Saved, Unavailable, WriteFailure };
+
+template
+SaveResult save(Filesystem* fs, const RoomAccessSettings& next) {
+ if (!validSettings(next)) return SaveResult::WriteFailure;
+ RoomAccessSettings checked;
+ if (!recover(fs, checked, true)) return SaveResult::Unavailable;
+ uint8_t header[HEADER_SIZE] = {};
+ memcpy(header, MAGIC, sizeof(MAGIC));
+ header[4] = static_cast(next.posts_per_minute);
+ header[5] = static_cast(next.posts_per_minute >> 8);
+ header[6] = static_cast(next.polls_per_minute);
+ header[7] = static_cast(next.polls_per_minute >> 8);
+ header[8] = next.ban_count;
+#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
+ auto file = fs->open(ROOM_ACCESS_TEMP_PATH, FILE_O_WRITE);
+#elif defined(ESP32_PLATFORM)
+ auto file = fs->open(ROOM_ACCESS_TEMP_PATH, "w", true);
+#else
+ auto file = fs->open(ROOM_ACCESS_TEMP_PATH, "w");
+#endif
+ if (!file) return SaveResult::WriteFailure;
+ bool written = !file.isDirectory()
+ && file.write(header, sizeof(header)) == sizeof(header);
+ uint32_t checksum = crc(UINT32_C(0xffffffff), header, sizeof(header));
+ for (size_t i = 0; written && i < next.ban_count; ++i) {
+ written = file.write(next.bans[i], ROOM_ACCESS_KEY_SIZE) == ROOM_ACCESS_KEY_SIZE;
+ checksum = crc(checksum, next.bans[i], ROOM_ACCESS_KEY_SIZE);
+ }
+ checksum ^= UINT32_C(0xffffffff);
+ uint8_t trailer[TRAILER_SIZE];
+ for (size_t i = 0; i < sizeof(trailer); ++i) trailer[i] = static_cast(checksum >> (8 * i));
+ written = written && file.write(trailer, sizeof(trailer)) == sizeof(trailer);
+ file.flush();
+ file.close();
+ if (!written || !readState(fs, ROOM_ACCESS_TEMP_PATH, checked, &next)) {
+ removeArtifact(fs, ROOM_ACCESS_TEMP_PATH);
+ return SaveResult::WriteFailure;
+ }
+ bool had_primary;
+ if (!filePresence(fs, ROOM_ACCESS_PRIMARY_PATH, had_primary)
+ || (had_primary && !fs->rename(ROOM_ACCESS_PRIMARY_PATH, ROOM_ACCESS_BACKUP_PATH))) {
+ removeArtifact(fs, ROOM_ACCESS_TEMP_PATH);
+ return SaveResult::WriteFailure;
+ }
+ if (!fs->rename(ROOM_ACCESS_TEMP_PATH, ROOM_ACCESS_PRIMARY_PATH)) {
+ if (had_primary) fs->rename(ROOM_ACCESS_BACKUP_PATH, ROOM_ACCESS_PRIMARY_PATH);
+ removeArtifact(fs, ROOM_ACCESS_TEMP_PATH);
+ return SaveResult::WriteFailure;
+ }
+ // Commit occurred at rename. Failed backup housekeeping cannot reject a
+ // policy that is already durable, nor publish an older in-memory policy.
+ removeArtifact(fs, ROOM_ACCESS_BACKUP_PATH);
+ return SaveResult::Saved;
+}
+} // namespace room_access_detail
+} // namespace mesh
diff --git a/src/helpers/RoomBoardProtocol.h b/src/helpers/RoomBoardProtocol.h
new file mode 100644
index 000000000..0387b9d9d
--- /dev/null
+++ b/src/helpers/RoomBoardProtocol.h
@@ -0,0 +1,250 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include "RoomBoardStore.h"
+
+namespace mesh {
+
+static constexpr uint8_t ROOM_BOARD_REQUEST_SUBTYPE = 0x0a;
+static constexpr size_t ROOM_BOARD_MAX_COMMAND_LENGTH = 512;
+
+namespace room_board_protocol_detail {
+inline uint8_t status(RoomBoardResult result) {
+ switch (result) {
+ case RoomBoardResult::Success: return 0;
+ case RoomBoardResult::Invalid: return 1;
+ case RoomBoardResult::Unavailable: return 2;
+ case RoomBoardResult::WriteFailure: return 3;
+ case RoomBoardResult::NotFound: return 4;
+ case RoomBoardResult::StaleVersion: return 5;
+ }
+ return 1;
+}
+inline size_t shortError(uint8_t op, RoomBoardResult result, uint8_t* reply, size_t capacity) {
+ if (!reply || capacity < 3) return 0;
+ reply[0] = ROOM_BOARD_REQUEST_SUBTYPE; reply[1] = op; reply[2] = status(result); return 3;
+}
+inline void textResult(char* reply, size_t capacity, RoomBoardResult result, uint32_t revision = 0) {
+ if (!reply || capacity == 0) return;
+ const char* message;
+ switch (result) {
+ case RoomBoardResult::Success: snprintf(reply, capacity, "OK rev=%lu", (unsigned long)revision); return;
+ case RoomBoardResult::Unavailable: message = "Error unavailable"; break;
+ case RoomBoardResult::WriteFailure: message = "Error write failure"; break;
+ case RoomBoardResult::NotFound: message = "Error not found"; break;
+ case RoomBoardResult::StaleVersion: message = "Error stale version"; break;
+ default: message = "Error invalid"; break;
+ }
+ snprintf(reply, capacity, "%s", message);
+}
+inline bool prefix(const char* command, const char* expected) {
+ const size_t length = strlen(expected);
+ return strncmp(command, expected, length) == 0 && (command[length] == 0 || command[length] == ' ');
+}
+inline const char* spaces(const char* cursor) { while (*cursor == ' ') ++cursor; return cursor; }
+inline bool number(const char*& cursor, uint32_t maximum, uint32_t& value) {
+ cursor = spaces(cursor); value = 0;
+ if (*cursor < '0' || *cursor > '9') return false;
+ do {
+ const uint8_t digit = uint8_t(*cursor++ - '0');
+ if (value > maximum / 10 || (value == maximum / 10 && digit > maximum % 10)) return false;
+ value = value * 10 + digit;
+ } while (*cursor >= '0' && *cursor <= '9');
+ return *cursor == 0 || *cursor == ' ';
+}
+inline size_t base64(const uint8_t* data, size_t length, char* output) {
+ static const char alphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+ size_t position = 0;
+ for (size_t i = 0; i < length; i += 3) {
+ const uint32_t bits = (uint32_t(data[i]) << 16)
+ | (i + 1 < length ? uint32_t(data[i + 1]) << 8 : 0)
+ | (i + 2 < length ? uint32_t(data[i + 2]) : 0);
+ output[position++] = alphabet[(bits >> 18) & 63];
+ output[position++] = alphabet[(bits >> 12) & 63];
+ output[position++] = i + 1 < length ? alphabet[(bits >> 6) & 63] : '=';
+ output[position++] = i + 2 < length ? alphabet[bits & 63] : '=';
+ }
+ output[position] = 0; return position;
+}
+} // namespace room_board_protocol_detail
+
+// Read-only requests, callable for any authorized room reader, including
+// guests. The caller reserves its transport tag outside this reply capacity.
+// All integer fields are little-endian. No partial index entry is emitted.
+template
+size_t handleRoomBoardRequest(Filesystem* fs, const uint8_t* payload, size_t length,
+ uint8_t* reply, size_t capacity) {
+ using namespace room_board_protocol_detail;
+ const uint8_t op = payload && length > 1 ? payload[1] : 0xff;
+ if (!payload || length < 2 || payload[0] != ROOM_BOARD_REQUEST_SUBTYPE
+ || !reply || capacity < 3 || op > 1) return shortError(op, RoomBoardResult::Invalid, reply, capacity);
+ if (op == 0) {
+ if (length < 7 || capacity < 10) return shortError(op, RoomBoardResult::Invalid, reply, capacity);
+ const uint32_t expected = room_board_detail::get32(payload + 2);
+ const uint8_t start = payload[6];
+ RoomBoardIndex index;
+ RoomBoardResult result = RoomBoardResult::Success;
+ if (!loadRoomBoard(fs, index)) result = RoomBoardResult::Unavailable;
+ else if ((!expected && start) || (expected && expected != index.revision)) result = RoomBoardResult::StaleVersion;
+ else if (start > index.count) result = RoomBoardResult::Invalid;
+ reply[0] = ROOM_BOARD_REQUEST_SUBTYPE; reply[1] = op;
+ room_board_detail::put32(reply + 3, index.revision);
+ reply[7] = index.count; reply[8] = start; reply[9] = start;
+ size_t used = 10;
+ if (result == RoomBoardResult::Success) {
+ uint8_t position = start;
+ while (position < index.count && position < size_t(start) + 2) {
+ const RoomBoardArticle& entry = index.articles[position];
+ const size_t title_length = strlen(entry.title);
+ const size_t entry_length = 8 + title_length;
+ if (entry_length > capacity - used) {
+ if (position == start) result = RoomBoardResult::Invalid;
+ break;
+ }
+ uint8_t* encoded = reply + used;
+ encoded[0] = entry.id; room_board_detail::put32(encoded + 1, entry.version);
+ room_board_detail::put16(encoded + 5, entry.body_length); encoded[7] = uint8_t(title_length);
+ memcpy(encoded + 8, entry.title, title_length); used += entry_length; ++position;
+ }
+ reply[9] = position;
+ }
+ reply[2] = status(result); return used;
+ }
+ if (length < 9 || capacity < 13) return shortError(op, RoomBoardResult::Invalid, reply, capacity);
+ const uint8_t id = payload[2];
+ const uint32_t version = room_board_detail::get32(payload + 3);
+ const uint16_t offset = room_board_detail::get16(payload + 7);
+ reply[0] = ROOM_BOARD_REQUEST_SUBTYPE; reply[1] = op; reply[3] = id;
+ room_board_detail::put32(reply + 4, version); room_board_detail::put16(reply + 8, offset);
+ room_board_detail::put16(reply + 10, 0); reply[12] = 0;
+ RoomBoardIndex index;
+ RoomBoardResult result = RoomBoardResult::Success;
+ const RoomBoardArticle* article = nullptr;
+ if (id == 0 || id > ROOM_BOARD_MAX_ARTICLES || version == 0) result = RoomBoardResult::Invalid;
+ else if (!loadRoomBoard(fs, index)) result = RoomBoardResult::Unavailable;
+ else {
+ for (uint8_t i = 0; i < index.count; ++i) if (index.articles[i].id == id) article = &index.articles[i];
+ if (!article) result = RoomBoardResult::NotFound;
+ else {
+ room_board_detail::put32(reply + 4, article->version);
+ room_board_detail::put16(reply + 10, article->body_length);
+ if (article->version != version) result = RoomBoardResult::StaleVersion;
+ else if (offset > article->body_length || (capacity == 13 && offset < article->body_length)) result = RoomBoardResult::Invalid;
+ }
+ }
+ size_t copied = 0;
+ if (result == RoomBoardResult::Success && offset < article->body_length) {
+ const size_t available = capacity - 13;
+ const size_t chunk = available < ROOM_BOARD_READ_MAX_CHUNK ? available : ROOM_BOARD_READ_MAX_CHUNK;
+ result = readRoomBoardArticle(fs, id, version, offset, reply + 13, chunk, copied);
+ }
+ reply[2] = status(result); reply[12] = uint8_t(copied); return 13 + copied;
+}
+
+// Caller owns administrative authorization for put/del (and may grant get
+// to ordinary room readers). The command body is borrowed, never buffered.
+// `get room.board [cursor]` returns `next=` for the next whole-entry page.
+template
+bool handleRoomBoardCommand(Filesystem* fs, const char* command, char* reply, size_t capacity) {
+ using namespace room_board_protocol_detail;
+ if (!command) return false;
+ const bool put = prefix(command, "room.board.put");
+ const bool del = prefix(command, "room.board.del");
+ const bool read = prefix(command, "get room.board.read");
+ const bool list = prefix(command, "get room.board");
+ if (!put && !del && !read && !list) return false;
+ if (!reply || capacity == 0) return true;
+ reply[0] = 0;
+ size_t length = 0;
+ while (length <= ROOM_BOARD_MAX_COMMAND_LENGTH && command[length]) ++length;
+ if (length > ROOM_BOARD_MAX_COMMAND_LENGTH) { textResult(reply, capacity, RoomBoardResult::Invalid); return true; }
+ if (put) {
+ const char* cursor = command + strlen("room.board.put"); uint32_t id;
+ if (!number(cursor, ROOM_BOARD_MAX_ARTICLES, id) || id == 0) { textResult(reply, capacity, RoomBoardResult::Invalid); return true; }
+ cursor = spaces(cursor); const char* separator = strchr(cursor, '|');
+ size_t title_length = separator ? size_t(separator - cursor) : 0;
+ while (title_length && cursor[title_length - 1] == ' ') --title_length;
+ if (!separator || title_length == 0 || title_length >= ROOM_BOARD_TITLE_CAPACITY) { textResult(reply, capacity, RoomBoardResult::Invalid); return true; }
+ char title[ROOM_BOARD_TITLE_CAPACITY] = {}; memcpy(title, cursor, title_length);
+ const char* body = separator + 1; const size_t body_length = strlen(body);
+ const RoomBoardResult result = saveRoomBoardArticle(fs, uint8_t(id), title, body_length,
+ [body](size_t offset, uint8_t* output, size_t count) -> size_t {
+ memcpy(output, body + offset, count); return count;
+ });
+ RoomBoardIndex index;
+ if (result == RoomBoardResult::Success) loadRoomBoard(fs, index);
+ textResult(reply, capacity, result, index.revision); return true;
+ }
+ if (del) {
+ const char* cursor = command + strlen("room.board.del"); uint32_t id, version;
+ if (!number(cursor, ROOM_BOARD_MAX_ARTICLES, id) || id == 0 || !number(cursor, UINT32_MAX, version)
+ || version == 0 || *spaces(cursor)) { textResult(reply, capacity, RoomBoardResult::Invalid); return true; }
+ const RoomBoardResult result = deleteRoomBoardArticle(fs, uint8_t(id), version);
+ RoomBoardIndex index;
+ if (result == RoomBoardResult::Success) loadRoomBoard(fs, index);
+ textResult(reply, capacity, result, index.revision); return true;
+ }
+ RoomBoardIndex index;
+ if (!loadRoomBoard(fs, index)) { textResult(reply, capacity, RoomBoardResult::Unavailable); return true; }
+ if (read) {
+ const char* cursor = command + strlen("get room.board.read"); uint32_t id, version, offset;
+ if (!number(cursor, ROOM_BOARD_MAX_ARTICLES, id) || id == 0 || !number(cursor, UINT32_MAX, version)
+ || version == 0 || !number(cursor, ROOM_BOARD_MAX_BODY_LENGTH, offset) || *spaces(cursor)) {
+ textResult(reply, capacity, RoomBoardResult::Invalid); return true;
+ }
+ const RoomBoardArticle* article = nullptr;
+ for (uint8_t i = 0; i < index.count; ++i) if (index.articles[i].id == id) article = &index.articles[i];
+ if (!article) { textResult(reply, capacity, RoomBoardResult::NotFound); return true; }
+ if (article->version != version) { textResult(reply, capacity, RoomBoardResult::StaleVersion); return true; }
+ if (offset > article->body_length) { textResult(reply, capacity, RoomBoardResult::Invalid); return true; }
+ const size_t remaining = article->body_length - offset;
+ size_t count = remaining < 80 ? remaining : 80;
+ int prefix_length = -1;
+ while (true) {
+ prefix_length = snprintf(reply, capacity, "id=%lu v=%lu off=%lu total=%u n=%u data=",
+ (unsigned long)id, (unsigned long)version, (unsigned long)offset,
+ unsigned(article->body_length), unsigned(count));
+ if (prefix_length >= 0 && size_t(prefix_length) + ((count + 2) / 3) * 4 < capacity) break;
+ if (count == 0) { textResult(reply, capacity, RoomBoardResult::Invalid); return true; }
+ --count;
+ }
+ if (count == 0 && remaining) { textResult(reply, capacity, RoomBoardResult::Invalid); return true; }
+ uint8_t data[80]; size_t copied = 0;
+ if (count) {
+ const RoomBoardResult result = readRoomBoardArticle(fs, uint8_t(id), version, offset, data, count, copied);
+ if (result != RoomBoardResult::Success) { textResult(reply, capacity, result); return true; }
+ }
+ base64(data, copied, reply + prefix_length); return true;
+ }
+ const char* cursor = spaces(command + strlen("get room.board")); uint32_t start = 0;
+ if ((*cursor && !number(cursor, ROOM_BOARD_MAX_ARTICLES, start)) || *spaces(cursor) || start > index.count) {
+ textResult(reply, capacity, RoomBoardResult::Invalid); return true;
+ }
+ // Format entries first, then the fixed prefix with the final next cursor.
+ char entries[2 * (ROOM_BOARD_TITLE_CAPACITY + 35)] = {};
+ size_t used = 0; uint32_t next = start;
+ while (next < index.count && next < start + 2) {
+ const RoomBoardArticle& article = index.articles[next];
+ const int written = snprintf(entries + used, sizeof(entries) - used, " %u@%lu/%u:%s",
+ unsigned(article.id), (unsigned long)article.version, unsigned(article.body_length), article.title);
+ char header[64];
+ const int header_length = snprintf(header, sizeof(header), "rev=%lu total=%u start=%lu next=%lu",
+ (unsigned long)index.revision, unsigned(index.count), (unsigned long)start, (unsigned long)(next + 1));
+ if (written < 0 || header_length < 0 || size_t(written) >= sizeof(entries) - used
+ || size_t(header_length) + used + size_t(written) >= capacity) {
+ entries[used] = 0;
+ if (next == start) { textResult(reply, capacity, RoomBoardResult::Invalid); return true; }
+ break;
+ }
+ used += size_t(written); ++next;
+ }
+ const int written = snprintf(reply, capacity, "rev=%lu total=%u start=%lu next=%lu%s",
+ (unsigned long)index.revision, unsigned(index.count), (unsigned long)start, (unsigned long)next, entries);
+ if (written < 0 || size_t(written) >= capacity) textResult(reply, capacity, RoomBoardResult::Invalid);
+ return true;
+}
+
+} // namespace mesh
diff --git a/src/helpers/RoomBoardStore.h b/src/helpers/RoomBoardStore.h
new file mode 100644
index 000000000..b5fc02706
--- /dev/null
+++ b/src/helpers/RoomBoardStore.h
@@ -0,0 +1,403 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include "FilePresence.h"
+
+namespace mesh {
+
+static constexpr uint8_t ROOM_BOARD_MAX_ARTICLES = 8;
+static constexpr size_t ROOM_BOARD_TITLE_CAPACITY = 64;
+static constexpr size_t ROOM_BOARD_MAX_BODY_LENGTH = 2048;
+static constexpr size_t ROOM_BOARD_READ_MAX_CHUNK = 128;
+static const char ROOM_BOARD_PRIMARY_PATH[] = "/room_board";
+static const char ROOM_BOARD_TEMP_PATH[] = "/room_board.tmp";
+static const char ROOM_BOARD_BACKUP_PATH[] = "/room_board.bak";
+
+struct RoomBoardArticle {
+ uint8_t id = 0; // Stable slots, 1 through 8.
+ uint8_t bank = 0; // Internal immutable-body file selection.
+ uint16_t body_length = 0;
+ uint32_t version = 0;
+ char title[ROOM_BOARD_TITLE_CAPACITY] = {};
+};
+
+struct RoomBoardIndex {
+ uint32_t revision = 0;
+ uint8_t count = 0;
+ RoomBoardArticle articles[ROOM_BOARD_MAX_ARTICLES]; // Sorted by article ID.
+};
+static_assert(sizeof(RoomBoardIndex) <= 640, "Board metadata must remain bounded");
+
+enum class RoomBoardResult : uint8_t {
+ Success, Invalid, Unavailable, WriteFailure, NotFound, StaleVersion
+};
+
+namespace room_board_detail {
+static constexpr size_t HEADER_SIZE = 16;
+static constexpr size_t ENTRY_SIZE = 72;
+static constexpr size_t STREAM_CHUNK = 64;
+static const uint8_t INDEX_MAGIC[4] = {'R', 'B', 'I', 1};
+static const uint8_t ARTICLE_MAGIC[4] = {'R', 'B', 'A', 1};
+
+inline uint32_t crc(uint32_t value, const uint8_t* data, size_t length) {
+ while (length-- != 0) {
+ value ^= *data++;
+ for (uint8_t bit = 0; bit < 8; ++bit) {
+ value = (value >> 1) ^ ((value & 1) ? UINT32_C(0xedb88320) : 0);
+ }
+ }
+ return value;
+}
+inline uint32_t get32(const uint8_t* bytes) {
+ return uint32_t(bytes[0]) | (uint32_t(bytes[1]) << 8)
+ | (uint32_t(bytes[2]) << 16) | (uint32_t(bytes[3]) << 24);
+}
+inline void put32(uint8_t* bytes, uint32_t value) {
+ for (uint8_t i = 0; i < 4; ++i) bytes[i] = uint8_t(value >> (8 * i));
+}
+inline uint16_t get16(const uint8_t* bytes) {
+ return uint16_t(bytes[0]) | (uint16_t(bytes[1]) << 8);
+}
+inline void put16(uint8_t* bytes, uint16_t value) {
+ bytes[0] = uint8_t(value); bytes[1] = uint8_t(value >> 8);
+}
+
+// Titles are complete UTF-8 strings; reject overlong encodings, surrogate
+// code points, embedded controls, and values beyond Unicode's maximum.
+inline bool validTitle(const char* title) {
+ if (!title) return false;
+ size_t length = 0;
+ while (length < ROOM_BOARD_TITLE_CAPACITY && title[length] != 0) ++length;
+ if (length == 0 || length == ROOM_BOARD_TITLE_CAPACITY) return false;
+ for (size_t i = 0; i < length;) {
+ const uint8_t first = uint8_t(title[i++]);
+ if (first < 0x80) {
+ if (first < 0x20 || first == 0x7f) return false;
+ continue;
+ }
+ uint8_t count;
+ uint32_t point, minimum;
+ if (first >= 0xc2 && first <= 0xdf) { count = 1; point = first & 0x1f; minimum = 0x80; }
+ else if (first >= 0xe0 && first <= 0xef) { count = 2; point = first & 0x0f; minimum = 0x800; }
+ else if (first >= 0xf0 && first <= 0xf4) { count = 3; point = first & 0x07; minimum = 0x10000; }
+ else return false;
+ if (i + count > length) return false;
+ while (count-- != 0) {
+ const uint8_t next = uint8_t(title[i++]);
+ if ((next & 0xc0) != 0x80) return false;
+ point = (point << 6) | (next & 0x3f);
+ }
+ if (point < minimum || point > 0x10ffff || (point >= 0xd800 && point <= 0xdfff)) return false;
+ }
+ return true;
+}
+
+inline void articlePath(const RoomBoardArticle& entry, char (&path)[24]) {
+ snprintf(path, sizeof(path), "/room_board_%u.%c", unsigned(entry.id), entry.bank ? 'b' : 'a');
+}
+inline void encodeEntry(const RoomBoardArticle& entry, uint8_t (&bytes)[ENTRY_SIZE]) {
+ memset(bytes, 0, sizeof(bytes));
+ bytes[0] = entry.id; bytes[1] = entry.bank;
+ put16(bytes + 2, entry.body_length); put32(bytes + 4, entry.version);
+ memcpy(bytes + 8, entry.title, sizeof(entry.title));
+}
+inline void articleHeader(const RoomBoardArticle& entry, uint8_t (&bytes)[HEADER_SIZE]) {
+ memset(bytes, 0, sizeof(bytes));
+ memcpy(bytes, ARTICLE_MAGIC, sizeof(ARTICLE_MAGIC));
+ bytes[4] = entry.id; bytes[5] = entry.bank;
+ put16(bytes + 6, entry.body_length); put32(bytes + 8, entry.version);
+}
+
+template
+auto openRead(Filesystem* fs, const char* path)
+#if defined(RP2040_PLATFORM)
+ -> decltype(fs->open(path, static_cast(nullptr))) { return fs->open(path, "r"); }
+#else
+ -> decltype(fs->open(path)) { return fs->open(path); }
+#endif
+
+template
+auto openWrite(Filesystem* fs, const char* path)
+#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
+ -> decltype(fs->open(path, FILE_O_WRITE)) { return fs->open(path, FILE_O_WRITE); }
+#elif defined(ESP32_PLATFORM)
+ -> decltype(fs->open(path, static_cast(nullptr), true)) { return fs->open(path, "w", true); }
+#else
+ -> decltype(fs->open(path, static_cast(nullptr))) { return fs->open(path, "w"); }
+#endif
+
+template
+bool removeArtifact(Filesystem* fs, const char* path) {
+ bool present;
+ if (!filePresence(fs, path, present)) return false;
+ if (!present) return true;
+ fs->remove(path);
+ return filePresence(fs, path, present) && !present;
+}
+
+// Validate the whole selected body before any caller may send the copied
+// slice. The only internal body buffer is 64 bytes, including readback.
+template
+bool readBody(Filesystem* fs, const RoomBoardArticle& entry,
+ uint8_t* output = nullptr, size_t offset = 0, size_t capacity = 0,
+ const uint32_t* expected_checksum = nullptr) {
+ char path[24]; articlePath(entry, path);
+ auto file = openRead(fs, path);
+ if (!file) return false;
+ uint8_t header[HEADER_SIZE], expected[HEADER_SIZE];
+ articleHeader(entry, expected);
+ bool valid = !file.isDirectory()
+ && file.size() == HEADER_SIZE + entry.body_length + 4
+ && static_cast(file.read(header, sizeof(header))) == sizeof(header)
+ && memcmp(header, expected, sizeof(header)) == 0;
+ uint32_t checksum = valid ? crc(UINT32_C(0xffffffff), header, sizeof(header)) : 0;
+ uint8_t chunk[STREAM_CHUNK];
+ for (size_t position = 0; valid && position < entry.body_length;) {
+ const size_t remaining = entry.body_length - position;
+ const size_t length = remaining < sizeof(chunk) ? remaining : sizeof(chunk);
+ valid = static_cast(file.read(chunk, length)) == length;
+ if (!valid) break;
+ checksum = crc(checksum, chunk, length);
+ if (output && position + length > offset && position < offset + capacity) {
+ const size_t start = position < offset ? offset - position : 0;
+ size_t end = length;
+ if (position + end > offset + capacity) end = offset + capacity - position;
+ memcpy(output + position + start - offset, chunk + start, end - start);
+ }
+ position += length;
+ }
+ uint8_t trailer[4];
+ if (valid) {
+ checksum ^= UINT32_C(0xffffffff);
+ valid = static_cast(file.read(trailer, sizeof(trailer))) == sizeof(trailer)
+ && get32(trailer) == checksum
+ && (!expected_checksum || *expected_checksum == checksum);
+ }
+ file.close();
+ if (!valid && output) memset(output, 0, capacity);
+ return valid;
+}
+
+inline bool sameIndex(const RoomBoardIndex& first, const RoomBoardIndex& second) {
+ if (first.revision != second.revision || first.count != second.count) return false;
+ for (uint8_t i = 0; i < first.count; ++i) {
+ uint8_t a[ENTRY_SIZE], b[ENTRY_SIZE];
+ encodeEntry(first.articles[i], a); encodeEntry(second.articles[i], b);
+ if (memcmp(a, b, sizeof(a)) != 0) return false;
+ }
+ return true;
+}
+
+template
+bool readIndex(Filesystem* fs, const char* path, RoomBoardIndex& staged,
+ const RoomBoardIndex* expected = nullptr) {
+ auto file = openRead(fs, path);
+ if (!file) return false;
+ uint8_t header[HEADER_SIZE];
+ bool valid = !file.isDirectory()
+ && static_cast(file.read(header, sizeof(header))) == sizeof(header);
+ if (valid) {
+ valid = memcmp(header, INDEX_MAGIC, sizeof(INDEX_MAGIC)) == 0
+ && get32(header + 4) != 0 && header[8] <= ROOM_BOARD_MAX_ARTICLES
+ && file.size() == HEADER_SIZE + header[8] * ENTRY_SIZE + 4;
+ for (size_t i = 9; i < sizeof(header); ++i) valid = valid && header[i] == 0;
+ }
+ uint32_t checksum = valid ? crc(UINT32_C(0xffffffff), header, sizeof(header)) : 0;
+ staged = RoomBoardIndex{};
+ if (valid) { staged.revision = get32(header + 4); staged.count = header[8]; }
+ for (uint8_t i = 0; valid && i < staged.count; ++i) {
+ uint8_t bytes[ENTRY_SIZE];
+ valid = static_cast(file.read(bytes, sizeof(bytes))) == sizeof(bytes);
+ if (!valid) break;
+ checksum = crc(checksum, bytes, sizeof(bytes));
+ RoomBoardArticle& entry = staged.articles[i];
+ entry.id = bytes[0]; entry.bank = bytes[1]; entry.body_length = get16(bytes + 2);
+ entry.version = get32(bytes + 4); memcpy(entry.title, bytes + 8, sizeof(entry.title));
+ valid = entry.id >= 1 && entry.id <= ROOM_BOARD_MAX_ARTICLES && entry.bank <= 1
+ && entry.body_length <= ROOM_BOARD_MAX_BODY_LENGTH && entry.version != 0
+ && entry.version <= staged.revision && validTitle(entry.title)
+ && (i == 0 || staged.articles[i - 1].id < entry.id);
+ bool terminated = false;
+ for (size_t j = 0; j < sizeof(entry.title); ++j) {
+ if (terminated && entry.title[j] != 0) valid = false;
+ if (entry.title[j] == 0) terminated = true;
+ }
+ }
+ uint8_t trailer[4];
+ if (valid) valid = static_cast(file.read(trailer, sizeof(trailer))) == sizeof(trailer)
+ && get32(trailer) == (checksum ^ UINT32_C(0xffffffff));
+ file.close();
+ if (valid && expected) valid = sameIndex(staged, *expected);
+ for (uint8_t i = 0; valid && i < staged.count; ++i) valid = readBody(fs, staged.articles[i]);
+ return valid;
+}
+
+// A present primary, including corrupt/future/unreadable metadata or bodies,
+// is authoritative. Never replace it with an older backup or delete evidence.
+// A temp index is uncommitted; only a verified backup may restore absence.
+template
+bool recover(Filesystem* fs, RoomBoardIndex& staged, bool require_cleanup) {
+ bool present;
+ if (!fs || !filePresence(fs, ROOM_BOARD_PRIMARY_PATH, present)) return false;
+ if (present) {
+ if (!readIndex(fs, ROOM_BOARD_PRIMARY_PATH, staged)) return false;
+ const bool temp_removed = removeArtifact(fs, ROOM_BOARD_TEMP_PATH);
+ const bool backup_removed = removeArtifact(fs, ROOM_BOARD_BACKUP_PATH);
+ return !require_cleanup || (temp_removed && backup_removed);
+ }
+ if (!filePresence(fs, ROOM_BOARD_BACKUP_PATH, present)) return false;
+ if (present) {
+ if (!readIndex(fs, ROOM_BOARD_BACKUP_PATH, staged)
+ || !fs->rename(ROOM_BOARD_BACKUP_PATH, ROOM_BOARD_PRIMARY_PATH)) return false;
+ } else staged = RoomBoardIndex{};
+ const bool temp_removed = removeArtifact(fs, ROOM_BOARD_TEMP_PATH);
+ return !require_cleanup || temp_removed;
+}
+
+template
+RoomBoardResult commitIndex(Filesystem* fs, const RoomBoardIndex& next) {
+ uint8_t header[HEADER_SIZE] = {};
+ memcpy(header, INDEX_MAGIC, sizeof(INDEX_MAGIC));
+ put32(header + 4, next.revision); header[8] = next.count;
+ auto file = openWrite(fs, ROOM_BOARD_TEMP_PATH);
+ if (!file) return RoomBoardResult::WriteFailure;
+ bool written = !file.isDirectory() && file.write(header, sizeof(header)) == sizeof(header);
+ uint32_t checksum = crc(UINT32_C(0xffffffff), header, sizeof(header));
+ for (uint8_t i = 0; written && i < next.count; ++i) {
+ uint8_t bytes[ENTRY_SIZE]; encodeEntry(next.articles[i], bytes);
+ written = file.write(bytes, sizeof(bytes)) == sizeof(bytes);
+ checksum = crc(checksum, bytes, sizeof(bytes));
+ }
+ uint8_t trailer[4]; put32(trailer, checksum ^ UINT32_C(0xffffffff));
+ written = written && file.write(trailer, sizeof(trailer)) == sizeof(trailer);
+ file.flush(); file.close();
+ RoomBoardIndex checked;
+ if (!written || !readIndex(fs, ROOM_BOARD_TEMP_PATH, checked, &next)) {
+ removeArtifact(fs, ROOM_BOARD_TEMP_PATH); return RoomBoardResult::WriteFailure;
+ }
+ bool had_primary;
+ if (!filePresence(fs, ROOM_BOARD_PRIMARY_PATH, had_primary)
+ || (had_primary && !fs->rename(ROOM_BOARD_PRIMARY_PATH, ROOM_BOARD_BACKUP_PATH))) {
+ removeArtifact(fs, ROOM_BOARD_TEMP_PATH); return RoomBoardResult::WriteFailure;
+ }
+ if (!fs->rename(ROOM_BOARD_TEMP_PATH, ROOM_BOARD_PRIMARY_PATH)) {
+ if (had_primary) fs->rename(ROOM_BOARD_BACKUP_PATH, ROOM_BOARD_PRIMARY_PATH);
+ removeArtifact(fs, ROOM_BOARD_TEMP_PATH); return RoomBoardResult::WriteFailure;
+ }
+ // The rename is the commit point. Failed housekeeping cannot undo success.
+ removeArtifact(fs, ROOM_BOARD_BACKUP_PATH);
+ return RoomBoardResult::Success;
+}
+} // namespace room_board_detail
+
+template
+bool loadRoomBoard(Filesystem* fs, RoomBoardIndex& index) {
+ RoomBoardIndex staged;
+ index = RoomBoardIndex{};
+ if (!room_board_detail::recover(fs, staged, false)) return false;
+ index = staged; return true;
+}
+
+// Main-loop single writer. The reader is called as reader(offset, buffer,
+// requested_length), returning exactly requested_length, in <=64-byte pieces.
+// expected_version == 0 permits an administrative create/overwrite; nonzero
+// versions perform compare-and-swap and reject an article changed since load.
+template
+RoomBoardResult saveRoomBoardArticle(Filesystem* fs, uint8_t id, const char* title,
+ size_t body_length, Reader reader,
+ uint32_t expected_version = 0) {
+ using namespace room_board_detail;
+ if (id == 0 || id > ROOM_BOARD_MAX_ARTICLES || !validTitle(title)
+ || body_length > ROOM_BOARD_MAX_BODY_LENGTH) return RoomBoardResult::Invalid;
+ RoomBoardIndex next;
+ if (!recover(fs, next, true)) return RoomBoardResult::Unavailable;
+ uint8_t position = 0;
+ while (position < next.count && next.articles[position].id < id) ++position;
+ const bool existing = position < next.count && next.articles[position].id == id;
+ if (expected_version && (!existing || next.articles[position].version != expected_version)) {
+ return RoomBoardResult::StaleVersion;
+ }
+ if (next.revision == UINT32_MAX) return RoomBoardResult::Unavailable;
+ RoomBoardArticle entry;
+ entry.id = id; entry.bank = existing ? 1 - next.articles[position].bank : 0;
+ entry.version = ++next.revision; entry.body_length = uint16_t(body_length);
+ memcpy(entry.title, title, strlen(title));
+ char path[24]; articlePath(entry, path);
+ if (!removeArtifact(fs, path)) return RoomBoardResult::WriteFailure;
+ auto file = openWrite(fs, path);
+ if (!file) return RoomBoardResult::WriteFailure;
+ uint8_t header[HEADER_SIZE]; articleHeader(entry, header);
+ bool written = !file.isDirectory() && file.write(header, sizeof(header)) == sizeof(header);
+ uint32_t checksum = crc(UINT32_C(0xffffffff), header, sizeof(header));
+ uint8_t chunk[STREAM_CHUNK];
+ for (size_t offset = 0; written && offset < body_length;) {
+ const size_t remaining = body_length - offset;
+ const size_t length = remaining < sizeof(chunk) ? remaining : sizeof(chunk);
+ written = reader(offset, chunk, length) == length
+ && file.write(chunk, length) == length;
+ if (written) checksum = crc(checksum, chunk, length);
+ offset += length;
+ }
+ checksum ^= UINT32_C(0xffffffff);
+ uint8_t trailer[4]; put32(trailer, checksum);
+ written = written && file.write(trailer, sizeof(trailer)) == sizeof(trailer);
+ file.flush(); file.close();
+ if (!written || !readBody(fs, entry, nullptr, 0, 0, &checksum)) {
+ removeArtifact(fs, path); return RoomBoardResult::WriteFailure;
+ }
+ if (!existing) {
+ for (uint8_t i = next.count; i > position; --i) next.articles[i] = next.articles[i - 1];
+ ++next.count;
+ }
+ next.articles[position] = entry;
+ return commitIndex(fs, next);
+}
+
+template
+RoomBoardResult deleteRoomBoardArticle(Filesystem* fs, uint8_t id,
+ uint32_t expected_version = 0) {
+ using namespace room_board_detail;
+ if (id == 0 || id > ROOM_BOARD_MAX_ARTICLES) return RoomBoardResult::Invalid;
+ RoomBoardIndex next;
+ if (!recover(fs, next, true)) return RoomBoardResult::Unavailable;
+ uint8_t position = 0;
+ while (position < next.count && next.articles[position].id != id) ++position;
+ if (position == next.count) return RoomBoardResult::NotFound;
+ if (expected_version && next.articles[position].version != expected_version) return RoomBoardResult::StaleVersion;
+ if (next.revision == UINT32_MAX) return RoomBoardResult::Unavailable;
+ ++next.revision;
+ for (uint8_t i = position; i + 1 < next.count; ++i) next.articles[i] = next.articles[i + 1];
+ next.articles[--next.count] = RoomBoardArticle{};
+ // Bodies are bounded to two banks per ID. Retaining unreferenced banks
+ // avoids deleting a body still needed by recovery after a power cut.
+ return commitIndex(fs, next);
+}
+
+// Every page must identify the exact published article version. The caller
+// supplies a <=128-byte capacity; offset==body_length returns successful EOF.
+template
+RoomBoardResult readRoomBoardArticle(Filesystem* fs, uint8_t id,
+ uint32_t expected_version, size_t offset,
+ uint8_t* output, size_t capacity, size_t& bytes_read) {
+ using namespace room_board_detail;
+ bytes_read = 0;
+ if (id == 0 || id > ROOM_BOARD_MAX_ARTICLES || expected_version == 0
+ || !output || capacity == 0 || capacity > ROOM_BOARD_READ_MAX_CHUNK) return RoomBoardResult::Invalid;
+ RoomBoardIndex index;
+ if (!recover(fs, index, false)) return RoomBoardResult::Unavailable;
+ uint8_t position = 0;
+ while (position < index.count && index.articles[position].id != id) ++position;
+ if (position == index.count) return RoomBoardResult::NotFound;
+ const RoomBoardArticle& entry = index.articles[position];
+ if (entry.version != expected_version) return RoomBoardResult::StaleVersion;
+ if (offset > entry.body_length) return RoomBoardResult::Invalid;
+ if (!readBody(fs, entry, output, offset, capacity)) return RoomBoardResult::Unavailable;
+ const size_t remaining = entry.body_length - offset;
+ bytes_read = remaining < capacity ? remaining : capacity;
+ return RoomBoardResult::Success;
+}
+
+} // namespace mesh
diff --git a/src/helpers/RoomHistoryStore.h b/src/helpers/RoomHistoryStore.h
new file mode 100644
index 000000000..5f8fe7997
--- /dev/null
+++ b/src/helpers/RoomHistoryStore.h
@@ -0,0 +1,443 @@
+#pragma once
+
+#include
+#include
+#include
+#include "FilePresence.h"
+
+namespace mesh {
+
+static constexpr uint8_t ROOM_HISTORY_MAX_RECORDS = 32;
+static constexpr uint8_t ROOM_HISTORY_JOURNAL_RECORDS = 64;
+static constexpr size_t ROOM_HISTORY_TEXT_CAPACITY = 152;
+static constexpr size_t ROOM_HISTORY_RECORD_SIZE = 192;
+static constexpr size_t ROOM_HISTORY_HEADER_SIZE = 12;
+static constexpr size_t ROOM_HISTORY_MAX_FILE_SIZE = ROOM_HISTORY_HEADER_SIZE
+ + ROOM_HISTORY_JOURNAL_RECORDS * ROOM_HISTORY_RECORD_SIZE;
+static const char ROOM_HISTORY_PATH[] = "/room_history";
+static const char ROOM_HISTORY_TEMP_PATH[] = "/room_history.tmp";
+static const char ROOM_HISTORY_BACKUP_PATH[] = "/room_history.bak";
+static const char ROOM_HISTORY_CONFIG_PATH[] = "/room_hist_cfg";
+static const char ROOM_HISTORY_CONFIG_TEMP_PATH[] = "/room_hist_cfg.tmp";
+static const char ROOM_HISTORY_CONFIG_BACKUP_PATH[] = "/room_hist_cfg.bak";
+
+struct RoomHistoryRecord {
+ uint8_t author[32];
+ uint32_t timestamp;
+ char text[ROOM_HISTORY_TEXT_CAPACITY];
+};
+
+struct RoomHistoryState {
+ uint8_t count = 0;
+ uint8_t total_records = 0;
+ bool needs_repair = false;
+ bool present = false;
+};
+
+namespace room_history_detail {
+
+inline uint32_t crcBytes(const uint8_t* data, size_t length) {
+ uint32_t crc = UINT32_C(0xffffffff);
+ for (size_t index = 0; index < length; ++index) {
+ crc ^= data[index];
+ for (uint8_t bit = 0; bit < 8; ++bit) {
+ crc = (crc >> 1) ^ ((crc & 1) ? UINT32_C(0xedb88320) : 0);
+ }
+ }
+ return crc ^ UINT32_C(0xffffffff);
+}
+
+inline void put32(uint8_t* data, uint32_t value) {
+ for (uint8_t index = 0; index < 4; ++index) data[index] = value >> (8 * index);
+}
+
+inline uint32_t get32(const uint8_t* data) {
+ uint32_t value = 0;
+ for (uint8_t index = 0; index < 4; ++index) value |= uint32_t(data[index]) << (8 * index);
+ return value;
+}
+
+template
+bool presence(Filesystem* fs, const char* path, bool& present) {
+ return filePresence(fs, path, present);
+}
+
+template
+bool removeArtifact(Filesystem* fs, const char* path) {
+ bool present;
+ if (!presence(fs, path, present)) return false;
+ if (!present) return true;
+ fs->remove(path);
+ return presence(fs, path, present) && !present;
+}
+
+template
+auto openRead(Filesystem* fs, const char* path)
+#if defined(RP2040_PLATFORM)
+ -> decltype(fs->open(path, static_cast(nullptr))) { return fs->open(path, "r"); }
+#else
+ -> decltype(fs->open(path)) { return fs->open(path); }
+#endif
+
+template
+auto openWrite(Filesystem* fs, const char* path, bool append)
+#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
+ -> decltype(fs->open(path, FILE_O_WRITE)) {
+ (void)append;
+ return fs->open(path, FILE_O_WRITE); // verified temp absence makes this a fresh file
+}
+#elif defined(ESP32_PLATFORM)
+ -> decltype(fs->open(path, static_cast(nullptr), true)) { return fs->open(path, append ? "a" : "w", true); }
+#else
+ -> decltype(fs->open(path, static_cast(nullptr))) { return fs->open(path, append ? "a" : "w"); }
+#endif
+
+inline void makeHeader(uint8_t (&image)[ROOM_HISTORY_HEADER_SIZE]) {
+ const uint8_t prefix[] = { 'R', 'H', 'J', 1, 192, 0,
+ ROOM_HISTORY_MAX_RECORDS, ROOM_HISTORY_JOURNAL_RECORDS };
+ memcpy(image, prefix, sizeof(prefix));
+ put32(image + 8, crcBytes(image, 8));
+}
+
+template
+bool readHeader(File& file) {
+ uint8_t actual[ROOM_HISTORY_HEADER_SIZE], expected[ROOM_HISTORY_HEADER_SIZE];
+ makeHeader(expected);
+ return static_cast(file.read(actual, sizeof(actual))) == sizeof(actual)
+ && memcmp(actual, expected, sizeof(actual)) == 0;
+}
+
+inline bool encodeRecord(const RoomHistoryRecord& record,
+ uint8_t (&image)[ROOM_HISTORY_RECORD_SIZE]) {
+ size_t length = 0;
+ while (length < ROOM_HISTORY_TEXT_CAPACITY && record.text[length] != 0) ++length;
+ bool author_nonzero = false;
+ for (uint8_t byte : record.author) author_nonzero |= byte != 0;
+ if (length == ROOM_HISTORY_TEXT_CAPACITY || record.timestamp == 0 || !author_nonzero) return false;
+ memset(image, 0, sizeof(image));
+ memcpy(image, record.author, sizeof(record.author));
+ put32(image + 32, record.timestamp);
+ memcpy(image + 36, record.text, length);
+ put32(image + 188, crcBytes(image, 188));
+ return true;
+}
+
+enum class RecordRead { Valid, Corrupt, Unreadable };
+
+// An incomplete physical tail is detected from file size by scanJournal. A
+// short read inside a physically present record is an I/O failure, not proof
+// that the record is corrupt and may safely be discarded.
+template
+RecordRead readRecord(File& file, RoomHistoryRecord* record = nullptr,
+ const uint8_t* expected = nullptr) {
+ uint8_t image[ROOM_HISTORY_RECORD_SIZE];
+ if (static_cast(file.read(image, sizeof(image))) != sizeof(image)) return RecordRead::Unreadable;
+ bool author_nonzero = false;
+ for (size_t index = 0; index < 32; ++index) author_nonzero |= image[index] != 0;
+ bool terminated = false;
+ for (size_t index = 36; index < 188; ++index) {
+ if (terminated && image[index] != 0) return RecordRead::Corrupt;
+ if (image[index] == 0) terminated = true;
+ }
+ if (!author_nonzero || get32(image + 32) == 0 || !terminated
+ || get32(image + 188) != crcBytes(image, 188)) return RecordRead::Corrupt;
+ if (expected && memcmp(image, expected, sizeof(image)) != 0) return RecordRead::Corrupt;
+ if (record) {
+ memcpy(record->author, image, sizeof(record->author));
+ record->timestamp = get32(image + 32);
+ memcpy(record->text, image + 36, sizeof(record->text));
+ }
+ return RecordRead::Valid;
+}
+
+template
+bool scanJournal(Filesystem* fs, const char* path, RoomHistoryState& result) {
+ auto file = openRead(fs, path);
+ if (!file) return false;
+ const size_t size = file.size();
+ bool valid = !file.isDirectory() && size >= ROOM_HISTORY_HEADER_SIZE
+ && size <= ROOM_HISTORY_MAX_FILE_SIZE && readHeader(file);
+ RoomHistoryState staged;
+ staged.present = true;
+ const size_t physical_records = size >= ROOM_HISTORY_HEADER_SIZE
+ ? (size - ROOM_HISTORY_HEADER_SIZE) / ROOM_HISTORY_RECORD_SIZE : 0;
+ for (size_t index = 0; valid && index < physical_records; ++index) {
+ const RecordRead read = readRecord(file);
+ if (read == RecordRead::Unreadable) valid = false;
+ else if (read == RecordRead::Corrupt) {
+ // A failed append can leave only the final physical record corrupt.
+ // Later complete records prove this is older storage corruption: keep
+ // the entire primary and do not publish a prefix for automatic repair.
+ valid = index + 1 == physical_records;
+ break;
+ }
+ else ++staged.total_records;
+ }
+ staged.count = staged.total_records > ROOM_HISTORY_MAX_RECORDS
+ ? ROOM_HISTORY_MAX_RECORDS : staged.total_records;
+ staged.needs_repair = size != ROOM_HISTORY_HEADER_SIZE
+ + staged.total_records * ROOM_HISTORY_RECORD_SIZE;
+ file.close();
+ if (valid) result = staged;
+ return valid;
+}
+
+template
+bool readConfig(Filesystem* fs, const char* path, bool& enabled) {
+ auto file = openRead(fs, path);
+ if (!file) return false;
+ uint8_t image[16];
+ bool valid = !file.isDirectory() && file.size() == sizeof(image)
+ && static_cast(file.read(image, sizeof(image))) == sizeof(image);
+ file.close();
+ const uint8_t magic[] = { 'R', 'H', 'C', 1 };
+ valid = valid && memcmp(image, magic, sizeof(magic)) == 0 && image[4] <= 1
+ && get32(image + 12) == crcBytes(image, 12);
+ for (size_t index = 5; valid && index < 12; ++index) valid = image[index] == 0;
+ if (valid) enabled = image[4] != 0;
+ return valid;
+}
+
+// Primary authority is preserved even when corrupt or from a future format.
+// Only an absent primary permits restoring backup; temp is never promoted.
+template
+bool recover(Filesystem* fs, const char* primary, const char* temp,
+ const char* backup, Validator validate, bool strict, bool& found) {
+ if (!presence(fs, primary, found)) return false;
+ if (found) {
+ if (!validate(primary)) return false;
+ const bool cleaned_temp = removeArtifact(fs, temp);
+ const bool cleaned_backup = removeArtifact(fs, backup);
+ return !strict || (cleaned_temp && cleaned_backup);
+ }
+ bool had_backup;
+ if (!presence(fs, backup, had_backup)) return false;
+ if (had_backup) {
+ if (!validate(backup) || !fs->rename(backup, primary)) return false;
+ found = true;
+ }
+ const bool cleaned_temp = removeArtifact(fs, temp);
+ return !strict || cleaned_temp;
+}
+
+template
+bool publish(Filesystem* fs, const char* primary, const char* temp, const char* backup) {
+ bool had_primary;
+ if (!presence(fs, primary, had_primary)
+ || (had_primary && !fs->rename(primary, backup))) {
+ removeArtifact(fs, temp);
+ return false;
+ }
+ if (!fs->rename(temp, primary)) {
+ if (had_primary) fs->rename(backup, primary);
+ removeArtifact(fs, temp);
+ return false;
+ }
+ removeArtifact(fs, backup); // post-commit cleanup cannot turn success into failure
+ return true;
+}
+
+struct EmptyProvider {
+ bool operator()(uint8_t, RoomHistoryRecord&) const { return false; }
+};
+
+} // namespace room_history_detail
+
+template
+bool loadRoomHistoryConfig(Filesystem* fs, bool& enabled) {
+ if (!fs) return false;
+ bool found, staged = false;
+ const auto validate = [&](const char* path) { return room_history_detail::readConfig(fs, path, staged); };
+ if (!room_history_detail::recover(fs, ROOM_HISTORY_CONFIG_PATH,
+ ROOM_HISTORY_CONFIG_TEMP_PATH, ROOM_HISTORY_CONFIG_BACKUP_PATH,
+ validate, false, found)) return false;
+ enabled = staged;
+ return true;
+}
+
+template
+bool saveRoomHistoryConfig(Filesystem* fs, bool enabled) {
+ if (!fs) return false;
+ bool found, previous = false;
+ const auto validate = [&](const char* path) { return room_history_detail::readConfig(fs, path, previous); };
+ if (!room_history_detail::recover(fs, ROOM_HISTORY_CONFIG_PATH,
+ ROOM_HISTORY_CONFIG_TEMP_PATH, ROOM_HISTORY_CONFIG_BACKUP_PATH,
+ validate, true, found)) return false;
+ uint8_t image[16] = { 'R', 'H', 'C', 1, uint8_t(enabled ? 1 : 0) };
+ room_history_detail::put32(image + 12, room_history_detail::crcBytes(image, 12));
+ auto file = room_history_detail::openWrite(fs, ROOM_HISTORY_CONFIG_TEMP_PATH, false);
+ if (!file) return false;
+ if (file.isDirectory()) {
+ file.close();
+ room_history_detail::removeArtifact(fs, ROOM_HISTORY_CONFIG_TEMP_PATH);
+ return false;
+ }
+ const bool written = file.write(image, sizeof(image)) == sizeof(image);
+ file.flush(); file.close();
+ bool readback = !enabled;
+ if (!written || !room_history_detail::readConfig(fs, ROOM_HISTORY_CONFIG_TEMP_PATH, readback)
+ || readback != enabled) {
+ room_history_detail::removeArtifact(fs, ROOM_HISTORY_CONFIG_TEMP_PATH);
+ return false;
+ }
+ return room_history_detail::publish(fs, ROOM_HISTORY_CONFIG_PATH,
+ ROOM_HISTORY_CONFIG_TEMP_PATH, ROOM_HISTORY_CONFIG_BACKUP_PATH);
+}
+
+// Validate the complete readable prefix before calling the consumer. The
+// consumer receives only the newest 32 records, oldest first. A later read or
+// consumer failure leaves state unchanged; boot callers must clear any partial
+// destination ring. No live ring reload is needed for ordinary appends.
+template
+bool loadRoomHistory(Filesystem* fs, RoomHistoryState& state, Consumer consume) {
+ if (!fs) return false;
+ RoomHistoryState staged;
+ bool found;
+ const auto validate = [&](const char* path) { return room_history_detail::scanJournal(fs, path, staged); };
+ if (!room_history_detail::recover(fs, ROOM_HISTORY_PATH, ROOM_HISTORY_TEMP_PATH,
+ ROOM_HISTORY_BACKUP_PATH, validate, false, found)) return false;
+ if (found && staged.count != 0) {
+ auto file = room_history_detail::openRead(fs, ROOM_HISTORY_PATH);
+ if (!file) return false;
+ bool valid = !file.isDirectory() && room_history_detail::readHeader(file)
+ && file.seek(ROOM_HISTORY_HEADER_SIZE
+ + (staged.total_records - staged.count) * ROOM_HISTORY_RECORD_SIZE);
+ RoomHistoryRecord record;
+ for (uint8_t index = 0; valid && index < staged.count; ++index) {
+ valid = room_history_detail::readRecord(file, &record) == room_history_detail::RecordRead::Valid
+ && consume(index, record);
+ }
+ file.close();
+ if (!valid) return false;
+ }
+ state = staged;
+ return true;
+}
+
+// Provider(index, record) supplies an immutable, oldest-first RAM snapshot.
+// One record at a time is rendered and read back; there is no second 6 KB ring.
+// Used to enable history, compact 64 journal entries, repair a failed tail, or
+// clear with count zero. State changes only after atomic publication succeeds.
+template
+bool saveRoomHistorySnapshot(Filesystem* fs, RoomHistoryState& state,
+ uint8_t count, Provider provide) {
+ if (!fs || count > ROOM_HISTORY_MAX_RECORDS) return false;
+ RoomHistoryState old;
+ bool found;
+ const auto validate = [&](const char* path) { return room_history_detail::scanJournal(fs, path, old); };
+ if (!room_history_detail::recover(fs, ROOM_HISTORY_PATH, ROOM_HISTORY_TEMP_PATH,
+ ROOM_HISTORY_BACKUP_PATH, validate, true, found)) return false;
+ auto file = room_history_detail::openWrite(fs, ROOM_HISTORY_TEMP_PATH, false);
+ if (!file) return false;
+ if (file.isDirectory()) {
+ file.close();
+ room_history_detail::removeArtifact(fs, ROOM_HISTORY_TEMP_PATH);
+ return false;
+ }
+ uint8_t header[ROOM_HISTORY_HEADER_SIZE]; room_history_detail::makeHeader(header);
+ bool valid = file.write(header, sizeof(header)) == sizeof(header);
+ RoomHistoryRecord record;
+ uint8_t image[ROOM_HISTORY_RECORD_SIZE];
+ for (uint8_t index = 0; valid && index < count; ++index) {
+ valid = provide(index, record) && room_history_detail::encodeRecord(record, image)
+ && file.write(image, sizeof(image)) == sizeof(image);
+ }
+ file.flush(); file.close();
+ if (valid) {
+ auto verify = room_history_detail::openRead(fs, ROOM_HISTORY_TEMP_PATH);
+ if (!verify) valid = false;
+ else {
+ valid = !verify.isDirectory()
+ && verify.size() == ROOM_HISTORY_HEADER_SIZE + count * ROOM_HISTORY_RECORD_SIZE
+ && room_history_detail::readHeader(verify);
+ for (uint8_t index = 0; valid && index < count; ++index) {
+ valid = provide(index, record) && room_history_detail::encodeRecord(record, image)
+ && room_history_detail::readRecord(verify, nullptr, image) == room_history_detail::RecordRead::Valid;
+ }
+ verify.close();
+ }
+ }
+ if (!valid) {
+ room_history_detail::removeArtifact(fs, ROOM_HISTORY_TEMP_PATH);
+ return false;
+ }
+ if (!room_history_detail::publish(fs, ROOM_HISTORY_PATH, ROOM_HISTORY_TEMP_PATH,
+ ROOM_HISTORY_BACKUP_PATH)) return false;
+ state.count = state.total_records = count;
+ state.needs_repair = false;
+ state.present = true;
+ return true;
+}
+
+template
+bool clearRoomHistory(Filesystem* fs, RoomHistoryState& state) {
+ return saveRoomHistorySnapshot(fs, state, 0, room_history_detail::EmptyProvider{});
+}
+
+// Single-writer main-loop use. A failed append can leave an uncommitted tail;
+// mark it for snapshot repair from the unchanged trusted live ring before a
+// retry. Never ACK or update the message replay cache when this returns false.
+template
+bool appendRoomHistory(Filesystem* fs, RoomHistoryState& state,
+ const uint8_t* author, uint32_t timestamp, const char* text) {
+ if (!fs || !author || !text || state.needs_repair
+ || state.total_records >= ROOM_HISTORY_JOURNAL_RECORDS) return false;
+ RoomHistoryRecord record;
+ memcpy(record.author, author, sizeof(record.author));
+ record.timestamp = timestamp;
+ size_t length = 0;
+ while (length < ROOM_HISTORY_TEXT_CAPACITY && text[length] != 0) ++length;
+ if (length == ROOM_HISTORY_TEXT_CAPACITY) return false;
+ memset(record.text, 0, sizeof(record.text)); memcpy(record.text, text, length);
+ uint8_t image[ROOM_HISTORY_RECORD_SIZE];
+ if (!room_history_detail::encodeRecord(record, image)) return false;
+ RoomHistoryState disk;
+ bool found;
+ const auto validate = [&](const char* path) { return room_history_detail::scanJournal(fs, path, disk); };
+ if (!room_history_detail::recover(fs, ROOM_HISTORY_PATH, ROOM_HISTORY_TEMP_PATH,
+ ROOM_HISTORY_BACKUP_PATH, validate, true, found)) {
+ state.needs_repair = true;
+ return false;
+ }
+ if (!state.present) {
+ // A caller must load an existing journal before appending. In particular,
+ // never turn a missed/failed load into an implicit clear of accepted posts.
+ if (found || state.count != 0 || state.total_records != 0) {
+ state.needs_repair = true;
+ return false;
+ }
+ if (!clearRoomHistory(fs, state)) return false;
+ disk = state;
+ found = true;
+ }
+ if (!found || disk.needs_repair || disk.total_records != state.total_records
+ || disk.count != state.count) {
+ state.needs_repair = true;
+ return false;
+ }
+ const size_t expected_size = ROOM_HISTORY_HEADER_SIZE
+ + state.total_records * ROOM_HISTORY_RECORD_SIZE;
+ auto file = room_history_detail::openWrite(fs, ROOM_HISTORY_PATH, true);
+ if (!file) { state.needs_repair = true; return false; }
+ if (file.isDirectory()) { file.close(); state.needs_repair = true; return false; }
+ const bool written = file.size() == expected_size
+ && file.write(image, sizeof(image)) == sizeof(image);
+ file.flush(); file.close();
+ bool verified = false;
+ if (written) {
+ auto verify = room_history_detail::openRead(fs, ROOM_HISTORY_PATH);
+ if (verify) {
+ verified = !verify.isDirectory()
+ && verify.size() == expected_size + ROOM_HISTORY_RECORD_SIZE
+ && room_history_detail::readHeader(verify) && verify.seek(expected_size)
+ && room_history_detail::readRecord(verify, nullptr, image) == room_history_detail::RecordRead::Valid;
+ verify.close();
+ }
+ }
+ if (!verified) { state.needs_repair = true; return false; }
+ ++state.total_records;
+ state.count = state.total_records > ROOM_HISTORY_MAX_RECORDS ? ROOM_HISTORY_MAX_RECORDS : state.total_records;
+ return true;
+}
+
+} // namespace mesh
diff --git a/src/helpers/RoomTopicStore.h b/src/helpers/RoomTopicStore.h
index 56b4dcac9..a41802f34 100644
--- a/src/helpers/RoomTopicStore.h
+++ b/src/helpers/RoomTopicStore.h
@@ -3,9 +3,7 @@
#include
#include
#include
-#if defined(ESP32_PLATFORM)
#include "FilePresence.h"
-#endif
namespace mesh {
@@ -30,12 +28,7 @@ inline uint32_t crcByte(uint32_t crc, uint8_t value) {
template
bool presence(Filesystem* fs, const char* path, bool& present) {
-#if defined(ESP32_PLATFORM)
return filePresence(fs, path, present);
-#else
- present = fs->exists(path);
- return true;
-#endif
}
template
diff --git a/src/helpers/RoomWebJson.h b/src/helpers/RoomWebJson.h
new file mode 100644
index 000000000..6e86efa66
--- /dev/null
+++ b/src/helpers/RoomWebJson.h
@@ -0,0 +1,112 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace mesh {
+
+// JsonDocument v7 is dynamically allocated. Bound actual allocations rather
+// than relying on the deprecated StaticJsonDocument capacity shim.
+class RoomJsonAllocator : public ArduinoJson::Allocator {
+ union Header { max_align_t alignment; size_t bytes; };
+ size_t used_ = 0;
+public:
+ static constexpr size_t BUDGET = 8192;
+ void* allocate(size_t bytes) override {
+ if (bytes > BUDGET - used_ || sizeof(Header) > BUDGET - used_ - bytes) return nullptr;
+ auto* header = static_cast(malloc(sizeof(Header) + bytes));
+ if (!header) return nullptr;
+ header->bytes = bytes; used_ += sizeof(Header) + bytes; return header + 1;
+ }
+ void deallocate(void* ptr) override {
+ if (!ptr) return;
+ auto* header = static_cast(ptr) - 1;
+ used_ -= sizeof(Header) + header->bytes; free(header);
+ }
+ void* reallocate(void* ptr, size_t bytes) override {
+ if (!ptr) return allocate(bytes);
+ auto* header = static_cast(ptr) - 1;
+ const size_t remaining = used_ - sizeof(Header) - header->bytes;
+ if (bytes > BUDGET - remaining || sizeof(Header) > BUDGET - remaining - bytes) return nullptr;
+ header = static_cast(realloc(header, sizeof(Header) + bytes));
+ if (!header) return nullptr;
+ header->bytes = bytes; used_ = remaining + sizeof(Header) + bytes; return header + 1;
+ }
+};
+
+// Replies use their fixed mailbox buffer, with no second JSON heap pool.
+class RoomJsonWriter {
+ char* output_;
+ size_t capacity_, used_ = 0;
+ bool valid_ = true;
+public:
+ RoomJsonWriter(char* output, size_t capacity) : output_(output), capacity_(capacity) {
+ if (!output || !capacity) valid_ = false;
+ else output[0] = 0;
+ }
+ void raw(const char* text) {
+ const size_t length = strlen(text);
+ if (!valid_ || length >= capacity_ - used_) { valid_ = false; return; }
+ memcpy(output_ + used_, text, length + 1); used_ += length;
+ }
+ void number(uint32_t value) { char text[12]; snprintf(text, sizeof(text), "%lu", (unsigned long)value); raw(text); }
+ void string(const char* text) {
+ raw("\"");
+ for (const auto* p = reinterpret_cast(text); valid_ && *p; ++p) {
+ char escaped[7] = {};
+ if (*p == '"' || *p == '\\') { escaped[0] = '\\'; escaped[1] = *p; }
+ else if (*p < 0x20) snprintf(escaped, sizeof(escaped), "\\u%04x", unsigned(*p));
+ else escaped[0] = *p;
+ raw(escaped);
+ }
+ raw("\"");
+ }
+ bool finish() {
+ if (!valid_ && output_ && capacity_ >= 29) strcpy(output_, "{\"error\":\"reply too large\"}");
+ return valid_;
+ }
+};
+
+inline void roomEncodeBase64(const uint8_t* bytes, size_t length, char* output) {
+ static const char alphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+ size_t used = 0;
+ for (size_t i = 0; i < length; i += 3) {
+ const uint32_t value = (uint32_t(bytes[i]) << 16)
+ | (i + 1 < length ? uint32_t(bytes[i + 1]) << 8 : 0)
+ | (i + 2 < length ? bytes[i + 2] : 0);
+ output[used++] = alphabet[(value >> 18) & 63]; output[used++] = alphabet[(value >> 12) & 63];
+ output[used++] = i + 1 < length ? alphabet[(value >> 6) & 63] : '=';
+ output[used++] = i + 2 < length ? alphabet[value & 63] : '=';
+ }
+ output[used] = 0;
+}
+
+inline bool roomDecodeBase64(const char* text, uint8_t* bytes, size_t capacity, size_t& length) {
+ length = 0;
+ const size_t encoded = strlen(text);
+ if (encoded % 4 != 0 || encoded > ((capacity + 2) / 3) * 4) return false;
+ auto digit = [](char c) -> int {
+ if (c >= 'A' && c <= 'Z') return c - 'A'; if (c >= 'a' && c <= 'z') return c - 'a' + 26;
+ if (c >= '0' && c <= '9') return c - '0' + 52; if (c == '+') return 62; if (c == '/') return 63;
+ return -1;
+ };
+ for (size_t i = 0; i < encoded; i += 4) {
+ int values[4];
+ for (size_t j = 0; j < 4; ++j) values[j] = text[i + j] == '=' ? 0 : digit(text[i + j]);
+ if (values[0] < 0 || values[1] < 0 || values[2] < 0 || values[3] < 0
+ || text[i] == '=' || text[i + 1] == '='
+ || (text[i + 2] == '=' && text[i + 3] != '=')
+ || (i + 4 != encoded && (text[i + 2] == '=' || text[i + 3] == '='))) return false;
+ const size_t n = text[i + 2] == '=' ? 1 : (text[i + 3] == '=' ? 2 : 3);
+ if (length + n > capacity || (n == 1 && (values[1] & 15)) || (n == 2 && (values[2] & 3))) return false;
+ const uint32_t value = (uint32_t(values[0]) << 18) | (uint32_t(values[1]) << 12)
+ | (uint32_t(values[2]) << 6) | uint32_t(values[3]);
+ for (size_t j = 0; j < n; ++j) bytes[length++] = uint8_t(value >> (16 - 8 * j));
+ }
+ return true;
+}
+} // namespace mesh
diff --git a/src/helpers/RoomWebMailbox.h b/src/helpers/RoomWebMailbox.h
new file mode 100644
index 000000000..62032247e
--- /dev/null
+++ b/src/helpers/RoomWebMailbox.h
@@ -0,0 +1,111 @@
+#pragma once
+
+#include
+#include
+#include
+
+namespace mesh {
+
+// One loop-owned operation and eight bounded browser sequence floors. Caller
+// serializes submit/read/begin/finish with its mutex; processing runs outside
+// that mutex. Running input and output cannot be recycled by an HTTP handler.
+// Sequence floors expire after twenty minutes and on reboot. Clients never
+// retry a write after that window or across a changed boot identifier.
+class RoomWebMailbox {
+public:
+ static constexpr size_t INPUT_CAPACITY = 4097;
+ static constexpr size_t OUTPUT_CAPACITY = 2048;
+ static constexpr uint32_t OWNER_TTL_MS = 1200000;
+ static constexpr uint32_t RESULT_TTL_MS = 30000;
+ enum class Result { Accepted, Busy, Invalid, Completed, Mismatch };
+ enum class State { Idle, Pending, Running, Done };
+private:
+ struct Owner { uint8_t token[32]; uint32_t sequence = 0, seen = 0; } owners_[8] = {};
+ uint8_t token_[32] = {}, digest_[32] = {};
+ uint32_t sequence_ = 0, finished_ = 0;
+ State state_ = State::Idle;
+ bool delivered_ = false;
+ bool owner_was_new_ = false;
+ char input_[INPUT_CAPACITY] = {}, output_[OUTPUT_CAPACITY] = {};
+public:
+ static bool decodeToken(const char* text, uint8_t (&out)[32]) {
+ if (!text || strlen(text) != 64) return false;
+ uint8_t parsed[32], nonzero = 0;
+ for (size_t i = 0; i < 32; ++i) {
+ unsigned byte = 0;
+ for (size_t n = 0; n < 2; ++n) {
+ char c = text[i * 2 + n];
+ unsigned value;
+ if (c >= '0' && c <= '9') value = c - '0';
+ else if (c >= 'a' && c <= 'f') value = c - 'a' + 10;
+ else if (c >= 'A' && c <= 'F') value = c - 'A' + 10;
+ else return false;
+ byte = (byte << 4) | value;
+ }
+ parsed[i] = uint8_t(byte); nonzero |= parsed[i];
+ }
+ if (!nonzero) return false;
+ memcpy(out, parsed, sizeof(parsed)); return true;
+ }
+ State state() const { return state_; }
+ uint32_t sequence() const { return sequence_; }
+ Result submit(const uint8_t (&token)[32], uint32_t sequence,
+ const uint8_t (&digest)[32], const char* body, size_t length, uint32_t now) {
+ if (!body || !sequence || !length || length >= INPUT_CAPACITY
+ || memchr(body, 0, length)) return Result::Invalid;
+ if (state_ != State::Idle && sequence == sequence_ && memcmp(token_, token, 32) == 0) {
+ return memcmp(digest_, digest, 32) == 0 ? Result::Accepted : Result::Mismatch;
+ }
+ Owner* owner = nullptr;
+ Owner* empty = nullptr;
+ for (auto& entry : owners_) {
+ if (entry.sequence && uint32_t(now - entry.seen) < OWNER_TTL_MS) {
+ if (memcmp(entry.token, token, 32) == 0) owner = &entry;
+ } else if (!empty) empty = &entry;
+ }
+ if (owner && sequence <= owner->sequence) return Result::Completed;
+ if (state_ == State::Pending || state_ == State::Running
+ || (state_ == State::Done && !delivered_
+ && uint32_t(now - finished_) < RESULT_TTL_MS)) return Result::Busy;
+ owner_was_new_ = !owner;
+ if (!owner) owner = empty;
+ if (!owner) return Result::Busy;
+ memcpy(owner->token, token, 32); owner->sequence = sequence; owner->seen = now;
+ memcpy(token_, token, 32); memcpy(digest_, digest, 32);
+ memcpy(input_, body, length); input_[length] = 0; output_[0] = 0;
+ sequence_ = sequence; delivered_ = false; state_ = State::Pending;
+ return Result::Accepted;
+ }
+ bool begin() {
+ if (state_ != State::Pending) return false;
+ state_ = State::Running; return true;
+ }
+ char* input() { return input_; }
+ char* output() { return output_; }
+ const uint8_t* token() const { return token_; }
+ void finish(uint32_t now, bool authenticated = true) {
+ if (state_ != State::Running) return;
+ memset(input_, 0, sizeof(input_)); // passwords and article data have served their purpose
+ output_[OUTPUT_CAPACITY - 1] = 0;
+ finished_ = now; state_ = State::Done;
+ // Unauthenticated traffic must not occupy all eight sequence-floor slots
+ // for twenty minutes. Existing authenticated floors are never erased.
+ if (!authenticated && owner_was_new_) {
+ for (auto& owner : owners_) if (owner.sequence == sequence_ && memcmp(owner.token, token_, 32) == 0) owner = Owner{};
+ }
+ }
+ // Output must be copied while caller holds the mutex. Wrong owners never
+ // learn whether another user's operation is pending or completed.
+ const char* read(const uint8_t (&token)[32], uint32_t sequence, bool& pending) {
+ pending = false;
+ if (state_ == State::Idle || sequence_ != sequence || memcmp(token_, token, 32) != 0) return nullptr;
+ if (state_ != State::Done) { pending = true; return nullptr; }
+ return output_;
+ }
+ void delivered(const uint8_t (&token)[32], uint32_t sequence) {
+ if (state_ == State::Done && sequence_ == sequence && memcmp(token_, token, 32) == 0) delivered_ = true;
+ }
+};
+static_assert(sizeof(RoomWebMailbox) <= 6656, "Update the room WebConfig heap budget");
+
+} // namespace mesh
diff --git a/src/helpers/esp32/RoomWebHtml.h b/src/helpers/esp32/RoomWebHtml.h
new file mode 100644
index 000000000..1bc8a9497
--- /dev/null
+++ b/src/helpers/esp32/RoomWebHtml.h
@@ -0,0 +1,206 @@
+#pragma once
+
+#include
+
+// Offline browser room client, served by the existing WebConfig listener.
+// No radio private key is imported; a separate random browser token identifies
+// posts and moderation. Keep untrusted room text out of HTML interpolation.
+static const char ROOM_WEB_HTML[] PROGMEM = R"roomhtml(
+
+MeshCore Room
+MeshCore Room
+Chat and selected notices over local Wi-Fi. This page also works without internet access.
+
+
+
+Access and history
+Your browser identity for room moderation:
+This browser identity is separate from a radio's public key. The password stays in memory for this session.
+Refresh chat
+
+Information board Refresh notices
+
+
+Radio configuration
+)roomhtml";
diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp
index fcc8cd429..aed6e541c 100644
--- a/src/helpers/esp32/WebConfigServer.cpp
+++ b/src/helpers/esp32/WebConfigServer.cpp
@@ -32,6 +32,8 @@ static_assert(sizeof(WEBCONFIG_AP_PREFIX) <= 28,
#endif
#include "WebConfigHtml.h"
+#include "RoomWebHtml.h"
+#include
#include
#include "WebTerminalStream.h"
#include
@@ -339,6 +341,9 @@ WebConfigServer::WebConfigServer(Callbacks* callbacks, void* mqtt_prefs, bool ow
_standalone_wifi(standalone_wifi || mqtt_prefs == nullptr || !owns_wifi), _pub_key(pub_key),
_fw_ver(fw_ver), _build_date(build_date), _role(role), _board_name(board_name) {
_mux = xSemaphoreCreateMutex();
+ if (_mux && _cb && _cb->supportsRoomService()) {
+ _room_mailbox = new (std::nothrow) mesh::RoomWebMailbox;
+ }
_cli_enabled = loadCliEnabled(true);
#ifdef WITH_MQTT_BRIDGE
@@ -364,6 +369,7 @@ WebConfigServer::WebConfigServer(Callbacks* callbacks, void* mqtt_prefs, bool ow
WebConfigServer::~WebConfigServer() {
detachRoutes();
closeTerminal();
+ delete _room_mailbox;
if (_mux) vSemaphoreDelete(_mux);
}
@@ -1043,6 +1049,8 @@ bool WebConfigServer::stopForOTA(char reply[]) {
void WebConfigServer::finalizeTeardown() {
closeTerminal();
+ delete _room_mailbox;
+ _room_mailbox = nullptr;
// Async requests keep a pointer to their server until disconnect. Retain the
// listener and route table for the firmware lifetime; only detach and reclaim
// the per-session state.
@@ -1127,6 +1135,7 @@ void WebConfigServer::tick(uint32_t now) {
if (!_board_cmds_probed) probeBoardCommands();
serviceTerminal(now);
+ serviceRoom(now);
// A primary ESP-NOW radio and its WiFi station cannot remain on different
// channels. Reject a router-driven channel move (for example a CSA) and put
@@ -1613,6 +1622,15 @@ void WebConfigServer::dispatchRequest(AsyncWebServerRequest* req,
}
void WebConfigServer::registerRoutes() {
+ _server->on("/room", HTTP_GET, [](AsyncWebServerRequest* r) {
+ dispatchRequest(r, &WebConfigServer::handleRoomPage);
+ });
+ _server->on("/api/room/result", HTTP_POST, [](AsyncWebServerRequest* r) {
+ dispatchRequest(r, &WebConfigServer::handleRoomResult);
+ });
+ _server->on("/api/room", HTTP_POST, [](AsyncWebServerRequest* r) {
+ dispatchRequest(r, &WebConfigServer::handleRoomPost);
+ }, NULL, collectBody);
_server->on("/ui", HTTP_GET, [](AsyncWebServerRequest* r) {
dispatchRequest(r, &WebConfigServer::handleUi);
});
@@ -1676,6 +1694,105 @@ void WebConfigServer::registerRoutes() {
});
}
+void WebConfigServer::serviceRoom(uint32_t now) {
+ if (!_room_mailbox && _mux && _cb && _cb->supportsRoomService()) {
+ WCLock lock(_mux);
+ _room_mailbox = new (std::nothrow) mesh::RoomWebMailbox;
+ }
+ mesh::RoomWebMailbox* operation = nullptr;
+ {
+ WCLock lock(_mux);
+ if (_room_mailbox && _room_mailbox->begin()) operation = _room_mailbox;
+ }
+ if (!operation) return;
+ // Async submit/read may run here, but cannot recycle a Running operation.
+ // No filesystem, room client or radio mutation runs on async_tcp.
+ _cb->processRoomRequest(operation->token(), operation->sequence(), operation->input(),
+ operation->output(), mesh::RoomWebMailbox::OUTPUT_CAPACITY);
+ {
+ WCLock lock(_mux);
+ operation->finish(now, _cb->roomRequestAuthenticated());
+ }
+}
+
+static bool roomRequestIdentity(AsyncWebServerRequest* req, uint8_t (&token)[32], uint32_t& sequence) {
+ if (!req || !req->hasHeader("X-Room-Token") || !req->hasHeader("X-Room-Seq")) return false;
+ if (req->hasHeader("Origin")) {
+ if (!req->hasHeader("Host") || req->getHeader("Origin")->value()
+ != String("http://") + req->getHeader("Host")->value()) return false;
+ }
+ return mesh::RoomWebMailbox::decodeToken(req->getHeader("X-Room-Token")->value().c_str(), token)
+ && mesh::cli::parseUnsignedIntegerStrict(req->getHeader("X-Room-Seq")->value().c_str(), sequence)
+ && sequence != 0;
+}
+
+void WebConfigServer::handleRoomPage(AsyncWebServerRequest* req) {
+ if (_mode == MODE_OFF || !_cb || !_cb->supportsRoomService()) { req->send(404); return; }
+ _last_activity = millis();
+ auto* response = new (std::nothrow) WebConfigPacedProgmemResponse(
+ "text/html; charset=utf-8", reinterpret_cast(ROOM_WEB_HTML), sizeof(ROOM_WEB_HTML) - 1);
+ if (!response) { req->send(503); return; }
+ response->addHeader("Cache-Control", "no-store");
+ response->addHeader("X-Content-Type-Options", "nosniff");
+ response->addHeader("Referrer-Policy", "no-referrer");
+ req->send(response);
+}
+
+void WebConfigServer::handleRoomPost(AsyncWebServerRequest* req) {
+ uint8_t token[32]; uint32_t sequence;
+ if (!roomRequestIdentity(req, token, sequence)) {
+ req->send(400, "application/json", "{\"error\":\"invalid room identity\"}"); return;
+ }
+ if (req->contentType() != "application/json" || !req->_tempObject
+ || req->contentLength() == 0 || req->contentLength() > MAX_BODY) {
+ req->send(400, "application/json", "{\"error\":\"invalid room body\"}"); return;
+ }
+ uint8_t digest[32];
+ mesh::Utils::sha256(digest, sizeof(digest), (const uint8_t*)req->_tempObject, req->contentLength());
+ mesh::RoomWebMailbox::Result result;
+ {
+ WCLock lock(_mux);
+ if (!_room_mailbox || _stopping || _mode == MODE_OFF) {
+ req->send(503, "application/json", "{\"error\":\"room service unavailable\"}"); return;
+ }
+ result = _room_mailbox->submit(token, sequence, digest, (const char*)req->_tempObject,
+ req->contentLength(), millis());
+ // Do not keep a second password/article buffer while tick parses the
+ // accepted copy. The request destructor tolerates a null temporary object.
+ memset(req->_tempObject, 0, req->contentLength());
+ free(req->_tempObject);
+ req->_tempObject = nullptr;
+ }
+ if (result == mesh::RoomWebMailbox::Result::Accepted) {
+ _last_activity = millis();
+ req->send(202, "application/json", "{\"accepted\":true}");
+ } else if (result == mesh::RoomWebMailbox::Result::Busy) {
+ req->send(429, "application/json", "{\"error\":\"room service busy\"}");
+ } else {
+ req->send(409, "application/json", "{\"error\":\"request already completed or changed; refresh to check\"}");
+ }
+}
+
+void WebConfigServer::handleRoomResult(AsyncWebServerRequest* req) {
+ uint8_t token[32]; uint32_t sequence;
+ if (!roomRequestIdentity(req, token, sequence)) { req->send(400); return; }
+ WCLock lock(_mux);
+ if (!_room_mailbox || _stopping || _mode == MODE_OFF) { req->send(503); return; }
+ bool pending;
+ const char* result = _room_mailbox->read(token, sequence, pending);
+ if (pending) req->send(202, "application/json", "{\"pending\":true}");
+ else if (!result) req->send(404, "application/json", "{\"error\":\"room result unavailable; refresh to check\"}");
+ else {
+ String body(result);
+ if (body.length() != strlen(result)) { req->send(503); return; }
+ auto* response = req->beginResponse(200, "application/json", body);
+ if (!response) { req->send(503); return; }
+ response->addHeader("Cache-Control", "no-store");
+ req->send(response);
+ _room_mailbox->delivered(token, sequence);
+ }
+}
+
// Accumulate a small JSON body into request->_tempObject (freed automatically
// by the request destructor). Oversized bodies are left unbuffered and
// rejected in the completion handler.
diff --git a/src/helpers/esp32/WebConfigServer.h b/src/helpers/esp32/WebConfigServer.h
index 903918288..ff91b7430 100644
--- a/src/helpers/esp32/WebConfigServer.h
+++ b/src/helpers/esp32/WebConfigServer.h
@@ -33,6 +33,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -161,6 +162,12 @@ public:
virtual void buildStatsJson(char* buf, size_t buf_size) = 0;
// Teardown finished (server + DNS freed, WiFi mode restored).
virtual void onWebConfigStopped() {}
+ virtual bool supportsRoomService() const { return false; }
+ // Input may be parsed in place. Authentication, storage and radio work
+ // happen only on tick(), never on async_tcp.
+ virtual void processRoomRequest(const uint8_t* token, uint32_t sequence,
+ char* request, char* response, size_t capacity) {}
+ virtual bool roomRequestAuthenticated() const { return false; }
};
// mqtt_prefs is an MQTTPrefs* on WITH_MQTT_BRIDGE builds and NULL otherwise.
@@ -339,6 +346,7 @@ private:
char _setup_wifi_handoff_ip[16] = {0};
BatchEntry _batch[MAX_BATCH];
WebTerminalStream* _terminal = NULL;
+ mesh::RoomWebMailbox* _room_mailbox = NULL;
// LAN-mode session (single slot; new login evicts the old session)
char _session_token[33] = {0};
@@ -378,6 +386,7 @@ private:
void drainBatch(uint32_t now);
void probeBoardCommands();
void serviceTerminal(uint32_t now);
+ void serviceRoom(uint32_t now);
void closeTerminal();
void serviceSetupWiFiHandoff(uint32_t now);
void finishBatch(uint32_t now);
@@ -404,6 +413,9 @@ private:
void handlePresets(AsyncWebServerRequest* req);
void handleReboot(AsyncWebServerRequest* req);
void handlePortalExit(AsyncWebServerRequest* req);
+ void handleRoomPage(AsyncWebServerRequest* req);
+ void handleRoomPost(AsyncWebServerRequest* req);
+ void handleRoomResult(AsyncWebServerRequest* req);
void handleNotFound(AsyncWebServerRequest* req);
};
diff --git a/test/fixtures/client_acl_infrastructure/test_client_acl_infrastructure.cpp b/test/fixtures/client_acl_infrastructure/test_client_acl_infrastructure.cpp
index 9f02fe062..42fe0633c 100644
--- a/test/fixtures/client_acl_infrastructure/test_client_acl_infrastructure.cpp
+++ b/test/fixtures/client_acl_infrastructure/test_client_acl_infrastructure.cpp
@@ -3,6 +3,9 @@
#include
#include
#include
+#include "filesystem.h"
+#include
+#include
#include
#include
@@ -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) {
diff --git a/test/fixtures/official_room_app_compatibility/room_messages.cpp b/test/fixtures/official_room_app_compatibility/room_messages.cpp
index bf7c52740..e94c8d7c7 100644
--- a/test/fixtures/official_room_app_compatibility/room_messages.cpp
+++ b/test/fixtures/official_room_app_compatibility/room_messages.cpp
@@ -16,7 +16,7 @@ struct WirePresentation {
int app_target_ver;
uint8_t out_frame[MAX_FRAME_SIZE] = {};
std::vector 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"
diff --git a/test/fixtures/room_history_integration/test.cpp b/test/fixtures/room_history_integration/test.cpp
new file mode 100644
index 000000000..e50d2ded2
--- /dev/null
+++ b/test/fixtures/room_history_integration/test.cpp
@@ -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
+#include
+#include
+#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 retained() const {
+ std::vector 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 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({"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);
+}
diff --git a/test/fixtures/room_history_store/filesystem.h b/test/fixtures/room_history_store/filesystem.h
new file mode 100644
index 000000000..0431b55a9
--- /dev/null
+++ b/test/fixtures/room_history_store/filesystem.h
@@ -0,0 +1,155 @@
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+struct PowerCut {};
+class MemoryFS;
+static MemoryFS* metadata_filesystem = nullptr;
+class File {
+ MemoryFS* fs = nullptr;
+ std::shared_ptr> 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>, 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>> files;
+ std::set faults, directories;
+ std::vector trace;
+ size_t boundary = 0, cut_at = 0;
+ bool short_write = false, short_read = false;
+ std::vector replacement_on_close;
+ unsigned write_opens = 0;
+ size_t capacity = std::numeric_limits::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>();
+ 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>();
+ 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& bytes) {
+ files[path] = std::make_shared>(bytes);
+ }
+ std::vector 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::max(); }
+};
+File::File(MemoryFS* owner, std::shared_ptr> 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(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
diff --git a/test/fixtures/room_history_store/test.cpp b/test/fixtures/room_history_store/test.cpp
new file mode 100644
index 000000000..38eeb2deb
--- /dev/null
+++ b/test/fixtures/room_history_store/test.cpp
@@ -0,0 +1,385 @@
+#include "filesystem.h"
+#include
+#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& 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& 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& expected, bool repair = false) {
+ State state; std::vector 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 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& 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 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 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 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 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 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 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> invalid_headers = {{}, {1}, std::vector(11), HEADER,
+ std::vector(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 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 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 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 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 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(10), record(11)} : old);
+ if (action == 1) expect(fs, appended ? std::vector{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);
+}
diff --git a/test/fixtures/room_receive_admission/test.cpp b/test/fixtures/room_receive_admission/test.cpp
new file mode 100644
index 000000000..d596401e0
--- /dev/null
+++ b/test/fixtures/room_receive_admission/test.cpp
@@ -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
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#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 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 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> 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 payload(10 + strlen(text), 0);
+ memcpy(payload.data(), ×tamp, 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 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;
+}
diff --git a/test/fixtures/room_topic_delivery/test.cpp b/test/fixtures/room_topic_delivery/test.cpp
index bd3bcb9ed..d487ee308 100644
--- a/test/fixtures/room_topic_delivery/test.cpp
+++ b/test/fixtures/room_topic_delivery/test.cpp
@@ -4,6 +4,8 @@
#include
#include
#include
+#include "../room_history_store/filesystem.h"
+#include
#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(&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");
}
diff --git a/test/fixtures/room_web_service/boundary.json b/test/fixtures/room_web_service/boundary.json
new file mode 100644
index 000000000..5ba59fec0
--- /dev/null
+++ b/test/fixtures/room_web_service/boundary.json
@@ -0,0 +1,5 @@
+{
+ "status": {"name":"Test room","topic":"Topic ","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=="}
+}
diff --git a/test/fixtures/room_web_service/service.cpp b/test/fixtures/room_web_service/service.cpp
new file mode 100644
index 000000000..ec72081e6
--- /dev/null
+++ b/test/fixtures/room_web_service/service.cpp
@@ -0,0 +1,279 @@
+#include "filesystem.h"
+#include
+#include
+#include
+#include
+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
+#undef malloc
+#undef free
+#undef realloc
+#include