Add owner-controlled personal room mailboxes and WiFi management

This commit is contained in:
mikecarper committed 2026-10-10 18:26:13 -07:00
1 parent 5165b6f490
commit 2c1c2c8e99
24 files changed
+3607 -24

No files matched your search

+5
View File
@@ -95,6 +95,8 @@ jobs:
python3 -m pip install cryptography
sudo apt-get update
sudo apt-get install -y libssl-dev
mkdir -p .pio/libdeps/room-mail-test
pio pkg install --global --library 'rweather/Crypto@0.4.0' --storage-dir .pio/libdeps/room-mail-test
- name: Verify ArduinoJson pin
run: |
@@ -134,6 +136,8 @@ jobs:
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_mail_store.py -v
python3 -B test/test_room_mail_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
@@ -564,6 +568,7 @@ jobs:
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
MESHCORE_REQUIRE_ROOM_BROWSER=1 python3 -B test/test_room_mail_web_ui.py -v
- name: Upload Test Results
# Upload test results even if the test step failed.
+1 -1
View File
@@ -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, history, and access controls](https://mikecarper.github.io/MeshCore/room_services/).** Save an announcement and optionally retain the newest 32 chat posts across reboots. Room users can manage their own return routes and skip older unread chat by date or keep the newest N messages. 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, a persistent information board, and an admin dashboard for all users' routes, delivery, activity, catch-up and access. The existing app's chat and catch-up formats remain supported.
* **[Room topics, mailboxes, history, and access controls](https://mikecarper.github.io/MeshCore/room_services/).** Save an announcement and optionally retain the newest 32 chat posts across reboots. Regular users manage personal mailboxes with public or selected-sender access, explicit receipts, and mailbox-only delivery for sleeping trackers. Room users can manage their own return routes and skip older unread chat by date or keep the newest N messages. 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, an information board, and an admin dashboard for mailboxes, routes, delivery, activity, catch-up and access. 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.
+9
View File
@@ -19,6 +19,15 @@ Infrastructure `get password` is available locally in every profile; LoRa
requests cannot read it. `get acl` and stored packet `log` are infrastructure
features, not Companion contact or live USB-logging commands.
Room users also have an owner-only command set described in
[Personal mailboxes](room_services.md#personal-mailboxes): `mail settings`,
`mail mode`, `mail allow`, `mail deny`, `mail delivery`, `mail check`,
`mail inbox`/`mail list`, `mail read`, `mail ack`, and `mail delete`.
Authenticated room writers can use `mail send`; readers cannot send.
These work as encrypted CLI requests or ordinary direct `!mail ...` messages
to the room. Regular users can also read `get room.board` and
`get room.board.read`; board publishing and deletion remain administrative.
Build columns mean:
- **Standard** - the ordinary non-MQTT artifact, without an explicit `ota`
+160 -1
View File
@@ -2,13 +2,170 @@
<p class="meshcore-hosted-doc-link"><a href="https://mikecarper.github.io/MeshCore/room_services/">View this page on MeshCore Docs</a>.</p>
<!-- meshcore-hosted-doc-link:end -->
# Room history, moderation, and information board
# Room history, personal mailboxes, 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.
## Personal mailboxes
The single room also provides personal mailboxes. Each mailbox belongs to a
complete public-key identity, independently of the public chat backlog. Regular
room readers and writers can choose their own access policy and delivery mode;
administrator permission is not required.
| Access | Who may leave mail |
| --- | --- |
| Closed (default) | The owner only |
| Public | Any authenticated room writer or administrator |
| Private | Up to eight selected full public-key identities, plus the owner |
Read-only users can manage and read their own mailbox but cannot send mail.
Room bans still apply to both senders and recipients. A private allow list does
not grant room login or writing permission; approved senders must also have
room writer access. Reading, acknowledging and deleting mail always use the
authenticated owner's identity, never a name or shortened key supplied in a
request. Changing access does not delete already accepted mail.
Mailboxes start closed and are created only when the owner saves a policy.
There are at most 32 mailbox owners, 16 queued messages across the radio, four
queued messages per owner, and eight selected senders per mailbox. Messages
contain 1-512 UTF-8 bytes. A full queue rejects new mail without replacing old
mail. Available filesystem space can impose a smaller limit.
The server stores mail separately from public chat. Mail is encrypted on each
LoRa connection, but is plaintext in the server's filesystem: the room operator
is trusted. This is not end-to-end encryption between sender and recipient.
Other ordinary users cannot list or read your inbox. Mail bodies are not
included in public chat, notices, or the Wi-Fi administrator dashboard. Wi-Fi
room access uses local HTTP, so use a trusted Wi-Fi network.
### Mail commands for regular users
Use an encrypted CLI request to the room, or send the same command as a direct
message prefixed with `!mail` from a normal Companion app. These direct messages
are reserved mailbox commands and never become public room posts:
~~~text
!mail mode private
!mail allow <complete-64-hex-sender-public-key>
!mail deny <complete-64-hex-sender-public-key>
!mail mode public
!mail mode closed
!mail delivery mailbox
!mail delivery chat
!mail settings
!mail settings 1
!mail send <complete-64-hex-recipient-public-key> dog lost
!mail check
!mail inbox
!mail list 1
!mail read <message-id>
!mail read <message-id> <next-offset>
!mail ack <message-id>
!mail delete <message-id>
~~~
In a CLI-capable client, use `mail ...` without `!`. These commands affect only
the requesting identity. CLI list/settings replies use entry cursors; continue
at the returned `next=` while it is below `total=` or `allowed=`. List entries
show the immutable message ID, byte length and sender key; a short route may
show a sender prefix. Reads return readable text with `id`, `off`, `next` and
`total`; continue at `next` until it equals `total`. Text chunks end on a UTF-8
character boundary. The route and command framing limit LoRa submissions to
less than the 512-byte storage limit; the browser can submit a full 512 bytes.
Reading never removes a message. `ack` confirms receipt and durably removes it;
`delete` deliberately discards it. Both retain a bounded retry receipt. Exact
sender/request/body retries return the original ID, including after receipt or
reboot; conflicting reuse of that request ID is rejected. Each owner retains
up to eight submission receipts. Queued receipts stay retained; the oldest
retired receipt is evicted when needed. A very old retry after its retired
receipt has been evicted can enqueue again.
`mail delivery mailbox` suppresses automatic public chat and topic delivery to
that identity, including retries and public backlog counts. It does not prevent
explicit public-board reads, change routes or erase chat. Returning to `chat`
resumes from the existing catch-up cursor. A copy already transmitted or queued
can still arrive while switching modes. Unreadable saved mailbox policy blocks
automatic chat rather than silently enabling it.
### Sleeping tracker example
A dog tracker can keep its mailbox private, approve its owner's sender identity,
and select mailbox-only delivery. The owner leaves `dog lost` while the tracker
is asleep. At its next scheduled check-in, the tracker authenticates to the
room, checks its inbox, retrieves that message in resumable chunks and confirms
receipt only after handling it. Lost replies and another sleep cycle do not
remove unread mail. Chat history and topics are not downloaded automatically.
Room Server provides the room-side queue and polling protocol. A tracker still
needs client firmware that schedules check-ins and interprets authenticated
commands. Ordinary mailbox text is never executed as a CLI command, and does
not itself enable a tracker, wake it remotely or change its GPS reporting rate.
### Wi-Fi mailbox management
Open `/room`, join with your room credentials, and open **Personal mailbox**.
The page shows your complete browser mailbox address, public/private/closed
access, selected senders and mailbox-only delivery. It provides sending, paged
inbox metadata, selective body downloads, resumable reads, explicit receipt
confirmation and deletion. Unsaved drafts survive failed requests. The page
honors saved mailbox-only mode before its initial chat download; **Refresh
chat** remains an explicit choice.
The browser has its own identity derived from its secret token and the server
identity. It cannot claim your radio's mailbox using a shared room password.
Mail sent to a radio key is retrieved by that radio; mail sent to the browser
address is retrieved using that browser token. Keep that token to retain access
to the same mailbox. Disconnecting clears downloaded private content from the
page's memory; the queued server mail remains.
Administrators have a **Mailboxes** panel with paged owner metadata, queued
counts, access policy, selected senders, blocking, and confirmed deletion or
purge. Enter a full owner address to provision a radio's mailbox before it
checks in. Policy changes compare the loaded revision, including creation revision
zero; concurrent changes require a refresh. The dashboard does not download
other users' message bodies. Inbox and administrator index pages use snapshot
revisions to reject mixed pages after a concurrent change.
### LoRa mailbox protocol
Authenticated room users send encrypted `PAYLOAD_TYPE_REQ` requests. The usual
four-byte request tag precedes each payload and is echoed before the response.
All multi-byte integers are little-endian. Subtype `0x0b` is separate from chat
catch-up and board subtype `0x0a`.
| Operation | Payload after tag |
| --- | --- |
| Settings/check | `0b 00` + expected revision `u32` + allow-list cursor `u8` |
| Inbox index | `0b 01` + expected revision `u32` + entry cursor `u8` |
| Read | `0b 02` + immutable message ID `u32` + offset `u16` |
| Send | `0b 03` + full recipient key (32 bytes) + body length `u16` + complete body |
| Confirm receipt | `0b 04` + message ID `u32` |
| Delete | `0b 05` + message ID `u32` |
| Allow sender | `0b 06` + expected revision `u32` + full sender key (32 bytes) |
| Deny sender | `0b 07` + expected revision `u32` + full sender key (32 bytes) |
| Access | `0b 08` + expected revision `u32` + mode `u8` (closed=0, public=1, private=2) |
| Delivery | `0b 09` + expected revision `u32` + mailbox-only `u8` (0 or 1) |
Start index/settings at revision zero and cursor zero, then use the returned
revision on subsequent pages. `next=255` means complete. Sends are single
requests, without fragmented uploads; the complete body must fit the encrypted
request route. Read responses contain raw bytes in chunks of at most 128, reduced
as needed by the reply route. Continue by offset plus copied byte count. Reads
never acknowledge receipt implicitly. Logical requests allow only zero trailing
encryption padding.
Responses begin with subtype, operation and status. Statuses are: `0` success,
`1` invalid, `2` storage unavailable, `3` write failure, `4` not found, `5` stale
revision, `6` forbidden, `7` full, `8` successful duplicate, `9` conflicting
request ID. Full response layouts are documented in
[RoomMailProtocol.h](https://github.com/mikecarper/MeshCore/blob/keymindCascade/src/helpers/RoomMailProtocol.h).
Mutations require space for their complete confirmation before writing.
## Optional persistent chat history
History uses RAM by default. From a local console or authenticated remote
@@ -268,6 +425,8 @@ This management area offers:
and send the corresponding Unix timestamp.
- Assigning or removing saved access, blocking and unblocking full identities,
editing the topic, enabling persistent history, and changing post/poll limits.
- Personal mailbox owner lists, policies, sender permissions and confirmed
message deletion or purge. Private mail bodies are not downloaded here.
- Optional refresh every 15 seconds. Unsaved route edits remain in place while
live status refreshes.
+77
View File
@@ -0,0 +1,77 @@
<!-- meshcore-hosted-doc-link:start -->
<p class="meshcore-hosted-doc-link"><a href="https://mikecarper.github.io/MeshCore/test-results/room_mailboxes/">View this page on MeshCore Docs</a>.</p>
<!-- meshcore-hosted-doc-link:end -->
# Personal room mailbox validation
Validated on 2026-10-10 against the source changes in this commit. These checks
cover the room-side implementation, not a sleeping tracker client or a live
LoRa/Wi-Fi transfer. No lab radio was flashed for these mailbox checks.
## Behavior exercised
- One public room, separate personal mailboxes, and full authenticated owner
identities for policy, reads, receipt confirmation and deletion.
- Closed/public/private policies, eight selected senders, reader/writer roles,
caller and recipient bans, and permission revocation before cached replies.
- Mailbox-only suppression of public posts, topic delivery, active retries and
public unread counts. Corrupt saved policy blocks automatic chat.
- A later dog-tracker check-in retrieving `dog lost`, resumable reads, no
implicit acknowledgement, and an explicit durable receipt. Message text
never executes administrative commands.
- Up to 32 owners, 16 globally queued messages, four per owner, 512 UTF-8 bytes
per body and bounded retry receipts. Full queues preserve earlier mail.
- Exact retry, conflicting nonce reuse, retry after receipt/reboot, owner-only
delete and administrator purge. Very old retries are not guaranteed unique
after their retired receipt is evicted.
- Atomic bank/index publication, every tested interrupted-write boundary,
corruption, short writes, failed receipt saves and preserved recovery data.
- AES zero-padded LoRa framing, rejection of nonzero trailing data, bounded
route replies and rejection of oversized commands before mutation.
- Browser identity isolation, stale revision-zero creation, snapshot paging,
delayed responses after disconnect, drafts, safe text rendering, narrow
screens and administrator provisioning by full radio address.
## Automated checks
The mailbox store passed 3,978 scenarios on each of five filesystem API
variants (19,890 total), using AddressSanitizer, UndefinedBehaviorSanitizer and
OpenSSL SHA-256. The wire/CLI protocol passed 1,393 scenarios per variant
(6,965 total), using the firmware's actual Crypto SHA-256 implementation and
the same sanitizers. Adafruit file mocks reject default construction, matching
the actual nRF52 library.
Production room transport tests passed 18 cases. Actual JSON handlers passed
seven existing base, six existing administrator and six new mailbox groups
with real ArduinoJson and stores. The delivery scheduler, room login, receive
admission, post permissions, HTTP ownership and stock app compatibility checks
passed after updating their production-code adapters.
Chromium passed 17 existing room browser tests and nine mailbox tests, including
new radio mailbox provisioning without downloading another owner's body.
These checks are included in the unit-test workflow.
## Firmware builds
The following Room Server builds passed the normal flash and runtime RAM gates:
- `heltec_v4_room_server`
- `RAK_4631_room_server`
- `t1000e_room_server`
- `RAK_WisMesh_Tag_room_server`
- `PicoW_room_server`
The first nRF52 build exposed a non-default-constructible Adafruit file handle;
the implementation and fixtures were corrected before final builds passed.
## Stack review
The actual T1000-E ARM ELF retains the existing 8 KiB loop stack allocation.
The largest mailbox store frame is 1,320 bytes. Streamed policy reads avoid
loading the full receipt table in the delivery scheduler; write and readback
verification run sequentially rather than stacking both operations.
A representative LoRa CLI send chain has 5,632 bytes of visible frames; the
binary request path has 4,352. These estimates exclude deeper filesystem,
library, virtual-call and task overhead, and are not proven worst-case bounds.
They do not replace a live long-duration stack-watermark test.
+165 -7
View File
@@ -24,6 +24,7 @@
#include <helpers/RoomLoginAuthorization.h>
#include <helpers/RoomTopicStore.h>
#include <helpers/RoomBoardProtocol.h>
#include <helpers/RoomMailProtocol.h>
#include <helpers/ClientPathPersistence.h>
#include <helpers/ClientPathObservation.h>
#include <helpers/RoomClientPathCommand.h>
@@ -43,6 +44,7 @@
#include <Preferences.h>
#include <ArduinoJson.h>
#include <helpers/RoomWebJson.h>
#include <helpers/RoomMailWeb.h>
#endif
#include <helpers/OtaChannel.h>
@@ -242,6 +244,94 @@ bool MyMesh::pushPostToClient(ClientInfo *client, PostInfo &post) {
return pushRoomTextToClient(client, post.post_timestamp, post.author, post.text);
}
bool MyMesh::roomClientChatEnabled(ClientInfo* client) {
bool mailbox_only = false;
// An unreadable saved policy must never turn a private-only subscriber back
// into a public chat subscriber. Existing users without a mailbox keep chat.
return mesh::getRoomMailboxOnly(_fs, client->id.pub_key, mailbox_only)
== mesh::RoomMailResult::Success && !mailbox_only;
}
bool MyMesh::handleRoomMailClientCommand(ClientInfo* client, const char* text,
uint64_t request_id, char* reply, size_t capacity) {
char command[mesh::RemoteCliReplyCache::MAX_REPLY_TEXT + 1];
if (strlen(text) >= sizeof(command)) {
snprintf(reply, capacity, "Error command too long"); return true;
}
StrHelper::strncpy(command, text, sizeof(command));
char prefix[4] = {};
const char* body = mesh::normalizeRoomClientPathCommand(command, prefix);
const uint8_t role = client->permissions & PERM_ACL_ROLE_MASK;
if (role != PERM_ACL_READ_ONLY && role != PERM_ACL_READ_WRITE && role != PERM_ACL_ADMIN
&& !(role == PERM_ACL_GUEST && client->last_activity != 0)) return false;
if (prefix[0]) {
if (capacity < 4) return true;
memcpy(reply, prefix, 3); reply += 3; capacity -= 3;
}
const char* action = mesh::room_mail_protocol_detail::prefix(body, "mail")
? mesh::room_mail_protocol_detail::spaces(body + 4) : "";
const char* target_text = mesh::room_mail_protocol_detail::prefix(action, "send")
? mesh::room_mail_protocol_detail::spaces(action + 4) : "";
if (strlen(target_text) >= 64) {
char target[65]; memcpy(target, target_text, 64); target[64] = 0;
uint8_t key[32];
if (mesh::room_mail_protocol_detail::key(target, key)
&& !room_access.allowsIdentity(key)) {
snprintf(reply, capacity, "Err - recipient is blocked"); return true;
}
}
const bool can_send = role == PERM_ACL_READ_WRITE || role == PERM_ACL_ADMIN;
if (mesh::handleRoomMailCommand(_fs, client->id.pub_key, can_send, request_id,
body, reply, capacity, getRTCClock()->getCurrentTime())) return true;
if (strncmp(body, "get room.board", 14) == 0) {
return mesh::handleRoomBoardCommand(_fs, body, reply, capacity);
}
return false;
}
bool MyMesh::handleRoomMailText(ClientInfo* client, mesh::Packet* packet,
const uint8_t* secret, uint8_t* data, size_t len) {
const char* text = reinterpret_cast<const char*>(data + 5);
if (strncmp(text, "!mail", 5) != 0 || (text[5] != 0 && text[5] != ' ')) return false;
const uint8_t role = client->permissions & PERM_ACL_ROLE_MASK;
if (role != PERM_ACL_READ_ONLY && role != PERM_ACL_READ_WRITE && role != PERM_ACL_ADMIN
&& !(role == PERM_ACL_GUEST && client->last_activity != 0)) return true;
uint32_t timestamp; memcpy(&timestamp, data, 4);
const uint32_t fingerprint = mesh::RemoteCliReplyCache::fingerprint(text, strlen(text));
const char* cached = nullptr;
const bool retry = remote_cli_reply_cache.lookup(client->id.pub_key, timestamp, fingerprint, &cached);
if (!retry && timestamp <= client->last_timestamp) return true;
serviceRoomQuotas();
if (!retry && !room_access.consumeKeepAlive(client->extra.room.poll_quota_used)) return true;
char reply[160] = {};
const size_t route_capacity = mesh::clientACLReplyCapacity(packet->isRouteFlood(), packet->path_len);
const size_t capacity = route_capacity > 5 ? std::min(sizeof(reply), route_capacity - 5) : 0;
if (!capacity) return true;
if (retry) StrHelper::strncpy(reply, cached, capacity);
else {
client->last_timestamp = timestamp;
if (!handleRoomMailClientCommand(client, text + 1, timestamp, reply, capacity)) {
snprintf(reply, capacity, "Err - use !mail settings, mode, allow, deny, delivery, send, list, read, ack or delete");
}
remote_cli_reply_cache.remember(client->id.pub_key, timestamp, fingerprint, reply);
}
client->last_activity = getRTCClock()->getCurrentTime();
uint32_t ack_hash;
mesh::Utils::sha256(reinterpret_cast<uint8_t*>(&ack_hash), sizeof(ack_hash), data,
5 + strlen(text), client->id.pub_key, PUB_KEY_SIZE);
auto ack = createAck(ack_hash);
if (ack) sendClientReply(client, ack, TXT_ACK_DELAY, packet->getPathHashSize());
uint8_t output[165];
const uint32_t now = getRTCClock()->getCurrentTimeUnique();
memcpy(output, &now, 4); output[4] = TXT_TYPE_PLAIN << 2;
const size_t length = strlen(reply); memcpy(output + 5, reply, length);
auto response = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, output, 5 + length);
if (response) sendClientReply(client, response, TXT_ACK_DELAY + REPLY_DELAY_MILLIS,
packet->getPathHashSize());
(void)len;
return true;
}
#ifdef WITH_WEBCONFIG
bool MyMesh::handleRoomWebCommand(const char* command, char* reply) {
if (strcmp(command, "get room.web") == 0) {
@@ -356,14 +446,20 @@ void MyMesh::processRoomRequest(const uint8_t* token, uint32_t sequence, char* r
error("admin permission required"); return;
}
const bool admin_read = strcmp(operation, "admin.users") == 0
|| strcmp(operation, "admin.user") == 0 || strcmp(operation, "admin.settings") == 0;
|| strcmp(operation, "admin.user") == 0 || strcmp(operation, "admin.settings") == 0
|| strcmp(operation, "admin.mail.list") == 0 || strcmp(operation, "admin.mail.settings") == 0;
const bool admin_write = strcmp(operation, "admin.route") == 0
|| strcmp(operation, "admin.catchup") == 0 || strcmp(operation, "admin.access") == 0
|| strcmp(operation, "admin.ban") == 0 || strcmp(operation, "admin.topic") == 0
|| strcmp(operation, "admin.history") == 0 || strcmp(operation, "admin.rates") == 0;
|| strcmp(operation, "admin.history") == 0 || strcmp(operation, "admin.rates") == 0
|| strcmp(operation, "admin.mail.policy") == 0 || strcmp(operation, "admin.mail.delete") == 0;
const bool mail_read = strcmp(operation, "mail.settings") == 0 || strcmp(operation, "mail.check") == 0
|| strcmp(operation, "mail.list") == 0 || strcmp(operation, "mail.read") == 0;
const bool mail_write = strcmp(operation, "mail.policy") == 0 || strcmp(operation, "mail.send") == 0
|| strcmp(operation, "mail.ack") == 0 || strcmp(operation, "mail.delete") == 0;
const bool writing = strcmp(operation, "post") == 0
|| strcmp(operation, "board.save") == 0 || strcmp(operation, "board.delete") == 0
|| admin_write;
|| admin_write || mail_write;
if (writing && (!input["boot"].is<uint32_t>()
|| input["boot"].as<uint32_t>() != room_web_boot_id)) {
error("radio restarted; refresh before sending"); return;
@@ -376,7 +472,8 @@ void MyMesh::processRoomRequest(const uint8_t* token, uint32_t sequence, char* r
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 && !admin_read && !admin_write) {
&& strcmp(operation, "board.delete") != 0 && !admin_read && !admin_write
&& !mail_read && !mail_write) {
error("unknown room operation"); return;
}
@@ -402,6 +499,10 @@ void MyMesh::processRoomRequest(const uint8_t* token, uint32_t sequence, char* r
}
char key[65]; mesh::Utils::toHex(key, author.pub_key, PUB_KEY_SIZE);
mesh::RoomJsonWriter json(response, capacity);
if (mesh::handleRoomMailWeb(_fs, author.pub_key,
role == PERM_ACL_READ_WRITE || role == PERM_ACL_ADMIN, role == PERM_ACL_ADMIN,
sequence, getRTCClock()->getCurrentTime(), input.as<JsonObjectConst>(), json,
[this](const uint8_t* identity) { return room_access.allowsIdentity(identity); })) return;
if (admin_operation) {
if (strcmp(operation, "admin.settings") == 0) {
json.raw("{\"topic\":"); json.string(room_topic);
@@ -545,12 +646,17 @@ void MyMesh::processRoomRequest(const uint8_t* token, uint32_t sequence, char* r
json.raw("{\"ok\":true}"); json.finish(); return;
}
if (strcmp(operation, "status") == 0) {
bool mailbox_only = false;
if (mesh::getRoomMailboxOnly(_fs, author.pub_key, mailbox_only) != mesh::RoomMailResult::Success) {
mailbox_only = true;
}
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(",\"mailbox_only\":"); json.raw(mailbox_only ? "true" : "false");
json.raw("}"); json.finish(); return;
}
if (strcmp(operation, "post") == 0) {
@@ -959,6 +1065,7 @@ bool MyMesh::pushRoomTextToClient(ClientInfo *client, uint32_t timestamp,
}
uint8_t MyMesh::getUnsyncedCount(ClientInfo *client) {
if (!roomClientChatEnabled(client)) return 0;
uint8_t count = 0;
for (int k = 0; k < MAX_UNSYNCED_POSTS; k++) {
if (posts[k].post_timestamp > client->extra.room.sync_since // is new post for this Client?
@@ -1043,6 +1150,18 @@ void MyMesh::serviceRoomPush() {
for (int i = 0; i < acl.getNumClients(); ++i) {
auto client = acl.getClientByIdx(i);
if (client->extra.room.pending_ack && !roomClientChatEnabled(client)) {
uint8_t retry_key[MAX_HASH_SIZE];
mesh::roomDeliveryRetryKey(retry_key, sizeof(retry_key), self_id.pub_key,
client->id.pub_key, client->extra.room.pending_topic_revision != 0,
[](uint8_t* out, size_t size, const uint8_t* bytes, size_t length) {
mesh::Utils::sha256(out, size, bytes, length);
});
cancelActiveMessageRetries(retry_key, client->extra.room.push_post_timestamp);
client->extra.room.pending_ack = 0;
client->extra.room.pending_topic_revision = 0;
continue;
}
if (client->extra.room.pending_ack
&& millisHasNowPassed(client->extra.room.ack_timeout)) {
const uint32_t topic_revision = client->extra.room.pending_topic_revision;
@@ -1067,7 +1186,7 @@ void MyMesh::serviceRoomPush() {
bool did_push = false;
if (client->extra.room.pending_ack == 0 && client->last_activity != 0
&& client->extra.room.push_failures < 3
&& room_access.allowsIdentity(client->id.pub_key)) {
&& room_access.allowsIdentity(client->id.pub_key) && roomClientChatEnabled(client)) {
const uint32_t now = getRTCClock()->getCurrentTime();
if (room_topic[0] != 0 && room_topic_ready
&& client->extra.room.topic_seen_revision != room_topic_revision
@@ -1128,6 +1247,21 @@ 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_MAIL_REQUEST_SUBTYPE) {
const uint8_t role = sender->permissions & PERM_ACL_ROLE_MASK;
if (role != PERM_ACL_READ_ONLY && role != PERM_ACL_READ_WRITE && role != PERM_ACL_ADMIN
&& !(role == PERM_ACL_GUEST && sender->last_activity != 0)) return 0;
if (payload_len >= 34 && payload[1] == 3 && !room_access.allowsIdentity(payload + 2)) {
const size_t length = mesh::room_mail_protocol_detail::header(payload[1],
mesh::RoomMailResult::Forbidden, reply_data + 4, reply_capacity - 4);
return length ? 4 + length : 0;
}
const size_t length = mesh::handleRoomMailRequest(_fs, sender->id.pub_key,
role == PERM_ACL_READ_WRITE || role == PERM_ACL_ADMIN, sender_timestamp,
payload, payload_len, reply_data + 4, reply_capacity - 4,
getRTCClock()->getCurrentTime());
return length ? 4 + length : 0;
}
if (payload[0] == mesh::ROOM_BOARD_REQUEST_SUBTYPE) {
const size_t length = mesh::handleRoomBoardRequest(_fs, payload, payload_len,
reply_data + 4, reply_capacity - 4);
@@ -1807,6 +1941,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx,
data[len] = 0; // need to make a C string again, with null terminator
const char* text = (const char*)&data[5];
const size_t text_len = strlen(text);
if (flags == TXT_TYPE_PLAIN && handleRoomMailText(client, packet, secret, data, len)) return;
uint8_t temp[5 + mesh::RemoteCliReplyCache::MAX_REPLY_TEXT + 1];
temp[5] = 0;
@@ -1865,7 +2000,9 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx,
const uint8_t role = client->permissions & PERM_ACL_ROLE_MASK;
const bool own_path_allowed =
(mesh::classifyRoomClientPathCommand(body) != mesh::RoomClientPathCommand::None
|| strcmp(body, "get room.catchup") == 0 || strncmp(body, "room.catchup ", 13) == 0)
|| strcmp(body, "get room.catchup") == 0 || strncmp(body, "room.catchup ", 13) == 0
|| strncmp(body, "mail ", 5) == 0 || strcmp(body, "mail") == 0
|| strncmp(body, "get room.board", 14) == 0)
&& (role == PERM_ACL_READ_ONLY || role == PERM_ACL_READ_WRITE
|| role == PERM_ACL_ADMIN
|| (role == PERM_ACL_GUEST && client->last_activity != 0));
@@ -1886,7 +2023,9 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx,
return;
} else {
if (!handleClientPathCommand(client, (char*)text, (char*)&temp[5])
&& !handleRoomCatchUpCommand(client, (char*)text, (char*)&temp[5])) {
&& !handleRoomCatchUpCommand(client, (char*)text, (char*)&temp[5])
&& !handleRoomMailClientCommand(client, text, request_id, (char*)&temp[5],
mesh::RemoteCliReplyCache::MAX_REPLY_TEXT + 1)) {
if (client->isAdmin()) {
handleCommand(sender_timestamp, (char*)text, (char*)&temp[5],
i, packet->getPathHashSize());
@@ -3532,6 +3671,25 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
mesh::cli::normalizeCommandVerb(command);
if (mesh::room_mail_protocol_detail::prefix(command, "mail")) {
uint64_t mail_request_id = sender_timestamp;
if (!mail_request_id) {
getRNG()->random(reinterpret_cast<uint8_t*>(&mail_request_id), sizeof(mail_request_id));
if (!mail_request_id) { strcpy(reply, "Err - mail request ID unavailable"); return; }
}
const char* mail_action = mesh::room_mail_protocol_detail::spaces(command + 4);
const char* mail_target = mesh::room_mail_protocol_detail::prefix(mail_action, "send")
? mesh::room_mail_protocol_detail::spaces(mail_action + 4) : "";
if (strlen(mail_target) >= 64) {
char target[65]; memcpy(target, mail_target, 64); target[64] = 0;
uint8_t key[32];
if (mesh::room_mail_protocol_detail::key(target, key) && !room_access.allowsIdentity(key)) {
strcpy(reply, "Err - recipient is blocked"); return;
}
}
if (mesh::handleRoomMailCommand(_fs, self_id.pub_key, true, mail_request_id,
command, reply, 157, getRTCClock()->getCurrentTime())) return;
}
if (handleRoomManagementCommand(command, reply)) return;
if (handleRoomTopicCommand(command, reply)) return;
if (handleRoomHistoryCommand(command, reply)) return;
+5
View File
@@ -602,6 +602,11 @@ public:
int gpio_client_index = -1,
uint8_t gpio_path_hash_size = 1);
bool handleClientPathCommand(ClientInfo* client, char* command, char* reply);
bool roomClientChatEnabled(ClientInfo* client);
bool handleRoomMailClientCommand(ClientInfo* client, const char* text, uint64_t request_id,
char* reply, size_t capacity);
bool handleRoomMailText(ClientInfo* client, mesh::Packet* packet, const uint8_t* secret,
uint8_t* data, size_t len);
bool executeClientPathCommand(ClientInfo* client, mesh::RoomClientPathCommand operation,
const char* body, char* reply);
bool setRoomClientPath(ClientInfo* client, const char* which, const char* value, char* reply);
+443
View File
@@ -0,0 +1,443 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include "RoomMailStore.h"
namespace mesh {
static constexpr uint8_t ROOM_MAIL_REQUEST_SUBTYPE = 0x0b;
static constexpr size_t ROOM_MAIL_PROTOCOL_READ_CHUNK = 128;
static constexpr size_t ROOM_MAIL_MAX_COMMAND_LENGTH = 600;
// Requests follow the existing encrypted room request tag. The trusted caller
// supplies the authenticated full sender key, send permission and durable nonce;
// requests never supply an inbox owner. All integers below are little-endian.
// Every response starts [0x0b, operation, status]. Status values follow
// RoomMailResult: 0 success, 1 invalid, 2 unavailable, 3 write failure,
// 4 not found, 5 stale revision, 6 forbidden, 7 full, 8 successful duplicate,
// 9 nonce/body mismatch. A short invalid response may contain only this header.
//
// op request after [0x0b, op] response after common header
// 0 expected revision u32, allow cursor u8 revision u32, mode u8,
// mailbox-only u8, allow-count u8,
// start u8, next u8, queued u8,
// zero or more full 32-byte keys
// 1 expected revision u32, cursor u8 revision u32, total u8, start u8,
// next u8, copied u8, entries:
// id u32, sender key[32], length u16
// 2 message id u32, offset u16 id u32, offset u16, total u16,
// copied u8, sender key[32], bytes
// 3 recipient key[32], body length u16, message id u32
// complete UTF-8 body bytes
// 4 message id u32 (acknowledge receipt) message id u32
// 5 message id u32 (delete) message id u32
// 6 expected revision u32, allowed key[32] new revision u32
// 7 expected revision u32, denied key[32] new revision u32
// 8 expected revision u32, mode u8 new revision u32
// 9 expected revision u32, mailbox-only u8 new revision u32
//
// Mode values: 0 closed, 1 public, 2 private (allow list). All queue entries are
// unacknowledged; ACK durably removes a message, preserving its retry receipt.
// Index responses contain at most two whole entries. next==255 means complete.
// Start an inbox index at revision=0,cursor=0, then retain its returned revision.
// Settings are private to this owner; their index uses the same revision rules.
// Policy changes must echo the current revision, including 0 for a new inbox.
// Message IDs identify immutable content; reads can resume by offset. Empty
// inboxes are closed by default and reads/checks never provision an inbox.
// Every mutating operation checks its complete response fits BEFORE writing.
// Bodies are not fragmented on upload: each send must fit its request route.
// Decrypted room packets include AES zero padding. Logical request lengths are
// determined by the operation (and send's declared body length); only zero bytes
// may follow the logical request. Padding never becomes part of a stored body.
namespace room_mail_protocol_detail {
inline uint16_t get16(const uint8_t* bytes) {
return uint16_t(bytes[0]) | (uint16_t(bytes[1]) << 8);
}
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 put16(uint8_t* bytes, uint16_t value) {
bytes[0] = uint8_t(value); bytes[1] = uint8_t(value >> 8);
}
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 uint8_t status(RoomMailResult result) {
switch (result) {
case RoomMailResult::Success: return 0;
case RoomMailResult::Invalid: return 1;
case RoomMailResult::Unavailable: return 2;
case RoomMailResult::WriteFailure: return 3;
case RoomMailResult::NotFound: return 4;
case RoomMailResult::StaleVersion: return 5;
case RoomMailResult::Forbidden: return 6;
case RoomMailResult::Full: return 7;
case RoomMailResult::Duplicate: return 8;
case RoomMailResult::Mismatch: return 9;
}
return 1;
}
inline size_t header(uint8_t operation, RoomMailResult result,
uint8_t* reply, size_t capacity) {
if (!reply || capacity < 3) return 0;
reply[0] = ROOM_MAIL_REQUEST_SUBTYPE; reply[1] = operation;
reply[2] = status(result); return 3;
}
inline bool successful(RoomMailResult result) {
return result == RoomMailResult::Success || result == RoomMailResult::Duplicate;
}
inline bool validKey(const uint8_t* key) {
if (!key) return false;
uint8_t nonzero = 0;
for (size_t i = 0; i < 32; ++i) nonzero |= key[i];
return nonzero != 0;
}
inline bool paddedLength(const uint8_t* payload, size_t length, size_t logical) {
if (!payload || length < logical) return false;
for (size_t i = logical; i < length; ++i) if (payload[i] != 0) return false;
return true;
}
inline bool key(const char* text, uint8_t (&out)[32]) {
if (!text || strlen(text) != 64) return false;
uint8_t parsed[32];
for (size_t i = 0; i < 32; ++i) {
unsigned value = 0;
for (size_t j = 0; j < 2; ++j) {
const char c = text[i * 2 + j]; unsigned digit;
if (c >= '0' && c <= '9') digit = unsigned(c - '0');
else if (c >= 'a' && c <= 'f') digit = unsigned(c - 'a' + 10);
else if (c >= 'A' && c <= 'F') digit = unsigned(c - 'A' + 10);
else return false;
value = (value << 4) | digit;
}
parsed[i] = uint8_t(value);
}
if (!validKey(parsed)) return false;
memcpy(out, parsed, 32); return true;
}
inline void hex(const uint8_t* bytes, char (&out)[65]) {
static const char alphabet[] = "0123456789abcdef";
for (size_t i = 0; i < 32; ++i) {
out[i * 2] = alphabet[bytes[i] >> 4]; out[i * 2 + 1] = alphabet[bytes[i] & 15];
}
out[64] = 0;
}
inline const char* modeName(RoomMailMode mode) {
return mode == RoomMailMode::Public ? "public" : mode == RoomMailMode::Private ? "private" : "closed";
}
inline const char* spaces(const char* cursor) { while (*cursor == ' ') ++cursor; return cursor; }
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 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 utf8Prefix(const uint8_t* text, size_t length) {
size_t cursor = 0;
while (cursor < length) {
const uint8_t first = text[cursor];
const size_t width = first < 0x80 ? 1 : first < 0xe0 ? 2 : first < 0xf0 ? 3 : 4;
if (width > length - cursor) break;
cursor += width;
}
return cursor;
}
inline void textResult(char* reply, size_t capacity, RoomMailResult result,
uint32_t id = 0, bool message = false) {
if (!reply || !capacity) return;
if (successful(result)) {
if (message) snprintf(reply, capacity, "OK id=%lu%s", (unsigned long)id,
result == RoomMailResult::Duplicate ? " duplicate" : "");
else snprintf(reply, capacity, "OK");
return;
}
const char* error = "invalid";
switch (result) {
case RoomMailResult::Unavailable: error = "unavailable"; break;
case RoomMailResult::WriteFailure: error = "write failure"; break;
case RoomMailResult::NotFound: error = "not found"; break;
case RoomMailResult::StaleVersion: error = "stale revision"; break;
case RoomMailResult::Forbidden: error = "permission denied"; break;
case RoomMailResult::Full: error = "mailbox full"; break;
case RoomMailResult::Mismatch: error = "request changed"; break;
default: break;
}
snprintf(reply, capacity, "Error %s", error);
}
template <typename Filesystem>
RoomMailResult ownStatus(Filesystem* fs, const uint8_t* owner, RoomMailStatus& value) {
const RoomMailResult result = getRoomMailStatus(fs, owner, value);
return result == RoomMailResult::NotFound ? RoomMailResult::Success : result;
}
inline RoomMailResult editSettings(RoomMailSettings& settings, uint8_t operation,
const uint8_t* value) {
if (operation == 8) {
if (*value > 2) return RoomMailResult::Invalid;
settings.mode = static_cast<RoomMailMode>(*value); return RoomMailResult::Success;
}
if (operation == 9) {
if (*value > 1) return RoomMailResult::Invalid;
settings.mailbox_only = *value != 0; return RoomMailResult::Success;
}
if (!validKey(value)) return RoomMailResult::Invalid;
uint8_t position = 0;
while (position < settings.allowed_count && memcmp(settings.allowed[position], value, 32) != 0) ++position;
if (operation == 6) {
if (position < settings.allowed_count) return RoomMailResult::Success;
if (settings.allowed_count >= 8) return RoomMailResult::Full;
memcpy(settings.allowed[settings.allowed_count++], value, 32);
} else {
if (position == settings.allowed_count) return RoomMailResult::Success;
for (uint8_t i = position; i + 1 < settings.allowed_count; ++i)
memcpy(settings.allowed[i], settings.allowed[i + 1], 32);
memset(settings.allowed[--settings.allowed_count], 0, 32);
}
return RoomMailResult::Success;
}
} // namespace room_mail_protocol_detail
template <typename Filesystem>
size_t handleRoomMailRequest(Filesystem* fs, const uint8_t* owner, bool can_send,
uint64_t request_id, const uint8_t* payload, size_t length,
uint8_t* reply, size_t capacity, uint32_t created = 0) {
using namespace room_mail_protocol_detail;
const uint8_t operation = payload && length > 1 ? payload[1] : 255;
if (!payload || length < 2 || payload[0] != ROOM_MAIL_REQUEST_SUBTYPE || operation > 9
|| !validKey(owner) || !reply || capacity < 3)
return header(operation, RoomMailResult::Invalid, reply, capacity);
RoomMailResult result = RoomMailResult::Invalid;
if (operation == 0 || operation == 1) {
if (!paddedLength(payload, length, 7) || capacity < (operation == 0 ? 13U : 11U))
return header(operation, result, reply, capacity);
RoomMailStatus state;
result = ownStatus(fs, owner, state);
if (result != RoomMailResult::Success) return header(operation, result, reply, capacity);
const uint32_t expected = get32(payload + 2); const uint8_t start = payload[6];
if ((expected && expected != state.revision) || (!expected && start))
return header(operation, RoomMailResult::StaleVersion, reply, capacity);
header(operation, result, reply, capacity); put32(reply + 3, state.revision);
if (operation == 0) {
if (start > state.settings.allowed_count) return header(operation, RoomMailResult::Invalid, reply, capacity);
reply[7] = static_cast<uint8_t>(state.settings.mode); reply[8] = state.settings.mailbox_only ? 1 : 0;
reply[9] = state.settings.allowed_count; reply[10] = start; reply[12] = state.count;
size_t used = 13; uint8_t next = start;
while (next < state.settings.allowed_count && capacity - used >= 32) {
memcpy(reply + used, state.settings.allowed[next++], 32); used += 32;
}
if (next == start && next < state.settings.allowed_count)
return header(operation, RoomMailResult::Invalid, reply, capacity);
reply[11] = next < state.settings.allowed_count ? next : 255; return used;
}
if (start > state.count) return header(operation, RoomMailResult::Invalid, reply, capacity);
const size_t room = (capacity - 11) / 38;
if (!room && start < state.count) return header(operation, RoomMailResult::Invalid, reply, capacity);
RoomMailMessage messages[2]; size_t copied = 0;
if (state.count) result = listRoomMail(fs, owner, state.revision, start, messages, room < 2 ? room : 2, copied);
if (result != RoomMailResult::Success) return header(operation, result, reply, capacity);
reply[7] = state.count; reply[8] = start; reply[9] = start + copied < state.count ? uint8_t(start + copied) : 255;
reply[10] = uint8_t(copied); size_t used = 11;
for (size_t i = 0; i < copied; ++i) {
put32(reply + used, messages[i].id); memcpy(reply + used + 4, messages[i].sender, 32);
put16(reply + used + 36, messages[i].length); used += 38;
}
return used;
}
if (operation == 2) {
if (!paddedLength(payload, length, 8) || capacity < 44) return header(operation, result, reply, capacity);
const uint32_t id = get32(payload + 2); const uint16_t offset = get16(payload + 6);
const size_t available = capacity - 44;
uint8_t bytes[ROOM_MAIL_PROTOCOL_READ_CHUNK]; size_t copied = 0; RoomMailMessage message;
// A zero-byte EOF still requires a nonzero read capacity in the store.
result = readRoomMail(fs, owner, id, offset, bytes,
available ? (available < sizeof(bytes) ? available : sizeof(bytes)) : 1, copied, message);
if (result != RoomMailResult::Success) return header(operation, result, reply, capacity);
if (copied > available) return header(operation, RoomMailResult::Invalid, reply, capacity);
header(operation, result, reply, capacity); put32(reply + 3, id); put16(reply + 7, offset);
put16(reply + 9, message.length); reply[11] = uint8_t(copied); memcpy(reply + 12, message.sender, 32);
if (copied) memcpy(reply + 44, bytes, copied);
return 44 + copied;
}
if (capacity < 7) return header(operation, result, reply, capacity);
uint32_t id = 0;
if (operation == 3) {
if (length < 36 || !validKey(payload + 2))
return header(operation, result, reply, capacity);
const size_t body_length = get16(payload + 34);
if (!paddedLength(payload, length, 36 + body_length)) return header(operation, result, reply, capacity);
result = can_send ? sendRoomMail(fs, owner, payload + 2, request_id,
reinterpret_cast<const char*>(payload + 36), body_length, id, created) : RoomMailResult::Forbidden;
} else if (operation == 4 || operation == 5) {
if (!paddedLength(payload, length, 6) || !(id = get32(payload + 2))) return header(operation, result, reply, capacity);
result = operation == 4 ? acknowledgeRoomMail(fs, owner, id) : deleteRoomMail(fs, owner, id);
} else {
if (!paddedLength(payload, length, operation <= 7 ? 38U : 7U)) return header(operation, result, reply, capacity);
RoomMailStatus state; result = ownStatus(fs, owner, state);
if (result != RoomMailResult::Success) return header(operation, result, reply, capacity);
if (get32(payload + 2) != state.revision) return header(operation, RoomMailResult::StaleVersion, reply, capacity);
result = editSettings(state.settings, operation, payload + 6);
if (result == RoomMailResult::Success) result = saveRoomMailSettings(fs, owner, state.settings, state.revision);
if (result == RoomMailResult::Success) {
result = ownStatus(fs, owner, state); id = state.revision;
}
}
header(operation, result, reply, capacity); put32(reply + 3, id); return 7;
}
// The caller strips an optional ! prefix for the stock-app !mail fallback.
// Only the trusted owner is used for settings/list/read/ack/delete. Ordinary
// authenticated readers may manage their own inbox; sends additionally require
// can_send. CLI reads return complete UTF-8 characters with the next byte offset.
// An offset in the middle of a character is rejected. Mutating replies
// require capacity >=32 so their ID/confirmation cannot be silently truncated.
// CLI next==total (or allowed count) completes an index. A short route may show
// a sender key prefix followed by ".."; only complete keys authorize/send mail.
template <typename Filesystem>
bool handleRoomMailCommand(Filesystem* fs, const uint8_t* owner, bool can_send,
uint64_t request_id, const char* command,
char* reply, size_t capacity, uint32_t created = 0) {
using namespace room_mail_protocol_detail;
if (!command || !prefix(command, "mail")) return false;
if (!reply || !capacity) return true;
reply[0] = 0;
size_t length = 0;
while (length <= ROOM_MAIL_MAX_COMMAND_LENGTH && command[length]) ++length;
if (length > ROOM_MAIL_MAX_COMMAND_LENGTH || !validKey(owner)) {
textResult(reply, capacity, RoomMailResult::Invalid); return true;
}
const char* body = spaces(command + 4);
const bool settings = prefix(body, "settings") || prefix(body, "check");
const bool inbox = prefix(body, "inbox");
const bool list = prefix(body, "list") || inbox, read = prefix(body, "read");
const bool send = prefix(body, "send"), ack = prefix(body, "ack"), del = prefix(body, "delete");
const bool mode = prefix(body, "mode"), delivery = prefix(body, "delivery");
const bool allow = prefix(body, "allow"), deny = prefix(body, "deny");
if (!(settings || list || read || send || ack || del || mode || delivery || allow || deny)) {
textResult(reply, capacity, RoomMailResult::Invalid); return true;
}
if (!(settings || list || read) && capacity < 32) {
textResult(reply, capacity, RoomMailResult::Invalid); return true;
}
RoomMailResult result = RoomMailResult::Invalid;
if (send) {
const char* cursor = spaces(body + 4); const char* end = strchr(cursor, ' ');
uint8_t recipient[32]; char text_key[65];
if (!end || end - cursor != 64) { textResult(reply, capacity, result); return true; }
memcpy(text_key, cursor, 64); text_key[64] = 0;
const char* text = spaces(end);
uint32_t id = 0;
if (key(text_key, recipient) && *text)
result = can_send ? sendRoomMail(fs, owner, recipient, request_id, text, strlen(text), id, created) : RoomMailResult::Forbidden;
textResult(reply, capacity, result, id, true); return true;
}
if (ack || del || read) {
const char* cursor = body + (ack ? 3 : del ? 6 : 4); uint32_t id, offset = 0;
if (!number(cursor, UINT32_MAX, id) || id == 0
|| (read && *spaces(cursor) && !number(cursor, 512, offset)) || *spaces(cursor)) {
textResult(reply, capacity, result); return true;
}
if (!read) {
result = ack ? acknowledgeRoomMail(fs, owner, id) : deleteRoomMail(fs, owner, id);
textResult(reply, capacity, result, id, true); return true;
}
RoomMailMessage message; uint8_t bytes[ROOM_MAIL_PROTOCOL_READ_CHUNK]; size_t copied = 0;
result = readRoomMail(fs, owner, id, offset, bytes, sizeof(bytes), copied, message);
if (result != RoomMailResult::Success) { textResult(reply, capacity, result); return true; }
if (copied && (bytes[0] & 0xc0) == 0x80) {
snprintf(reply, capacity, "Error offset inside UTF-8 character"); return true;
}
size_t count = utf8Prefix(bytes, copied); int prefix_length;
do {
prefix_length = snprintf(reply, capacity, "id=%lu off=%lu next=%lu total=%u text=",
(unsigned long)id, (unsigned long)offset, (unsigned long)(offset + count), unsigned(message.length));
if (prefix_length >= 0 && size_t(prefix_length) + count < capacity) break;
if (!count) { textResult(reply, capacity, RoomMailResult::Invalid); return true; }
count = utf8Prefix(bytes, count - 1);
} while (true);
if (!count && copied) { textResult(reply, capacity, RoomMailResult::Invalid); return true; }
memcpy(reply + prefix_length, bytes, count); reply[prefix_length + count] = 0; return true;
}
RoomMailStatus state; result = ownStatus(fs, owner, state);
if (result != RoomMailResult::Success) { textResult(reply, capacity, result); return true; }
if (settings || list) {
const char* cursor = body + (settings ? (prefix(body, "check") ? 5 : 8) : inbox ? 5 : 4); uint32_t start = 0;
const uint8_t total = settings ? state.settings.allowed_count : state.count;
if ((*spaces(cursor) && !number(cursor, total, start)) || *spaces(cursor)) {
textResult(reply, capacity, RoomMailResult::Invalid); return true;
}
char entries[160] = {}; size_t used = 0; uint32_t next = start; int prefix_length;
RoomMailMessage messages[2]; size_t copied = 0;
if (list && state.count) result = listRoomMail(fs, owner, state.revision, start, messages, 2, copied);
if (result != RoomMailResult::Success) { textResult(reply, capacity, result); return true; }
while (next < total && (settings || next - start < copied)) {
char full_key[65]; char entry[90];
if (settings) { hex(state.settings.allowed[next], full_key); snprintf(entry, sizeof(entry), " %s", full_key); }
else {
const auto& message = messages[next - start]; hex(message.sender, full_key);
snprintf(entry, sizeof(entry), " %lu/%u:%s", (unsigned long)message.id, unsigned(message.length), full_key);
}
char header_text[128];
const int n = settings ? snprintf(header_text, sizeof(header_text),
"rev=%lu mode=%s delivery=%s queued=%u allowed=%u start=%lu next=%lu",
(unsigned long)state.revision, modeName(state.settings.mode), state.settings.mailbox_only ? "mailbox" : "chat",
unsigned(state.count), unsigned(total), (unsigned long)start, (unsigned long)(next + 1))
: snprintf(header_text, sizeof(header_text), "rev=%lu total=%u start=%lu next=%lu",
(unsigned long)state.revision, unsigned(total), (unsigned long)start, (unsigned long)(next + 1));
if (!settings && n >= 0 && size_t(n) + used + strlen(entry) >= capacity) {
const auto& message = messages[next - start];
for (size_t digits = 16; digits >= 8; digits -= 8) {
full_key[digits] = 0;
snprintf(entry, sizeof(entry), " %lu/%u:%s..", (unsigned long)message.id, unsigned(message.length), full_key);
if (size_t(n) + used + strlen(entry) < capacity) break;
}
}
const size_t entry_length = strlen(entry);
if (n < 0 || size_t(n) >= sizeof(header_text) || entry_length >= sizeof(entries) - used
|| size_t(n) + used + entry_length >= capacity) break;
memcpy(entries + used, entry, entry_length + 1); used += entry_length; ++next;
}
if (next == start && next < total) { textResult(reply, capacity, RoomMailResult::Invalid); return true; }
prefix_length = settings ? snprintf(reply, capacity,
"rev=%lu mode=%s delivery=%s queued=%u allowed=%u start=%lu next=%lu%s",
(unsigned long)state.revision, modeName(state.settings.mode), state.settings.mailbox_only ? "mailbox" : "chat",
unsigned(state.count), unsigned(total), (unsigned long)start, (unsigned long)next, entries)
: snprintf(reply, capacity, "rev=%lu total=%u start=%lu next=%lu%s", (unsigned long)state.revision,
unsigned(total), (unsigned long)start, (unsigned long)next, entries);
if (prefix_length < 0 || size_t(prefix_length) >= capacity) textResult(reply, capacity, RoomMailResult::Invalid);
return true;
}
const char* value = spaces(body + (mode ? 4 : delivery ? 8 : allow ? 5 : 4));
uint8_t bytes[32] = {}; uint8_t operation;
if (mode) {
operation = 8;
if (strcmp(value, "closed") == 0) bytes[0] = 0;
else if (strcmp(value, "public") == 0) bytes[0] = 1;
else if (strcmp(value, "private") == 0) bytes[0] = 2;
else { textResult(reply, capacity, RoomMailResult::Invalid); return true; }
} else if (delivery) {
operation = 9;
if (strcmp(value, "mailbox") == 0) bytes[0] = 1;
else if (strcmp(value, "chat") != 0) { textResult(reply, capacity, RoomMailResult::Invalid); return true; }
} else {
operation = allow ? 6 : 7;
if (!key(value, bytes)) { textResult(reply, capacity, RoomMailResult::Invalid); return true; }
}
result = editSettings(state.settings, operation, bytes);
if (result == RoomMailResult::Success) result = saveRoomMailSettings(fs, owner, state.settings, state.revision);
textResult(reply, capacity, result); return true;
}
} // namespace mesh
+729
View File
@@ -0,0 +1,729 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <SHA256.h>
#include "FilePresence.h"
namespace mesh {
static constexpr uint8_t ROOM_MAIL_MAX_OWNERS = 32;
static constexpr uint8_t ROOM_MAIL_MAX_MESSAGES = 16;
static constexpr uint8_t ROOM_MAIL_MAX_PER_OWNER = 4;
static constexpr uint8_t ROOM_MAIL_MAX_ALLOWED = 8;
static constexpr uint8_t ROOM_MAIL_RETRY_RECEIPTS = 8;
static constexpr size_t ROOM_MAIL_MAX_BODY_LENGTH = 512;
static constexpr size_t ROOM_MAIL_READ_MAX_CHUNK = 128;
static const char ROOM_MAIL_PRIMARY_PATH[] = "/room_mail";
static const char ROOM_MAIL_TEMP_PATH[] = "/room_mail.tmp";
static const char ROOM_MAIL_BACKUP_PATH[] = "/room_mail.bak";
enum class RoomMailResult : uint8_t {
Success, Invalid, Unavailable, WriteFailure, NotFound, StaleVersion,
Forbidden, Full, Duplicate, Mismatch
};
enum class RoomMailMode : uint8_t { Closed = 0, Public = 1, Private = 2 };
struct RoomMailSettings {
RoomMailMode mode = RoomMailMode::Closed;
bool mailbox_only = false;
uint8_t allowed_count = 0;
uint8_t allowed[ROOM_MAIL_MAX_ALLOWED][32] = {};
};
struct RoomMailStatus {
uint32_t revision = 0;
uint8_t count = 0;
RoomMailSettings settings;
};
struct RoomMailMessage {
uint32_t id = 0;
uint64_t request_id = 0;
uint8_t sender[32] = {};
uint16_t length = 0;
uint32_t created = 0;
};
struct RoomMailStats {
uint32_t revision = 0;
uint8_t owners = 0;
uint8_t messages = 0;
};
// The caller supplies the authenticated full identity for owner operations.
// Public means eligible room writers; room roles and bans remain the caller's
// responsibility. The room operator is trusted: bodies are plaintext at rest.
inline bool roomMailSenderAllowed(const RoomMailSettings& settings,
const uint8_t* owner, const uint8_t* sender) {
if (!owner || !sender) return false;
if (memcmp(owner, sender, 32) == 0) return true;
if (settings.mode == RoomMailMode::Public) return true;
if (settings.mode != RoomMailMode::Private) return false;
for (uint8_t i = 0; i < settings.allowed_count && i < ROOM_MAIL_MAX_ALLOWED; ++i)
if (memcmp(settings.allowed[i], sender, 32) == 0) return true;
return false;
}
namespace room_mail_detail {
static constexpr size_t INDEX_HEADER_SIZE = 16;
static constexpr size_t INDEX_ENTRY_SIZE = 36;
static constexpr size_t OWNER_HEADER_SIZE = 48;
static constexpr size_t POLICY_SIZE = 260;
static constexpr size_t RECORD_SIZE = 88;
static constexpr size_t BODY_START = OWNER_HEADER_SIZE + POLICY_SIZE
+ ROOM_MAIL_RETRY_RECEIPTS * RECORD_SIZE;
static constexpr size_t STREAM_CHUNK = 64;
static const uint8_t INDEX_MAGIC[4] = {'R', 'M', 'I', 1};
static const uint8_t OWNER_MAGIC[4] = {'R', 'M', 'O', 1};
enum : uint8_t { Queued = 1, Acknowledged = 2, Deleted = 3 };
struct Entry {
uint8_t owner[32] = {};
uint8_t slot = 0, bank = 0, count = 0;
};
struct Index {
uint32_t revision = 0, slots = 0;
uint8_t count = 0, messages = 0;
};
struct Record {
RoomMailMessage message;
uint8_t digest[32] = {};
uint16_t offset = 0;
uint8_t state = 0;
};
struct Owner {
RoomMailSettings settings;
uint32_t revision = 0;
uint8_t count = 0, receipts = 0;
Record records[ROOM_MAIL_RETRY_RECEIPTS];
};
static_assert(sizeof(Owner) <= 1152, "Mailbox working metadata must remain bounded");
inline void clearSettings(RoomMailSettings& settings) {
settings.mode = RoomMailMode::Closed;
settings.mailbox_only = false; settings.allowed_count = 0;
memset(settings.allowed, 0, sizeof(settings.allowed));
}
inline void clearStatus(RoomMailStatus& status) {
status.revision = 0; status.count = 0; clearSettings(status.settings);
}
// Avoid an aggregate Owner{} assignment: some size-optimized embedded builds
// reserve a second full Owner temporary on the stack even for the error path.
inline void clearOwner(Owner& owner) {
owner.revision = 0; owner.count = 0; owner.receipts = 0;
clearSettings(owner.settings);
for (uint8_t i = 0; i < ROOM_MAIL_RETRY_RECEIPTS; ++i) {
Record& record = owner.records[i];
record.message.id = 0; record.message.request_id = 0;
record.message.length = 0; record.message.created = 0;
memset(record.message.sender, 0, sizeof(record.message.sender));
memset(record.digest, 0, sizeof(record.digest));
record.offset = 0; record.state = 0;
}
}
inline bool nonzero(const uint8_t* key) {
if (!key) return false;
for (uint8_t i = 0; i < 32; ++i) if (key[i]) return true;
return false;
}
inline uint32_t get32(const uint8_t* p) {
return uint32_t(p[0]) | (uint32_t(p[1]) << 8) | (uint32_t(p[2]) << 16) | (uint32_t(p[3]) << 24);
}
inline void put32(uint8_t* p, uint32_t value) {
for (uint8_t i = 0; i < 4; ++i) p[i] = uint8_t(value >> (8 * i));
}
inline uint16_t get16(const uint8_t* p) { return uint16_t(p[0]) | (uint16_t(p[1]) << 8); }
inline void put16(uint8_t* p, uint16_t value) { p[0] = uint8_t(value); p[1] = uint8_t(value >> 8); }
inline uint64_t get64(const uint8_t* p) {
return uint64_t(get32(p)) | (uint64_t(get32(p + 4)) << 32);
}
inline void put64(uint8_t* p, uint64_t value) {
put32(p, uint32_t(value)); put32(p + 4, uint32_t(value >> 32));
}
inline uint32_t crc(uint32_t value, const uint8_t* bytes, size_t length) {
while (length--) {
value ^= *bytes++;
for (uint8_t bit = 0; bit < 8; ++bit)
value = (value >> 1) ^ ((value & 1) ? UINT32_C(0xedb88320) : 0);
}
return value;
}
inline bool zero(const uint8_t* bytes, size_t length) {
while (length--) if (*bytes++) return false;
return true;
}
inline bool validSettings(const RoomMailSettings& s) {
if (uint8_t(s.mode) > 2 || s.allowed_count > ROOM_MAIL_MAX_ALLOWED) return false;
for (uint8_t i = 0; i < s.allowed_count; ++i) {
if (!nonzero(s.allowed[i])) return false;
for (uint8_t j = 0; j < i; ++j) if (!memcmp(s.allowed[i], s.allowed[j], 32)) return false;
}
return true;
}
// Complete UTF-8 text only. Newlines and tabs are valid; embedded NUL is not.
inline bool validBody(const char* body, size_t length) {
if (!body || !length || length > ROOM_MAIL_MAX_BODY_LENGTH) return false;
for (size_t i = 0; i < length;) {
const uint8_t first = uint8_t(body[i++]);
if (!first) return false;
if (first < 0x80) 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 & 7; minimum = 0x10000; }
else return false;
if (count > length - i) return false;
while (count--) {
const uint8_t next = uint8_t(body[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 bodyDigest(const char* body, size_t length, uint8_t* output) {
SHA256 sha; sha.update(body, length); sha.finalize(output, 32);
}
inline void ownerPath(const Entry& entry, char (&path)[24]) {
snprintf(path, sizeof(path), "/room_mail_%u.%c", unsigned(entry.slot), entry.bank ? 'b' : 'a');
}
inline void encodeEntry(const Entry& entry, uint8_t (&bytes)[INDEX_ENTRY_SIZE]) {
memcpy(bytes, entry.owner, 32); bytes[32] = entry.slot; bytes[33] = entry.bank;
bytes[34] = entry.count; bytes[35] = 0;
}
inline void encodeRecord(const Record& r, uint8_t (&bytes)[RECORD_SIZE]) {
memset(bytes, 0, sizeof(bytes));
if (!r.state) return;
memcpy(bytes, r.message.sender, 32); put64(bytes + 32, r.message.request_id);
put32(bytes + 40, r.message.id); put32(bytes + 44, r.message.created);
put16(bytes + 48, r.message.length); bytes[50] = r.state;
memcpy(bytes + 52, r.digest, 32);
}
template <typename Filesystem>
auto openRead(Filesystem* fs, const char* path)
#if defined(RP2040_PLATFORM)
-> decltype(fs->open(path, static_cast<const char*>(nullptr))) { return fs->open(path, "r"); }
#else
-> decltype(fs->open(path)) { return fs->open(path); }
#endif
template <typename Filesystem>
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<const char*>(nullptr), true)) { return fs->open(path, "w", true); }
#else
-> decltype(fs->open(path, static_cast<const char*>(nullptr))) { return fs->open(path, "w"); }
#endif
template <typename Filesystem>
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;
}
// Index validation is streamed: no 32-owner RAM registry. Keys are sorted and
// slots are stable independently of ACL eviction or index cursor changes.
template <typename Filesystem>
bool readIndex(Filesystem* fs, const char* path, Index& index,
const uint8_t* owner = nullptr, Entry* found = nullptr,
bool* has_owner = nullptr, const uint32_t* expected_crc = nullptr) {
index = Index{}; if (has_owner) *has_owner = false;
auto file = openRead(fs, path); if (!file) return false;
uint8_t header[INDEX_HEADER_SIZE];
bool valid = !file.isDirectory() && size_t(file.read(header, sizeof(header))) == sizeof(header);
if (valid) valid = !memcmp(header, INDEX_MAGIC, 4) && get32(header + 4) != 0
&& header[8] <= ROOM_MAIL_MAX_OWNERS && header[9] <= ROOM_MAIL_MAX_MESSAGES
&& zero(header + 10, 6) && file.size() == INDEX_HEADER_SIZE + header[8] * INDEX_ENTRY_SIZE + 4;
uint32_t sum = valid ? crc(UINT32_C(0xffffffff), header, sizeof(header)) : 0;
uint8_t previous[32] = {}; uint8_t total = 0;
if (valid) { index.revision = get32(header + 4); index.count = header[8]; index.messages = header[9]; }
for (uint8_t i = 0; valid && i < index.count; ++i) {
uint8_t bytes[INDEX_ENTRY_SIZE];
valid = size_t(file.read(bytes, sizeof(bytes))) == sizeof(bytes);
if (!valid) break;
sum = crc(sum, bytes, sizeof(bytes));
valid = nonzero(bytes) && bytes[32] < ROOM_MAIL_MAX_OWNERS && bytes[33] <= 1
&& bytes[34] <= ROOM_MAIL_MAX_PER_OWNER && bytes[35] == 0
&& !(index.slots & (UINT32_C(1) << bytes[32]))
&& (i == 0 || memcmp(previous, bytes, 32) < 0);
if (!valid) break;
index.slots |= UINT32_C(1) << bytes[32]; total += bytes[34]; memcpy(previous, bytes, 32);
if (owner && !memcmp(owner, bytes, 32)) {
if (found) { memcpy(found->owner, bytes, 32); found->slot = bytes[32]; found->bank = bytes[33]; found->count = bytes[34]; }
if (has_owner) *has_owner = true;
}
}
uint8_t trailer[4]; sum ^= UINT32_C(0xffffffff);
if (valid) valid = total == index.messages && size_t(file.read(trailer, 4)) == 4
&& get32(trailer) == sum && (!expected_crc || sum == *expected_crc);
file.close();
if (!valid) { index = Index{}; if (has_owner) *has_owner = false; }
return valid;
}
// Present but invalid primary metadata is authoritative. Preserve all evidence.
// Only absence permits restoration of a verified backup. Temp is uncommitted.
template <typename Filesystem>
bool recover(Filesystem* fs, Index& index, const uint8_t* owner,
Entry& entry, bool& found, bool cleanup) {
index = Index{}; found = false;
bool present;
if (!fs || !filePresence(fs, ROOM_MAIL_PRIMARY_PATH, present)) return false;
if (present) {
if (!readIndex(fs, ROOM_MAIL_PRIMARY_PATH, index, owner, &entry, &found)) return false;
} else {
if (!filePresence(fs, ROOM_MAIL_BACKUP_PATH, present)) return false;
if (present) {
if (!readIndex(fs, ROOM_MAIL_BACKUP_PATH, index, owner, &entry, &found)
|| !fs->rename(ROOM_MAIL_BACKUP_PATH, ROOM_MAIL_PRIMARY_PATH)) return false;
}
}
if (cleanup) return removeArtifact(fs, ROOM_MAIL_TEMP_PATH) && removeArtifact(fs, ROOM_MAIL_BACKUP_PATH);
return true;
}
// Entire selected bank is verified before releasing any requested slice. Owner
// metadata is optional: validation needs only fixed 88-byte/64-byte buffers.
template <typename Filesystem>
bool readOwner(Filesystem* fs, const Entry& entry, Owner* out,
uint32_t selected_id = 0, size_t offset = 0,
uint8_t* output = nullptr, size_t capacity = 0,
const uint32_t* expected_crc = nullptr,
RoomMailStatus* status = nullptr, bool* mailbox_only = nullptr,
uint32_t max_revision = UINT32_MAX) {
if (out) clearOwner(*out);
if (status) clearStatus(*status);
if (mailbox_only) *mailbox_only = false;
char path[24]; ownerPath(entry, path);
auto file = openRead(fs, path); if (!file) return false;
uint8_t header[OWNER_HEADER_SIZE];
bool valid = !file.isDirectory() && size_t(file.read(header, sizeof(header))) == sizeof(header);
if (valid) valid = !memcmp(header, OWNER_MAGIC, 4) && !memcmp(header + 4, entry.owner, 32)
&& get32(header + 36) != 0 && get32(header + 36) <= max_revision
&& header[40] == entry.slot && header[41] == entry.bank
&& header[42] == entry.count && header[43] <= ROOM_MAIL_RETRY_RECEIPTS
&& zero(header + 44, 4);
uint32_t sum = valid ? crc(UINT32_C(0xffffffff), header, sizeof(header)) : 0;
uint8_t policy[4];
if (valid) valid = size_t(file.read(policy, 4)) == 4 && policy[0] <= 2 && policy[1] <= 1
&& policy[2] <= ROOM_MAIL_MAX_ALLOWED && policy[3] == 0;
if (valid) {
sum = crc(sum, policy, 4);
if (out) { out->revision = get32(header + 36); out->count = header[42]; out->receipts = header[43];
out->settings.mode = RoomMailMode(policy[0]); out->settings.mailbox_only = policy[1] != 0;
out->settings.allowed_count = policy[2]; }
if (status) {
status->revision = get32(header + 36); status->count = header[42];
status->settings.mode = RoomMailMode(policy[0]);
status->settings.mailbox_only = policy[1] != 0;
status->settings.allowed_count = policy[2];
}
}
uint8_t previous[32] = {};
for (uint8_t i = 0; valid && i < ROOM_MAIL_MAX_ALLOWED; ++i) {
uint8_t key[32]; valid = size_t(file.read(key, 32)) == 32;
if (!valid) break;
sum = crc(sum, key, 32);
valid = i < policy[2] ? nonzero(key) && (i == 0 || memcmp(previous, key, 32) < 0) : zero(key, 32);
if (i < policy[2]) memcpy(previous, key, 32);
if (out) memcpy(out->settings.allowed[i], key, 32);
if (status) memcpy(status->settings.allowed[i], key, 32);
}
uint32_t previous_id = 0; uint8_t queued = 0;
size_t body_length = 0, selected_position = 0, selected_length = 0;
for (uint8_t i = 0; valid && i < ROOM_MAIL_RETRY_RECEIPTS; ++i) {
uint8_t bytes[RECORD_SIZE]; valid = size_t(file.read(bytes, sizeof(bytes))) == sizeof(bytes);
if (!valid) break;
sum = crc(sum, bytes, sizeof(bytes));
if (i >= header[43]) { valid = zero(bytes, sizeof(bytes)); continue; }
const uint32_t id = get32(bytes + 40); const uint16_t length = get16(bytes + 48);
valid = nonzero(bytes) && get64(bytes + 32) != 0 && id > previous_id
&& id <= get32(header + 36) && length != 0 && length <= ROOM_MAIL_MAX_BODY_LENGTH
&& bytes[50] >= Queued && bytes[50] <= Deleted && bytes[51] == 0 && zero(bytes + 84, 4);
previous_id = id;
if (out) {
Record& record = out->records[i]; memcpy(record.message.sender, bytes, 32);
record.message.request_id = get64(bytes + 32); record.message.id = id;
record.message.created = get32(bytes + 44); record.message.length = length;
record.state = bytes[50]; memcpy(record.digest, bytes + 52, 32);
record.offset = uint16_t(BODY_START + body_length);
}
if (bytes[50] == Queued) {
if (id == selected_id) { selected_position = body_length; selected_length = length; }
body_length += length; ++queued;
}
}
if (valid) valid = queued == entry.count && file.size() == BODY_START + body_length + 4;
uint8_t chunk[STREAM_CHUNK];
for (size_t position = 0; valid && position < body_length;) {
const size_t remaining = body_length - position;
const size_t length = remaining < sizeof(chunk) ? remaining : sizeof(chunk);
valid = size_t(file.read(chunk, length)) == length;
if (!valid) break;
sum = crc(sum, chunk, length);
const size_t begin = selected_position + offset;
size_t wanted = selected_length > offset ? selected_length - offset : 0;
if (wanted > capacity) wanted = capacity;
if (output && selected_length && position + length > begin && position < begin + wanted) {
const size_t start = position < begin ? begin - position : 0;
size_t end = length; if (position + end > begin + wanted) end = begin + wanted - position;
memcpy(output + position + start - begin, chunk + start, end - start);
}
position += length;
}
uint8_t trailer[4]; sum ^= UINT32_C(0xffffffff);
if (valid) valid = size_t(file.read(trailer, 4)) == 4 && get32(trailer) == sum
&& (!expected_crc || sum == *expected_crc);
file.close();
if (!valid) {
if (out) clearOwner(*out);
if (status) clearStatus(*status);
if (output) memset(output, 0, capacity);
} else if (mailbox_only) *mailbox_only = policy[1] != 0;
return valid;
}
template <typename File>
bool writeBytes(File& file, uint32_t& sum, const uint8_t* bytes, size_t length) {
if (file.write(bytes, length) != length) return false;
sum = crc(sum, bytes, length); return true;
}
template <typename Filesystem>
bool writeOwner(Filesystem* fs, const Entry& entry, const Owner& owner,
bool had_owner, const Entry& previous,
uint32_t& written_checksum,
uint32_t new_id = 0, const char* new_body = nullptr) {
char path[24]; ownerPath(entry, path);
if (!removeArtifact(fs, path)) return false;
auto file = openWrite(fs, path); if (!file) return false;
uint8_t header[OWNER_HEADER_SIZE] = {};
memcpy(header, OWNER_MAGIC, 4); memcpy(header + 4, entry.owner, 32);
put32(header + 36, owner.revision); header[40] = entry.slot; header[41] = entry.bank;
header[42] = owner.count; header[43] = owner.receipts;
uint32_t sum = UINT32_C(0xffffffff);
bool okay = !file.isDirectory() && writeBytes(file, sum, header, sizeof(header));
uint8_t policy[4] = {uint8_t(owner.settings.mode), uint8_t(owner.settings.mailbox_only), owner.settings.allowed_count, 0};
okay = okay && writeBytes(file, sum, policy, 4);
for (uint8_t i = 0; okay && i < ROOM_MAIL_MAX_ALLOWED; ++i)
okay = writeBytes(file, sum, owner.settings.allowed[i], 32);
for (uint8_t i = 0; okay && i < ROOM_MAIL_RETRY_RECEIPTS; ++i) {
uint8_t bytes[RECORD_SIZE]; encodeRecord(owner.records[i], bytes);
okay = writeBytes(file, sum, bytes, sizeof(bytes));
}
// Adafruit LittleFS File requires its filesystem at construction. Obtain
// the source through open(), including its valid filesystem-bound failure
// value, rather than default-constructing an optional File handle.
Entry source_entry = previous;
if (!had_owner) source_entry.bank = 1 - entry.bank;
char source_path[24]; ownerPath(source_entry, source_path);
auto source = openRead(fs, source_path);
if (!had_owner && source) source.close();
size_t source_position = 0;
uint8_t chunk[STREAM_CHUNK];
for (uint8_t i = 0; okay && i < owner.receipts; ++i) {
const Record& record = owner.records[i]; if (record.state != Queued) continue;
if (record.message.id != new_id && (!had_owner || !source || source.isDirectory())) { okay = false; break; }
while (okay && record.message.id != new_id && source_position < record.offset) {
size_t length = record.offset - source_position; if (length > sizeof(chunk)) length = sizeof(chunk);
okay = size_t(source.read(chunk, length)) == length; source_position += length;
}
for (size_t position = 0; okay && position < record.message.length;) {
size_t length = record.message.length - position; if (length > sizeof(chunk)) length = sizeof(chunk);
if (record.message.id == new_id) {
if (!new_body) { okay = false; break; }
memcpy(chunk, new_body + position, length);
} else { okay = size_t(source.read(chunk, length)) == length; source_position += length; }
okay = okay && writeBytes(file, sum, chunk, length); position += length;
}
}
if (source) source.close();
sum ^= UINT32_C(0xffffffff); uint8_t trailer[4]; put32(trailer, sum);
okay = okay && file.write(trailer, 4) == 4; file.flush(); file.close();
written_checksum = sum;
return okay;
}
// Publish a streamed sorted registry pointing at an already verified bank.
// A failed rename can leave a verified backup; recover() restores it on retry.
template <typename Filesystem>
RoomMailResult publish(Filesystem* fs, const Index& prior, const Entry& entry, bool existed,
uint32_t revision) {
if (!removeArtifact(fs, ROOM_MAIL_TEMP_PATH) || !removeArtifact(fs, ROOM_MAIL_BACKUP_PATH))
return RoomMailResult::WriteFailure;
auto file = openWrite(fs, ROOM_MAIL_TEMP_PATH); if (!file) return RoomMailResult::WriteFailure;
uint8_t header[INDEX_HEADER_SIZE] = {};
memcpy(header, INDEX_MAGIC, 4); put32(header + 4, revision);
header[8] = prior.count + (existed ? 0 : 1);
uint8_t old_count = 0;
Entry ignored; Index checked; bool found = false;
if (existed && !readIndex(fs, ROOM_MAIL_PRIMARY_PATH, checked, entry.owner, &ignored, &found)) {
file.close(); return RoomMailResult::WriteFailure;
}
if (found) old_count = ignored.count;
header[9] = prior.messages - old_count + entry.count;
uint32_t sum = UINT32_C(0xffffffff);
bool okay = !file.isDirectory() && writeBytes(file, sum, header, sizeof(header));
auto source = openRead(fs, ROOM_MAIL_PRIMARY_PATH);
if (prior.revision) {
uint8_t skipped[INDEX_HEADER_SIZE];
okay = okay && source && !source.isDirectory() && size_t(source.read(skipped, sizeof(skipped))) == sizeof(skipped);
}
bool emitted = false;
for (uint8_t i = 0; okay && i < prior.count; ++i) {
uint8_t bytes[INDEX_ENTRY_SIZE]; okay = size_t(source.read(bytes, sizeof(bytes))) == sizeof(bytes);
if (!okay) break;
const int comparison = memcmp(entry.owner, bytes, 32);
if (!emitted && comparison <= 0) {
uint8_t added[INDEX_ENTRY_SIZE]; encodeEntry(entry, added);
okay = writeBytes(file, sum, added, sizeof(added)); emitted = true;
}
if (comparison != 0) okay = okay && writeBytes(file, sum, bytes, sizeof(bytes));
}
if (okay && !emitted) { uint8_t bytes[INDEX_ENTRY_SIZE]; encodeEntry(entry, bytes); okay = writeBytes(file, sum, bytes, sizeof(bytes)); }
if (source) source.close();
sum ^= UINT32_C(0xffffffff); uint8_t trailer[4]; put32(trailer, sum);
okay = okay && file.write(trailer, 4) == 4; file.flush(); file.close();
Index verify;
if (!okay || !readIndex(fs, ROOM_MAIL_TEMP_PATH, verify, nullptr, nullptr, nullptr, &sum))
return RoomMailResult::WriteFailure;
bool primary;
if (!filePresence(fs, ROOM_MAIL_PRIMARY_PATH, primary)
|| (primary && !fs->rename(ROOM_MAIL_PRIMARY_PATH, ROOM_MAIL_BACKUP_PATH)))
return RoomMailResult::WriteFailure;
if (!fs->rename(ROOM_MAIL_TEMP_PATH, ROOM_MAIL_PRIMARY_PATH)) {
if (primary) fs->rename(ROOM_MAIL_BACKUP_PATH, ROOM_MAIL_PRIMARY_PATH);
return RoomMailResult::WriteFailure;
}
// Registry rename is the commit. Housekeeping failure never undoes success.
removeArtifact(fs, ROOM_MAIL_BACKUP_PATH);
return RoomMailResult::Success;
}
template <typename Filesystem>
RoomMailResult commit(Filesystem* fs, Index& index, Entry& entry, Owner& owner,
bool existed, uint32_t new_id = 0, const char* new_body = nullptr) {
if (index.revision == UINT32_MAX) return RoomMailResult::Unavailable;
Entry previous = entry;
owner.revision = index.revision + 1; entry.bank = existed ? 1 - entry.bank : 0; entry.count = owner.count;
uint32_t checksum;
if (!writeOwner(fs, entry, owner, existed, previous, checksum, new_id, new_body)
|| !readOwner(fs, entry, nullptr, 0, 0, nullptr, 0, &checksum))
return RoomMailResult::WriteFailure;
return publish(fs, index, entry, existed, owner.revision);
}
template <typename Filesystem>
RoomMailResult load(Filesystem* fs, const uint8_t* key, Index& index, Entry& entry, Owner& owner,
bool& found, bool cleanup = false) {
if (!nonzero(key)) return RoomMailResult::Invalid;
if (!recover(fs, index, key, entry, found, false)) return RoomMailResult::Unavailable;
if (found && (!readOwner(fs, entry, &owner) || owner.revision > index.revision))
return RoomMailResult::Unavailable;
// Validate the selected committed bank before removing recovery evidence.
if (cleanup && (!removeArtifact(fs, ROOM_MAIL_TEMP_PATH)
|| !removeArtifact(fs, ROOM_MAIL_BACKUP_PATH))) return RoomMailResult::Unavailable;
if (!found) { clearOwner(owner); return RoomMailResult::NotFound; }
return RoomMailResult::Success;
}
} // namespace room_mail_detail
template <typename Filesystem>
RoomMailResult getRoomMailStatus(Filesystem* fs, const uint8_t* owner, RoomMailStatus& status) {
room_mail_detail::clearStatus(status); room_mail_detail::Index index; room_mail_detail::Entry entry;
bool found;
if (!room_mail_detail::nonzero(owner)) return RoomMailResult::Invalid;
if (!room_mail_detail::recover(fs, index, owner, entry, found, false)) return RoomMailResult::Unavailable;
if (!found) return RoomMailResult::NotFound;
return room_mail_detail::readOwner(fs, entry, nullptr, 0, 0, nullptr, 0,
nullptr, &status, nullptr, index.revision) ? RoomMailResult::Success : RoomMailResult::Unavailable;
}
template <typename Filesystem>
RoomMailResult getRoomMailboxOnly(Filesystem* fs, const uint8_t* owner, bool& value) {
value = false; room_mail_detail::Index index; room_mail_detail::Entry entry; bool found;
if (!room_mail_detail::nonzero(owner)) return RoomMailResult::Invalid;
if (!room_mail_detail::recover(fs, index, owner, entry, found, false)) return RoomMailResult::Unavailable;
if (!found) return RoomMailResult::Success;
return room_mail_detail::readOwner(fs, entry, nullptr, 0, 0, nullptr, 0,
nullptr, nullptr, &value, index.revision) ? RoomMailResult::Success : RoomMailResult::Unavailable;
}
template <typename Filesystem>
RoomMailResult saveRoomMailSettings(Filesystem* fs, const uint8_t* key,
const RoomMailSettings& settings, uint32_t expected_revision) {
using namespace room_mail_detail;
if (!nonzero(key) || !validSettings(settings)) return RoomMailResult::Invalid;
Index index; Entry entry; Owner state; bool found;
const auto result = load(fs, key, index, entry, state, found, true);
if (result != RoomMailResult::Success && result != RoomMailResult::NotFound) return result;
if (expected_revision != state.revision) return RoomMailResult::StaleVersion;
if (!found) {
if (index.count >= ROOM_MAIL_MAX_OWNERS) return RoomMailResult::Full;
memcpy(entry.owner, key, 32); entry.slot = 0;
while (index.slots & (UINT32_C(1) << entry.slot)) ++entry.slot;
}
state.settings = settings;
for (uint8_t i = settings.allowed_count; i < ROOM_MAIL_MAX_ALLOWED; ++i) memset(state.settings.allowed[i], 0, 32);
for (uint8_t i = 1; i < settings.allowed_count; ++i)
for (uint8_t j = i; j > 0 && memcmp(state.settings.allowed[j - 1], state.settings.allowed[j], 32) > 0; --j) {
uint8_t swap[32]; memcpy(swap, state.settings.allowed[j - 1], 32);
memcpy(state.settings.allowed[j - 1], state.settings.allowed[j], 32); memcpy(state.settings.allowed[j], swap, 32);
}
return commit(fs, index, entry, state, found);
}
// Convenience for local single-loop callers. Explicit revision arguments are
// always strict: revision zero can provision only a still-absent mailbox.
template <typename Filesystem>
RoomMailResult saveRoomMailSettings(Filesystem* fs, const uint8_t* key,
const RoomMailSettings& settings) {
RoomMailStatus status;
const auto result = getRoomMailStatus(fs, key, status);
if (result != RoomMailResult::Success && result != RoomMailResult::NotFound) return result;
return saveRoomMailSettings(fs, key, settings, status.revision);
}
template <typename Filesystem>
RoomMailResult listRoomMail(Filesystem* fs, const uint8_t* key, uint32_t expected_revision,
size_t start, RoomMailMessage* output, size_t capacity, size_t& copied) {
copied = 0;
if (!output || !capacity || capacity > ROOM_MAIL_MAX_PER_OWNER) return RoomMailResult::Invalid;
room_mail_detail::Index index; room_mail_detail::Entry entry; room_mail_detail::Owner state; bool found;
const auto result = room_mail_detail::load(fs, key, index, entry, state, found);
if (result != RoomMailResult::Success) return result;
if (expected_revision && expected_revision != state.revision) return RoomMailResult::StaleVersion;
if (start > state.count) return RoomMailResult::Invalid;
size_t cursor = 0;
for (uint8_t i = 0; i < state.receipts && copied < capacity; ++i)
if (state.records[i].state == room_mail_detail::Queued && cursor++ >= start) output[copied++] = state.records[i].message;
return RoomMailResult::Success;
}
template <typename Filesystem>
RoomMailResult readRoomMail(Filesystem* fs, const uint8_t* key, uint32_t id, size_t offset,
uint8_t* output, size_t capacity, size_t& copied, RoomMailMessage& message) {
copied = 0; message = RoomMailMessage{};
if (!id || !output || !capacity || capacity > ROOM_MAIL_READ_MAX_CHUNK) return RoomMailResult::Invalid;
room_mail_detail::Index index; room_mail_detail::Entry entry; room_mail_detail::Owner state; bool found;
const auto result = room_mail_detail::load(fs, key, index, entry, state, found);
if (result != RoomMailResult::Success) return result;
for (uint8_t i = 0; i < state.receipts; ++i) {
const auto& record = state.records[i];
if (record.message.id != id || record.state != room_mail_detail::Queued) continue;
if (offset > record.message.length) return RoomMailResult::Invalid;
if (!room_mail_detail::readOwner(fs, entry, nullptr, id, offset, output, capacity)) return RoomMailResult::Unavailable;
message = record.message; copied = message.length - offset; if (copied > capacity) copied = capacity;
return RoomMailResult::Success;
}
return RoomMailResult::NotFound;
}
// Up to eight accepted submission receipts remain: queued receipts never evict,
// and the oldest retired receipt is replaced when needed. Exact nonce/body
// retries return Duplicate across reboot and after
// ACK/delete. Reusing a retained nonce with different content returns Mismatch.
// An ancient retry after its retired receipt is evicted can enqueue again.
template <typename Filesystem>
RoomMailResult sendRoomMail(Filesystem* fs, const uint8_t* sender, const uint8_t* key,
uint64_t request_id, const char* body, size_t length,
uint32_t& id, uint32_t created = 0) {
using namespace room_mail_detail;
id = 0;
if (!nonzero(sender) || !nonzero(key) || !request_id || !validBody(body, length)) return RoomMailResult::Invalid;
Index index; Entry entry; Owner state; bool found;
const auto result = load(fs, key, index, entry, state, found, true);
if (result != RoomMailResult::Success) return result;
if (!roomMailSenderAllowed(state.settings, key, sender)) return RoomMailResult::Forbidden;
uint8_t digest[32]; bodyDigest(body, length, digest);
for (uint8_t i = 0; i < state.receipts; ++i) {
const auto& record = state.records[i];
if (record.message.request_id != request_id || memcmp(record.message.sender, sender, 32)) continue;
if (record.message.length != length || memcmp(record.digest, digest, 32)) return RoomMailResult::Mismatch;
id = record.message.id; return RoomMailResult::Duplicate;
}
if (state.count >= ROOM_MAIL_MAX_PER_OWNER || index.messages >= ROOM_MAIL_MAX_MESSAGES) return RoomMailResult::Full;
if (index.revision == UINT32_MAX) return RoomMailResult::Unavailable;
if (state.receipts == ROOM_MAIL_RETRY_RECEIPTS) {
uint8_t retired = 0; while (retired < state.receipts && state.records[retired].state == Queued) ++retired;
if (retired == state.receipts) return RoomMailResult::Full;
for (uint8_t i = retired; i + 1 < state.receipts; ++i) state.records[i] = state.records[i + 1];
--state.receipts;
}
Record& added = state.records[state.receipts++]; added = Record{};
added.message.id = index.revision + 1; added.message.request_id = request_id;
added.message.created = created; added.message.length = uint16_t(length);
memcpy(added.message.sender, sender, 32); memcpy(added.digest, digest, 32);
added.state = Queued; ++state.count;
const auto saved = commit(fs, index, entry, state, true, added.message.id, body);
if (saved == RoomMailResult::Success) id = added.message.id;
return saved;
}
namespace room_mail_detail {
template <typename Filesystem>
RoomMailResult retire(Filesystem* fs, const uint8_t* key, uint32_t id, uint8_t kind, bool purge) {
Index index; Entry entry; Owner state; bool found;
const auto result = load(fs, key, index, entry, state, found, true);
if (result != RoomMailResult::Success) return result;
bool known = false, changed = false;
for (uint8_t i = 0; i < state.receipts; ++i) {
auto& record = state.records[i];
if (!purge && record.message.id != id) continue;
known = true;
if (record.state == Queued) { record.state = kind; --state.count; changed = true; }
}
if (!known && !purge) return RoomMailResult::NotFound;
if (!changed) return RoomMailResult::Success;
return commit(fs, index, entry, state, true);
}
} // namespace room_mail_detail
template <typename Filesystem>
RoomMailResult acknowledgeRoomMail(Filesystem* fs, const uint8_t* owner, uint32_t id) {
if (!id) return RoomMailResult::Invalid;
return room_mail_detail::retire(fs, owner, id, room_mail_detail::Acknowledged, false);
}
template <typename Filesystem>
RoomMailResult deleteRoomMail(Filesystem* fs, const uint8_t* owner, uint32_t id) {
return room_mail_detail::retire(fs, owner, id, room_mail_detail::Deleted, id == 0);
}
template <typename Filesystem>
RoomMailResult getRoomMailStats(Filesystem* fs, RoomMailStats& stats) {
stats = RoomMailStats{}; room_mail_detail::Index index; room_mail_detail::Entry entry; bool found;
if (!room_mail_detail::recover(fs, index, nullptr, entry, found, false)) return RoomMailResult::Unavailable;
stats.revision = index.revision; stats.owners = index.count; stats.messages = index.messages;
return RoomMailResult::Success;
}
// Administrator-only owner enumeration. Caller must authorize this explicitly.
// Public mailbox reads never disclose the registry or another owner's policy.
template <typename Filesystem>
RoomMailResult listRoomMailOwners(Filesystem* fs, uint32_t expected_revision, size_t start,
uint8_t (*keys)[32], size_t capacity, size_t& copied,
RoomMailStats& stats) {
copied = 0;
if (!keys || !capacity || capacity > ROOM_MAIL_MAX_OWNERS) return RoomMailResult::Invalid;
const auto result = getRoomMailStats(fs, stats);
if (result != RoomMailResult::Success) return result;
if (expected_revision && expected_revision != stats.revision) return RoomMailResult::StaleVersion;
if (start > stats.owners) return RoomMailResult::Invalid;
if (!stats.owners) return RoomMailResult::Success;
auto file = room_mail_detail::openRead(fs, ROOM_MAIL_PRIMARY_PATH); if (!file) return RoomMailResult::Unavailable;
uint8_t header[room_mail_detail::INDEX_HEADER_SIZE];
bool okay = size_t(file.read(header, sizeof(header))) == sizeof(header);
for (size_t i = 0; okay && i < stats.owners && copied < capacity; ++i) {
uint8_t bytes[room_mail_detail::INDEX_ENTRY_SIZE]; okay = size_t(file.read(bytes, sizeof(bytes))) == sizeof(bytes);
if (okay && i >= start) memcpy(keys[copied++], bytes, 32);
}
file.close();
if (!okay) { copied = 0; return RoomMailResult::Unavailable; }
return RoomMailResult::Success;
}
} // namespace mesh
+247
View File
@@ -0,0 +1,247 @@
#pragma once
#include "RoomMailStore.h"
#include "RoomWebJson.h"
namespace mesh {
inline void roomMailWebHex(char* output, const uint8_t* key, size_t length) {
static const char digits[] = "0123456789abcdef";
for (size_t i = 0; i < length; ++i) {
output[2 * i] = digits[key[i] >> 4]; output[2 * i + 1] = digits[key[i] & 15];
}
output[length * 2] = 0;
}
inline bool roomMailWebKey(uint8_t* output, size_t length, const char* text) {
if (!text || strlen(text) != 2 * length) return false;
for (size_t i = 0; i < length; ++i) {
unsigned byte = 0;
for (size_t n = 0; n < 2; ++n) {
const char c = text[2 * i + n];
unsigned digit;
if (c >= '0' && c <= '9') digit = c - '0';
else if (c >= 'a' && c <= 'f') digit = c - 'a' + 10;
else if (c >= 'A' && c <= 'F') digit = c - 'A' + 10;
else return false;
byte = 16 * byte + digit;
}
output[i] = uint8_t(byte);
}
return true;
}
inline const char* roomMailModeName(RoomMailMode mode) {
return mode == RoomMailMode::Public ? "public" : mode == RoomMailMode::Private ? "private" : "closed";
}
inline const char* roomMailError(RoomMailResult result) {
switch (result) {
case RoomMailResult::Invalid: return "invalid mailbox request";
case RoomMailResult::Unavailable: return "mailbox storage unavailable; previous data retained";
case RoomMailResult::WriteFailure: return "mailbox save failed; previous data retained";
case RoomMailResult::NotFound: return "mailbox or message not found";
case RoomMailResult::StaleVersion: return "mailbox changed; refresh before editing";
case RoomMailResult::Forbidden: return "recipient is not accepting messages from this identity";
case RoomMailResult::Full: return "mailbox or server queue full; message not accepted";
case RoomMailResult::Mismatch: return "request identifier already used for different mail";
default: return "mailbox request failed";
}
}
inline void writeRoomMailSettingsJson(RoomJsonWriter& json, const uint8_t* owner,
const RoomMailStatus& status, bool blocked = false) {
char key[65]; roomMailWebHex(key, owner, 32);
json.raw("{\"owner\":"); json.string(key);
json.raw(",\"revision\":"); json.number(status.revision);
json.raw(",\"mode\":"); json.string(roomMailModeName(status.settings.mode));
json.raw(",\"mailbox_only\":"); json.raw(status.settings.mailbox_only ? "true" : "false");
json.raw(",\"allowlist\":[");
for (uint8_t i = 0; i < status.settings.allowed_count; ++i) {
if (i) json.raw(",");
roomMailWebHex(key, status.settings.allowed[i], 32); json.string(key);
}
json.raw("],\"total\":"); json.number(status.count);
json.raw(",\"unread\":"); json.number(status.count);
json.raw(",\"blocked\":"); json.raw(blocked ? "true" : "false"); json.raw("}");
}
// The caller has already authenticated the browser's own token identity and
// charged room quotas. Only explicit administrator operations may select a
// different owner; a shared room password never proves a radio's private key.
template <typename Filesystem, typename AllowsIdentity>
bool handleRoomMailWeb(Filesystem* fs, const uint8_t* caller, bool can_send,
bool administrator, uint64_t request_id, uint32_t created,
JsonObjectConst input, RoomJsonWriter& json,
AllowsIdentity allows) {
const char* operation = input["op"].as<const char*>();
if (!operation) return false;
const bool admin = strncmp(operation, "admin.mail.", 11) == 0;
if (!admin && strncmp(operation, "mail.", 5) != 0) return false;
auto error = [&json](const char* message) {
json.reset();
json.raw("{\"error\":"); json.string(message); json.raw("}"); json.finish();
};
if (json.capacity() < 1024) { error("mailbox reply capacity too small"); return true; }
auto checked = [&error](RoomMailResult result) {
if (result == RoomMailResult::Success || result == RoomMailResult::Duplicate) return true;
error(roomMailError(result)); return false;
};
auto keyValue = [](JsonVariantConst value, uint8_t* out) {
if (!value.is<const char*>()) return false;
const JsonString text = value.as<JsonString>();
if (text.size() != 64 || strlen(text.c_str()) != 64
|| !roomMailWebKey(out, 32, text.c_str())) return false;
uint8_t any = 0; for (size_t i = 0; i < 32; ++i) any |= out[i];
return any != 0;
};
if (admin && !administrator) { error("admin permission required"); return true; }
uint8_t owner[32]; memcpy(owner, caller, sizeof(owner));
if (admin && strcmp(operation, "admin.mail.list") != 0
&& !keyValue(input["owner"], owner)) {
error("complete mailbox owner public key required"); return true;
}
if (strcmp(operation, "admin.mail.list") == 0) {
if (!input["cursor"].is<uint8_t>() || !input["revision"].is<uint32_t>()
|| (input["cursor"].as<uint8_t>() && !input["revision"].as<uint32_t>())) {
error("mailbox cursor and snapshot revision required"); return true;
}
uint8_t keys[2][32]; size_t copied = 0; RoomMailStats stats;
const uint8_t cursor = input["cursor"].as<uint8_t>();
if (!checked(listRoomMailOwners(fs, input["revision"].as<uint32_t>(), cursor,
keys, 2, copied, stats))) return true;
json.raw("{\"mailboxes\":[");
for (size_t i = 0; i < copied; ++i) {
RoomMailStatus status;
const auto result = getRoomMailStatus(fs, keys[i], status);
if (result != RoomMailResult::Success) { error(roomMailError(result)); return true; }
if (i) json.raw(",");
char key[65]; roomMailWebHex(key, keys[i], 32);
json.raw("{\"owner\":"); json.string(key);
json.raw(",\"mode\":"); json.string(roomMailModeName(status.settings.mode));
json.raw(",\"revision\":"); json.number(status.revision);
json.raw(",\"mailbox_only\":"); json.raw(status.settings.mailbox_only ? "true" : "false");
json.raw(",\"unread\":"); json.number(status.count);
json.raw(",\"total\":"); json.number(status.count);
json.raw(",\"blocked\":"); json.raw(allows(keys[i]) ? "false" : "true"); json.raw("}");
}
json.raw("],\"next\":");
if (cursor + copied < stats.owners) json.number(cursor + copied); else json.raw("null");
json.raw(",\"total\":"); json.number(stats.owners);
json.raw(",\"queued\":"); json.number(stats.messages);
json.raw(",\"revision\":"); json.number(stats.revision); json.raw("}"); json.finish(); return true;
}
if (strcmp(operation, "mail.send") == 0) {
if (!can_send) { error("read-only room access cannot send mail"); return true; }
uint8_t recipient[32];
if (!keyValue(input["recipient"], recipient)) { error("complete recipient public key required"); return true; }
if (!allows(recipient)) { error("recipient is blocked"); return true; }
if (!input["text"].is<const char*>()) { error("message required"); return true; }
const JsonString text = input["text"].as<JsonString>();
if (!text.size() || text.size() > 512 || strlen(text.c_str()) != text.size()) {
error("mail must contain 1-512 UTF-8 bytes"); return true;
}
uint32_t id = 0;
if (!checked(sendRoomMail(fs, caller, recipient, request_id, text.c_str(), text.size(), id, created))) return true;
json.raw("{\"ok\":true,\"id\":"); json.number(id); json.raw("}"); json.finish(); return true;
}
if (strcmp(operation, "mail.policy") == 0 || strcmp(operation, "admin.mail.policy") == 0) {
if (!input["expected_revision"].is<uint32_t>() || !input["mailbox_only"].is<bool>()
|| !input["mode"].is<const char*>() || !input["allowlist"].is<JsonArrayConst>()) {
error("mailbox mode, delivery, sender list and expected revision required"); return true;
}
RoomMailSettings settings;
const char* mode = input["mode"].as<const char*>();
if (strcmp(mode, "public") == 0) settings.mode = RoomMailMode::Public;
else if (strcmp(mode, "private") == 0) settings.mode = RoomMailMode::Private;
else if (strcmp(mode, "closed") != 0) { error("use public, private or closed mailbox access"); return true; }
settings.mailbox_only = input["mailbox_only"].as<bool>();
JsonArrayConst allowed = input["allowlist"].as<JsonArrayConst>();
if (allowed.size() > 8) { error("at most eight selected senders"); return true; }
for (JsonVariantConst value : allowed) {
if (!keyValue(value, settings.allowed[settings.allowed_count])) {
error("selected senders need complete public keys"); return true;
}
++settings.allowed_count;
}
if (!checked(saveRoomMailSettings(fs, owner, settings, input["expected_revision"].as<uint32_t>()))) return true;
}
if (strcmp(operation, "mail.ack") == 0 || strcmp(operation, "mail.delete") == 0
|| strcmp(operation, "admin.mail.delete") == 0) {
if (!input["id"].is<uint32_t>() || (!admin && input["id"].as<uint32_t>() == 0)) {
error("message identifier required"); return true;
}
const auto result = strcmp(operation, "mail.ack") == 0
? acknowledgeRoomMail(fs, owner, input["id"].as<uint32_t>())
: deleteRoomMail(fs, owner, input["id"].as<uint32_t>());
if (!checked(result)) return true;
json.raw("{\"ok\":true}"); json.finish(); return true;
}
RoomMailStatus status;
const auto loaded = getRoomMailStatus(fs, owner, status);
if (loaded != RoomMailResult::Success && loaded != RoomMailResult::NotFound) {
error(roomMailError(loaded)); return true;
}
if (strcmp(operation, "mail.settings") == 0 || strcmp(operation, "mail.policy") == 0
|| strcmp(operation, "admin.mail.settings") == 0 || strcmp(operation, "admin.mail.policy") == 0) {
writeRoomMailSettingsJson(json, owner, status, !allows(owner)); json.finish(); return true;
}
if (strcmp(operation, "mail.check") == 0) {
char key[65]; roomMailWebHex(key, owner, 32);
json.raw("{\"owner\":"); json.string(key);
json.raw(",\"unread\":"); json.number(status.count);
json.raw(",\"total\":"); json.number(status.count);
json.raw(",\"revision\":"); json.number(status.revision);
json.raw(",\"mailbox_only\":"); json.raw(status.settings.mailbox_only ? "true" : "false");
json.raw("}"); json.finish(); return true;
}
if (strcmp(operation, "mail.list") == 0) {
if (!input["cursor"].is<uint8_t>() || !input["revision"].is<uint32_t>()
|| (input["cursor"].as<uint8_t>() && !input["revision"].as<uint32_t>())) {
error("mail cursor and snapshot revision required"); return true;
}
const uint8_t cursor = input["cursor"].as<uint8_t>();
const uint32_t expected_revision = input["revision"].as<uint32_t>();
if (expected_revision && expected_revision != status.revision) {
error(roomMailError(RoomMailResult::StaleVersion)); return true;
}
if (loaded == RoomMailResult::NotFound) {
if (cursor) { error("invalid mail cursor"); return true; }
json.raw("{\"messages\":[],\"next\":null,\"revision\":0}"); json.finish(); return true;
}
RoomMailMessage messages[2]; size_t copied = 0;
if (!checked(listRoomMail(fs, owner, status.revision, cursor, messages, 2, copied))) return true;
json.raw("{\"messages\":[");
for (size_t i = 0; i < copied; ++i) {
if (i) json.raw(","); char key[65]; roomMailWebHex(key, messages[i].sender, 32);
json.raw("{\"id\":"); json.number(messages[i].id);
json.raw(",\"sender\":"); json.string(key);
json.raw(",\"created\":"); json.number(messages[i].created);
json.raw(",\"length\":"); json.number(messages[i].length); json.raw(",\"acked\":false}");
}
json.raw("],\"next\":");
if (cursor + copied < status.count) json.number(cursor + copied); else json.raw("null");
json.raw(",\"revision\":"); json.number(status.revision); json.raw("}"); json.finish(); return true;
}
if (strcmp(operation, "mail.read") == 0) {
if (!input["id"].is<uint32_t>() || !input["offset"].is<uint16_t>()) {
error("message identifier and offset required"); return true;
}
uint8_t bytes[128]; char encoded[173]; size_t copied = 0; RoomMailMessage message;
const uint16_t offset = input["offset"].as<uint16_t>();
if (!checked(readRoomMail(fs, owner, input["id"].as<uint32_t>(), offset,
bytes, sizeof(bytes), copied, message))) return true;
roomEncodeBase64(bytes, copied, encoded); char key[65]; roomMailWebHex(key, message.sender, 32);
json.raw("{\"id\":"); json.number(message.id);
json.raw(",\"sender\":"); json.string(key);
json.raw(",\"created\":"); json.number(message.created);
json.raw(",\"length\":"); json.number(message.length);
json.raw(",\"offset\":"); json.number(offset);
json.raw(",\"count\":"); json.number(copied);
json.raw(",\"next\":"); json.number(offset + copied);
json.raw(",\"data64\":"); json.string(encoded); json.raw(",\"acked\":false}"); json.finish(); return true;
}
error("unknown mailbox operation"); return true;
}
} // namespace mesh
+5
View File
@@ -48,6 +48,11 @@ public:
if (!output || !capacity) valid_ = false;
else output[0] = 0;
}
void reset() {
used_ = 0; valid_ = output_ && capacity_;
if (valid_) output_[0] = 0;
}
size_t capacity() const { return capacity_; }
void raw(const char* text) {
const size_t length = strlen(text);
if (!valid_ || length >= capacity_ - used_) { valid_ = false; return; }
+242 -5
View File
@@ -16,9 +16,10 @@ button:disabled{opacity:.45;cursor:default}label{display:block}.muted{color:#b5b
.post{padding:10px 0;border-bottom:1px solid #364351}.post small{display:block;color:#b5beca}
details{margin:18px 0}summary{cursor:pointer}code{overflow-wrap:anywhere}a{color:#a8d5ff}[hidden]{display:none!important}
.panel{border:1px solid #526273;border-radius:10px;padding:18px;margin:20px 0}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px}.card{background:#202b38;padding:12px;border-radius:6px}.card strong{display:block;font-size:1.3em}.table-wrap{overflow:auto}table{border-collapse:collapse;width:100%;font-size:.92em}th,td{text-align:left;padding:9px 7px;border-bottom:1px solid #364351;vertical-align:top}th{color:#b5beca}.key{font-family:monospace;overflow-wrap:anywhere}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:16px}.notice{padding:10px;background:#273947;border-radius:6px}.warning{color:#ffd3a3}.success{color:#a5e1b1}dl{display:grid;grid-template-columns:minmax(110px,1fr) 2fr;gap:8px}dt{color:#b5beca}dd{margin:0;overflow-wrap:anywhere}select{max-width:100%}.inline{display:flex;flex-wrap:wrap;align-items:center;gap:8px}.inline label{display:inline}.inline input{width:auto;margin:0}fieldset{border:1px solid #526273;border-radius:6px;margin:14px 0;padding:12px}.small{font-size:.9em}
#mail-panel fieldset,#mail-panel .grid>form{min-width:0}#mail-panel select{width:100%}
</style></head><body>
<h1 id="room-name">MeshCore Room</h1>
<p class="muted">Chat and selected notices over local Wi-Fi. This page also works without internet access.</p>
<p class="muted">Chat, personal mail and selected notices over local Wi-Fi. This page also works without internet access.</p>
<form id="join"><label for="password">Room password (leave empty for public read-only access)</label>
<input id="password" type="password" autocomplete="current-password" maxlength="64">
<label for="name">Your display name</label><input id="name" autocomplete="nickname" value="Web">
@@ -36,6 +37,26 @@ details{margin:18px 0}summary{cursor:pointer}code{overflow-wrap:anywhere}a{color
<p id="browser-catchup-result" class="muted" role="status"></p></details>
<form id="compose"><label for="message">Message</label><textarea id="message"></textarea>
<p id="budget" class="muted"></p><button id="send" type="submit">Send</button></form>
<section class="panel" aria-labelledby="mail-heading"><h2 id="mail-heading">Personal mailbox</h2>
<p class="muted">Mail waits on this room until you collect it. Your browser mailbox uses its own identity, separate from your radio.</p>
<p id="mail-summary" class="notice" hidden></p>
<button id="mail-open" type="button">Open my mailbox</button>
<section id="mail-panel" hidden><p>Your complete mailbox address: <code id="mail-key" class="key"></code> <button id="mail-copy" type="button">Copy address</button></p>
<p id="mail-state" class="notice" role="status"></p>
<div class="grid"><form id="mail-policy"><fieldset><legend>Who can send you mail</legend>
<label for="mail-mode">Mailbox access</label><select id="mail-mode"><option value="closed">Closed - receive no new mail</option><option value="public">Public - room writers may send</option><option value="private">Private - approved senders only</option></select>
<label for="mail-allowlist">Approved sender addresses (up to 8 complete public keys, one per line)</label><textarea id="mail-allowlist" class="key" spellcheck="false" autocomplete="off"></textarea>
<label class="inline"><input id="mail-only" type="checkbox"> Mailbox only: pause automatic chat downloads</label>
<p class="muted small">A closed mailbox is the default. Reading mail leaves it queued; confirm receipt after a complete download to remove it from the server.</p>
<button id="mail-policy-save" type="submit">Save mailbox settings</button><button id="mail-policy-reload" type="button">Reload settings</button><p id="mail-policy-result" role="status"></p></fieldset></form>
<form id="mail-compose"><fieldset><legend>Send personal mail</legend><label for="mail-recipient">Recipient's complete mailbox address</label><input id="mail-recipient" class="key" maxlength="64" spellcheck="false" autocomplete="off">
<label for="mail-text">Message (up to 512 UTF-8 bytes)</label><textarea id="mail-text"></textarea><p id="mail-budget" class="muted"></p>
<button id="mail-send" type="submit">Send mail</button><p id="mail-send-result" role="status"></p></fieldset></form></div>
<h3>Inbox</h3><button id="mail-refresh" type="button">Check and refresh inbox</button><p id="mail-count" role="status"></p>
<div class="table-wrap"><table><thead><tr><th scope="col">Sender</th><th scope="col">Sent</th><th scope="col">Receipt</th><th scope="col">Message</th></tr></thead><tbody id="mail-rows"></tbody></table></div>
<div class="inline"><button id="mail-previous" type="button" disabled>Previous mail</button><button id="mail-next" type="button" disabled>Next mail</button><span id="mail-page" class="muted"></span></div>
<section id="mail-selected" hidden><h3>Selected mail</h3><p id="mail-selected-meta" class="key"></p><pre id="mail-body" class="notice" style="white-space:pre-wrap;overflow-wrap:anywhere"></pre>
<p id="mail-read-state" role="status"></p><button id="mail-resume" type="button">Read or resume download</button><button id="mail-ack" type="button" disabled>Confirm received and remove</button><button id="mail-delete" type="button">Delete this mail</button></section></section></section>
<h2>Information board</h2><button id="refresh-board" type="button">Refresh notices</button>
<div id="notices"></div><h3 id="article-title" hidden></h3><pre id="article" hidden></pre>
<section id="editor" hidden><h3>Publish or edit a notice</h3><form id="publish">
@@ -46,6 +67,14 @@ details{margin:18px 0}summary{cursor:pointer}code{overflow-wrap:anywhere}a{color
<button type="submit">Save notice</button><button id="delete" type="button">Delete selected notice</button></form></section>
<section id="admin-panel" class="panel" hidden><h2>Room administration</h2>
<p class="muted">Manage radio users, their room-to-user routes, and chat delivery. Activity and delivery details are available only with admin access.</p>
<details id="admin-mail"><summary>Mailbox owners and delivery</summary><p class="muted">Review mailbox policies and counts. Private message bodies are available only to their recipients.</p><button id="admin-mail-refresh" type="button">Load mailbox owners</button><p id="admin-mail-state" role="status"></p>
<label for="admin-mail-owner">Manage a radio or browser mailbox by its complete public key</label><input id="admin-mail-owner" class="key" maxlength="64" spellcheck="false" autocomplete="off"><button id="admin-mail-load-owner" type="button">Load mailbox by address</button><p class="muted small">A new address starts closed. Save its policy to create the mailbox.</p>
<div class="table-wrap"><table><thead><tr><th scope="col">Complete owner address</th><th scope="col">Access</th><th scope="col">Mail</th><th scope="col">Manage</th></tr></thead><tbody id="admin-mail-rows"></tbody></table></div>
<div class="inline"><button id="admin-mail-previous" type="button" disabled>Previous mailboxes</button><button id="admin-mail-next" type="button" disabled>Next mailboxes</button><span id="admin-mail-page" class="muted"></span></div>
<form id="admin-mail-policy" hidden><fieldset><legend>Selected mailbox policy</legend><p id="admin-mail-key" class="key"></p><label for="admin-mail-mode">Mailbox access</label><select id="admin-mail-mode"><option value="closed">Closed</option><option value="public">Public</option><option value="private">Private</option></select>
<label for="admin-mail-allowlist">Approved senders (up to 8 complete public keys, one per line)</label><textarea id="admin-mail-allowlist" class="key" spellcheck="false" autocomplete="off"></textarea>
<label class="inline"><input id="admin-mail-only" type="checkbox"> Mailbox only</label><button id="admin-mail-save" type="submit">Save selected policy</button><button id="admin-mail-reload" type="button">Reload selected policy</button><button id="admin-mail-block" type="button">Block owner</button>
<label for="admin-mail-delete-id">Message ID to delete</label><input id="admin-mail-delete-id" type="number" min="1" max="4294967295"><button id="admin-mail-delete" type="button">Review message deletion</button><button id="admin-mail-purge" type="button">Review inbox purge</button><p id="admin-mail-result" role="status"></p></fieldset></form></details>
<div class="inline"><button id="admin-refresh" type="button">Load users and delivery</button><label><input id="admin-live" type="checkbox"> Refresh every 15 seconds</label></div>
<p id="admin-state" role="status" class="muted">Load users to begin.</p>
<div id="admin-cards" class="cards" hidden></div>
@@ -74,6 +103,8 @@ let chain=Promise.resolve(), notices=new Map(), transfers=new Map();
let sending=false, editId=1, editVersion=0;
let adminLoaded=false, adminLoading=false, adminBusy=false, adminCursor=0, adminNext=null, adminPages=[];
let selectedKey='', selectedUser=null, adminSettings=null;
let mailPolicy=null,mailDirty=false,mailBusy=false,mailCursor=0,mailNext=null,mailPages=[],mailItems=new Map(),mailTransfers=new Map(),mailSelected=null,mailGeneration=0,mailboxOnly=false,mailSnapshot=0;
let adminMailPolicy=null,adminMailDirty=false,adminMailBusy=false,adminMailCursor=0,adminMailNext=null,adminMailPages=[],adminMailSnapshot=0;
const routeDirty={outpath:false,altpath:false},routeBaseline={outpath:'',altpath:''};
function saved(key,fallback){try{return localStorage.getItem(key)||fallback}catch(e){return fallback}}
function remember(key,value){try{localStorage.setItem(key,String(value))}catch(e){}}
@@ -132,8 +163,10 @@ function updateBudget(){
}
async function status(){
const result=await api('status');
if(boot&&boot!==result.boot){after=0;$('posts').replaceChildren();transfers.clear();notices.clear();clearAdmin()}
if(boot&&boot!==result.boot){after=0;$('posts').replaceChildren();transfers.clear();notices.clear();clearAdmin();clearMail()}
if(role>result.role)clearMail();
boot=result.boot;role=result.role;
mailboxOnly=result.mailbox_only===true;updateMailSummary();
$('room-name').textContent=result.name;$('topic').textContent=result.topic;
$('web-key').textContent=result.web_key;
$('history').textContent=result.persistent_history?'Radio retains its newest 32 posts across reboots.':'Radio history is in RAM and is lost on reboot.';
@@ -141,10 +174,11 @@ async function status(){
$('editor').hidden=role!==3;$('admin-panel').hidden=role!==3;
if(role!==3)clearAdmin();updateAdminButtons();updateBudget();
}
async function refreshChat(){
async function refreshChat(automatic=false){
if(polling||!joined)return;polling=true;
try{
await status();
if(automatic&&mailboxOnly)return;
for(let i=0;i<32;i++){
const result=await api('posts',{after:after}), post=result.post;if(!post)break;
if(!Number.isInteger(post.timestamp)||post.timestamp<=after)throw Error('Invalid message cursor.');
@@ -212,6 +246,7 @@ async function browserCatchup(mode){
}
function requireAdmin(){if(!joined||role!==3)throw Error('Admin permission required. Reconnect with the room admin password.');}
function clearAdmin(){
clearAdminMail();
adminLoaded=false;adminCursor=0;adminNext=null;adminPages=[];selectedKey='';selectedUser=null;adminSettings=null;
routeDirty.outpath=false;routeDirty.altpath=false;routeBaseline.outpath='';routeBaseline.altpath='';
$('admin-user-rows').replaceChildren();$('admin-details').replaceChildren();$('admin-cards').replaceChildren();
@@ -238,6 +273,7 @@ function updateAdminButtons(){
$('admin-previous').disabled=locked||!adminPages.length;$('admin-next').disabled=locked||adminNext===null;
for(const input of $('admin-panel').querySelectorAll('input,select,textarea'))if(input.id!=='admin-live')input.disabled=locked;
for(const prefix of ['admin-out','admin-alt'])$(prefix+'-value').disabled=locked||$(prefix+'-mode').value!=='path';
updateMailButtons();
}
function validKey(key){return typeof key==='string'&&/^[0-9a-fA-F]{64}$/.test(key)&&!/^0+$/.test(key);}
function renderAdminCards(result){
@@ -356,6 +392,176 @@ async function saveAdminSetting(which){
await adminMutation(async()=>{await api('admin.'+which,values);await loadAdminSettings();},'Room '+which+' saved.');
$('admin-settings-result').textContent='Saved. Changes are confirmed by the radio.';
}
function clearMail(){
mailGeneration++;
mailboxOnly=false;updateMailSummary();
mailPolicy=null;mailDirty=false;mailCursor=0;mailNext=null;mailPages=[];mailSnapshot=0;mailItems.clear();mailTransfers.clear();mailSelected=null;
$('mail-panel').hidden=true;$('mail-selected').hidden=true;$('mail-rows').replaceChildren();$('mail-key').textContent='';$('mail-body').textContent='';$('mail-selected-meta').textContent='';
for(const id of ['mail-state','mail-policy-result','mail-send-result','mail-count','mail-page','mail-read-state'])$(id).textContent='';
$('mail-mode').value='closed';$('mail-allowlist').value='';$('mail-only').checked=false;$('mail-recipient').value='';$('mail-text').value='';updateMailButtons();
}
function clearAdminMail(){
adminMailPolicy=null;adminMailDirty=false;adminMailCursor=0;adminMailNext=null;adminMailPages=[];adminMailSnapshot=0;
$('admin-mail-rows').replaceChildren();$('admin-mail-policy').hidden=true;$('admin-mail-key').textContent='';$('admin-mail-owner').value='';$('admin-mail-allowlist').value='';$('admin-mail-delete-id').value='';
for(const id of ['admin-mail-state','admin-mail-page','admin-mail-result'])$(id).textContent='';updateMailButtons();
}
function requireMail(){if(!joined||![1,2,3].includes(role))throw Error('Connect to the room before opening mail.');}
function updateMailSummary(){$('mail-summary').hidden=!mailboxOnly;$('mail-summary').textContent=mailboxOnly?'Mailbox only: automatic chat downloads are paused. Refresh chat downloads once.':'';}
function mailApi(op,values={}){const generation=mailGeneration;return api(op,values).then(result=>{requireMail();if(generation!==mailGeneration)throw Error('Mailbox session changed; open your mailbox again.');return result;});}
function policyValues(prefix){
const mode=$(prefix+'mode').value,raw=$(prefix+'allowlist').value.trim();
const allowlist=raw?raw.split(/[\s,]+/).map(key=>key.toLowerCase()):[];
if(!['closed','public','private'].includes(mode)||allowlist.length>8||allowlist.some(key=>!validKey(key))||new Set(allowlist).size!==allowlist.length)throw Error('Choose up to 8 different complete nonzero public keys.');
return {mode:mode,mailbox_only:$(prefix+'only').checked,allowlist:allowlist};
}
function checkedMailPolicy(result,owner){
if(!result||!validKey(result.owner)||(owner&&result.owner.toLowerCase()!==owner.toLowerCase())||!Number.isInteger(result.revision)||result.revision<0
||!['closed','public','private'].includes(result.mode)||!Array.isArray(result.allowlist)||result.allowlist.length>8||result.allowlist.some(key=>!validKey(key)))throw Error('Invalid mailbox settings; reload before editing.');
return result;
}
function updateMailButtons(){
const locked=!joined||mailBusy,writer=role===2||role===3,size=encoder.encode($('mail-text').value).length;
$('mail-budget').textContent=size+' / 512 UTF-8 bytes'+(writer?'':' - read-only access cannot send mail');
for(const button of $('mail-panel').querySelectorAll('button'))button.disabled=locked;
$('mail-open').disabled=!joined||mailBusy;$('mail-send').disabled=locked||!writer||!validKey($('mail-recipient').value.trim())||size>512||!$('mail-text').value.trim();
$('mail-previous').disabled=locked||!mailPages.length;$('mail-next').disabled=locked||mailNext===null;
const transfer=mailSelected&&mailTransfers.get(mailSelected.id);
$('mail-ack').disabled=locked||!mailSelected||!transfer||transfer.next!==mailSelected.length||!transfer.complete;
$('mail-resume').disabled=locked||!mailSelected;$('mail-delete').disabled=locked||!mailSelected;
const adminLocked=!joined||role!==3||adminBusy||adminLoading||adminMailBusy;
for(const button of $('admin-mail').querySelectorAll('button'))button.disabled=adminLocked;
for(const field of $('admin-mail').querySelectorAll('input,select,textarea'))field.disabled=adminLocked;
$('admin-mail-previous').disabled=adminLocked||!adminMailPages.length;$('admin-mail-next').disabled=adminLocked||adminMailNext===null;
}
function renderMailPolicy(result,force=false){
const previous=mailPolicy;checkedMailPolicy(result,$('web-key').textContent);
mailboxOnly=result.mailbox_only===true;updateMailSummary();
$('mail-key').textContent=result.owner;$('mail-panel').hidden=false;
$('mail-state').textContent='Mailbox '+result.mode+'. '+(result.mailbox_only?'Automatic chat downloads are paused; Refresh chat still works.':'Automatic chat downloads are enabled.')+' Settings revision '+result.revision+'.';
if(!mailDirty||force){mailPolicy=result;$('mail-mode').value=result.mode;$('mail-allowlist').value=result.allowlist.join('\n');$('mail-only').checked=result.mailbox_only;mailDirty=false;}
else if(previous&&previous.revision!==result.revision)$('mail-policy-result').textContent='Settings changed on the radio. Your draft is preserved; reload before replacing them.';
updateMailButtons();
}
async function loadMailSettings(force=false){
requireMail();if(force&&mailDirty&&!confirm('Replace your unsaved mailbox settings with the current radio settings?'))return;
const result=await mailApi('mail.settings');requireMail();renderMailPolicy(result,force);
}
async function openMailbox(){
requireMail();if(mailBusy)return;mailBusy=true;updateMailButtons();
try{await loadMailSettings();await refreshMail();}finally{mailBusy=false;updateMailButtons();}
}
async function saveMailPolicy(){
requireMail();if(!mailPolicy)throw Error('Load your mailbox settings first.');if(mailBusy)return;
const values=Object.assign(policyValues('mail-'),{expected_revision:mailPolicy.revision}),generation=connectionGeneration;
mailBusy=true;updateMailButtons();$('mail-policy-result').textContent='Saving mailbox settings...';
try{const result=await mailApi('mail.policy',values);requireMail();if(generation!==connectionGeneration)return;renderMailPolicy(result,true);$('mail-policy-result').textContent='Mailbox settings saved and confirmed.';}
catch(error){$('mail-policy-result').textContent=String(error.message||error);throw error;}
finally{mailBusy=false;updateMailButtons();}
}
async function sendMail(){
requireMail();if(role!==2&&role!==3)throw Error('Read and write room access is required to send mail.');if(mailBusy)return;
const recipient=$('mail-recipient').value.trim().toLowerCase(),text=$('mail-text').value;
if(!validKey(recipient)||!text.trim()||encoder.encode(text).length>512)throw Error('Use a complete recipient address and a message up to 512 UTF-8 bytes.');
mailBusy=true;updateMailButtons();$('mail-send-result').textContent='Waiting for the radio to save your mail...';
try{const result=await mailApi('mail.send',{recipient:recipient,text:text});requireMail();
if(result.ok!==true||!Number.isInteger(result.id)||result.id<1)throw Error('Mail was not confirmed; check before retrying.');
if($('mail-text').value===text)$('mail-text').value='';$('mail-send-result').textContent='Mail '+result.id+' saved for '+recipient+'.';}
catch(error){$('mail-send-result').textContent=String(error.message||error);throw error;}
finally{mailBusy=false;updateMailButtons();}
}
function checkedMailItem(item){
if(!item||!Number.isInteger(item.id)||item.id<1||!validKey(item.sender)||!Number.isInteger(item.created)||item.created<0||!Number.isInteger(item.length)||item.length<1||item.length>512)throw Error('Invalid inbox entry; refresh mail.');
return item;
}
async function refreshMail(cursor=mailCursor){
requireMail();const count=await mailApi('mail.check');requireMail();
if(!Number.isInteger(count.total)||!Number.isInteger(count.unread))throw Error('Invalid mailbox counts.');
$('mail-count').textContent=count.unread+' waiting for receipt; '+count.total+' queued messages.';
const result=await mailApi('mail.list',{cursor:cursor,revision:cursor?mailSnapshot:0});requireMail();
if(!Number.isInteger(result.revision)||result.revision<0||!Array.isArray(result.messages)||result.messages.length>16||(result.next!==null&&(!Number.isInteger(result.next)||result.next<=cursor)))throw Error('Invalid inbox page.');
if(cursor&&result.revision!==mailSnapshot)throw Error('Mailbox changed; check and refresh the inbox before continuing.');
const items=result.messages.map(checkedMailItem);mailCursor=cursor;mailNext=result.next;mailSnapshot=result.revision;mailItems=new Map(items.map(item=>[item.id,item]));$('mail-rows').replaceChildren();
if(!items.length){const row=document.createElement('tr'),cell=document.createElement('td');cell.colSpan=4;cell.textContent='No mail on this page.';row.append(cell);$('mail-rows').append(row);}
for(const item of items){const row=document.createElement('tr');
for(const text of [item.sender,new Date(item.created*1000).toLocaleString(),'Waiting for receipt']){const cell=document.createElement('td');cell.textContent=text;if(text===item.sender)cell.className='key';row.append(cell);}
const cell=document.createElement('td'),button=document.createElement('button');button.type='button';button.textContent='Read '+item.id+' ('+item.length+' bytes)';button.addEventListener('click',()=>openMail(item).catch(message));cell.append(button);row.append(cell);$('mail-rows').append(row);}
$('mail-page').textContent='Page '+(mailPages.length+1);updateMailButtons();
}
async function openMail(item=mailSelected){
requireMail();checkedMailItem(item);if(mailBusy)return;mailBusy=true;mailSelected=item;$('mail-selected').hidden=false;
$('mail-selected-meta').textContent='Message '+item.id+' from '+item.sender+' - '+new Date(item.created*1000).toLocaleString();$('mail-body').textContent='';
let transfer=mailTransfers.get(item.id);
if(!transfer||transfer.sender!==item.sender||transfer.created!==item.created||transfer.bytes.length!==item.length){transfer={sender:item.sender,created:item.created,next:0,bytes:new Uint8Array(item.length),complete:false};mailTransfers.set(item.id,transfer);}
updateMailButtons();
try{while(transfer.next<item.length){
$('mail-read-state').textContent='Downloaded '+transfer.next+' / '+item.length+' bytes. Receipt has not been confirmed.';
const result=await mailApi('mail.read',{id:item.id,offset:transfer.next});requireMail();
const bytes=Uint8Array.from(atob(result.data64),c=>c.charCodeAt(0));
if(result.id!==item.id||result.sender!==item.sender||result.created!==item.created||result.length!==item.length||result.offset!==transfer.next||result.count!==bytes.length
||!bytes.length||bytes.length>128||result.next!==transfer.next+bytes.length||result.next>item.length)throw Error('Mail changed or its download is invalid; refresh the inbox.');
transfer.bytes.set(bytes,transfer.next);transfer.next=result.next;
}
const text=new TextDecoder('utf-8',{fatal:true}).decode(transfer.bytes);transfer.complete=true;$('mail-body').textContent=text;
$('mail-read-state').textContent='Complete. Confirm receipt to remove the server copy, or keep it queued for later.';
}catch(error){$('mail-read-state').textContent='Downloaded '+transfer.next+' / '+item.length+' bytes. '+String(error.message||error)+' Click Read or resume download to continue.';throw error;}
finally{mailBusy=false;updateMailButtons();}
}
async function removeMail(received){
requireMail();if(!mailSelected||mailBusy)return;const item=mailSelected,transfer=mailTransfers.get(item.id);
if(received&&(!transfer||!transfer.complete))throw Error('Read the complete message before confirming receipt.');
if(!confirm((received?'Confirm receipt and remove':'Delete without confirming receipt')+' for message '+item.id+'?'))return;
mailBusy=true;updateMailButtons();
try{const result=await mailApi(received?'mail.ack':'mail.delete',{id:item.id});requireMail();if(result.ok!==true)throw Error('Mail removal was not confirmed.');
mailTransfers.delete(item.id);mailSelected=null;$('mail-body').textContent='';$('mail-selected').hidden=true;mailPages=[];await refreshMail(0);
$('mail-count').textContent+=(received?' Receipt confirmed.':' Message deleted.');}
finally{mailBusy=false;updateMailButtons();}
}
async function loadAdminMail(cursor=adminMailCursor){
requireAdmin();if(adminMailBusy)return;adminMailBusy=true;updateMailButtons();
try{const result=await api('admin.mail.list',{cursor:cursor,revision:cursor?adminMailSnapshot:0});requireAdmin();
if(!Number.isInteger(result.revision)||result.revision<0||!Array.isArray(result.mailboxes)||result.mailboxes.length>16||(result.next!==null&&(!Number.isInteger(result.next)||result.next<=cursor)))throw Error('Invalid mailbox owner page.');
if(cursor&&result.revision!==adminMailSnapshot)throw Error('Mailboxes changed; load mailbox owners again before continuing.');
adminMailCursor=cursor;adminMailNext=result.next;adminMailSnapshot=result.revision;$('admin-mail-rows').replaceChildren();
for(const owner of result.mailboxes){if(!validKey(owner.owner))throw Error('Invalid mailbox identity.');const row=document.createElement('tr');
for(const text of [owner.owner,(owner.blocked?'Blocked; ':'')+owner.mode+' (revision '+owner.revision+')',owner.unread+' unread / '+owner.total+' queued']){const cell=document.createElement('td');cell.textContent=text;if(text===owner.owner)cell.className='key';row.append(cell);}
const cell=document.createElement('td'),button=document.createElement('button');button.type='button';button.textContent='Manage policy';button.addEventListener('click',()=>selectAdminMail(owner.owner).catch(message));cell.append(button);row.append(cell);$('admin-mail-rows').append(row);}
$('admin-mail-page').textContent='Page '+(adminMailPages.length+1);$('admin-mail-state').textContent=result.mailboxes.length?'Mailbox policies and counts loaded. No message bodies were downloaded.':'No configured mailboxes.';
}finally{adminMailBusy=false;updateMailButtons();}
}
async function selectAdminMail(owner,force=false){
requireAdmin();if(!validKey(owner))throw Error('Use a complete mailbox owner key.');
if(adminMailDirty&&!confirm('Replace the unsaved selected mailbox policy?'))return;
const result=await api('admin.mail.settings',{owner:owner});requireAdmin();adminMailPolicy=checkedMailPolicy(result,owner);adminMailDirty=false;
$('admin-mail-key').textContent=result.owner;$('admin-mail-mode').value=result.mode;$('admin-mail-allowlist').value=result.allowlist.join('\n');$('admin-mail-only').checked=result.mailbox_only;
$('admin-mail-block').textContent=result.blocked?'Unblock owner':'Block owner';$('admin-mail-delete-id').value='';$('admin-mail-result').textContent='Loaded policy revision '+result.revision+'.';$('admin-mail-policy').hidden=false;updateMailButtons();
}
async function saveAdminMailPolicy(){
requireAdmin();if(!adminMailPolicy||adminMailBusy)return;const owner=adminMailPolicy.owner,values=Object.assign(policyValues('admin-mail-'),{owner:owner,expected_revision:adminMailPolicy.revision});
if(!confirm('Save '+values.mode+' mailbox policy for '+owner+'?'))return;
adminMailBusy=true;updateMailButtons();
try{const result=await api('admin.mail.policy',values);requireAdmin();adminMailPolicy=checkedMailPolicy(result,owner);adminMailDirty=false;
$('admin-mail-result').textContent='Policy saved and confirmed at revision '+result.revision+'.';if(mailPolicy&&mailPolicy.owner===owner)renderMailPolicy(result);}
catch(error){$('admin-mail-result').textContent=String(error.message||error);throw error;}
finally{adminMailBusy=false;updateMailButtons();}
}
async function deleteAdminMail(all=false){
requireAdmin();if(!adminMailPolicy||adminMailBusy)return;const owner=adminMailPolicy.owner,value=$('admin-mail-delete-id').value,id=all?0:Number(value);
if(!all&&(!/^\d+$/.test(value)||!Number.isInteger(id)||id<1||id>4294967295))throw Error('Enter a valid message ID.');
if(!confirm((all?'Purge every queued message':'Delete queued message '+id)+' for mailbox '+owner+'?\nThis permanently removes server copies without confirming receipt.'))return;
adminMailBusy=true;updateMailButtons();
try{const result=await api('admin.mail.delete',{owner:owner,id:id});requireAdmin();if(result.ok!==true)throw Error('Mailbox deletion was not confirmed.');$('admin-mail-result').textContent=all?'Inbox purge confirmed.':'Message deletion confirmed.';
if(mailPolicy&&mailPolicy.owner===owner){mailTransfers.clear();mailSelected=null;$('mail-body').textContent='';$('mail-selected').hidden=true;await refreshMail(0);}}
finally{adminMailBusy=false;updateMailButtons();}
await loadAdminMail();
}
async function blockAdminMail(){
requireAdmin();if(!adminMailPolicy||adminMailBusy)return;const owner=adminMailPolicy.owner,banned=!adminMailPolicy.blocked;
if(!confirm((banned?'Block ':'Unblock ')+owner+' for room access and mailbox operations?'))return;
adminMailBusy=true;updateMailButtons();
try{await api('admin.ban',{key:owner,banned:banned});requireAdmin();adminMailPolicy.blocked=banned;$('admin-mail-block').textContent=banned?'Unblock owner':'Block owner';$('admin-mail-result').textContent=banned?'Owner blocked for room access.':'Owner unblocked.';}
finally{adminMailBusy=false;updateMailButtons();}
await loadAdminMail();
}
async function openArticle(item){
if(!Number.isInteger(item.length)||item.length<0||item.length>2048)throw Error("Article exceeds the radio's limit.");
let transfer=transfers.get(item.id);
@@ -383,7 +589,8 @@ async function openArticle(item){
$('join').addEventListener('submit',async event=>{
event.preventDefault();message('');
connectionGeneration++;joined=false;role=0;clearAdmin();$('editor').hidden=true;$('admin-panel').hidden=true;$('room').hidden=true;updateBudget();
try{makeIdentity();password=$('password').value;$('password').value='';await status();joined=true;$('room').hidden=false;updateBudget();await refreshChat();await refreshBoard()}
clearMail();
try{makeIdentity();password=$('password').value;$('password').value='';await status();joined=true;$('room').hidden=false;updateBudget();await refreshChat(true);await refreshBoard()}
catch(e){message(e)}
});
$('name').value=saved('mc-room-name','Web');
@@ -398,7 +605,37 @@ $('compose').addEventListener('submit',async event=>{
$('refresh').addEventListener('click',()=>refreshChat().catch(message));
$('disconnect').addEventListener('click',()=>{
connectionGeneration++;joined=false;role=0;password='';clearAdmin();$('room').hidden=true;$('admin-panel').hidden=true;$('editor').hidden=true;$('state').textContent='Disconnected';updateBudget();
clearMail();
});
$('mail-open').addEventListener('click',()=>openMailbox().catch(message));
$('mail-copy').addEventListener('click',async()=>{
try{requireMail();const key=$('mail-key').textContent;if(!validKey(key))throw Error('Open your mailbox first.');
if(navigator.clipboard&&window.isSecureContext)await navigator.clipboard.writeText(key);
else{const range=document.createRange();range.selectNodeContents($('mail-key'));const selection=window.getSelection();selection.removeAllRanges();selection.addRange(range);if(!document.execCommand('copy'))throw Error('Select and copy the complete mailbox address above.');selection.removeAllRanges();}
$('mail-state').textContent='Complete mailbox address copied.';
}catch(error){message(error)}
});
for(const id of ['mail-mode','mail-only','mail-allowlist'])$(id).addEventListener(id==='mail-allowlist'?'input':'change',()=>{mailDirty=true});
for(const id of ['mail-recipient','mail-text'])$(id).addEventListener('input',updateMailButtons);
$('mail-policy').addEventListener('submit',event=>{event.preventDefault();saveMailPolicy().catch(message)});
$('mail-policy-reload').addEventListener('click',()=>loadMailSettings(true).catch(message));
$('mail-compose').addEventListener('submit',event=>{event.preventDefault();sendMail().catch(message)});
$('mail-refresh').addEventListener('click',()=>{if(mailBusy)return;mailPages=[];refreshMail(0).catch(message)});
$('mail-next').addEventListener('click',()=>{if(mailNext===null||mailBusy)return;const previous=mailCursor;mailPages.push(previous);refreshMail(mailNext).catch(error=>{mailPages.pop();message(error);updateMailButtons()})});
$('mail-previous').addEventListener('click',()=>{if(!mailPages.length||mailBusy)return;const cursor=mailPages.pop();refreshMail(cursor).catch(error=>{mailPages.push(cursor);message(error);updateMailButtons()})});
$('mail-resume').addEventListener('click',()=>openMail().catch(message));
$('mail-ack').addEventListener('click',()=>removeMail(true).catch(message));
$('mail-delete').addEventListener('click',()=>removeMail(false).catch(message));
$('admin-mail-refresh').addEventListener('click',()=>{adminMailPages=[];loadAdminMail(0).catch(message)});
$('admin-mail-load-owner').addEventListener('click',()=>selectAdminMail($('admin-mail-owner').value.trim().toLowerCase()).catch(message));
$('admin-mail-next').addEventListener('click',()=>{if(adminMailNext===null||adminMailBusy)return;const previous=adminMailCursor;adminMailPages.push(previous);loadAdminMail(adminMailNext).catch(error=>{adminMailPages.pop();message(error);updateMailButtons()})});
$('admin-mail-previous').addEventListener('click',()=>{if(!adminMailPages.length||adminMailBusy)return;const cursor=adminMailPages.pop();loadAdminMail(cursor).catch(error=>{adminMailPages.push(cursor);message(error);updateMailButtons()})});
for(const id of ['admin-mail-mode','admin-mail-only','admin-mail-allowlist'])$(id).addEventListener(id==='admin-mail-allowlist'?'input':'change',()=>{adminMailDirty=true});
$('admin-mail-policy').addEventListener('submit',event=>{event.preventDefault();saveAdminMailPolicy().catch(message)});
$('admin-mail-reload').addEventListener('click',()=>{if(adminMailPolicy)selectAdminMail(adminMailPolicy.owner,true).catch(message)});
$('admin-mail-delete').addEventListener('click',()=>deleteAdminMail(false).catch(message));
$('admin-mail-purge').addEventListener('click',()=>deleteAdminMail(true).catch(message));
$('admin-mail-block').addEventListener('click',()=>blockAdminMail().catch(message));
$('browser-catchup-form').addEventListener('submit',event=>event.preventDefault());
$('browser-skip-before').addEventListener('click',()=>browserCatchup('before').catch(message));
$('browser-skip-number').addEventListener('click',()=>browserCatchup('keep').catch(message));
@@ -449,7 +686,7 @@ $('delete').addEventListener('click',async()=>{
try{await api('board.delete',{id:id,version:entry.version});transfers.delete(id);await refreshBoard();$('article').hidden=true;$('article-title').hidden=true}
catch(e){message(e)}
});
setInterval(()=>{if(joined)refreshChat().catch(message);if(joined&&role===3&&adminLoaded&&$('admin-live').checked)loadAdmin().catch(message)},15000);
setInterval(()=>{if(joined)refreshChat(true).catch(message);if(joined&&role===3&&adminLoaded&&$('admin-live').checked)loadAdmin().catch(message)},15000);
updateBudget();
updateAdminButtons();
</script></body></html>)roomhtml";
+13 -1
View File
@@ -7,6 +7,8 @@
#include "filesystem.h"
#include <Packet.h>
#include <helpers/RoomAccessPolicy.h>
#include <helpers/RoomMailProtocol.h>
#include <helpers/RoomBoardProtocol.h>
#include <helpers/RoomClientPathCommand.h>
#include <helpers/RoomCatchUp.h>
#include <helpers/ClientPathObservation.h>
@@ -93,6 +95,7 @@ static uint32_t ticks = 100;
static uint32_t millis() { return ticks; }
struct Sent {
mesh::Packet packet;
uint8_t recipient[PUB_KEY_SIZE]{};
uint8_t path[MAX_PATH_SIZE]{};
uint8_t path_len = 0;
unsigned long delay = 0;
@@ -115,6 +118,7 @@ struct MyMesh {
FakeRng rng;
uint8_t reply_data[MAX_PACKET_PAYLOAD]{};
mesh::Packet pool[64];
mesh::Identity datagram_recipients[64]{};
unsigned used = 0, allocations = 0, releases = 0, fail_allocation = UINT32_MAX;
bool send_ok = true;
unsigned general_commands = 0, post_count = 0, retry_replacements = 0, _num_post_pushes = 0;
@@ -155,10 +159,12 @@ struct MyMesh {
return &pool[used++];
}
void releasePacket(mesh::Packet* p) { assert(p != nullptr); ++releases; }
mesh::Packet* createDatagram(uint8_t type, const mesh::Identity&, const uint8_t*,
mesh::Packet* createDatagram(uint8_t type, const mesh::Identity& recipient, const uint8_t*,
const uint8_t* data, size_t n) {
auto p = obtainNewPacket();
if (p) {
assert(n <= sizeof(p->payload));
datagram_recipients[p - pool] = recipient;
p->header = type << PH_TYPE_SHIFT;
p->payload_len = n;
memcpy(p->payload, data, n);
@@ -177,6 +183,7 @@ struct MyMesh {
bool sendDirect(mesh::Packet* p, const uint8_t* path, uint8_t n, unsigned long delay = 0) {
assert(p && mesh::Packet::isValidPathLen(n));
Sent value; value.packet = *p; value.path_len = n; value.delay = delay;
memcpy(value.recipient, datagram_recipients[p - pool].pub_key, PUB_KEY_SIZE);
value.retry_enabled = _prefs.direct_retry_enabled != 0;
memcpy(value.path, path, mesh::encodedClientPathByteLength(n));
sent.push_back(value);
@@ -184,6 +191,7 @@ struct MyMesh {
}
bool sendFloodReply(mesh::Packet* p, unsigned long delay, uint8_t hash_size) {
Sent value; value.packet = *p; value.delay = delay; value.direct = false;
memcpy(value.recipient, datagram_recipients[p - pool].pub_key, PUB_KEY_SIZE);
value.path_len = (hash_size - 1) << 6;
sent.push_back(value);
return send_ok;
@@ -210,6 +218,9 @@ struct MyMesh {
int handleRequest(ClientInfo*, uint32_t, uint8_t*, size_t, size_t) { return 0; }
static bool saveFilter(ClientInfo*);
bool handleClientPathCommand(ClientInfo*, char*, char*);
bool roomClientChatEnabled(ClientInfo*);
bool handleRoomMailClientCommand(ClientInfo*, const char*, uint64_t, char*, size_t);
bool handleRoomMailText(ClientInfo*, mesh::Packet*, const uint8_t*, uint8_t*, size_t);
bool executeClientPathCommand(ClientInfo*, mesh::RoomClientPathCommand, const char*, char*);
bool handleRoomCatchUpCommand(ClientInfo*, char*, char*);
bool applyRoomCatchUpCommand(ClientInfo*, const char*, uint32_t, char*);
@@ -220,6 +231,7 @@ struct MyMesh {
void onPeerDataRecv(mesh::Packet*, uint8_t, int, const uint8_t*, uint8_t*, size_t);
void command(const char* text, uint32_t timestamp = 100, unsigned sender = 0,
uint32_t logical = 0, uint8_t flags = TXT_TYPE_CLI_COMMAND) {
metadata_filesystem = &policy_fs;
std::vector<uint8_t> data(5 + strlen(text) + mesh::RemoteCliRequest::EXTENSION_SIZE + 1);
memcpy(data.data(), &timestamp, 4); data[4] = flags << 2;
memcpy(data.data() + 5, text, strlen(text));
+43 -1
View File
@@ -8,6 +8,7 @@
#include <helpers/RoomAccessPolicy.h>
#include <helpers/ClientPathPersistence.h>
#include <helpers/RoomClientPathCommand.h>
#include <helpers/RoomMailStore.h>
#define PUB_KEY_SIZE 32
#define MAX_PATH_SIZE 64
@@ -103,6 +104,9 @@ struct MyMesh {
std::vector<Sent> sent;
bool pool_available = true, queue_available = true, alternate_available = true;
unsigned releases = 0;
unsigned cancellations = 0;
uint8_t retry_key[MAX_HASH_SIZE] = {};
uint32_t retry_timestamp = 0;
uint8_t reply_data[MAX_PACKET_PAYLOAD] = {};
unsigned long next_push = 0, room_topic_ready_at = 0;
bool room_topic_ready = true;
@@ -158,7 +162,9 @@ struct MyMesh {
memcpy(entry.path, path, mesh::encodedClientPathByteLength(length));
return true;
}
void replaceActiveMessageRetries(mesh::Packet*, const uint8_t*, uint32_t) {}
void replaceActiveMessageRetries(mesh::Packet*, const uint8_t* key, uint32_t timestamp) {
memcpy(retry_key, key, sizeof(retry_key)); retry_timestamp = timestamp;
}
bool pushPostToClient(ClientInfo*, PostInfo&);
bool sendClientReply(ClientInfo*, mesh::Packet*, unsigned long, uint8_t);
bool pushRoomTextToClient(ClientInfo*, uint32_t, const mesh::Identity&, const char*, uint32_t = 0);
@@ -166,6 +172,12 @@ struct MyMesh {
void activateRoomTopic();
bool handleRoomTopicCommand(const char*, char*);
void serviceRoomPush();
uint8_t getUnsyncedCount(ClientInfo*);
bool roomClientChatEnabled(ClientInfo*);
bool cancelActiveMessageRetries(const uint8_t* key, uint32_t timestamp) {
assert(memcmp(key, retry_key, sizeof(retry_key)) == 0 && timestamp == retry_timestamp);
++cancellations; return true;
}
void tick(uint32_t delta = 0) {
ticks += delta + 1;
next_push = uint32_t(ticks - 1);
@@ -362,5 +374,35 @@ int main() {
}
assert(m._prefs.direct_retry_enabled == 1);
}
for (bool pending_topic : {false, true}) {
MyMesh m; auto& client = m.acl.clients[0];
m.topic("Public topic");
if (!pending_topic) m.post(0, 900, "Public chat");
m.tick(6000); assert(m.sent.size() == 1 && client.extra.room.pending_ack);
const uint32_t old_ack = client.extra.room.pending_ack;
mesh::RoomMailSettings settings; settings.mailbox_only = true;
assert(mesh::saveRoomMailSettings(&m.policy_fs, client.id.pub_key, settings) == mesh::RoomMailResult::Success);
assert(!m.roomClientChatEnabled(&client) && m.getUnsyncedCount(&client) == 0);
m.tick(); assert(!client.extra.room.pending_ack && !client.extra.room.pending_topic_revision);
assert(m.cancellations == 1 && !m.processAck(reinterpret_cast<const uint8_t*>(&old_ack)));
for (int poll = 0; poll < 10; ++poll) m.tick(12000);
assert(m.sent.size() == 1 && client.extra.room.sync_since == 0);
settings.mailbox_only = false;
assert(mesh::saveRoomMailSettings(&m.policy_fs, client.id.pub_key, settings) == mesh::RoomMailResult::Success);
m.tick(); assert(m.sent.size() == 2); m.ack();
}
{
// An intermittently connected mailbox subscriber never downloads chat or
// the topic; corrupt saved policy remains closed to automatic delivery.
MyMesh m; auto& client = m.acl.clients[0];
mesh::RoomMailSettings settings; settings.mailbox_only = true;
assert(mesh::saveRoomMailSettings(&m.policy_fs, client.id.pub_key, settings) == mesh::RoomMailResult::Success);
m.topic("Public topic"); m.post(0, 900, "Public chat");
for (int poll = 0; poll < 10; ++poll) m.tick(6000);
assert(m.sent.empty() && !m.getUnsyncedCount(&client));
m.policy_fs.put(mesh::ROOM_MAIL_PRIMARY_PATH, {'c','o','r','r','u','p','t'});
for (int poll = 0; poll < 10; ++poll) m.tick(6000);
assert(m.sent.empty() && !m.roomClientChatEnabled(&client));
}
puts("room topic delivery regressions passed");
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"status": {"name":"Test room","topic":"Topic <img src=x onerror=attack()>","boot":71,"role":2,"web_key":"5f5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a","persistent_history":false},
"status": {"name":"Test room","topic":"Topic <img src=x onerror=attack()>","boot":71,"role":2,"web_key":"5f5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a","persistent_history":false,"mailbox_only":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=="}
}
+210 -4
View File
@@ -24,6 +24,7 @@ static void* trackedRealloc(void* ptr, size_t size) {
#undef free
#undef realloc
#include <helpers/RoomBoardStore.h>
#include <helpers/RoomMailWeb.h>
#include <helpers/RoomHistoryStore.h>
#include <helpers/RoomAccessPolicy.h>
#include <helpers/RoomLoginAuthorization.h>
@@ -174,6 +175,7 @@ struct MyMesh {
bool applyRoomCatchUpCommand(ClientInfo*, const char*, uint32_t, char*);
static bool saveFilter(ClientInfo*);
uint8_t getUnsyncedCount(ClientInfo*);
bool roomClientChatEnabled(ClientInfo*);
bool handleRoomHistoryCommand(const char*, char*);
bool handleRoomTopicCommand(const char*, char*);
void activateRoomTopic();
@@ -237,7 +239,8 @@ static void seedClients(MyMesh& m, unsigned count = 5) {
}
static void adminAuthorizationCases() {
const char* operations[] = {"admin.users", "admin.user", "admin.route", "admin.catchup",
"admin.access", "admin.ban", "admin.settings", "admin.topic", "admin.history", "admin.rates"};
"admin.access", "admin.ban", "admin.settings", "admin.topic", "admin.history", "admin.rates",
"admin.mail.list", "admin.mail.settings", "admin.mail.policy", "admin.mail.delete"};
unsigned denied = 0;
for (const char* password : {"", "wrong-password", "write-secret"}) {
for (const char* operation : operations) {
@@ -554,7 +557,7 @@ static void adminInvalidAndSessionCases() {
assert(m.room_access.postRate() == 0 && m.room_access.pollRate() == 0);
}
const char* writes[] = {"admin.route", "admin.catchup", "admin.access", "admin.ban",
"admin.topic", "admin.history", "admin.rates"};
"admin.topic", "admin.history", "admin.rates", "admin.mail.policy", "admin.mail.delete"};
for (const char* operation : writes) {
MyMesh m; enable(m); seedClients(m);
error(m, adminClientRequest(operation, m.acl.clients[1], "", 70), 99, "restarted");
@@ -581,7 +584,205 @@ static void adminInvalidAndSessionCases() {
assert(banned.room_access.addBan(banned._fs, author) == mesh::RoomAccessPolicy::BanResult::Saved);
error(banned, request("admin.settings", "admin-secret"), 1, "access denied");
assert(floors(banned) == 0 && banned.acl.lookups == 0 && !banned.room_web_request_authenticated);
printf("ADMIN_INVALID: %zu strict schemas, 21 boot guards, replay/auth floors, bounded sessions and banned browser passed\n", std::size(invalid));
printf("ADMIN_INVALID: %zu strict schemas, %zu boot guards, replay/auth floors, bounded sessions and banned browser passed\n", std::size(invalid), std::size(writes) * 3);
}
static std::string browserKey(const MyMesh& m, uint8_t owner) {
const auto identity = token(owner); uint8_t author[32]; char key[65];
mesh::Utils::sha256(author, sizeof(author), identity.data(), identity.size(), m.self_id.pub_key, PUB_KEY_SIZE);
mesh::Utils::toHex(key, author, sizeof(author)); return key;
}
static std::string quoted(const std::string& value) {
JsonDocument json; json.set(value); std::string output; serializeJson(json, output); return output;
}
static std::string mailPolicyFields(const char* mode, uint32_t revision, bool only = false,
const std::vector<std::string>& allowed = {}) {
std::string fields = ",\"boot\":71,\"mode\":" + quoted(mode) + ",\"expected_revision\":" + std::to_string(revision)
+ ",\"mailbox_only\":" + (only ? "true" : "false") + ",\"allowlist\":[";
for (size_t i = 0; i < allowed.size(); ++i) { if (i) fields += ','; fields += quoted(allowed[i]); }
return fields + ']';
}
static std::string mailSendRequest(const std::string& recipient, const std::string& text,
const char* password = "write-secret") {
return request("mail.send", password, ",\"boot\":71,\"recipient\":" + quoted(recipient) + ",\"text\":" + quoted(text));
}
static std::string mailListRequest(uint8_t cursor = 0, uint32_t revision = 0,
const char* password = "") {
return request("mail.list", password, ",\"cursor\":" + std::to_string(cursor) + ",\"revision\":" + std::to_string(revision));
}
static std::string mailOwnerRequest(const char* operation, const std::string& owner,
const std::string& fields = "") {
return request(operation, "admin-secret", ",\"owner\":" + quoted(owner) + fields);
}
static uint32_t mailRevision(MyMesh& m, uint32_t& sequence, uint8_t owner) {
auto settings = result(m, request("mail.settings", ""), sequence++, owner);
assert(!settings["error"] && settings["revision"].is<uint32_t>());
return settings["revision"].as<uint32_t>();
}
static void mailAuthorizationAndPolicyCases() {
MyMesh m; enable(m); uint32_t sequence = 1;
const auto first = result(m, request("mail.settings", ""), sequence++, 1);
const std::string owner = first["owner"].as<std::string>(), sender = browserKey(m, 2);
assert(owner == browserKey(m, 1) && owner != sender);
assert(first["mode"] == "closed" && first["revision"] == 0 && first["total"] == 0 && first["mailbox_only"] == false);
const auto impersonated = result(m, request("mail.settings", "", ",\"owner\":" + quoted(owner)
+ ",\"key\":" + quoted(clientKey(m.acl.radio_admin)) + ",\"permissions\":3"), sequence++, 2);
assert(impersonated["owner"] == sender && m.acl.lookups == 0);
auto policy = result(m, request("mail.policy", "", mailPolicyFields("public", 0, true)), sequence++, 1);
assert(policy["mode"] == "public" && policy["revision"].as<uint32_t>() > 0 && policy["mailbox_only"] == true);
const uint32_t initial_revision = policy["revision"];
error(m, request("mail.policy", "", mailPolicyFields("closed", 0)), sequence++, "changed", 1);
assert(result(m, request("mail.settings", ""), sequence++, 1)["mode"] == "public");
assert(result(m, request("status", ""), sequence++, 1)["mailbox_only"] == true);
assert(result(m, request("status", ""), sequence++, 2)["mailbox_only"] == false);
error(m, mailSendRequest(owner, "reader cannot send", ""), sequence++, "read-only", 2);
auto sent = result(m, mailSendRequest(owner, "public mail"), sequence++, 2);
assert(sent["ok"] == true && sent["id"].as<uint32_t>() > initial_revision);
const uint32_t id = sent["id"], revision = mailRevision(m, sequence, 1);
error(m, request("mail.read", "", ",\"owner\":" + quoted(owner) + ",\"id\":" + std::to_string(id) + ",\"offset\":0"), sequence++, "not found", 2);
error(m, request("mail.ack", "", ",\"boot\":71,\"owner\":" + quoted(owner) + ",\"id\":" + std::to_string(id)), sequence++, "not found", 2);
assert(result(m, request("mail.check", ""), sequence++, 1)["unread"] == 1);
policy = result(m, request("mail.policy", "", mailPolicyFields("private", revision, true, {sender})), sequence++, 1);
assert(policy["allowlist"].size() == 1 && policy["allowlist"][0] == sender);
error(m, mailSendRequest(owner, "unapproved mail"), sequence++, "not accepting", 3);
assert(result(m, mailSendRequest(owner, "approved mail"), sequence++, 2)["ok"] == true);
const uint32_t private_revision = mailRevision(m, sequence, 1);
policy = result(m, request("mail.policy", "", mailPolicyFields("closed", private_revision)), sequence++, 1);
assert(policy["mode"] == "closed");
error(m, mailSendRequest(owner, "closed mail"), sequence++, "not accepting", 2);
assert(result(m, request("mail.check", ""), sequence++, 1)["total"] == 2 && m._num_posted == 0);
assert(result(m, request("status", ""), sequence++, 1)["mailbox_only"] == false);
const auto before = m.storage.get(mesh::ROOM_MAIL_PRIMARY_PATH);
const uint32_t closed_revision = mailRevision(m, sequence, 1);
error(m, request("mail.policy", "", mailPolicyFields("private", closed_revision, false, {sender, sender})), sequence++, "invalid", 1);
std::vector<std::string> too_many(9, sender);
error(m, request("mail.policy", "", mailPolicyFields("private", closed_revision, false, too_many)), sequence++, "eight", 1);
assert(m.storage.get(mesh::ROOM_MAIL_PRIMARY_PATH) == before && m.acl.lookups == 0);
printf("MAIL_AUTH: browser token identity isolation, reader/writer gates, strict stale-zero policy, public/private/closed and sender limits passed\n");
}
static void mailDeliveryAndDurabilityCases() {
MyMesh m; enable(m); const std::string recipient = browserKey(m, 2);
assert(result(m, request("mail.policy", "", mailPolicyFields("public", 0, true)), 1, 2)["mode"] == "public");
std::string text; for (unsigned i = 0; i < 128; ++i) text += "\xF0\x9F\x93\xA1";
assert(text.size() == 512);
const auto submission = mailSendRequest(recipient, text); auto sent = result(m, submission, 200, 1);
assert(sent["ok"] == true); const uint32_t id = sent["id"];
error(m, submission, 200, "completed", 1);
error(m, mailSendRequest(recipient, text + "x"), 201, "1-512", 1);
error(m, request("mail.send", "write-secret", ",\"boot\":71,\"recipient\":" + quoted(recipient) + ",\"text\":\"before\\u0000after\""), 202, "1-512", 1);
error(m, mailSendRequest(recipient.substr(0, 12), "bad recipient"), 203, "complete", 1);
MyMesh restored; restored.storage.files = m.storage.files; enable(restored);
const auto duplicate = result(restored, submission, 200, 1);
assert(duplicate["ok"] == true && duplicate["id"] == id && restored.storage.write_opens == 0);
uint32_t sequence = 1;
auto page = result(restored, mailListRequest(), sequence++, 2);
assert(page["messages"].size() == 1 && page["messages"][0]["id"] == id && page["messages"][0]["sender"] == browserKey(restored, 1));
assert(page["messages"][0]["length"] == 512 && page["messages"][0]["acked"] == false && page["next"].isNull());
std::string assembled;
for (unsigned offset = 0; offset < 512;) {
auto chunk = result(restored, request("mail.read", "", ",\"id\":" + std::to_string(id) + ",\"offset\":" + std::to_string(offset)), sequence++, 2);
uint8_t bytes[128]; size_t length = 0;
assert(chunk["id"] == id && chunk["sender"] == browserKey(restored, 1) && chunk["length"] == 512 && chunk["created"] == 1000);
assert(chunk["offset"] == offset && chunk["acked"] == false && mesh::roomDecodeBase64(chunk["data64"], bytes, sizeof(bytes), length));
assert(length == 128 && chunk["count"] == length && chunk["next"] == offset + length);
assembled.append(reinterpret_cast<const char*>(bytes), length); offset += length;
}
assert(assembled == text);
auto count = result(restored, request("mail.check", ""), sequence++, 2);
assert(count["total"] == 1 && count["unread"] == 1 && count["mailbox_only"] == true);
const auto eof = result(restored, request("mail.read", "", ",\"id\":" + std::to_string(id) + ",\"offset\":512"), sequence++, 2);
assert(eof["count"] == 0 && eof["next"] == 512 && eof["data64"] == "");
assert(result(restored, request("mail.ack", "", ",\"boot\":71,\"id\":" + std::to_string(id)), sequence++, 2)["ok"] == true);
assert(result(restored, request("mail.check", ""), sequence++, 2)["unread"] == 0);
error(restored, request("mail.read", "", ",\"id\":" + std::to_string(id) + ",\"offset\":0"), sequence++, "not found", 2);
MyMesh after_ack; after_ack.storage.files = restored.storage.files; enable(after_ack);
const auto repeated = result(after_ack, submission, 200, 1);
assert(repeated["ok"] == true && repeated["id"] == id);
assert(result(after_ack, request("mail.check", ""), 1, 2)["total"] == 0);
assert(result(after_ack, request("mail.ack", "", ",\"boot\":71,\"id\":" + std::to_string(id)), 2, 2)["ok"] == true);
printf("MAIL_DELIVERY: 512-byte UTF-8, bounded chunks/EOF, read retains unread mail, explicit durable ACK and retry across reboot passed\n");
}
static void mailAdministratorCases() {
MyMesh m; enable(m); uint32_t sequence = 1; const std::string target = browserKey(m, 2), sender = browserKey(m, 1);
auto selected = result(m, mailOwnerRequest("admin.mail.settings", target), sequence++, 9);
assert(selected["owner"] == target && selected["revision"] == 0 && selected["blocked"] == false);
selected = result(m, mailOwnerRequest("admin.mail.policy", target, mailPolicyFields("private", 0, true, {sender})), sequence++, 9);
assert(selected["owner"] == target && selected["allowlist"][0] == sender && selected["mailbox_only"] == true);
const uint32_t revision = selected["revision"];
error(m, mailOwnerRequest("admin.mail.policy", target, mailPolicyFields("closed", 0)), sequence++, "changed", 9);
error(m, mailOwnerRequest("admin.mail.policy", target, mailPolicyFields("closed", revision - 1)), sequence++, "changed", 9);
const auto sent = result(m, mailSendRequest(target, "recipient-only content"), sequence++, 1); assert(sent["ok"] == true);
auto metadata = result(m, request("admin.mail.list", "admin-secret", ",\"cursor\":0,\"revision\":0"), sequence++, 9);
assert(metadata["mailboxes"].size() == 1 && metadata["mailboxes"][0]["owner"] == target && metadata["mailboxes"][0]["total"] == 1);
assert(!metadata["mailboxes"][0]["text"] && !metadata["mailboxes"][0]["data64"] && !metadata["mailboxes"][0]["allowlist"]);
error(m, mailOwnerRequest("admin.mail.read", target, ",\"id\":" + std::to_string(sent["id"].as<uint32_t>()) + ",\"offset\":0"), sequence++, "unknown", 9);
auto own = result(m, request("mail.list", "admin-secret", ",\"owner\":" + quoted(target) + ",\"cursor\":0,\"revision\":0"), sequence++, 9);
assert(own["messages"].size() == 0);
assert(result(m, request("admin.ban", "admin-secret", ",\"boot\":71,\"key\":" + quoted(target) + ",\"banned\":true"), sequence++, 9)["ok"] == true);
selected = result(m, mailOwnerRequest("admin.mail.settings", target), sequence++, 9);
assert(selected["blocked"] == true && selected["mode"] == "private" && selected["allowlist"][0] == sender);
error(m, mailSendRequest(target, "blocked recipient"), sequence++, "blocked", 1);
error(m, request("mail.check", ""), sequence++, "denied", 2);
assert(result(m, mailOwnerRequest("admin.mail.delete", target, ",\"boot\":71,\"id\":0"), sequence++, 9)["ok"] == true);
selected = result(m, mailOwnerRequest("admin.mail.settings", target), sequence++, 9);
assert(selected["total"] == 0 && selected["mode"] == "private" && selected["blocked"] == true);
assert(result(m, request("admin.ban", "admin-secret", ",\"boot\":71,\"key\":" + quoted(target) + ",\"banned\":false"), sequence++, 9)["ok"] == true);
error(m, request("mail.delete", "", ",\"boot\":71,\"id\":0"), sequence++, "identifier", 2);
printf("MAIL_ADMIN: selected full-key policies, metadata-only enumeration, blocked field/gates, owner-isolated admin reads and confirmed purge passed\n");
}
static void mailPagingAndStorageCases() {
MyMesh m; enable(m); uint32_t sequence = 1; const std::string target = browserKey(m, 2);
assert(result(m, request("mail.policy", "", mailPolicyFields("public", 0)), sequence++, 2)["mode"] == "public");
std::vector<uint32_t> ids;
for (unsigned i = 0; i < 3; ++i) { auto sent = result(m, mailSendRequest(target, "queued " + std::to_string(i)), sequence++, 1); assert(sent["ok"] == true); ids.push_back(sent["id"]); }
auto page = result(m, mailListRequest(), sequence++, 2); assert(page["messages"].size() == 2 && page["next"] == 2);
const uint32_t snapshot = page["revision"];
assert(result(m, mailSendRequest(target, "arrival between pages"), sequence++, 1)["ok"] == true);
error(m, mailListRequest(2, snapshot), sequence++, "changed", 2);
page = result(m, mailListRequest(), sequence++, 2);
const uint32_t newer = page["revision"];
assert(result(m, request("mail.delete", "", ",\"boot\":71,\"id\":" + std::to_string(ids[0])), sequence++, 2)["ok"] == true);
error(m, mailListRequest(2, newer), sequence++, "changed", 2);
error(m, mailListRequest(2, 0), sequence++, "revision", 2);
page = result(m, mailListRequest(), sequence++, 2);
auto last = result(m, mailListRequest(2, page["revision"]), sequence++, 2);
assert(last["messages"].size() == 1 && last["next"].isNull());
for (uint8_t owner = 3; owner <= 4; ++owner) assert(result(m, mailOwnerRequest("admin.mail.policy", browserKey(m, owner), mailPolicyFields("public", 0)), sequence++, 9)["mode"] == "public");
auto owners = result(m, request("admin.mail.list", "admin-secret", ",\"cursor\":0,\"revision\":0"), sequence++, 9);
assert(owners["mailboxes"].size() == 2 && owners["next"] == 2); const uint32_t owner_snapshot = owners["revision"];
assert(result(m, mailSendRequest(target, "admin snapshot mutation"), sequence++, 1)["ok"] == true);
error(m, request("admin.mail.list", "admin-secret", ",\"cursor\":2,\"revision\":" + std::to_string(owner_snapshot)), sequence++, "changed", 9);
owners = result(m, request("admin.mail.list", "admin-secret", ",\"cursor\":0,\"revision\":0"), sequence++, 9);
auto owners_last = result(m, request("admin.mail.list", "admin-secret", ",\"cursor\":2,\"revision\":" + std::to_string(owners["revision"].as<uint32_t>())), sequence++, 9);
assert(owners_last["mailboxes"].size() == 1 && owners_last["next"].isNull());
error(m, mailSendRequest(target, "owner queue is full"), sequence++, "full", 1);
const auto primary = m.storage.get(mesh::ROOM_MAIL_PRIMARY_PATH);
m.storage.capacity = 0;
error(m, request("mail.ack", "", ",\"boot\":71,\"id\":" + std::to_string(ids[1])), sequence++, "save failed", 2);
assert(m.storage.get(mesh::ROOM_MAIL_PRIMARY_PATH) == primary); m.storage.reset();
assert(result(m, request("mail.check", ""), sequence++, 2)["total"] == 4);
uint8_t key[32]; assert(mesh::roomMailWebKey(key, sizeof(key), target.c_str()));
mesh::room_mail_detail::Index index; mesh::room_mail_detail::Entry entry; mesh::room_mail_detail::Owner owner; bool found = false;
assert(mesh::room_mail_detail::load(m._fs, key, index, entry, owner, found, false) == mesh::RoomMailResult::Success && found);
char bank[24]; mesh::room_mail_detail::ownerPath(entry, bank);
auto corrupt = m.storage.get(bank); corrupt.back() ^= 1; m.storage.put(bank, corrupt);
auto denied = result(m, request("mail.read", "", ",\"id\":" + std::to_string(ids[1]) + ",\"offset\":0"), sequence++, 2);
assert(denied.as<JsonObjectConst>().size() == 1 && strstr(denied["error"].as<const char*>(), "storage unavailable"));
error(m, mailSendRequest(target, "preserve corrupt evidence"), sequence++, "storage unavailable", 1);
assert(m.storage.get(bank) == corrupt && m.storage.get(mesh::ROOM_MAIL_PRIMARY_PATH) == primary);
assert(result(m, request("status", ""), sequence++, 2)["mailbox_only"] == true);
printf("MAIL_PAGING: explicit revisions reject message arrivals/deletions between pages and changed admin snapshots passed\n");
printf("MAIL_STORAGE: full owner queue, failed ACK retention, corrupt-bank fail-closed reads/status and preserved evidence passed\n");
}
static void mailGlobalQueueCases() {
MyMesh m; enable(m); uint32_t sequence = 1;
for (uint8_t owner = 2; owner <= 6; ++owner) assert(result(m, mailOwnerRequest("admin.mail.policy", browserKey(m, owner), mailPolicyFields("public", 0)), sequence++, 9)["mode"] == "public");
for (uint8_t owner = 2; owner <= 5; ++owner) for (unsigned i = 0; i < 4; ++i) assert(result(m, mailSendRequest(browserKey(m, owner), "bounded global queue"), sequence++, 1)["ok"] == true);
error(m, mailSendRequest(browserKey(m, 6), "seventeenth message"), sequence++, "full", 1);
const auto owners = result(m, request("admin.mail.list", "admin-secret", ",\"cursor\":0,\"revision\":0"), sequence++, 9);
assert(owners["queued"] == 16);
assert(result(m, mailOwnerRequest("admin.mail.delete", browserKey(m, 2), ",\"boot\":71,\"id\":0"), sequence++, 9)["ok"] == true);
assert(result(m, mailSendRequest(browserKey(m, 6), "space released by purge"), sequence++, 1)["ok"] == true);
printf("MAIL_GLOBAL: bounded 16-message server queue rejects without acceptance and recovers capacity after explicit purge passed\n");
}
int main() {
unsigned scenarios = 0;
@@ -714,11 +915,16 @@ int main() {
adminListingCases();
adminAccessAndSettingsCases();
adminInvalidAndSessionCases();
mailAuthorizationAndPolicyCases();
mailDeliveryAndDurabilityCases();
mailAdministratorCases();
mailPagingAndStorageCases();
mailGlobalQueueCases();
// Deliver concrete backend responses for the browser boundary fixture.
MyMesh boundary; enable(boundary);
printf("BOUNDARY_STATUS %s\n", execute(boundary, request("status"), 1).c_str());
assert(result(boundary, saveRequest(1, 0, "Instructions", "Read only this article"), 2)["ok"] == true);
printf("BOUNDARY_INDEX %s\n", execute(boundary, request("board.index", "write-secret", ",\"revision\":0,\"cursor\":0"), 3).c_str());
printf("BOUNDARY_READ %s\n", execute(boundary, request("board.read", "write-secret", ",\"id\":1,\"version\":1,\"offset\":0"), 4).c_str());
printf("PASS: %u base and 6 admin actual room web service authorization, storage, quota, JSON and boundary groups\n", scenarios);
printf("PASS: %u base, 6 admin and 6 mail actual room web service authorization, storage, quota, JSON and boundary groups\n", scenarios);
}
@@ -37,6 +37,9 @@ def room_outputs(work, compiler):
"bool MyMesh::pushPostToClient(", "bool MyMesh::pushRoomTextToClient(",
"bool MyMesh::processAck(", "void MyMesh::activateRoomTopic(",
"bool MyMesh::handleRoomTopicCommand(", "void MyMesh::serviceRoomPush("))
definitions += "\n" + extract_braced(source, "bool MyMesh::roomClientChatEnabled(").replace(
"getRoomMailboxOnly(_fs,", "getRoomMailboxOnly(&policy_fs,")
definitions += "\n" + extract_braced(source, "uint8_t MyMesh::getUnsyncedCount(")
packet = (ROOT / "src/Packet.cpp").read_text()
definitions = "namespace mesh {\n" + extract_braced(
packet, "bool Packet::isValidPathLen(") + "\n}\n" + definitions
@@ -67,6 +70,7 @@ def room_outputs(work, compiler):
checked([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-parameter", "-Wno-missing-field-initializers", *SANITIZERS,
f"-I{work}", f"-I{ROOT / 'src'}",
f"-I{ROOT / 'test/mocks'}",
f"-I{ROOT / 'test/fixtures/room_topic_delivery'}",
str(FIXTURES / "room_messages.cpp"), "-o", str(binary)])
records = []
+209
View File
@@ -404,6 +404,192 @@ static void catchupPolls() {
puts("normal keep-alive echoes preserve selected catch-up while forward cursors and exact ACK retries work passed");
}
static std::string mailKey(const MyMesh& mesh, unsigned index) {
char key[65]; mesh::room_mail_protocol_detail::hex(mesh.acl.clients[index].id.pub_key, key); return key;
}
static mesh::RoomMailStatus mailStatus(MyMesh& mesh, unsigned index, bool provisioned = true) {
metadata_filesystem = &mesh.policy_fs; mesh::RoomMailStatus status;
const auto result = mesh::getRoomMailStatus(&mesh.policy_fs, mesh.acl.clients[index].id.pub_key, status);
assert(result == (provisioned ? mesh::RoomMailResult::Success : mesh::RoomMailResult::NotFound));
return status;
}
static uint32_t mailId(const std::string& reply) {
unsigned long id = 0; assert(sscanf(reply.c_str(), "OK id=%lu", &id) == 1 && id <= UINT32_MAX && id);
return uint32_t(id);
}
static void mailPlain(MyMesh& mesh, const std::string& command, uint32_t timestamp, unsigned sender = 0) {
const size_t before = mesh.sent.size();
mesh.command(command.c_str(), timestamp, sender, 0, TXT_TYPE_PLAIN);
if (mesh.sent.size() > before && mesh.sent.back().packet.getPayloadType() == PAYLOAD_TYPE_TXT_MSG) {
assert(mesh.sent.back().packet.payload[4] >> 2 == TXT_TYPE_PLAIN);
assert(memcmp(mesh.sent.back().recipient, mesh.acl.clients[sender].id.pub_key, PUB_KEY_SIZE) == 0);
}
}
static void mailPlainCommands() {
MyMesh mesh; auto& reader = mesh.acl.clients[0]; reader.permissions = PERM_ACL_READ_ONLY;
assert(mesh.roomClientChatEnabled(&reader));
mailPlain(mesh, "!mail mode public", 100); assert(mesh.reply() == "OK");
assert(mesh.sent.size() == 2 && mesh.sent[0].packet.getPayloadType() == PAYLOAD_TYPE_ACK);
mailPlain(mesh, "!mail delivery mailbox", 101);
assert(mailStatus(mesh, 0).settings.mailbox_only && !mesh.roomClientChatEnabled(&reader));
assert(mesh.roomClientChatEnabled(&mesh.acl.clients[1]));
mailPlain(mesh, "!mail settings", 102); assert(mesh.reply().find("delivery=mailbox") != std::string::npos);
mailPlain(mesh, "!mail bogus", 103); assert(mesh.reply().find("Error ") == 0);
mailPlain(mesh, "!mail", 104); assert(mesh.reply().find("Error ") == 0);
mailPlain(mesh, "!mail read 0", 105); assert(mesh.reply().find("Error ") == 0);
mesh.command("AB|mail delivery chat", 106);
assert(mesh.reply() == "AB|OK" && mesh.sent.back().packet.payload[4] >> 2 == TXT_TYPE_CLI_DATA);
assert(!mailStatus(mesh, 0).settings.mailbox_only && mesh.roomClientChatEnabled(&reader));
assert(mesh.post_count == 0 && mesh.general_commands == 0 && mesh.acl.saves == 0);
assert(reader.permissions == PERM_ACL_READ_ONLY && mesh.acl.clients[1].permissions == PERM_ACL_READ_WRITE);
MyMesh ordinary;
ordinary.command("dog lost: please call home", 100, 0, 0, TXT_TYPE_PLAIN);
ordinary.command("!mailbox is just chat", 101, 0, 0, TXT_TYPE_PLAIN);
assert(ordinary.post_count == 2 && ordinary.general_commands == 0);
// A saved mailbox-only preference survives cache replacement and blocks
// public unread counts without discarding retained chat.
mailPlain(mesh, "!mail delivery mailbox", 107);
mesh.remote_cli_reply_cache.clear(); assert(!mesh.roomClientChatEnabled(&reader));
backlog(mesh); assert(mesh.getUnsyncedCount(&reader) == 0);
assert(mesh.posts[0].post_timestamp != 0);
auto corrupt = mesh.policy_fs.get(mesh::ROOM_MAIL_PRIMARY_PATH); corrupt[0] ^= 1;
mesh.policy_fs.put(mesh::ROOM_MAIL_PRIMARY_PATH, corrupt);
assert(!mesh.roomClientChatEnabled(&reader)); // Corrupt settings fail closed.
puts("plain !mail commands privately reply, reserve unknown commands, preserve owner policy and mailbox-only chat admission passed");
}
static void mailOwnershipAndDog() {
MyMesh mesh; mesh.acl.clients[0].permissions = PERM_ACL_READ_ONLY;
mailPlain(mesh, "!mail mode public", 100);
mailPlain(mesh, "!mail delivery mailbox", 101);
const std::string dog = "dog lost: please call home; set password is ordinary text";
mailPlain(mesh, "!mail send " + mailKey(mesh, 0) + " " + dog, 100, 1);
const uint32_t id = mailId(mesh.reply()); assert(mailStatus(mesh, 0).count == 1);
assert(mesh.post_count == 0 && mesh.general_commands == 0);
mailPlain(mesh, "!mail inbox", 102); assert(mesh.reply().find(std::to_string(id) + "/") != std::string::npos);
// A sleeping radio checks later; reading retains the item until explicit ACK.
mesh.clock.now += 1800;
mailPlain(mesh, "!mail read " + std::to_string(id), 103);
assert(mesh.reply().find("text=" + dog) != std::string::npos && mailStatus(mesh, 0).count == 1);
mailPlain(mesh, "!mail read " + std::to_string(id), 101, 1);
assert(mesh.reply().find(dog) == std::string::npos && mesh.reply() == "Error not found");
mailPlain(mesh, "!mail ack " + std::to_string(id), 102, 1);
assert(mesh.reply() == "Error not found" && mailStatus(mesh, 0).count == 1);
mailPlain(mesh, "!mail delete " + std::to_string(id), 103, 1);
assert(mesh.reply() == "Error not found" && mailStatus(mesh, 0).count == 1);
mailPlain(mesh, "!mail mode closed", 104);
mailPlain(mesh, "!mail ack " + std::to_string(id), 105);
assert(mailStatus(mesh, 0).count == 0);
mailPlain(mesh, "!mail ack " + std::to_string(id), 105); assert(mailStatus(mesh, 0).count == 0);
mailPlain(mesh, "!mail delete " + std::to_string(id), 106); assert(mesh.reply().find("OK id=") == 0);
mailPlain(mesh, "!mail read " + std::to_string(id), 107); assert(mesh.reply() == "Error not found");
assert(mesh.post_count == 0 && mesh.general_commands == 0 && mesh.acl.saves == 0);
// Even administrators use their authenticated own inbox in these commands.
mesh.acl.clients[1].permissions = PERM_ACL_ADMIN;
mailPlain(mesh, "!mail settings", 104, 1); assert(mesh.reply().find("mode=closed") != std::string::npos);
assert(mailStatus(mesh, 1, false).count == 0);
puts("later dog-lost check and owner-only inbox read ACK delete keep bodies private and treat command-looking body as text passed");
}
static void mailModesRolesAndBans() {
MyMesh policy; policy.acl.clients[0].permissions = PERM_ACL_READ_ONLY;
mailPlain(policy, "!mail mode private", 100);
mailPlain(policy, "!mail send " + mailKey(policy, 0) + " forbidden", 100, 1);
assert(policy.reply() == "Error permission denied");
mailPlain(policy, "!mail allow " + mailKey(policy, 1), 101);
mailPlain(policy, "!mail send " + mailKey(policy, 0) + " allowed", 101, 1);
assert(policy.reply().find("OK id=") == 0 && mailStatus(policy, 0).count == 1);
mailPlain(policy, "!mail deny " + mailKey(policy, 1), 102);
mailPlain(policy, "!mail send " + mailKey(policy, 0) + " denied", 102, 1);
assert(policy.reply() == "Error permission denied" && mailStatus(policy, 0).count == 1);
mailPlain(policy, "!mail mode public", 103);
mailPlain(policy, "!mail send " + mailKey(policy, 0) + " public", 103, 1);
assert(policy.reply().find("OK id=") == 0 && mailStatus(policy, 0).count == 2);
mailPlain(policy, "!mail mode closed", 104);
mailPlain(policy, "!mail send " + mailKey(policy, 0) + " closed", 104, 1);
assert(policy.reply() == "Error permission denied" && mailStatus(policy, 0).count == 2);
for (uint8_t role : {uint8_t(PERM_ACL_GUEST), uint8_t(PERM_ACL_READ_ONLY), uint8_t(PERM_ACL_READ_WRITE),
uint8_t(PERM_ACL_ADMIN), uint8_t(PERM_ACL_FILTER_MGR), uint8_t(PERM_ACL_REGION_MGR)}) {
for (bool active : {false, true}) {
MyMesh mesh; mesh.acl.clients[0].permissions = role; mesh.acl.clients[0].last_activity = active ? 900 : 0;
mailPlain(mesh, "!mail mode public", 100, 1); mesh.sent.clear();
mailPlain(mesh, "!mail send " + mailKey(mesh, 1) + " role test", 100);
const bool writer = role == PERM_ACL_READ_WRITE || role == PERM_ACL_ADMIN;
assert(mailStatus(mesh, 1).count == (writer ? 1U : 0U));
assert(mesh.post_count == 0 && mesh.general_commands == 0);
mailPlain(mesh, "!mail mode private", 101);
const bool own = writer || role == PERM_ACL_READ_ONLY || (role == PERM_ACL_GUEST && active);
if (own) assert(mailStatus(mesh, 0).settings.mode == mesh::RoomMailMode::Private);
else assert(mailStatus(mesh, 0, false).revision == 0);
}
}
MyMesh blocked; mailPlain(blocked, "!mail mode public", 100, 1);
assert(blocked.room_access.addBan(&blocked.policy_fs, blocked.acl.clients[1].id.pub_key)
== mesh::RoomAccessPolicy::BanResult::Saved);
mailPlain(blocked, "!mail send " + mailKey(blocked, 1) + " banned recipient", 100);
assert(blocked.reply().find("recipient is blocked") != std::string::npos && mailStatus(blocked, 1).count == 0);
assert(blocked.room_access.addBan(&blocked.policy_fs, blocked.acl.clients[0].id.pub_key)
== mesh::RoomAccessPolicy::BanResult::Saved);
const size_t replies = blocked.sent.size(); mailPlain(blocked, "!mail settings", 101);
assert(blocked.sent.size() == replies && blocked.post_count == 0 && blocked.general_commands == 0);
puts("private allow deny public closed policies honor writer reader guest roles and sender recipient bans passed");
}
static void mailTransportRetries() {
MyMesh mesh; mailPlain(mesh, "!mail mode public", 100);
const std::string submission = "!mail send " + mailKey(mesh, 0) + " dog lost";
mailPlain(mesh, submission, 100, 1); const uint32_t id = mailId(mesh.reply()); const std::string original = mesh.reply();
mailPlain(mesh, "!mail settings", 101, 1);
mailPlain(mesh, submission, 100, 1);
assert(mesh.reply() == original && mailStatus(mesh, 0).count == 1 && mesh.acl.clients[1].last_timestamp == 101);
const size_t replies = mesh.sent.size(); mailPlain(mesh, submission + " changed", 100, 1);
assert(mesh.sent.size() == replies && mailStatus(mesh, 0).count == 1);
mesh.acl.clients[1].permissions = PERM_ACL_READ_ONLY;
mailPlain(mesh, submission, 100, 1); assert(mesh.reply() == original && mailStatus(mesh, 0).count == 1);
mailPlain(mesh, submission + " new", 102, 1);
assert(mesh.reply() == "Error permission denied" && mailStatus(mesh, 0).count == 1);
// Restore the same persisted files with a new transport cache and replay floor.
MyMesh reboot;
for (const auto& entry : mesh.policy_fs.files)
reboot.policy_fs.put(entry.first.c_str(), *entry.second);
mailPlain(reboot, submission, 100, 1);
assert(mailId(reboot.reply()) == id && reboot.reply().find("duplicate") != std::string::npos);
assert(mailStatus(reboot, 0).count == 1);
mailPlain(reboot, "!mail read " + std::to_string(id), 100);
assert(reboot.reply().find("text=dog lost") != std::string::npos);
reboot.acl.clients[0].permissions = PERM_ACL_FILTER_MGR;
const size_t before_revoke = reboot.sent.size(); mailPlain(reboot, "!mail read " + std::to_string(id), 100);
assert(reboot.sent.size() == before_revoke || reboot.reply().find("dog lost") == std::string::npos);
reboot.acl.clients[0].permissions = PERM_ACL_READ_ONLY;
mailPlain(reboot, "!mail ack " + std::to_string(id), 101); assert(mailStatus(reboot, 0).count == 0);
reboot.remote_cli_reply_cache.clear(); reboot.acl.clients[1].last_timestamp = 99;
mailPlain(reboot, submission, 100, 1); assert(mailId(reboot.reply()) == id && mailStatus(reboot, 0).count == 0);
assert(reboot.room_access.addBan(&reboot.policy_fs, reboot.acl.clients[1].id.pub_key)
== mesh::RoomAccessPolicy::BanResult::Saved);
const size_t before_ban = reboot.sent.size(); mailPlain(reboot, submission, 100, 1);
assert(reboot.sent.size() == before_ban && reboot.post_count == 0 && reboot.general_commands == 0);
puts("plain transport retries persist once across cache reboot ACK and role or ban changes without exposing revoked mail passed");
}
static void mailInputLengths() {
MyMesh mesh; mailPlain(mesh, "!mail mode public", 100);
const std::string prefix = "mail send " + mailKey(mesh, 0) + " ";
const std::string longest = prefix + std::string(mesh::RemoteCliReplyCache::MAX_REPLY_TEXT - prefix.size(), 'x');
char reply[180]{};
assert(mesh.handleRoomMailClientCommand(&mesh.acl.clients[1], longest.c_str(), 800, reply, sizeof(reply)));
const uint32_t id = mailId(reply); assert(mailStatus(mesh, 0).count == 1);
mesh::RoomMailMessage message; uint8_t bytes[128]; size_t copied = 0;
assert(mesh::readRoomMail(&mesh.policy_fs, mesh.acl.clients[0].id.pub_key, id, 0,
bytes, sizeof(bytes), copied, message) == mesh::RoomMailResult::Success);
assert(message.length == longest.size() - prefix.size() && copied == message.length);
assert(std::string(reinterpret_cast<const char*>(bytes), copied) == longest.substr(prefix.size()));
for (const std::string& input : {longest + "x", prefix + std::string(200, 'y'), prefix + std::string(512, 'z')}) {
memset(reply, 0, sizeof(reply));
assert(mesh.handleRoomMailClientCommand(&mesh.acl.clients[1], input.c_str(), 801, reply, sizeof(reply)));
assert(strstr(reply, "Error command too long") && mailStatus(mesh, 0).count == 1);
}
mailPlain(mesh, "!" + longest + "x", 100, 1);
assert(mesh.reply() == "Error command too long" && mailStatus(mesh, 0).count == 1);
assert(mesh.post_count == 0 && mesh.general_commands == 0);
puts("mail transport rejects oversized commands without truncating bodies while exact buffer boundary preserves complete text passed");
}
int main(int argc, char** argv) {
assert(argc == 2);
if (!strcmp(argv[1], "roles")) roleCases();
@@ -417,6 +603,11 @@ int main(int argc, char** argv) {
else if (!strcmp(argv[1], "catchup-retry")) catchupRetriesAndDenials();
else if (!strcmp(argv[1], "admin-clients")) adminClients();
else if (!strcmp(argv[1], "catchup-poll")) catchupPolls();
else if (!strcmp(argv[1], "mail-plain")) mailPlainCommands();
else if (!strcmp(argv[1], "mail-owner")) mailOwnershipAndDog();
else if (!strcmp(argv[1], "mail-policies")) mailModesRolesAndBans();
else if (!strcmp(argv[1], "mail-retries")) mailTransportRetries();
else if (!strcmp(argv[1], "mail-lengths")) mailInputLengths();
else assert(false);
}
'''
@@ -444,6 +635,8 @@ def production_source():
raise AssertionError("missing production constant " + name)
constants += "\n" + found[0]
methods = "\n".join(extract_braced(room, signature) for signature in (
"bool MyMesh::roomClientChatEnabled(", "bool MyMesh::handleRoomMailClientCommand(",
"bool MyMesh::handleRoomMailText(",
"bool MyMesh::handleClientPathCommand(", "bool MyMesh::executeClientPathCommand(",
"bool MyMesh::sendClientReply(",
"bool MyMesh::handleRoomCatchUpCommand(", "bool MyMesh::applyRoomCatchUpCommand(",
@@ -478,6 +671,7 @@ class RoomClientPathTests(unittest.TestCase):
binary = work / name
command = [cls.compiler, "-std=c++17", "-O1", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter",
"-I" + str(ROOT / "test/fixtures/room_history_store"), "-I" + str(ROOT / "src"),
"-I" + str(ROOT / "test/mocks"),
str(source), "-o", str(binary)]
if sys.platform.startswith("linux"):
command[1:1] = ["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
@@ -543,6 +737,21 @@ class RoomClientPathTests(unittest.TestCase):
def test_actual_keepalive_echo_cannot_undo_skip_and_exact_retries_still_ack(self):
self.run_case("catchup-poll", "normal keep-alive echoes preserve selected catch-up while forward cursors and exact ACK retries work passed")
def test_actual_plain_mail_commands_are_reserved_private_and_save_delivery_policy(self):
self.run_case("mail-plain", "plain !mail commands privately reply, reserve unknown commands, preserve owner policy and mailbox-only chat admission passed")
def test_actual_owner_only_mail_poll_read_and_durable_ack_for_later_dog_check(self):
self.run_case("mail-owner", "later dog-lost check and owner-only inbox read ACK delete keep bodies private and treat command-looking body as text passed")
def test_actual_mail_sender_modes_reader_writer_roles_and_bans(self):
self.run_case("mail-policies", "private allow deny public closed policies honor writer reader guest roles and sender recipient bans passed")
def test_actual_plain_mail_retries_reboot_receipts_and_current_authorization(self):
self.run_case("mail-retries", "plain transport retries persist once across cache reboot ACK and role or ban changes without exposing revoked mail passed")
def test_actual_mail_input_length_rejection_preserves_complete_accepted_body(self):
self.run_case("mail-lengths", "mail transport rejects oversized commands without truncating bodies while exact buffer boundary preserves complete text passed")
def test_negative_controls_detect_rollback_cache_authorization_and_retry_regressions(self):
controls = (
("rollback", "memcpy(selected, previous, sizeof(previous));",
+7
View File
@@ -29,6 +29,7 @@ HARNESS = r'''
#include <helpers/ClientPathObservation.h>
#include <helpers/RoomClientPathCommand.h>
#include <helpers/RoomCatchUp.h>
#include <helpers/RoomMailStore.h>
#include <helpers/TxtDataHelpers.h>
#include <Packet.h>
#include <helpers/RoomLoginAuthorization.h>
@@ -169,6 +170,7 @@ struct MyMesh {
unsigned path_acks = 0;
bool processAck(const uint8_t*) { ++path_acks; return true; }
uint8_t getUnsyncedCount(ClientInfo*);
bool roomClientChatEnabled(ClientInfo*);
int getExtraAckTransmitCount() { return 0; }
unsigned requests = 0;
uint8_t last_request_type = 0;
@@ -256,6 +258,8 @@ struct MyMesh {
mesh::Packet packet; packet.header = ROUTE_TYPE_DIRECT;
onPeerDataRecv(&packet, PAYLOAD_TYPE_REQ, 0, acl.client.shared_secret, data, sizeof(data));
}
bool handleRoomMailText(ClientInfo*, mesh::Packet*, const uint8_t*, uint8_t*, size_t) { return false; }
bool handleRoomMailClientCommand(ClientInfo*, const char*, uint64_t, char*, size_t) { return false; }
void onPeerDataRecv(mesh::Packet*, uint8_t, int, const uint8_t*, uint8_t*, size_t);
bool onPeerPathRecv(mesh::Packet*, int, const uint8_t*, uint8_t*, uint8_t, uint8_t, uint8_t*, uint8_t);
void onAnonDataRecv(mesh::Packet*, const uint8_t*, const mesh::Identity&,
@@ -272,6 +276,7 @@ struct MyMesh {
@ROOM_CATCHUP_HANDLER@
@ROOM_CATCHUP_APPLY@
@ROOM_UNSYNCED_COUNT@
@ROOM_MAIL_STATUS@
static mesh::Identity identity(uint8_t n = 1) {
mesh::Identity sender{};
@@ -876,6 +881,7 @@ class RoomLoginIntegrationTests(unittest.TestCase):
room, "bool MyMesh::applyRoomCatchUpCommand(")),
"@ROOM_UNSYNCED_COUNT@": re.sub(r"\bposts\b", "retained_posts", extract_braced(
room, "uint8_t MyMesh::getUnsyncedCount(")),
"@ROOM_MAIL_STATUS@": extract_braced(room, "bool MyMesh::roomClientChatEnabled("),
"@LOGIN_HANDLER@": extract_braced(room, "void MyMesh::onAnonDataRecv("),
}
generated = HARNESS
@@ -889,6 +895,7 @@ class RoomLoginIntegrationTests(unittest.TestCase):
cls.binary = work / "room-login"
command = [compiler, "-std=c++17", "-O1", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-parameter", "-I" + str(ROOT / "test/fixtures/room_history_store"), "-I" + str(ROOT / "src"),
"-I" + str(ROOT / "test/mocks"),
str(source), "-o", str(cls.binary)]
if sys.platform.startswith("linux"):
command[1:1] = ["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
+377
View File
@@ -0,0 +1,377 @@
#!/usr/bin/env python3
"""Execute owner-bound room mail LoRa/CLI handlers against their real store."""
from pathlib import Path
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from test_room_board_store import FILESYSTEM_HARNESS, LFS_MOCK, STAT_MOCK
ROOT = Path(__file__).resolve().parents[1]
TESTS = r'''
#define FILE_O_WRITE 1
#include <helpers/RoomMailProtocol.h>
using Result = mesh::RoomMailResult;
static unsigned scenarios = 0;
static uint8_t a[32], b[32], c[32];
static std::string key(const uint8_t* bytes) {
char out[65]; mesh::room_mail_protocol_detail::hex(bytes, out); return out;
}
static std::map<std::string, std::vector<uint8_t>> snapshot(const MemoryFS& fs) {
std::map<std::string, std::vector<uint8_t>> out;
for (const auto& entry : fs.files) out[entry.first] = *entry.second;
return out;
}
static std::vector<uint8_t> request(MemoryFS& fs, const uint8_t* owner,
const std::vector<uint8_t>& input, size_t capacity = 160,
bool can_send = true, uint64_t nonce = 1) {
metadata_filesystem = &fs;
std::vector<uint8_t> output(capacity + 2, 0x7f);
const size_t used = mesh::handleRoomMailRequest(&fs, owner, can_send, nonce,
input.data(), input.size(), output.data() + 1, capacity, 123456);
assert(used <= capacity && output.front() == 0x7f && output.back() == 0x7f);
++scenarios; return std::vector<uint8_t>(output.begin() + 1, output.begin() + 1 + used);
}
static std::string command(MemoryFS& fs, const uint8_t* owner,
const std::string& text, size_t capacity = 160, bool can_send = true,
uint64_t nonce = 1, bool handled = true) {
metadata_filesystem = &fs;
std::vector<char> output(capacity + 2, 0x7f);
assert(mesh::handleRoomMailCommand(&fs, owner, can_send, nonce, text.c_str(),
output.data() + 1, capacity, 123456) == handled);
assert(output.front() == 0x7f && output.back() == 0x7f); ++scenarios;
if (!capacity || !handled) return "";
assert(memchr(output.data() + 1, 0, capacity)); return output.data() + 1;
}
static mesh::RoomMailStatus status(MemoryFS& fs, const uint8_t* owner) {
metadata_filesystem = &fs; mesh::RoomMailStatus out;
assert(mesh::getRoomMailStatus(&fs, owner, out) == Result::Success); return out;
}
static std::vector<uint8_t> edit(uint8_t op, uint32_t revision, uint8_t value) {
std::vector<uint8_t> out(7); out[0] = 11; out[1] = op;
mesh::room_mail_protocol_detail::put32(out.data() + 2, revision); out[6] = value; return out;
}
static std::vector<uint8_t> editKey(uint8_t op, uint32_t revision, const uint8_t* bytes) {
std::vector<uint8_t> out(38); out[0] = 11; out[1] = op;
mesh::room_mail_protocol_detail::put32(out.data() + 2, revision);
memcpy(out.data() + 6, bytes, 32); return out;
}
static std::vector<uint8_t> indexRequest(uint32_t revision = 0, uint8_t cursor = 0) {
std::vector<uint8_t> out(7); out[0] = 11; out[1] = 1;
mesh::room_mail_protocol_detail::put32(out.data() + 2, revision); out[6] = cursor; return out;
}
static std::vector<uint8_t> settings(uint32_t revision = 0, uint8_t cursor = 0) {
auto out = indexRequest(revision, cursor); out[1] = 0; return out;
}
static std::vector<uint8_t> read(uint32_t id, uint16_t offset = 0) {
std::vector<uint8_t> out(8); out[0] = 11; out[1] = 2;
mesh::room_mail_protocol_detail::put32(out.data() + 2, id);
mesh::room_mail_protocol_detail::put16(out.data() + 6, offset); return out;
}
static std::vector<uint8_t> send(const uint8_t* recipient, const std::string& body) {
std::vector<uint8_t> out(36 + body.size()); out[0] = 11; out[1] = 3;
memcpy(out.data() + 2, recipient, 32);
mesh::room_mail_protocol_detail::put16(out.data() + 34, uint16_t(body.size()));
memcpy(out.data() + 36, body.data(), body.size()); return out;
}
static std::vector<uint8_t> remove(uint8_t op, uint32_t id) {
std::vector<uint8_t> out(6); out[0] = 11; out[1] = op;
mesh::room_mail_protocol_detail::put32(out.data() + 2, id); return out;
}
static std::vector<uint8_t> padded(std::vector<uint8_t> payload) {
payload.resize(((payload.size() + 4 + 15) / 16) * 16 - 4, 0); return payload;
}
static uint32_t id(const std::vector<uint8_t>& response) {
assert(response.size() == 7 && (response[2] == 0 || response[2] == 8));
return mesh::room_mail_protocol_detail::get32(response.data() + 3);
}
int main() {
memset(a, 0xa1, 32); memset(b, 0xb2, 32); memset(c, 0xc3, 32);
MemoryFS fs;
auto response = request(fs, a, settings());
assert(response.size() == 13 && response[2] == 0 && response[7] == 0
&& response[8] == 0 && response[9] == 0 && response[11] == 255 && response[12] == 0);
assert(fs.files.empty());
assert(request(fs, a, indexRequest())[9] == 255 && fs.files.empty());
assert(command(fs, a, "mail settings").find("mode=closed delivery=chat queued=0") != std::string::npos);
assert(command(fs, a, "mail mode public", 160, false) == "OK");
assert(command(fs, a, "mail delivery mailbox", 160, false) == "OK");
auto own = status(fs, a); assert(own.settings.mailbox_only);
response = request(fs, a, editKey(6, own.revision, b)); assert(response[2] == 0);
own = status(fs, a); assert(own.settings.allowed_count == 1);
const uint32_t before_policy = own.revision;
response = request(fs, a, edit(8, own.revision, 2), 7, false); assert(response[2] == 0);
response = request(fs, a, edit(9, before_policy, 0)); assert(response[2] == 5);
own = status(fs, a); assert(own.settings.mode == mesh::RoomMailMode::Private && own.settings.mailbox_only);
response = request(fs, a, settings(), 45);
assert(response.size() == 45 && response[9] == 1 && response[11] == 255
&& memcmp(response.data() + 13, b, 32) == 0);
assert(request(fs, b, settings())[9] == 0); // Another key cannot enumerate the allow list.
assert(request(fs, c, send(a, "not authorized"))[2] == 6);
assert(request(fs, b, send(a, "writer permission required"), 160, false)[2] == 6);
assert(command(fs, b, "mail send " + key(a) + " blocked by role", 160, false) == "Error permission denied");
assert(status(fs, a).count == 0);
const std::string full_body(512, 'p'); const uint64_t nonce = UINT64_C(0x1234567800000009);
response = request(fs, b, send(a, full_body), 160, true, nonce); const uint32_t first = id(response);
own = status(fs, a); assert(own.count == 1);
response = request(fs, b, send(a, full_body), 160, true, nonce);
assert(response[2] == 8 && id(response) == first && status(fs, a).count == 1);
assert(request(fs, b, send(a, "changed"), 160, true, nonce)[2] == 9);
assert(request(fs, b, send(a, "different nonce high bits"), 160, true, nonce + (UINT64_C(1) << 32))[2] == 0);
assert(status(fs, a).count == 2);
const auto preserved = snapshot(fs);
for (const uint8_t* stranger : {b, c}) {
for (const auto& payload : {read(first), remove(4, first), remove(5, first)}) {
response = request(fs, stranger, payload);
assert(response[2] == 4 || response[2] == 6);
assert(response.size() <= 7);
}
const std::string output = command(fs, stranger, "mail read " + std::to_string(first));
assert(output == "Error not found" || output == "Error permission denied");
}
assert(snapshot(fs) == preserved && status(fs, a).count == 2);
own = status(fs, a);
response = request(fs, a, indexRequest());
assert(response[2] == 0 && response[7] == 2 && response[9] == 255 && response[10] == 2);
assert(response.size() == 87 && memcmp(response.data() + 15, b, 32) == 0);
response = request(fs, a, indexRequest(own.revision, 1), 49);
assert(response.size() == 49 && response[9] == 255 && response[10] == 1);
assert(request(fs, a, indexRequest(own.revision - 1))[2] == 5);
assert(request(fs, a, indexRequest(0, 1))[2] == 5);
assert(request(fs, a, indexRequest(own.revision, 3))[2] == 1);
// Check every route reply capacity, including capacities too short for a
// complete private header or entry. Response canaries prohibit overflows.
for (size_t capacity = 0; capacity <= 200; ++capacity) {
response = request(fs, a, indexRequest(), capacity);
if (capacity < 3) assert(response.empty());
else if (capacity < 49) assert(response.size() == 3 && response[2] == 1);
else {
assert(response[2] == 0 && response.size() == 11 + size_t(response[10]) * 38);
assert(response[10] >= 1 && response[10] <= 2);
}
response = request(fs, a, read(first), capacity);
if (capacity < 3) assert(response.empty());
else if (capacity <= 44) assert(response.size() == 3 && response[2] == 1);
else {
assert(response[2] == 0 && response.size() == 44 + size_t(response[11]));
assert(response[11] == std::min(size_t(128), capacity - 44));
assert(memcmp(response.data() + 12, b, 32) == 0);
}
response = request(fs, a, settings(), capacity);
if (capacity < 3) assert(response.empty());
else if (capacity < 45) assert(response.size() == 3 && response[2] == 1);
else assert(response.size() == 45 && response[2] == 0);
}
std::string assembled;
for (uint16_t offset = 0; offset < 512;) {
response = request(fs, a, read(first, offset), 90);
assert(response[2] == 0 && response[11] && response[11] <= 46);
assert(mesh::room_mail_protocol_detail::get16(response.data() + 7) == offset);
assert(mesh::room_mail_protocol_detail::get16(response.data() + 9) == 512);
assembled.append(reinterpret_cast<const char*>(response.data() + 44), response[11]); offset += response[11];
}
assert(assembled == full_body);
response = request(fs, a, read(first, 512), 44); assert(response.size() == 44 && response[11] == 0);
assert(request(fs, a, read(first, 513))[2] == 1);
assembled.clear();
for (size_t offset = 0; offset < 512;) {
const std::string output = command(fs, a, "mail read " + std::to_string(first) + " " + std::to_string(offset), 157);
const size_t data = output.find("text="); assert(data != std::string::npos);
const std::string bytes = output.substr(data + 5); assert(!bytes.empty());
assembled += bytes; offset += bytes.size();
}
assert(assembled == full_body);
assert(command(fs, a, "mail read " + std::to_string(first) + " 512").find("next=512 total=512 text=") != std::string::npos);
assert(command(fs, a, "mail inbox") == command(fs, a, "mail list"));
assert(command(fs, a, "mail list", 80).find("..") != std::string::npos);
{
MemoryFS utf_fs;
assert(command(utf_fs, a, "mail mode public") == "OK");
std::string unicode;
for (size_t i = 0; i < 128; ++i) unicode += "\xf0\x9f\x93\xa1";
const uint32_t unicode_id = id(request(utf_fs, b, send(a, unicode), 160, true, 300));
std::string text;
for (size_t offset = 0; offset < unicode.size();) {
const std::string output = command(utf_fs, a, "mail read " + std::to_string(unicode_id) + " " + std::to_string(offset), 83);
const size_t marker = output.find("text="); assert(marker != std::string::npos);
const std::string part = output.substr(marker + 5); assert(!part.empty() && part.size() % 4 == 0);
assert(mesh::room_mail_detail::validBody(part.data(), part.size()));
unsigned long got_id, got_offset, next; unsigned total;
assert(sscanf(output.c_str(), "id=%lu off=%lu next=%lu total=%u text=", &got_id, &got_offset, &next, &total) == 4);
assert(got_id == unicode_id && got_offset == offset && next == offset + part.size() && total == unicode.size());
text += part; offset = next;
}
assert(text == unicode);
for (unsigned offset = 1; offset <= 3; ++offset)
assert(command(utf_fs, a, "mail read " + std::to_string(unicode_id) + " " + std::to_string(offset)) == "Error offset inside UTF-8 character");
assert(request(utf_fs, a, read(unicode_id, 1))[2] == 0); // Binary reads retain arbitrary byte offsets.
}
for (size_t capacity = 0; capacity <= 160; ++capacity) {
command(fs, a, "mail settings", capacity);
command(fs, a, "mail list", capacity);
command(fs, a, "mail read " + std::to_string(first), capacity);
}
// No mutation is admitted if its complete confirmation cannot be returned.
for (size_t capacity = 0; capacity < 7; ++capacity) {
const auto before = snapshot(fs);
for (const auto& payload : {send(a, "capacity"), remove(4, first), remove(5, first),
edit(8, own.revision, 1), edit(9, own.revision, 0), editKey(6, own.revision, c), editKey(7, own.revision, b)})
request(fs, a, payload, capacity, true, 500);
assert(snapshot(fs) == before);
}
for (size_t capacity = 0; capacity < 32; ++capacity) {
const auto before = snapshot(fs);
command(fs, a, "mail mode public", capacity);
command(fs, a, "mail ack " + std::to_string(first), capacity);
command(fs, b, "mail send " + key(a) + " short confirmation", capacity, true, 501);
assert(snapshot(fs) == before);
}
response = request(fs, a, remove(4, first)); assert(response[2] == 0);
assert(status(fs, a).count == 1);
response = request(fs, b, send(a, full_body), 160, true, nonce);
assert(response[2] == 8 && id(response) == first && status(fs, a).count == 1);
assert(request(fs, a, remove(4, first))[2] == 0);
assert(request(fs, a, remove(5, first))[2] == 0);
assert(request(fs, a, read(first))[2] == 4);
{
MemoryFS air;
assert(request(air, a, padded(settings()))[2] == 0 && air.files.empty());
assert(request(air, a, padded(edit(8, 0, 2)))[2] == 0);
assert(request(air, a, padded(editKey(6, status(air, a).revision, b)))[2] == 0);
assert(request(air, a, padded(edit(9, status(air, a).revision, 1)))[2] == 0);
const std::string dog = "dog lost: please call home";
response = request(air, b, padded(send(a, dog)), 160, true, 700);
const uint32_t dog_id = id(response);
assert(request(air, a, padded(indexRequest()))[7] == 1);
response = request(air, a, padded(read(dog_id)));
assert(response[11] == dog.size() && std::string(reinterpret_cast<const char*>(response.data() + 44), response[11]) == dog);
assert(status(air, a).count == 1); // Reading on a later wake leaves queued mail intact.
assert(request(air, a, padded(remove(4, dog_id)))[2] == 0 && status(air, a).count == 0);
assert(request(air, a, padded(remove(5, dog_id)))[2] == 0);
assert(request(air, a, padded(editKey(7, status(air, a).revision, b)))[2] == 0);
const auto before = snapshot(air);
for (auto input : {settings(), indexRequest(), read(dog_id), send(a, "changed padding"),
remove(4, dog_id), remove(5, dog_id), edit(8, status(air, a).revision, 1),
edit(9, status(air, a).revision, 0), editKey(6, status(air, a).revision, b), editKey(7, status(air, a).revision, b)}) {
input.push_back(1); assert(request(air, a, input, 160, true, 701)[2] == 1);
}
assert(snapshot(air) == before);
}
assert(command(fs, a, "mail deny " + key(b)) == "OK");
assert(request(fs, b, send(a, "denied"), 160, true, 999)[2] == 6);
assert(command(fs, a, "mail mode public") == "OK");
response = request(fs, c, send(a, "public mode"), 160, true, 1000); const uint32_t public_id = id(response);
assert(command(fs, a, "mail delete " + std::to_string(public_id)).find("OK id=") == 0);
assert(command(fs, a, "mail delivery chat") == "OK" && !status(fs, a).settings.mailbox_only);
for (uint8_t value = 1; value <= 8; ++value) {
uint8_t allowed[32]; memset(allowed, value, 32);
assert(command(fs, a, "mail allow " + key(allowed)) == "OK");
}
assert(command(fs, a, "mail allow " + key(b)) == "Error mailbox full");
assert(command(fs, a, "mail settings").find("next=1") != std::string::npos);
assert(command(fs, a, "mail settings 7").find("next=8") != std::string::npos);
assert(request(fs, a, settings(status(fs, a).revision, 7), 45)[11] == 255);
assert(command(fs, a, "mail check").find("allowed=8") != std::string::npos);
assert(request(fs, c, send(a, std::string("bad\0body", 8)), 160, true, 1010)[2] == 1);
assert(request(fs, c, send(a, std::string("\xed\xa0\x80", 3)), 160, true, 1011)[2] == 1);
assert(request(fs, c, send(a, "cafe\xc3\xa9"), 160, true, 1012)[2] == 0);
assert(request(fs, c, send(a, "third queued"), 160, true, 1013)[2] == 0);
assert(request(fs, c, send(a, "fourth queued"), 160, true, 1014)[2] == 0);
assert(status(fs, a).count == 4);
assert(request(fs, c, send(a, "over quota"), 160, true, 1015)[2] == 7);
response = request(fs, a, indexRequest());
assert(response[7] == 4 && response[9] == 2 && response[10] == 2);
response = request(fs, a, indexRequest(status(fs, a).revision, 2));
assert(response[7] == 4 && response[9] == 255 && response[10] == 2);
for (const std::string& text : std::vector<std::string>{"mail", "mail bogus", "mail mode", "mail mode administrator",
"mail mode public trailing", "mail delivery radio", "mail allow " + key(b).substr(0, 63),
"mail deny " + std::string(64, '0'), "mail allow " + std::string(64, 'z'),
"mail allow " + key(b) + " trailing", "mail send " + key(a),
"mail send " + key(a) + " " + std::string(513, 'x'), "mail read 0", "mail read -1",
"mail read 4294967296", "mail read 1 513", "mail read 1 0 trailing", "mail ack 0",
"mail delete 1 trailing", "mail settings 9", "mail list 17"}) {
const auto before = snapshot(fs);
assert(command(fs, a, text).find("Error ") == 0); assert(snapshot(fs) == before);
}
command(fs, a, "mailbox settings", 160, true, 1, false);
command(fs, a, "unrelated", 160, true, 1, false);
const auto before_invalid = snapshot(fs);
for (const std::vector<uint8_t>& payload : std::vector<std::vector<uint8_t>>{
{}, {11}, {11, 255}, {11, 0}, {11, 0, 9}, {11, 0, 0, 1}, {11, 1}, {11, 2},
{11, 3}, {11, 4}, {11, 5}, edit(8, status(fs, a).revision, 3), edit(9, status(fs, a).revision, 2),
std::vector<uint8_t>(38, 0)}) {
response = request(fs, a, payload); assert(response.empty() || response[2] == 1);
}
assert(snapshot(fs) == before_invalid);
uint8_t zero[32] = {};
assert(request(fs, zero, settings())[2] == 1);
assert(command(fs, zero, "mail settings") == "Error invalid");
printf("PASS: %u owner-bound room mail LoRa/CLI protocol, capacity and durable retry scenarios\n", scenarios);
}
'''
class RoomMailProtocolTests(unittest.TestCase):
def test_actual_protocol_and_store_all_backend_modes(self):
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
if compiler is None:
self.skipTest("a host C++ compiler is required")
candidates = [Path(os.environ["MESHCORE_CRYPTO_DIR"])] if os.environ.get("MESHCORE_CRYPTO_DIR") else []
candidates += sorted((ROOT / ".pio/libdeps").glob("*/Crypto"))
crypto = next((path for path in candidates if (path / "SHA256.cpp").is_file()), None)
if crypto is None:
raise RuntimeError("Install rweather/Crypto or set MESHCORE_CRYPTO_DIR (no hash mock fallback)")
filesystem = FILESYSTEM_HARNESS.split("#define FILE_O_WRITE 1")[0]
filesystem = filesystem.replace(" File() = default;", """#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
File() = delete;
#else
File() = default;
#endif
explicit File(MemoryFS* owner): fs(owner) {}""")
filesystem = filesystem.replace("return {};", "return File(this);")
filesystem = filesystem.replace(
" void _lockFS() {}\n void _unlockFS() {}\n MemoryFS* _getFS() { return this; }\n", "", 1
)
filesystem = filesystem.replace(" void point(const std::string& event) {", """ int lock_depth = 0;
void _lockFS() { assert(lock_depth++ == 0); }
void _unlockFS() { assert(--lock_depth == 0); }
MemoryFS* _getFS() { return this; }
void point(const std::string& event) {""")
sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
if sys.platform.startswith("linux") else [])
with tempfile.TemporaryDirectory(prefix="meshcore-room-mail-protocol-") as temporary:
work = Path(temporary)
(work / "sys").mkdir()
(work / "sys/stat.h").write_text(STAT_MOCK)
source = work / "protocol.cpp"
for platform in (None, "NRF52_PLATFORM", "STM32_PLATFORM", "RP2040_PLATFORM", "ESP32_PLATFORM"):
with self.subTest(platform=platform):
source.write_text(filesystem + (LFS_MOCK if platform in ("NRF52_PLATFORM", "STM32_PLATFORM") else "") + TESTS)
binary = work / (platform or "generic")
defines = ["-D" + platform + "=1"] if platform else []
built = subprocess.run(
[compiler, "-std=c++11", "-Wall", "-Wextra", "-Werror", "-DHOST_BUILD", *sanitizers,
*defines, "-I" + str(work), "-I" + str(ROOT / "src"), "-I" + str(crypto), str(source),
str(crypto / "SHA256.cpp"), str(crypto / "Hash.cpp"), str(crypto / "Crypto.cpp"), "-o", str(binary)],
capture_output=True, text=True, timeout=60,
)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=60)
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
self.assertIn("owner-bound room mail LoRa/CLI protocol", checked.stdout)
print(checked.stdout.strip())
if __name__ == "__main__":
unittest.main()
+331
View File
@@ -0,0 +1,331 @@
#!/usr/bin/env python3
"""Run the actual owner-bound mailbox transaction under ASAN and storage faults."""
from pathlib import Path
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from test_room_topic_store import HARNESS as FILESYSTEM_HARNESS, STAT_MOCK
from test_room_board_store import LFS_MOCK
ROOT = Path(__file__).resolve().parents[1]
# Host-only adapter supplies the same incremental SHA256 interface as Arduino
# Crypto, using independently implemented OpenSSL for reproducible digests.
SHA_MOCK = r'''
#pragma once
#include <cassert>
#include <cstring>
#include <openssl/evp.h>
class SHA256 {
EVP_MD_CTX* context;
public:
SHA256(): context(EVP_MD_CTX_new()) { assert(context && EVP_DigestInit_ex(context, EVP_sha256(), nullptr)); }
~SHA256() { EVP_MD_CTX_free(context); }
void update(const void* input, size_t length) { assert(EVP_DigestUpdate(context, input, length)); }
void finalize(void* output, size_t length) {
unsigned count = 0; unsigned char digest[32];
assert(EVP_DigestFinal_ex(context, digest, &count) && count == 32 && length <= 32);
memcpy(output, digest, length);
}
};
'''
TESTS = r'''
#define FILE_O_WRITE 1
#include <helpers/RoomMailStore.h>
using Result = mesh::RoomMailResult;
using Settings = mesh::RoomMailSettings;
using Mode = mesh::RoomMailMode;
using Message = mesh::RoomMailMessage;
using Status = mesh::RoomMailStatus;
static const char* P = mesh::ROOM_MAIL_PRIMARY_PATH;
static const char* T = mesh::ROOM_MAIL_TEMP_PATH;
static const char* B = mesh::ROOM_MAIL_BACKUP_PATH;
static unsigned scenarios = 0;
static uint8_t OWNER[32] = {1}, OTHER[32] = {2}, SENDER[32] = {3};
static const std::string OLD_BODY = "Existing private message";
static const std::string NEW_BODY(512, 'n');
static void use(MemoryFS& fs) { metadata_filesystem = &fs; }
static MemoryFS clone(const MemoryFS& source) {
MemoryFS fs; fs.directories = source.directories;
for (const auto& file : source.files) fs.put(file.first.c_str(), *file.second);
return fs;
}
static std::map<std::string, std::vector<uint8_t>> snapshot(const MemoryFS& fs) {
std::map<std::string, std::vector<uint8_t>> result;
for (const auto& file : fs.files) result[file.first] = *file.second;
return result;
}
static Status status(MemoryFS& fs, const uint8_t* owner = OWNER) {
use(fs); Status out;
assert(mesh::getRoomMailStatus(&fs, owner, out) == Result::Success); return out;
}
static Result provision(MemoryFS& fs, const uint8_t* owner = OWNER, Mode mode = Mode::Public) {
use(fs); Settings settings; settings.mode = mode;
return mesh::saveRoomMailSettings(&fs, owner, settings);
}
static Result send(MemoryFS& fs, uint64_t nonce, const std::string& body, uint32_t& id,
const uint8_t* owner = OWNER, const uint8_t* sender = SENDER, uint32_t created = 42) {
use(fs); return mesh::sendRoomMail(&fs, sender, owner, nonce, body.data(), body.size(), id, created);
}
static void bodyIs(MemoryFS& fs, const uint8_t* owner, uint32_t id, const std::string& expected) {
use(fs); std::string assembled; Message info;
for (size_t offset = 0; offset <= expected.size();) {
uint8_t output[39]; memset(output, 0x7f, sizeof(output)); size_t copied = 999;
assert(mesh::readRoomMail(&fs, owner, id, offset, output + 1, 37, copied, info) == Result::Success);
assert(output[0] == 0x7f && output[38] == 0x7f && copied <= 37 && info.length == expected.size());
assembled.append(reinterpret_cast<const char*>(output + 1), copied); offset += copied;
if (!copied) break;
}
assert(assembled == expected);
}
static void checksum(std::vector<uint8_t>& image) {
const uint32_t value = mesh::room_mail_detail::crc(0xffffffffu, image.data(), image.size() - 4) ^ 0xffffffffu;
mesh::room_mail_detail::put32(image.data() + image.size() - 4, value);
}
static uint32_t firstId(MemoryFS& fs) {
use(fs); Message list[4]; size_t copied;
assert(mesh::listRoomMail(&fs, OWNER, 0, 0, list, 4, copied) == Result::Success && copied == 1);
return list[0].id;
}
static void preserveUnavailable(MemoryFS& fs) {
use(fs); const auto before = snapshot(fs); const auto directories = fs.directories;
Status out; out.count = 99; out.revision = 99;
assert(mesh::getRoomMailStatus(&fs, OWNER, out) == Result::Unavailable && !out.count && !out.revision);
uint32_t id = 123;
assert(send(fs, 2, NEW_BODY, id) == Result::Unavailable && id == 0);
assert(mesh::acknowledgeRoomMail(&fs, OWNER, 2) == Result::Unavailable);
assert(provision(fs) == Result::Unavailable);
assert(snapshot(fs) == before && fs.directories == directories && fs.write_opens == 0);
++scenarios;
}
int main() {
{ MemoryFS fs; use(fs); Status out;
assert(mesh::getRoomMailStatus(&fs, OWNER, out) == Result::NotFound && out.settings.mode == Mode::Closed && !out.count);
bool mailbox_only = true; assert(mesh::getRoomMailboxOnly(&fs, OWNER, mailbox_only) == Result::Success && !mailbox_only);
uint32_t id; assert(send(fs, 1, "No mailbox", id) == Result::NotFound);
assert(fs.files.empty() && fs.write_opens == 0);
assert(provision(fs, OWNER, Mode::Closed) == Result::Success);
Settings stale; stale.mode = Mode::Public;
const auto before = snapshot(fs);
assert(mesh::saveRoomMailSettings(&fs, OWNER, stale, 0) == Result::StaleVersion);
assert(snapshot(fs) == before && status(fs).settings.mode == Mode::Closed);
assert(send(fs, 1, "Denied", id) == Result::Forbidden);
assert(send(fs, 1, "Owner always permitted", id, OWNER, OWNER) == Result::Success);
assert(mesh::acknowledgeRoomMail(&fs, OWNER, id) == Result::Success);
assert(mesh::acknowledgeRoomMail(&fs, OWNER, id) == Result::Success);
assert(send(fs, 1, "Owner always permitted", id, OWNER, OWNER) == Result::Duplicate);
assert(send(fs, 1, "Changed", id, OWNER, OWNER) == Result::Mismatch);
++scenarios;
}
{ MemoryFS fs; assert(provision(fs) == Result::Success); Settings settings;
settings.mode = Mode::Private; settings.mailbox_only = true; settings.allowed_count = 2;
memcpy(settings.allowed[0], SENDER, 32); memcpy(settings.allowed[1], OTHER, 32);
assert(mesh::saveRoomMailSettings(&fs, OWNER, settings, 1) == Result::Success);
Status own = status(fs); assert(own.settings.mailbox_only && own.settings.allowed_count == 2);
assert(memcmp(own.settings.allowed[0], OTHER, 32) == 0); // Canonical full-key order.
bool only = false; assert(mesh::getRoomMailboxOnly(&fs, OWNER, only) == Result::Success && only);
uint32_t id; assert(send(fs, UINT64_C(0x100000001), u8"Caf\u00e9 \u5730\u56f3 \U0001f4e1", id) == Result::Success);
assert(send(fs, UINT64_C(0x200000001), "Different high nonce bits", id) == Result::Success);
uint8_t collision[32] = {3}; collision[31] = 1;
assert(send(fs, 4, "Short key collision", id, OWNER, collision) == Result::Forbidden);
assert(mesh::saveRoomMailSettings(&fs, OWNER, settings, 2) == Result::StaleVersion);
assert(provision(fs, OTHER) == Result::Success);
Message list[4]; size_t copied;
assert(mesh::listRoomMail(&fs, OTHER, 0, 0, list, 4, copied) == Result::Success && copied == 0);
uint8_t output[8]; Message info;
assert(mesh::readRoomMail(&fs, OTHER, 3, 0, output, sizeof(output), copied, info) == Result::NotFound);
assert(mesh::acknowledgeRoomMail(&fs, OTHER, 3) == Result::NotFound);
assert(mesh::deleteRoomMail(&fs, OTHER, 3) == Result::NotFound);
assert(status(fs).count == 2); ++scenarios;
}
{ MemoryFS fs; assert(provision(fs) == Result::Success); uint32_t id;
assert(send(fs, 1, NEW_BODY, id) == Result::Success); bodyIs(fs, OWNER, id, NEW_BODY);
use(fs); Message info; uint8_t output[128]; size_t copied;
assert(mesh::readRoomMail(&fs, OWNER, id, 512, output, 128, copied, info) == Result::Success && copied == 0);
assert(mesh::readRoomMail(&fs, OWNER, id, 513, output, 128, copied, info) == Result::Invalid && !copied);
assert(mesh::readRoomMail(&fs, OWNER, id, 0, output, 129, copied, info) == Result::Invalid && !copied);
assert(send(fs, 1, NEW_BODY, id, OWNER, SENDER, 9876) == Result::Duplicate);
++scenarios;
}
{ MemoryFS fs; assert(provision(fs) == Result::Success); uint32_t id;
use(fs); uint8_t zero[32] = {}; Settings bad; bad.allowed_count = 9;
assert(mesh::saveRoomMailSettings(&fs, OWNER, bad) == Result::Invalid);
bad.allowed_count = 1; assert(mesh::saveRoomMailSettings(&fs, OWNER, bad) == Result::Invalid);
assert(mesh::saveRoomMailSettings(&fs, zero, Settings{}) == Result::Invalid);
const auto before = snapshot(fs); fs.reset();
for (const std::string& body : {std::string(), std::string(513, 'x'), std::string("a\0b", 3), std::string("\xc0\xaf"), std::string("\xed\xa0\x80"), std::string("\xf4\x90\x80\x80"), std::string("\xe2\x82")})
assert(send(fs, 1, body, id) == Result::Invalid && !id);
assert(send(fs, 0, "x", id) == Result::Invalid);
assert(send(fs, 1, "x", id, OWNER, zero) == Result::Invalid);
assert(snapshot(fs) == before && fs.write_opens == 0); ++scenarios;
}
{ MemoryFS fs; uint32_t id;
for (uint8_t owner = 1; owner <= 32; ++owner) {
uint8_t key[32] = {owner}; assert(provision(fs, key) == Result::Success);
if (owner <= 4) for (uint64_t nonce = 1; nonce <= 4; ++nonce) assert(send(fs, nonce, "Queue", id, key) == Result::Success);
}
uint8_t extra[32] = {33}; assert(provision(fs, extra) == Result::Full);
assert(send(fs, 5, "Per owner full", id) == Result::Full);
uint8_t fifth[32] = {5}; assert(send(fs, 1, "Global full", id, fifth) == Result::Full);
use(fs); mesh::RoomMailStats stats; assert(mesh::getRoomMailStats(&fs, stats) == Result::Success && stats.owners == 32 && stats.messages == 16);
uint8_t keys[3][32]; size_t copied;
assert(mesh::listRoomMailOwners(&fs, stats.revision, 2, keys, 3, copied, stats) == Result::Success && copied == 3 && keys[0][0] == 3);
assert(mesh::listRoomMailOwners(&fs, 1, 0, keys, 3, copied, stats) == Result::StaleVersion);
assert(mesh::deleteRoomMail(&fs, OWNER, 0) == Result::Success);
assert(mesh::getRoomMailStats(&fs, stats) == Result::Success && stats.messages == 12);
assert(send(fs, 1, "Queue", id) == Result::Duplicate);
assert(send(fs, 5, "Space reclaimed", id) == Result::Success); ++scenarios;
}
{ MemoryFS fs; assert(provision(fs) == Result::Success); uint32_t live, id;
assert(send(fs, 100, "Keep this old queued message", live) == Result::Success);
for (uint64_t nonce = 1; nonce <= 20; ++nonce) {
assert(send(fs, nonce, "Retired", id) == Result::Success);
assert(mesh::deleteRoomMail(&fs, OWNER, id) == Result::Success);
assert(send(fs, nonce, "Retired", id) == Result::Duplicate);
}
bodyIs(fs, OWNER, live, "Keep this old queued message");
assert(send(fs, 100, "Keep this old queued message", id) == Result::Duplicate && id == live);
assert(mesh::deleteRoomMail(&fs, OWNER, 0) == Result::Success && !status(fs).count); ++scenarios;
}
MemoryFS initial; assert(provision(initial) == Result::Success); uint32_t original_id;
assert(send(initial, 1, OLD_BODY, original_id) == Result::Success); initial.reset();
const auto old_primary = initial.get(P);
const std::string old_path = "/room_mail_0.b", new_path = "/room_mail_0.a";
// Every byte of selected owner bank and registry is protected. A corrupt
// primary cannot be replaced by backups or uncommitted temporary images.
for (const std::string& path : {std::string(P), old_path}) {
const auto original = initial.get(path.c_str());
for (size_t position = 0; position < original.size(); ++position) {
MemoryFS fs = clone(initial); auto corrupted = original; corrupted[position] ^= 1;
fs.put(path.c_str(), corrupted); fs.put(B, old_primary); fs.put(T, old_primary);
preserveUnavailable(fs);
}
}
{ MemoryFS fs = clone(initial); auto future = fs.get(P); future[3] = 2; checksum(future); fs.put(P, future); preserveUnavailable(fs); }
{ MemoryFS fs = clone(initial); auto invalid = fs.get(old_path.c_str()); invalid[42] = 5; checksum(invalid); fs.put(old_path.c_str(), invalid); preserveUnavailable(fs); }
{ MemoryFS fs = clone(initial); auto invalid = fs.get(P); invalid[51] = 1; checksum(invalid); fs.put(P, invalid); preserveUnavailable(fs); }
{ MemoryFS fs = clone(initial); fs.files.erase(P); fs.put(B, old_primary); fs.put(T, old_primary);
bodyIs(fs, OWNER, original_id, OLD_BODY); assert(fs.files.count(P) && !fs.files.count(B)); ++scenarios; }
{ MemoryFS fs = clone(initial); fs.files.erase(P); auto broken = old_primary; broken[0] ^= 1;
fs.put(B, broken); fs.put(T, old_primary); preserveUnavailable(fs); }
// Faults before publication preserve the previous committed mailbox. The
// inactive bank may contain an orphan; it is never selected without commit.
for (const std::string& fault : {std::string("open:w:") + new_path, std::string("write:") + new_path,
std::string("flush:") + new_path, std::string("close:w:") + new_path,
std::string("open:r:") + new_path, std::string("directory:") + new_path,
std::string("open:w:") + T, std::string("write:") + T, std::string("flush:") + T,
std::string("close:w:") + T, std::string("open:r:") + T,
std::string("rename:") + P + ":" + B, std::string("rename:") + T + ":" + P}) {
MemoryFS fs = clone(initial); fs.faults.insert(fault); uint32_t id;
assert(send(fs, 2, NEW_BODY, id) == Result::WriteFailure && !id);
fs.reset(); assert(status(fs).count == 1 && firstId(fs) == original_id); bodyIs(fs, OWNER, original_id, OLD_BODY); ++scenarios;
}
{ MemoryFS fs = clone(initial); fs.short_write = true; uint32_t id;
assert(send(fs, 2, NEW_BODY, id) == Result::WriteFailure && !id); fs.reset(); assert(status(fs).count == 1); ++scenarios; }
{ MemoryFS fs = clone(initial); fs.short_read = true; uint32_t id;
assert(send(fs, 2, NEW_BODY, id) == Result::Unavailable); fs.reset(); assert(status(fs).count == 1); ++scenarios; }
for (size_t budget = 0; budget < 1800; ++budget) {
MemoryFS fs = clone(initial); fs.write_budget = budget; uint32_t id;
const Result result = send(fs, 2, NEW_BODY, id); fs.reset();
assert(result == Result::Success || result == Result::WriteFailure);
assert(status(fs).count == (result == Result::Success ? 2 : 1)); bodyIs(fs, OWNER, original_id, OLD_BODY); ++scenarios;
}
// Reboot at every observable filesystem boundary of send, ACK, purge and
// settings changes. Each recovered state is entirely old or entirely new.
for (uint8_t operation = 0; operation < 4; ++operation) {
auto mutate = [&](MemoryFS& fs) -> Result {
use(fs); uint32_t id;
if (operation == 0) return send(fs, 2, NEW_BODY, id);
if (operation == 1) return mesh::acknowledgeRoomMail(&fs, OWNER, original_id);
if (operation == 2) return mesh::deleteRoomMail(&fs, OWNER, 0);
Settings settings; settings.mode = Mode::Private; settings.mailbox_only = true;
return mesh::saveRoomMailSettings(&fs, OWNER, settings);
};
MemoryFS successful = clone(initial); assert(mutate(successful) == Result::Success);
const size_t boundaries = successful.boundary;
for (size_t cut = 1; cut <= boundaries; ++cut) {
MemoryFS fs = clone(initial); fs.cut_at = cut;
try { mutate(fs); } catch (const PowerCut&) {}
fs.reset(); const Status restored = status(fs);
if (operation == 0) {
assert(restored.count == 1 || restored.count == 2); bodyIs(fs, OWNER, original_id, OLD_BODY);
uint32_t id; const Result retry = send(fs, 2, NEW_BODY, id);
assert(retry == Result::Success || retry == Result::Duplicate);
assert(status(fs).count == 2); bodyIs(fs, OWNER, id, NEW_BODY);
} else if (operation < 3) {
assert(restored.count <= 1); assert(mutate(fs) == Result::Success && status(fs).count == 0);
uint32_t id; assert(send(fs, 1, OLD_BODY, id) == Result::Duplicate && id == original_id);
} else {
assert(restored.count == 1 && (restored.settings.mode == Mode::Public || restored.settings.mode == Mode::Private));
bodyIs(fs, OWNER, original_id, OLD_BODY);
assert(mutate(fs) == Result::Success && status(fs).settings.mailbox_only);
}
++scenarios;
}
}
printf("%u room mailbox ownership, policy, bounded storage, retry, fault and power-cut scenarios passed\n", scenarios);
}
'''
class RoomMailStoreTest(unittest.TestCase):
def test_actual_store_with_all_backend_modes_faults_and_powercuts(self):
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
if compiler is None:
self.skipTest("a host C++ compiler is required")
filesystem = FILESYSTEM_HARNESS.split("#define FILE_O_WRITE 1")[0]
# Real Adafruit LittleFS files are filesystem-bound and have no default
# constructor. A permissive mock must not hide unsupported handles.
filesystem = filesystem.replace(" File() = default;", """#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
File() = delete;
#else
File() = default;
#endif
explicit File(MemoryFS* owner): fs(owner) {}""")
filesystem = filesystem.replace("return {};", "return File(this);")
filesystem = filesystem.replace(" void _lockFS() {}\n void _unlockFS() {}\n MemoryFS* _getFS() { return this; }\n", "", 1)
filesystem = filesystem.replace(" void point(const std::string& event) {", """ int lock_depth = 0;
void _lockFS() { assert(lock_depth++ == 0); }
void _unlockFS() { assert(--lock_depth == 0); }
MemoryFS* _getFS() { return this; }
size_t write_budget = std::numeric_limits<size_t>::max();
void point(const std::string& event) {""")
filesystem = filesystem.replace(" bytes->insert(bytes->end(), data, data + length);", """ length = std::min(length, fs->write_budget);
fs->write_budget -= length;
bytes->insert(bytes->end(), data, data + length);""")
filesystem = filesystem.replace("void reset() { trace.clear();", "void reset() { lock_depth = 0; write_budget = std::numeric_limits<size_t>::max(); trace.clear();")
sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
if sys.platform.startswith("linux") else [])
with tempfile.TemporaryDirectory(prefix="meshcore-room-mail-") as temporary:
work = Path(temporary)
(work / "sys").mkdir()
(work / "sys/stat.h").write_text(STAT_MOCK)
(work / "SHA256.h").write_text(SHA_MOCK)
source = work / "mail.cpp"
for platform in (None, "NRF52_PLATFORM", "STM32_PLATFORM", "RP2040_PLATFORM", "ESP32_PLATFORM"):
with self.subTest(platform=platform):
source.write_text(filesystem + (LFS_MOCK if platform in ("NRF52_PLATFORM", "STM32_PLATFORM") else "") + TESTS)
binary = work / (platform or "generic")
defines = ["-D" + platform + "=1"] if platform else []
built = subprocess.run([compiler, "-std=c++11", "-Wall", "-Wextra", "-Werror", *sanitizers,
*defines, "-I" + str(work), "-I" + str(ROOT / "src"), str(source), "-lcrypto", "-o", str(binary)],
capture_output=True, text=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=120)
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
self.assertIn("room mailbox ownership, policy, bounded storage, retry, fault and power-cut scenarios passed", checked.stdout)
print(checked.stdout.strip())
if __name__ == "__main__":
unittest.main()
+313
View File
@@ -0,0 +1,313 @@
#!/usr/bin/env python3
"""Exercise the actual personal mailbox page in Chromium, without hardware."""
import json
import unittest
import test_room_web_ui as room_ui
MAIL_COMMON = r'''
const ownKey=boundary.status.web_key,senderKey='ab'.repeat(32),otherOwner='cd'.repeat(32);
let serverRole=2,settingsRevision=0,serverMode='closed',serverOnly=false,serverAllow=[],inbox=[],chunks=[],confirmations=[];
const settings=(owner=ownKey,extra={})=>Object.assign({owner:owner,revision:settingsRevision,mode:serverMode,mailbox_only:serverOnly,allowlist:serverAllow,blocked:false},extra);
const mailBase=p=>{
if(p.op==='status')return response(Object.assign({},boundary.status,{role:serverRole,mailbox_only:serverOnly}));
if(p.op==='posts')return response({post:null});
if(p.op==='board.index')return response({revision:0,articles:[],next:255});
if(p.op==='mail.settings')return response(settings());
if(p.op==='mail.check')return response({owner:ownKey,revision:settingsRevision,mailbox_only:serverOnly,total:inbox.length,unread:inbox.length});
if(p.op==='mail.list')return response({messages:inbox,next:null,revision:settingsRevision});
return null;
};
const joinedMailRoom=async()=>{submit('join');await until(()=>!$('room').hidden&&$('notices').textContent==='No notices published.')};
const edit=id=>$(id).dispatchEvent(new Event(id.includes('allowlist')||id==='mail-text'||id==='mail-recipient'?'input':'change'));
window.confirm=text=>{confirmations.push(text);return true};
'''
class RoomMailWebUiTest(unittest.TestCase):
run_page = room_ui.RoomWebUiTest.run_page
attribute = room_ui.RoomWebUiTest.attribute
def test_saved_mailbox_only_join_skips_chat_until_explicit_refresh(self):
dom = self.run_page(MAIL_COMMON + r'''
serverOnly=true;
window.fetch=(url,options)=>{const p=record(url,options),result=mailBase(p);if(result)return result;throw Error('unexpected '+p.op)};
window.addEventListener('load',()=>{(async()=>{
await joinedMailRoom();mark('initial-posts',calls.filter(c=>c.op==='posts').length);mark('initial-mail',calls.filter(c=>c.op.startsWith('mail.')).length);
mark('hint',$('mail-summary').textContent);mark('hint-visible',!$('mail-summary').hidden);await refreshChat(true);mark('auto-posts',calls.filter(c=>c.op==='posts').length);
await refreshChat();mark('manual-posts',calls.filter(c=>c.op==='posts').length);mark('lazy-panel',$('mail-panel').hidden);mark('done',true);
})().catch(error=>mark('js-error',error.message))});
''')
self.assertEqual(self.attribute(dom, "initial-posts"), "0")
self.assertEqual(self.attribute(dom, "initial-mail"), "0")
self.assertEqual(self.attribute(dom, "auto-posts"), "0")
self.assertEqual(self.attribute(dom, "manual-posts"), "1")
self.assertEqual(self.attribute(dom, "hint-visible"), "true")
self.assertIn("automatic chat downloads are paused", self.attribute(dom, "hint"))
self.assertEqual(self.attribute(dom, "lazy-panel"), "true")
def test_lazy_closed_mailbox_selective_safe_reads_resume_and_explicit_receipt(self):
dom = self.run_page(MAIL_COMMON + r'''
const text='<img src=x onerror=attack()> '+ 'x'.repeat(230),bytes=new TextEncoder().encode(text);
const item={id:7,sender:senderKey,created:1001,length:bytes.length,acked:false};inbox=[item];let fail=true,acks=0;
window.fetch=(url,options)=>{
const p=record(url,options);
if(p.op==='mail.read'){
chunks.push(p.offset);if(p.offset===128&&fail){fail=false;return response({error:'link lost'})}
const part=bytes.slice(p.offset,p.offset+128);
return response({id:7,sender:senderKey,created:1001,length:bytes.length,offset:p.offset,count:part.length,next:p.offset+part.length,data64:btoa(Array.from(part,b=>String.fromCharCode(b)).join('')),acked:false});
}
if(p.op==='mail.ack'){acks++;inbox=[];return response({ok:true})}
const result=mailBase(p);if(result)return result;throw Error('unexpected '+p.op);
};
window.addEventListener('load',()=>{(async()=>{
await joinedMailRoom();mark('initial-mail-requests',calls.filter(c=>c.op.startsWith('mail.')).length);
await openMailbox();mark('default-mode',$('mail-mode').value);mark('address',$('mail-key').textContent);
document.body.style.width='360px';document.body.style.boxSizing='border-box';const panel=$('mail-panel').getBoundingClientRect();mark('responsive-fit',Array.from($('mail-panel').querySelectorAll('fieldset')).every(field=>field.getBoundingClientRect().right<=panel.right+1));
mark('before-body-reads',chunks.length);mark('sender',$('mail-rows').textContent);
try{await openMail(item)}catch(error){}
mark('partial',mailTransfers.get(7).next);mark('partial-ack-disabled',$('mail-ack').disabled);mark('before-acks',acks);
await openMail(item);mark('body',$('mail-body').textContent);mark('images',document.querySelectorAll('img').length);
mark('complete-ack-disabled',$('mail-ack').disabled);mark('read-acks',acks);await removeMail(true);
mark('acks',acks);mark('offsets',JSON.stringify(chunks));mark('removed-body',$('mail-body').textContent);mark('removed-cache',mailTransfers.size);mark('receipt-confirmation',confirmations[0]);mark('done',true);
})().catch(error=>mark('js-error',error.message))});
''')
self.assertEqual(self.attribute(dom, "initial-mail-requests"), "0")
self.assertEqual(self.attribute(dom, "default-mode"), "closed")
self.assertEqual(self.attribute(dom, "responsive-fit"), "true")
self.assertEqual(len(self.attribute(dom, "address")), 64)
self.assertEqual(self.attribute(dom, "before-body-reads"), "0")
self.assertIn("ab" * 32, self.attribute(dom, "sender"))
self.assertEqual(self.attribute(dom, "partial"), "128")
self.assertEqual(self.attribute(dom, "partial-ack-disabled"), "true")
self.assertEqual(self.attribute(dom, "complete-ack-disabled"), "false")
self.assertEqual(self.attribute(dom, "read-acks"), "0")
self.assertEqual(self.attribute(dom, "acks"), "1")
self.assertEqual(self.attribute(dom, "images"), "0")
self.assertIn("<img src=x onerror=attack()>", self.attribute(dom, "body"))
self.assertEqual(json.loads(self.attribute(dom, "offsets")), [0, 128, 128, 256])
self.assertEqual(self.attribute(dom, "removed-body"), "")
self.assertEqual(self.attribute(dom, "removed-cache"), "0")
self.assertIn("Confirm receipt and remove", self.attribute(dom, "receipt-confirmation"))
def test_policy_stale_revision_retains_draft_key_limit_and_mailbox_only_poll(self):
dom = self.run_page(MAIL_COMMON + r'''
let writes=[];
window.fetch=(url,options)=>{
const p=record(url,options);
if(p.op==='mail.policy'){
writes.push(p);if(p.expected_revision!==settingsRevision)return response({error:'mailbox policy changed; reload'});
settingsRevision++;serverMode=p.mode;serverAllow=p.allowlist;serverOnly=p.mailbox_only;return response(settings());
}
const result=mailBase(p);if(result)return result;throw Error('unexpected '+p.op);
};
window.addEventListener('load',()=>{(async()=>{
await joinedMailRoom();await openMailbox();$('mail-mode').value='private';edit('mail-mode');$('mail-allowlist').value=senderKey;edit('mail-allowlist');$('mail-only').checked=true;edit('mail-only');
settingsRevision=2;await loadMailSettings();mark('baseline-after-refresh',mailPolicy.revision);mark('draft-before-save',$('mail-allowlist').value);
try{await saveMailPolicy()}catch(error){}mark('failed-draft',$('mail-allowlist').value);mark('failed-revision',writes[0].expected_revision);
await loadMailSettings(true);$('mail-mode').value='private';edit('mail-mode');$('mail-allowlist').value=senderKey;edit('mail-allowlist');$('mail-only').checked=true;edit('mail-only');await saveMailPolicy();
mark('success',$('mail-policy-result').textContent);mark('allowlist',JSON.stringify(writes[1].allowlist));mark('saved-revision',mailPolicy.revision);
const before=calls.filter(c=>c.op==='posts').length;await refreshChat(true);mark('auto-chat',calls.filter(c=>c.op==='posts').length-before);await refreshChat();mark('manual-chat',calls.filter(c=>c.op==='posts').length-before);
$('mail-allowlist').value=Array.from({length:9},(_,i)=>(i+1).toString(16).padStart(64,'0')).join('\n');edit('mail-allowlist');
try{await saveMailPolicy()}catch(error){mark('limit-error',error.message)}mark('write-count',writes.length);mark('done',true);
})().catch(error=>mark('js-error',error.message))});
''')
self.assertEqual(self.attribute(dom, "baseline-after-refresh"), "0")
self.assertEqual(self.attribute(dom, "failed-revision"), "0")
self.assertEqual(self.attribute(dom, "failed-draft"), "ab" * 32)
self.assertEqual(json.loads(self.attribute(dom, "allowlist")), ["ab" * 32])
self.assertEqual(self.attribute(dom, "saved-revision"), "3")
self.assertIn("saved and confirmed", self.attribute(dom, "success"))
self.assertEqual(self.attribute(dom, "auto-chat"), "0")
self.assertEqual(self.attribute(dom, "manual-chat"), "1")
self.assertIn("up to 8", self.attribute(dom, "limit-error"))
self.assertEqual(self.attribute(dom, "write-count"), "2")
def test_send_identical_http_retry_utf8_limit_failed_draft_and_reader_permission(self):
dom = self.run_page(MAIL_COMMON + r'''
let attempts=0,effects=0,first=null,pending=null;
window.fetch=(url,options)=>{
const p=record(url,options);if(url==='/api/room/result')return response(pending);
if(p.op==='mail.send'){
attempts++;if(attempts===1){first=calls[calls.length-1];effects++;pending={ok:true,id:11};return Promise.reject(Error('lost response'))}
if(attempts===2){const current=calls[calls.length-1];mark('same-seq',current.seq===first.seq);mark('same-body',current.body===first.body);return response({},202)}
return response({error:'recipient mailbox is full'});
}
const result=mailBase(p);if(result)return result;throw Error('unexpected '+p.op);
};
window.addEventListener('load',()=>{(async()=>{
await joinedMailRoom();await openMailbox();$('mail-recipient').value=senderKey;edit('mail-recipient');$('mail-text').value='\u{1f4e1}'.repeat(128);edit('mail-text');mark('max-disabled',$('mail-send').disabled);
$('mail-text').value+='x';edit('mail-text');mark('over-disabled',$('mail-send').disabled);try{await sendMail()}catch(error){}mark('over-attempts',attempts);
$('mail-text').value='hello';edit('mail-text');await sendMail();mark('confirmed-draft',$('mail-text').value);mark('confirmed-status',$('mail-send-result').textContent);
$('mail-text').value='keep private draft';edit('mail-text');try{await sendMail()}catch(error){}mark('failed-draft',$('mail-text').value);mark('effects',effects);mark('attempts',attempts);
serverRole=1;await status();$('mail-recipient').value=senderKey;$('mail-text').value='reader attempt';updateMailButtons();mark('reader-disabled',$('mail-send').disabled);
try{await sendMail()}catch(error){mark('reader-error',error.message)}mark('reader-attempts',attempts);mark('done',true);
})().catch(error=>mark('js-error',error.message))});
''')
self.assertEqual(self.attribute(dom, "max-disabled"), "false")
self.assertEqual(self.attribute(dom, "over-disabled"), "true")
self.assertEqual(self.attribute(dom, "over-attempts"), "0")
self.assertEqual(self.attribute(dom, "same-seq"), "true")
self.assertEqual(self.attribute(dom, "same-body"), "true")
self.assertEqual(self.attribute(dom, "effects"), "1")
self.assertEqual(self.attribute(dom, "confirmed-draft"), "")
self.assertIn("saved for", self.attribute(dom, "confirmed-status"))
self.assertEqual(self.attribute(dom, "failed-draft"), "keep private draft")
self.assertEqual(self.attribute(dom, "reader-disabled"), "true")
self.assertEqual(self.attribute(dom, "reader-attempts"), self.attribute(dom, "attempts"))
def test_admin_metadata_only_pages_policy_purge_block_and_role_loss(self):
dom = self.run_page(MAIL_COMMON + r'''
serverRole=3;let changes=[],deletions=[],bans=[],acceptedConfirm=true;
window.confirm=text=>{confirmations.push(text);return acceptedConfirm};
window.fetch=(url,options)=>{
const p=record(url,options);
if(p.op==='admin.mail.list')return response({mailboxes:[{owner:p.cursor===0?ownKey:otherOwner,mode:'private',revision:5,total:2,unread:2,blocked:false}],next:p.cursor===0?1:null,revision:5});
if(p.op==='admin.mail.settings')return response(settings(p.owner,{revision:5,mode:'private',allowlist:[senderKey]}));
if(p.op==='admin.mail.policy'){changes.push(p);return response({error:'mailbox policy changed; reload'})}
if(p.op==='admin.mail.delete'){deletions.push(p);return response({ok:true})}
if(p.op==='admin.ban'){bans.push(p);return response({ok:true})}
if(p.op==='mail.read')throw Error('admin downloaded private mail');
const result=mailBase(p);if(result)return result;throw Error('unexpected '+p.op);
};
window.addEventListener('load',()=>{(async()=>{
await joinedMailRoom();await loadAdminMail(0);mark('full-owner',$('admin-mail-rows').textContent);mark('next-enabled',!$('admin-mail-next').disabled);
await loadAdminMail(1);mark('second-owner',$('admin-mail-rows').textContent);await selectAdminMail(otherOwner);mark('selected-address',$('admin-mail-key').textContent);mark('allowlist',$('admin-mail-allowlist').value);
$('admin-mail-mode').value='public';edit('admin-mail-mode');try{await saveAdminMailPolicy()}catch(error){}mark('admin-failed-draft',$('admin-mail-mode').value);mark('policy-request',JSON.stringify(changes[0]));
acceptedConfirm=false;await deleteAdminMail(true);mark('cancel-deletions',deletions.length);acceptedConfirm=true;await deleteAdminMail(true);mark('purge',JSON.stringify(deletions[0]));
await blockAdminMail();mark('ban',JSON.stringify(bans[0]));mark('body-reads',calls.filter(c=>c.op==='mail.read').length);
serverRole=2;await status();mark('cleared-key',$('admin-mail-key').textContent);mark('cleared-allowlist',$('admin-mail-allowlist').value);mark('hidden-policy',$('admin-mail-policy').hidden);mark('confirmations',JSON.stringify(confirmations));mark('done',true);
})().catch(error=>mark('js-error',error.message))});
''')
self.assertIn("cd" * 32, self.attribute(dom, "second-owner"))
self.assertEqual(self.attribute(dom, "next-enabled"), "true")
self.assertEqual(self.attribute(dom, "selected-address"), "cd" * 32)
self.assertEqual(self.attribute(dom, "allowlist"), "ab" * 32)
self.assertEqual(self.attribute(dom, "admin-failed-draft"), "public")
policy = json.loads(self.attribute(dom, "policy-request"))
self.assertEqual((policy["owner"], policy["expected_revision"], policy["mode"]), ("cd" * 32, 5, "public"))
self.assertEqual(self.attribute(dom, "cancel-deletions"), "0")
purge = json.loads(self.attribute(dom, "purge"))
self.assertEqual((purge["owner"], purge["id"]), ("cd" * 32, 0))
ban = json.loads(self.attribute(dom, "ban"))
self.assertEqual((ban["key"], ban["banned"]), ("cd" * 32, True))
self.assertEqual(self.attribute(dom, "body-reads"), "0")
self.assertEqual(self.attribute(dom, "cleared-key"), "")
self.assertEqual(self.attribute(dom, "cleared-allowlist"), "")
self.assertEqual(self.attribute(dom, "hidden-policy"), "true")
def test_admin_provisions_new_radio_mailbox_by_complete_address(self):
dom = self.run_page(MAIL_COMMON + r'''
serverRole=3;const radioOwner='ef'.repeat(32);let configured=false,loaded=[],policies=[];
window.fetch=(url,options)=>{
const p=record(url,options);
if(p.op==='admin.mail.settings'){loaded.push(p.owner);return response(settings(p.owner,{revision:0,mode:'closed',mailbox_only:false,allowlist:[]}))}
if(p.op==='admin.mail.policy'){policies.push(p);if(p.owner!==radioOwner||p.expected_revision!==0)throw Error('incorrect new radio policy');configured=true;return response(settings(radioOwner,{revision:1,mode:p.mode,mailbox_only:p.mailbox_only,allowlist:p.allowlist}))}
if(p.op==='admin.mail.list')return response({mailboxes:configured?[{owner:radioOwner,mode:'private',revision:1,unread:0,total:0,blocked:false}]:[],revision:configured?1:0,next:null});
const result=mailBase(p);if(result)return result;throw Error('unexpected '+p.op);
};
window.addEventListener('load',()=>{(async()=>{
await joinedMailRoom();await loadAdminMail(0);$('admin-mail-owner').value='short-key';$('admin-mail-load-owner').click();await until(()=>$('error').textContent.includes('complete'));mark('invalid-loads',loaded.length);
$('admin-mail-owner').value=radioOwner.toUpperCase();$('admin-mail-load-owner').click();await until(()=>!$('admin-mail-policy').hidden);mark('selected-address',$('admin-mail-key').textContent);mark('initial-mode',$('admin-mail-mode').value);mark('initial-revision',adminMailPolicy.revision);
$('admin-mail-mode').value='private';edit('admin-mail-mode');$('admin-mail-allowlist').value=senderKey;edit('admin-mail-allowlist');$('admin-mail-only').checked=true;edit('admin-mail-only');await saveAdminMailPolicy();await loadAdminMail(0);
mark('policy',JSON.stringify(policies[0]));mark('listed-owner',$('admin-mail-rows').textContent);mark('body-reads',calls.filter(c=>c.op==='mail.read').length);serverRole=2;await status();mark('cleared-entry',$('admin-mail-owner').value);mark('admin-hidden',$('admin-panel').hidden);mark('done',true);
})().catch(error=>mark('js-error',error.message))});
''')
self.assertEqual(self.attribute(dom, "invalid-loads"), "0")
self.assertEqual(self.attribute(dom, "selected-address"), "ef" * 32)
self.assertEqual(self.attribute(dom, "initial-mode"), "closed")
self.assertEqual(self.attribute(dom, "initial-revision"), "0")
policy = json.loads(self.attribute(dom, "policy"))
self.assertEqual((policy["owner"], policy["expected_revision"], policy["mode"]), ("ef" * 32, 0, "private"))
self.assertEqual(policy["allowlist"], ["ab" * 32])
self.assertTrue(policy["mailbox_only"])
self.assertIn("ef" * 32, self.attribute(dom, "listed-owner"))
self.assertEqual(self.attribute(dom, "body-reads"), "0")
self.assertEqual(self.attribute(dom, "cleared-entry"), "")
self.assertEqual(self.attribute(dom, "admin-hidden"), "true")
def test_disconnect_rejects_late_private_response_and_clears_sensitive_drafts(self):
dom = self.run_page(MAIL_COMMON + r'''
const item={id:3,sender:senderKey,created:1001,length:6,acked:false};inbox=[item];let resolveRead;
window.fetch=(url,options)=>{
const p=record(url,options);if(p.op==='mail.read')return new Promise(resolve=>resolveRead=resolve);
const result=mailBase(p);if(result)return result;throw Error('unexpected '+p.op);
};
window.addEventListener('load',()=>{(async()=>{
await joinedMailRoom();await openMailbox();$('mail-text').value='private draft';$('mail-allowlist').value=senderKey;
const reading=openMail(item).catch(error=>error.message);await until(()=>resolveRead);$('disconnect').click();
resolveRead({ok:true,status:200,json:()=>Promise.resolve({id:3,sender:senderKey,created:1001,length:6,offset:0,count:6,next:6,data64:btoa('secret')})});
mark('late-error',await reading);mark('body',$('mail-body').textContent);mark('cache',mailTransfers.size);mark('draft',$('mail-text').value);mark('allowlist',$('mail-allowlist').value);mark('panel-hidden',$('mail-panel').hidden);mark('selected-hidden',$('mail-selected').hidden);mark('done',true);
})().catch(error=>mark('js-error',error.message))});
''')
self.assertIn("connection changed", self.attribute(dom, "late-error"))
for field in ["body", "draft", "allowlist"]:
self.assertEqual(self.attribute(dom, field), "")
self.assertEqual(self.attribute(dom, "cache"), "0")
self.assertEqual(self.attribute(dom, "panel-hidden"), "true")
self.assertEqual(self.attribute(dom, "selected-hidden"), "true")
def test_mail_and_admin_pages_pin_revision_until_explicit_refresh(self):
dom = self.run_page(MAIL_COMMON + r'''
serverRole=3;settingsRevision=10;serverMode='public';let serverRevision=10,mailPagesAsked=[],adminPagesAsked=[];
const entries=Array.from({length:3},(_,i)=>({id:i+1,sender:senderKey,created:1001+i,length:6,acked:false}));
const owners=[ownKey,senderKey,otherOwner].map(owner=>({owner:owner,mode:'public',revision:10,unread:1,total:1,blocked:false}));
window.fetch=(url,options)=>{
const p=record(url,options);
if(p.op==='mail.list'||p.op==='admin.mail.list'){
(p.op==='mail.list'?mailPagesAsked:adminPagesAsked).push(p);
if(p.cursor&&p.revision!==serverRevision)return response({error:'mailbox changed; refresh before continuing'});
const data=p.op==='mail.list'?{messages:entries.slice(p.cursor,p.cursor+2)}:{mailboxes:owners.slice(p.cursor,p.cursor+2)};
return response(Object.assign(data,{next:p.cursor===0?2:null,revision:serverRevision}));
}
const result=mailBase(p);if(result)return result;throw Error('unexpected '+p.op);
};
window.addEventListener('load',()=>{(async()=>{
await joinedMailRoom();await openMailbox();serverRevision=11;
try{await refreshMail(2)}catch(error){mark('mail-stale-error',error.message)}
mark('retained-mail-rows',$('mail-rows').textContent);mark('mail-cursor-after-stale',mailCursor);mark('mail-stale-revision',mailPagesAsked[1].revision);
mailPages=[];await refreshMail(0);await refreshMail(2);mark('mail-fresh-revision',mailPagesAsked[3].revision);mark('mail-last-page',$('mail-rows').textContent);
await loadAdminMail(0);serverRevision=12;try{await loadAdminMail(2)}catch(error){mark('admin-stale-error',error.message)}
mark('admin-cursor-after-stale',adminMailCursor);mark('admin-stale-revision',adminPagesAsked[1].revision);adminMailPages=[];await loadAdminMail(0);await loadAdminMail(2);
mark('admin-fresh-revision',adminPagesAsked[3].revision);mark('admin-last-owner',$('admin-mail-rows').textContent);mark('body-requests',calls.filter(c=>c.op==='mail.read').length);mark('done',true);
})().catch(error=>mark('js-error',error.message))});
''')
self.assertIn("changed", self.attribute(dom, "mail-stale-error"))
self.assertEqual(self.attribute(dom, "mail-cursor-after-stale"), "0")
self.assertEqual(self.attribute(dom, "mail-stale-revision"), "10")
self.assertEqual(self.attribute(dom, "mail-fresh-revision"), "11")
self.assertIn("Read 3", self.attribute(dom, "mail-last-page"))
self.assertIn("changed", self.attribute(dom, "admin-stale-error"))
self.assertEqual(self.attribute(dom, "admin-cursor-after-stale"), "0")
self.assertEqual(self.attribute(dom, "admin-stale-revision"), "11")
self.assertEqual(self.attribute(dom, "admin-fresh-revision"), "12")
self.assertIn("cd" * 32, self.attribute(dom, "admin-last-owner"))
self.assertEqual(self.attribute(dom, "body-requests"), "0")
def test_invalid_chunk_cannot_enable_receipt_or_replace_saved_bytes(self):
dom = self.run_page(MAIL_COMMON + r'''
const item={id:3,sender:senderKey,created:1001,length:6,acked:false};inbox=[item];let acks=0;
window.fetch=(url,options)=>{
const p=record(url,options);
if(p.op==='mail.read')return response({id:3,sender:otherOwner,created:1001,length:6,offset:0,count:6,next:6,data64:btoa('secret')});
if(p.op==='mail.ack'){acks++;return response({ok:true})}
const result=mailBase(p);if(result)return result;throw Error('unexpected '+p.op);
};
window.addEventListener('load',()=>{(async()=>{
await joinedMailRoom();await openMailbox();try{await openMail(item)}catch(error){mark('read-error',error.message)}
try{await removeMail(true)}catch(error){mark('receipt-error',error.message)}
mark('offset',mailTransfers.get(3).next);mark('body',$('mail-body').textContent);mark('ack-disabled',$('mail-ack').disabled);mark('acks',acks);mark('done',true);
})().catch(error=>mark('js-error',error.message))});
''')
self.assertIn("invalid", self.attribute(dom, "read-error"))
self.assertIn("complete", self.attribute(dom, "receipt-error"))
self.assertEqual(self.attribute(dom, "offset"), "0")
self.assertEqual(self.attribute(dom, "body"), "")
self.assertEqual(self.attribute(dom, "ack-disabled"), "true")
self.assertEqual(self.attribute(dom, "acks"), "0")
if __name__ == "__main__":
unittest.main()
+7 -1
View File
@@ -30,7 +30,12 @@ class RoomTopicDeliveryTests(unittest.TestCase):
"bool MyMesh::sendClientReply(",
"bool MyMesh::pushPostToClient(", "bool MyMesh::pushRoomTextToClient(",
"bool MyMesh::processAck(", "void MyMesh::activateRoomTopic(",
"bool MyMesh::handleRoomTopicCommand(", "void MyMesh::serviceRoomPush("))
"bool MyMesh::handleRoomTopicCommand(", "void MyMesh::serviceRoomPush(",
"uint8_t MyMesh::getUnsyncedCount("))
# The legacy topic fixture has a separate adapter for topic-only saves.
# Mail policy uses its real filesystem, without replacing its handler.
definitions += "\n" + extract_braced(source, "bool MyMesh::roomClientChatEnabled(").replace(
"getRoomMailboxOnly(_fs,", "getRoomMailboxOnly(&policy_fs,")
packet = (ROOT / "src/Packet.cpp").read_text()
definitions = "namespace mesh {\n" + extract_braced(
packet, "bool Packet::isValidPathLen(") + "\n}\n" + definitions
@@ -41,6 +46,7 @@ class RoomTopicDeliveryTests(unittest.TestCase):
binary = work / "delivery"
command = [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-parameter", "-I" + str(work), "-I" + str(ROOT / "src"),
"-I" + str(ROOT / "test/mocks"),
str(FIXTURE), "-o", str(binary)]
if sys.platform.startswith("linux"):
command[1:1] = ["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
+4 -2
View File
@@ -34,7 +34,7 @@ class RoomWebServiceTest(unittest.TestCase):
"bool MyMesh::executeClientPathCommand(", "bool MyMesh::setRoomClientPath(",
"bool MyMesh::applyRoomCatchUpCommand(", "bool MyMesh::saveFilter(",
"void MyMesh::writeRoomClientJson(",
"uint8_t MyMesh::getUnsyncedCount(", "bool MyMesh::handleRoomHistoryCommand(",
"bool MyMesh::roomClientChatEnabled(", "uint8_t MyMesh::getUnsyncedCount(", "bool MyMesh::handleRoomHistoryCommand(",
"bool MyMesh::handleRoomTopicCommand(", "void MyMesh::activateRoomTopic("))
packet = (ROOT / "src/Packet.cpp").read_text()
production = "namespace mesh {\n" + extract_braced(
@@ -73,6 +73,7 @@ class RoomWebServiceTest(unittest.TestCase):
"-Wno-misleading-indentation", *sanitizers, "-DESP32_PLATFORM=1",
"-DARDUINOJSON_POOL_CAPACITY=64",
"-I" + str(work), "-I" + str(ROOT / "src"), "-I" + str(json_header.parent),
"-I" + str(ROOT / "test/mocks"),
str(FIXTURES / "service.cpp"), "-o", str(binary)],
capture_output=True, text=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
@@ -80,7 +81,8 @@ class RoomWebServiceTest(unittest.TestCase):
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
self.assertIn("actual room web service", checked.stdout)
for group in ("ADMIN_AUTH", "ADMIN_ROUTES", "ADMIN_CATCHUP", "ADMIN_USERS",
"ADMIN_SETTINGS", "ADMIN_INVALID"):
"ADMIN_SETTINGS", "ADMIN_INVALID", "MAIL_AUTH", "MAIL_DELIVERY",
"MAIL_ADMIN", "MAIL_PAGING", "MAIL_STORAGE", "MAIL_GLOBAL"):
self.assertRegex(checked.stdout, r"(?m)^" + group + ":")
for kind in ("STATUS", "INDEX", "READ"):
match = re.search(r"^BOUNDARY_" + kind + r" (.+)$", checked.stdout, re.M)