mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-22 11:12:26 +00:00
Let room users manage their own return routes
This commit is contained in:
1 parent
5cb8367d12
commit
0eb1d03a16
15 files changed
+1098
-54
No files matched your search
@@ -121,6 +121,7 @@ jobs:
|
||||
python3 -B test/test_room_post_permissions.py -v
|
||||
python3 -B test/test_room_login_authorization.py -v
|
||||
python3 -B test/test_room_login_integration.py -v
|
||||
python3 -B test/test_room_client_paths.py -v
|
||||
python3 -B test/test_room_topic_store.py -v
|
||||
python3 -B test/test_room_topic_delivery.py -v
|
||||
python3 -B test/test_room_acl_persistence.py -v
|
||||
|
||||
@@ -193,8 +193,8 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
|
||||
| Routing | [`get/set/del flood.channel.scope.require*`](cli_commands.md#require-valid-incoming-scopes-only-on-selected-channels) | Repeater | Yes | Yes | Yes |
|
||||
| Routing | [`get/set/del flood.rule*`; `get/set/del flood.filter*`; `get/set/del flood.filter.blacklist*`](cli_commands.md#change-persistent-flood-rules-in-the-field) | Repeater; `flood.rule`/`flood.filter` also on FULL ESP32 room server (no blacklist) | Yes | Yes | Yes |
|
||||
| Routing | [`get/set/del flood.moderation*`](cli_commands.md#moderate-flood-group-text-by-channel-sender-and-source-path) | Repeater | Yes | Yes | Yes |
|
||||
| Routing | [`get/set outpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater remote-client context | Yes | Yes | Yes |
|
||||
| Routing | [`get/set altpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater remote-client context | Yes | Yes | Yes |
|
||||
| Routing | [`get/set outpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater or [room user](room_services.md#your-own-return-routes), own remote-client context | Yes | Yes | Yes |
|
||||
| Routing | [`get/set altpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater or [room user](room_services.md#your-own-return-routes), own remote-client context | Yes | Yes | Yes |
|
||||
| ACL | [`setperm <pubkey> <permissions>`](cli_commands.md#add-update-or-remove-permissions-for-a-companion) | Repeater, room server, or sensor | Yes | Yes | Yes |
|
||||
| ACL | [`get acl [page]`](cli_commands.md#view-the-current-acl) | Repeater, room server, or sensor; admin over LoRa | Yes | Yes | Yes |
|
||||
| ACL | [`get/set allow.read.only`](cli_commands.md#view-or-change-this-room-servers-read-only-flag) | Room server | Yes | Yes | No |
|
||||
@@ -369,8 +369,8 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
|
||||
| Routing | [`get/set/del flood.channel.scope.require*`](cli_commands.md#require-valid-incoming-scopes-only-on-selected-channels) | Repeater role handler | Yes | Yes | Yes | Yes | Yes |
|
||||
| Routing | [`get/set/del flood.rule*`; `get/set/del flood.filter*`; `get/set/del flood.filter.blacklist*`](cli_commands.md#change-persistent-flood-rules-in-the-field) | Repeater role handler; `flood.rule`/`flood.filter` also on FULL ESP32 room server (no blacklist) | Yes | Yes | Yes | Yes | Yes |
|
||||
| Routing | [`get/set/del flood.moderation*`](cli_commands.md#moderate-flood-group-text-by-channel-sender-and-source-path) | Repeater role handler | Yes | Yes | Yes | Yes | Yes |
|
||||
| Routing | [`get/set outpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater remote-client context | Yes | Yes | Yes | Yes | Yes |
|
||||
| Routing | [`get/set altpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater remote-client context | Yes | Yes | Yes | Yes | Yes |
|
||||
| Routing | [`get/set outpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater or [room user](room_services.md#your-own-return-routes), own remote-client context | Yes | Yes | Yes | Yes | Yes |
|
||||
| Routing | [`get/set altpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater or [room user](room_services.md#your-own-return-routes), own remote-client context | Yes | Yes | Yes | Yes | Yes |
|
||||
| ACL | [`setperm <pubkey> <permissions>`](cli_commands.md#add-update-or-remove-permissions-for-a-companion) | Repeater, room server, or sensor | Yes | Yes | Yes | Yes | Yes |
|
||||
| ACL | [`get acl [page]`](cli_commands.md#view-the-current-acl) | Repeater, room server, or sensor; admin over LoRa | Yes | Yes | Yes | Yes | Yes |
|
||||
| ACL | [`get/set allow.read.only`](cli_commands.md#view-or-change-this-room-servers-read-only-flag) | Room server | Yes | Yes | No | Yes | Yes |
|
||||
|
||||
@@ -2452,6 +2452,9 @@ The pin number is the Arduino pin number used by that target (the normal GPIO nu
|
||||
|
||||
**Notes:**
|
||||
- These commands require remote client context and update the caller's ACL entry.
|
||||
- Room Server readers and writers may manage their own `outpath` and `altpath`
|
||||
without administrator permission. See [room return routes](room_services.md#your-own-return-routes)
|
||||
for the command list, session persistence and alternate delivery behavior.
|
||||
- `get outpath path` reports the reciprocal `PAYLOAD_TYPE_PATH` received after
|
||||
the caller's latest flood login without changing the selected output route.
|
||||
Because that packet is asynchronous, an immediate query can report
|
||||
|
||||
@@ -203,6 +203,10 @@ rows; an unpaged local serial query prints the full table.
|
||||
`outpath` and `altpath` apply to the current remote client ACL entry. They need
|
||||
remote client context, so they are not useful from the local serial CLI.
|
||||
|
||||
Room Server also supports these commands for readers and writers, restricted
|
||||
to the caller's own routes. See [room return routes](room_services.md#your-own-return-routes)
|
||||
for session persistence, alternate chat delivery and client requirements.
|
||||
|
||||
Set paths with comma-separated hop hashes. Each hop must be `2`, `4`, or `6`
|
||||
hex characters, and all hops in one path must use the same width. Hex input is
|
||||
case-insensitive. Replies use uppercase hex and retain the commas, so the value
|
||||
|
||||
@@ -101,6 +101,65 @@ Rejected posts do not enter the retry cache.
|
||||
Changing to a different identity creates a different user. These controls
|
||||
complement forwarding and airtime policies.
|
||||
|
||||
## Your own return routes
|
||||
|
||||
Room readers, writers and administrators can use a private CLI request to
|
||||
inspect or change the room's route back to their own radio:
|
||||
|
||||
~~~text
|
||||
get outpath
|
||||
get outpath path
|
||||
set outpath A1B2C3,D4E5F6
|
||||
set outpath path
|
||||
set outpath direct
|
||||
set outpath clear
|
||||
set outpath flood
|
||||
get altpath
|
||||
set altpath 71CE82,BA09F0
|
||||
set altpath direct
|
||||
set altpath clear
|
||||
~~~
|
||||
|
||||
Each hop is 2, 4 or 6 hexadecimal characters; all hops in a path use the same
|
||||
width. `direct` means zero repeaters. `clear` enables normal discovery again.
|
||||
`outpath flood` forces flood replies until you change that selection. The
|
||||
first command word is case-insensitive, and an optional Companion CLI prefix
|
||||
is preserved in the reply.
|
||||
|
||||
These commands always affect the authenticated requesting identity. They
|
||||
cannot select another user's public key, change room settings, grant roles,
|
||||
read passwords or run other administrative commands. Active public read-only
|
||||
sessions can manage their own routes too. Bans still block access, and management
|
||||
roles alone do not grant room-user command access.
|
||||
|
||||
Commands require encrypted CLI message framing. Typing them as ordinary room
|
||||
chat posts sends chat text instead. Use a CLI-capable client; this feature does
|
||||
not grant administrator status or add a non-admin command screen to the mobile
|
||||
app. No new protocol message type is required.
|
||||
|
||||
`get outpath path` reports the reciprocal route observed after the most recent
|
||||
flood login. It can return `> path pending` during its one-minute observation
|
||||
window. The read does not change the selected route; ordinary room discovery
|
||||
still learns a live route automatically. A route explicitly selected while
|
||||
waiting is protected from the delayed login PATH. `set outpath path` selects
|
||||
the observed route, or leaves the selection unchanged if none is available.
|
||||
|
||||
When the primary and alternate routes are valid and different, chat, topic
|
||||
updates and CLI/data replies send one encrypted copy along each route. The
|
||||
primary owns ACK tracking, catch-up and retries; the alternate has no separate
|
||||
retry loop. Identical routes send one copy. A missing packet for the alternate
|
||||
does not prevent primary delivery. A flood primary does not send an alternate
|
||||
copy; `altpath flood` also disables that secondary copy, so prefer
|
||||
`set altpath clear` when removing it. Keep-alive and post ACKs retain their
|
||||
existing primary-route behavior.
|
||||
|
||||
Routes are saved for already-retained ACL entries, including explicit readers
|
||||
and writers and password-created administrators. Ordinary guest-password and
|
||||
public-reader sessions keep route selections only for the current session;
|
||||
changing a route never makes their permissions permanent. Failed saves restore
|
||||
the previous route and report an error. Repeating an unchanged selection or an
|
||||
exact request retry does not cause another settings write.
|
||||
|
||||
## Local Wi-Fi chat on ESP32
|
||||
|
||||
ESP32 Room Server images with WebConfig have an offline browser room page.
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
#include <helpers/RoomTopicStore.h>
|
||||
#include <helpers/RoomBoardProtocol.h>
|
||||
#include <helpers/ClientPathPersistence.h>
|
||||
#include <helpers/ClientPathObservation.h>
|
||||
#include <helpers/RoomClientPathCommand.h>
|
||||
#include <helpers/LazyPersistence.h>
|
||||
#if MESH_PACKET_LOGGING
|
||||
#include <helpers/SerialPacketLog.h>
|
||||
@@ -452,6 +454,134 @@ void MyMesh::processRoomRequest(const uint8_t* token, uint32_t sequence, char* r
|
||||
}
|
||||
#endif
|
||||
|
||||
bool MyMesh::sendClientReply(ClientInfo* client, mesh::Packet* packet,
|
||||
unsigned long delay_millis, uint8_t path_hash_size) {
|
||||
if (packet == NULL) return false;
|
||||
if (client == NULL || !mesh::Packet::isValidPathLen(client->out_path_len)) {
|
||||
return sendFloodReply(packet, delay_millis, path_hash_size);
|
||||
}
|
||||
|
||||
mesh::Packet* alternate = NULL;
|
||||
if (mesh::Packet::isValidPathLen(client->alt_path_len)
|
||||
&& (client->alt_path_len != client->out_path_len
|
||||
|| memcmp(client->alt_path, client->out_path,
|
||||
mesh::encodedClientPathByteLength(client->out_path_len)) != 0)) {
|
||||
alternate = obtainNewPacket();
|
||||
if (alternate != NULL) *alternate = *packet;
|
||||
}
|
||||
const bool sent = sendDirect(packet, client->out_path, client->out_path_len,
|
||||
delay_millis);
|
||||
if (alternate != NULL) {
|
||||
if (sent) {
|
||||
// The primary owns retry and delivery state; the extra route is one copy.
|
||||
const uint8_t retries = _prefs.direct_retry_enabled;
|
||||
_prefs.direct_retry_enabled = 0;
|
||||
sendDirect(alternate, client->alt_path, client->alt_path_len, delay_millis);
|
||||
_prefs.direct_retry_enabled = retries;
|
||||
} else {
|
||||
releasePacket(alternate);
|
||||
}
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
bool MyMesh::handleClientPathCommand(ClientInfo* client, char* command, char* reply) {
|
||||
char* body = mesh::normalizeRoomClientPathCommand(command, reply);
|
||||
const mesh::RoomClientPathCommand operation = mesh::classifyRoomClientPathCommand(body);
|
||||
if (operation == mesh::RoomClientPathCommand::None) return false;
|
||||
if (strlen(command) > 4 && command[2] == '|') reply += 3;
|
||||
// Leading whitespace can precede the same single Companion prefix.
|
||||
else {
|
||||
const char* start = command;
|
||||
while (*start == ' ' || *start == '\t') start++;
|
||||
if (strlen(start) > 4 && start[2] == '|') reply += 3;
|
||||
}
|
||||
if (client == NULL) {
|
||||
strcpy(reply, "Err - command needs remote client context");
|
||||
return true;
|
||||
}
|
||||
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))) {
|
||||
strcpy(reply, "Err - room user permission required");
|
||||
return true;
|
||||
}
|
||||
const bool alternate = operation == mesh::RoomClientPathCommand::GetAlt
|
||||
|| operation == mesh::RoomClientPathCommand::SetAlt;
|
||||
const bool observed = operation == mesh::RoomClientPathCommand::GetObserved
|
||||
|| operation == mesh::RoomClientPathCommand::SetObserved;
|
||||
const bool setting = operation == mesh::RoomClientPathCommand::SetOut
|
||||
|| operation == mesh::RoomClientPathCommand::SetObserved
|
||||
|| operation == mesh::RoomClientPathCommand::SetAlt;
|
||||
uint8_t* selected = alternate ? client->alt_path : client->out_path;
|
||||
uint8_t* selected_length = alternate ? &client->alt_path_len : &client->out_path_len;
|
||||
if (observed && mesh::isObservedClientPathPending(*client, futureMillis(0))) {
|
||||
strcpy(reply, setting ? "Err - path pending" : "> path pending");
|
||||
return true;
|
||||
}
|
||||
if (!setting) {
|
||||
mesh::formatRoomClientPathReply(observed ? client->observed_path : selected,
|
||||
observed ? client->observed_path_len : *selected_length, reply, 157);
|
||||
return true;
|
||||
}
|
||||
|
||||
uint8_t path[MAX_PATH_SIZE] = {};
|
||||
uint8_t length = OUT_PATH_UNKNOWN;
|
||||
if (observed) {
|
||||
length = client->observed_path_len;
|
||||
if (!mesh::isValidObservedClientPathLength(length, sizeof(path))) {
|
||||
strcpy(reply, "Err - no path received");
|
||||
return true;
|
||||
}
|
||||
memcpy(path, client->observed_path, mesh::observedClientPathByteLength(length));
|
||||
} else {
|
||||
const char* spec = mesh::cli::skipRecentRepeaterSpaces(body + 11);
|
||||
if (mesh::cli::terminalPathKeywordMatches(spec, "flood")) length = OUT_PATH_FORCE_FLOOD;
|
||||
else if (mesh::cli::terminalPathKeywordMatches(spec, "none")
|
||||
|| mesh::cli::terminalPathKeywordMatches(spec, "-")) length = OUT_PATH_UNKNOWN;
|
||||
else {
|
||||
mesh::cli::TerminalPath parsed;
|
||||
if (mesh::cli::parseTerminalPath(spec, path, sizeof(path), 63, parsed)
|
||||
!= mesh::cli::TerminalPathParseResult::Valid) {
|
||||
strcpy(reply, "Err - invalid path");
|
||||
return true;
|
||||
}
|
||||
length = parsed.mode == mesh::cli::TerminalPathMode::Clear
|
||||
? OUT_PATH_UNKNOWN : parsed.encoded_len;
|
||||
}
|
||||
}
|
||||
const size_t path_bytes = mesh::Packet::isValidPathLen(length)
|
||||
? mesh::encodedClientPathByteLength(length) : 0;
|
||||
const bool same = length == *selected_length
|
||||
&& (path_bytes == 0 || memcmp(path, selected, path_bytes) == 0)
|
||||
&& (alternate || client->out_path_is_persistable);
|
||||
if (!same) {
|
||||
const uint8_t previous_length = *selected_length;
|
||||
uint8_t previous[MAX_PATH_SIZE];
|
||||
memcpy(previous, selected, sizeof(previous));
|
||||
const bool was_persistable = client->out_path_is_persistable;
|
||||
memcpy(selected, path, sizeof(path));
|
||||
*selected_length = length;
|
||||
if (!alternate) client->out_path_is_persistable = true;
|
||||
// A route never promotes a password-derived session into a saved ACL role.
|
||||
// Only already-retained entries write through the room's existing filter.
|
||||
if (saveFilter(client)) {
|
||||
if (!acl.save(_fs, MyMesh::saveFilter)) {
|
||||
memcpy(selected, previous, sizeof(previous));
|
||||
*selected_length = previous_length;
|
||||
client->out_path_is_persistable = was_persistable;
|
||||
strcpy(reply, "Err - path save failed");
|
||||
return true;
|
||||
}
|
||||
mesh::resetLazyPersistenceAfterSuccess(dirty_contacts_expiry, contacts_save_failures);
|
||||
}
|
||||
}
|
||||
if (!alternate && length == OUT_PATH_UNKNOWN) strcpy(reply, "> outpath cleared");
|
||||
else mesh::formatRoomClientPathReply(selected, *selected_length, reply, 157);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MyMesh::pushRoomTextToClient(ClientInfo *client, uint32_t timestamp,
|
||||
const mesh::Identity& author, const char* text,
|
||||
uint32_t topic_revision) {
|
||||
@@ -495,7 +625,7 @@ bool MyMesh::pushRoomTextToClient(ClientInfo *client, uint32_t timestamp,
|
||||
client->extra.room.ack_timeout = futureMillis(PUSH_ACK_TIMEOUT_FLOOD);
|
||||
}
|
||||
} else {
|
||||
sent = sendDirect(reply, client->out_path, client->out_path_len);
|
||||
sent = sendClientReply(client, reply, 0, _prefs.path_hash_mode + 1);
|
||||
|
||||
if (sent) {
|
||||
uint8_t path_hash_count = client->out_path_len & 63;
|
||||
@@ -1217,6 +1347,7 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m
|
||||
*client, client_existed, perm, PERM_ACL_ROLE_MASK,
|
||||
secret, sender_timestamp, getRTCClock()->getCurrentTime(),
|
||||
reset_out_path, OUT_PATH_UNKNOWN);
|
||||
mesh::clearObservedClientPath(*client, OUT_PATH_UNKNOWN);
|
||||
client->extra.room.sync_since = sender_sync_since;
|
||||
client->extra.room.pending_ack = 0;
|
||||
client->extra.room.push_failures = 0;
|
||||
@@ -1252,7 +1383,9 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m
|
||||
// let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response
|
||||
mesh::Packet *path = createPathReturn(sender, client->shared_secret, packet->path, packet->path_len,
|
||||
PAYLOAD_TYPE_RESPONSE, reply_data, 13);
|
||||
if (path) sendFloodReply(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize());
|
||||
if (path && sendFloodReply(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize())) {
|
||||
mesh::beginObservedClientPath(*client, OUT_PATH_UNKNOWN, futureMillis(60000));
|
||||
}
|
||||
} else {
|
||||
mesh::Packet *reply = createDatagram(PAYLOAD_TYPE_RESPONSE, sender, client->shared_secret, reply_data, 13);
|
||||
if (reply) {
|
||||
@@ -1344,6 +1477,10 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx,
|
||||
}
|
||||
#endif
|
||||
if (type == PAYLOAD_TYPE_TXT_MSG && len > 5) { // a CLI command or new Post
|
||||
// An inactive unassigned entry cannot create a reader session by sending
|
||||
// rejected commands that would otherwise refresh last_activity below.
|
||||
if ((client->permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST
|
||||
&& client->last_activity == 0) return;
|
||||
uint32_t sender_timestamp;
|
||||
memcpy(&sender_timestamp, data, 4); // timestamp (by sender's RTC clock - which could be wrong)
|
||||
uint8_t flags = (data[4] >> 2); // message attempt number, and other flags
|
||||
@@ -1406,7 +1543,22 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx,
|
||||
client->last_timestamp = sender_timestamp;
|
||||
}
|
||||
|
||||
if (cached_retry) {
|
||||
// Cached results must obey current permissions too. A former admin may
|
||||
// recover an own-route reply, but cannot replay cached privileged output.
|
||||
char normalized[mesh::RemoteCliReplyCache::MAX_REPLY_TEXT + 1];
|
||||
StrHelper::strncpy(normalized, text, sizeof(normalized));
|
||||
char prefix[4] = {};
|
||||
const char* body = mesh::normalizeRoomClientPathCommand(normalized, prefix);
|
||||
const uint8_t role = client->permissions & PERM_ACL_ROLE_MASK;
|
||||
const bool own_path_allowed =
|
||||
mesh::classifyRoomClientPathCommand(body) != mesh::RoomClientPathCommand::None
|
||||
&& (role == PERM_ACL_READ_ONLY || role == PERM_ACL_READ_WRITE
|
||||
|| role == PERM_ACL_ADMIN
|
||||
|| (role == PERM_ACL_GUEST && client->last_activity != 0));
|
||||
if (cached_retry && !client->isAdmin() && !own_path_allowed) {
|
||||
strcpy((char*)&temp[5], "Err - admin permission required");
|
||||
temp[4] = (TXT_TYPE_CLI_DATA << 2);
|
||||
} else if (cached_retry) {
|
||||
MESH_DEBUG_PRINTLN("onPeerDataRecv: replaying cached remote CLI reply");
|
||||
size_t cached_len = strlen(cached_response);
|
||||
if (cached_len > mesh::RemoteCliReplyCache::MAX_REPLY_TEXT) {
|
||||
@@ -1418,21 +1570,21 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx,
|
||||
} else if (repeated_timestamp) {
|
||||
MESH_DEBUG_PRINTLN("onPeerDataRecv: duplicate remote CLI request has no cached reply");
|
||||
return;
|
||||
} else if (client->isAdmin()) {
|
||||
handleCommand(sender_timestamp, (char*)text, (char*)&temp[5],
|
||||
i, packet->getPathHashSize());
|
||||
} else {
|
||||
if (!handleClientPathCommand(client, (char*)text, (char*)&temp[5])) {
|
||||
if (client->isAdmin()) {
|
||||
handleCommand(sender_timestamp, (char*)text, (char*)&temp[5],
|
||||
i, packet->getPathHashSize());
|
||||
} else {
|
||||
strcpy((char*)&temp[5], "Err - admin permission required");
|
||||
}
|
||||
}
|
||||
temp[5 + mesh::RemoteCliReplyCache::MAX_REPLY_TEXT] = 0;
|
||||
if (temp[5] == 0) strcpy((char*)&temp[5], "OK");
|
||||
remote_cli_reply_cache.remember(client->id.pub_key, request_id,
|
||||
command_fingerprint,
|
||||
(char*)&temp[5]);
|
||||
temp[4] = (TXT_TYPE_CLI_DATA << 2);
|
||||
} else {
|
||||
const char* error = "Err - admin permission required";
|
||||
strcpy((char*)&temp[5], error);
|
||||
remote_cli_reply_cache.remember(client->id.pub_key, request_id,
|
||||
command_fingerprint, error);
|
||||
temp[4] = (TXT_TYPE_CLI_DATA << 2);
|
||||
}
|
||||
// CLI_DATA replies are the result signal; no separate ACK is expected.
|
||||
}
|
||||
@@ -1482,13 +1634,8 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx,
|
||||
auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret,
|
||||
temp, 5 + reply_text_len);
|
||||
if (reply) {
|
||||
if (!mesh::Packet::isValidPathLen(client->out_path_len)) {
|
||||
sendFloodReply(reply, delay_millis + SERVER_RESPONSE_DELAY,
|
||||
sendClientReply(client, reply, delay_millis + SERVER_RESPONSE_DELAY,
|
||||
packet->getPathHashSize());
|
||||
} else {
|
||||
sendDirect(reply, client->out_path, client->out_path_len,
|
||||
delay_millis + SERVER_RESPONSE_DELAY);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (type == PAYLOAD_TYPE_REQ && len >= 5) {
|
||||
@@ -1576,11 +1723,8 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx,
|
||||
} else {
|
||||
mesh::Packet *reply = createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len);
|
||||
if (reply) {
|
||||
if (mesh::Packet::isValidPathLen(client->out_path_len)) { // we have an out_path, so send DIRECT
|
||||
sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY);
|
||||
} else {
|
||||
sendFloodReply(reply, SERVER_RESPONSE_DELAY, packet->getPathHashSize());
|
||||
}
|
||||
sendClientReply(client, reply, SERVER_RESPONSE_DELAY,
|
||||
packet->getPathHashSize());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1598,15 +1742,22 @@ bool MyMesh::onPeerPathRecv(mesh::Packet *packet, int sender_idx, const uint8_t
|
||||
MESH_DEBUG_PRINTLN("PATH to client, path_len=%d", (uint32_t)path_len);
|
||||
auto client = acl.getClientByIdx(i);
|
||||
if (!room_access.allowsIdentity(client->id.pub_key)) return false;
|
||||
const bool captured_login_path = packet->isRouteDirect() && extra_type == 0x0F
|
||||
&& mesh::captureObservedClientPath(*client, path, path_len, futureMillis(0));
|
||||
// PATH has no durable replay freshness signal. Keep it RAM-only and do not
|
||||
// let a replay replace an operator-selected force-flood route.
|
||||
const bool persistence_allowed = mesh::clientPathPersistenceAllowed(
|
||||
MyMesh::saveFilter(client), false /* no durable replay proof */);
|
||||
const mesh::ClientPathUpdateResult path_update =
|
||||
mesh::applyReceivedClientPath(
|
||||
*client, path, path_len, persistence_allowed,
|
||||
OUT_PATH_FORCE_FLOOD);
|
||||
(void)path_update;
|
||||
// Preserve normal room-app discovery, but do not let the delayed login
|
||||
// PATH overwrite a route the user explicitly selected in the meantime.
|
||||
if (!captured_login_path || !client->out_path_is_persistable
|
||||
|| !mesh::Packet::isValidPathLen(client->out_path_len)) {
|
||||
const bool persistence_allowed = mesh::clientPathPersistenceAllowed(
|
||||
MyMesh::saveFilter(client), false /* no durable replay proof */);
|
||||
const mesh::ClientPathUpdateResult path_update =
|
||||
mesh::applyReceivedClientPath(
|
||||
*client, path, path_len, persistence_allowed,
|
||||
OUT_PATH_FORCE_FLOOD);
|
||||
(void)path_update;
|
||||
}
|
||||
client->last_activity = getRTCClock()->getCurrentTime();
|
||||
} else {
|
||||
MESH_DEBUG_PRINTLN("onPeerPathRecv: invalid peer idx: %d", i);
|
||||
@@ -2100,7 +2251,7 @@ bool MyMesh::resolveAlertScope(TransportKey& dest) {
|
||||
return false;
|
||||
}
|
||||
|
||||
void MyMesh::sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size) {
|
||||
bool MyMesh::sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size) {
|
||||
TransportKey req_scope;
|
||||
bool is_wildcard = recv_pkt_region != NULL && recv_pkt_region->isWildcard();
|
||||
bool req_scope_known = recv_pkt_region != NULL && !is_wildcard
|
||||
@@ -2108,17 +2259,15 @@ void MyMesh::sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, ui
|
||||
|
||||
switch (mesh::chooseReplyScope(req_scope_known, is_wildcard, !default_scope.isNull())) {
|
||||
case mesh::REPLY_SCOPE_REQUEST:
|
||||
sendFloodScoped(req_scope, packet, delay_millis, path_hash_size); // reply with same scope as request
|
||||
break;
|
||||
return sendFloodScoped(req_scope, packet, delay_millis, path_hash_size); // reply with same scope as request
|
||||
case mesh::REPLY_SCOPE_DEFAULT:
|
||||
// requester's scope is unknown: DIRECT request (no transport codes), or code matched no Region.
|
||||
// un-scoped would be dropped at hop 0 by repeaters running flood.max.unscoped=0
|
||||
sendFloodScoped(default_scope, packet, delay_millis, path_hash_size);
|
||||
break;
|
||||
return sendFloodScoped(default_scope, packet, delay_millis, path_hash_size);
|
||||
case mesh::REPLY_SCOPE_NONE:
|
||||
sendFlood(packet, delay_millis, path_hash_size); // send un-scoped
|
||||
break;
|
||||
return sendFlood(packet, delay_millis, path_hash_size); // send un-scoped
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void MyMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins, uint16_t preamble) {
|
||||
|
||||
@@ -466,7 +466,7 @@ protected:
|
||||
}
|
||||
#endif
|
||||
|
||||
void sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size);
|
||||
bool sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size);
|
||||
#if MESH_ENABLE_TELEMETRY_HISTORY
|
||||
bool handleTelemetryHistoryCommand(const char* command, char* reply);
|
||||
void sampleTelemetryHistory();
|
||||
@@ -598,6 +598,9 @@ public:
|
||||
void handleCommand(uint32_t sender_timestamp, char* command, char* reply,
|
||||
int gpio_client_index = -1,
|
||||
uint8_t gpio_path_hash_size = 1);
|
||||
bool handleClientPathCommand(ClientInfo* client, char* command, char* reply);
|
||||
bool sendClientReply(ClientInfo* client, mesh::Packet* packet,
|
||||
unsigned long delay_millis, uint8_t path_hash_size);
|
||||
bool _wireless_usb_command = false;
|
||||
void handleUsbCommand(char* command, char* reply) {
|
||||
_wireless_usb_command = true;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
#pragma once
|
||||
|
||||
#include "CLICommandUtils.h"
|
||||
#include "ClientPathPersistence.h"
|
||||
|
||||
namespace mesh {
|
||||
|
||||
enum class RoomClientPathCommand : uint8_t {
|
||||
None, GetOut, GetObserved, GetAlt, SetOut, SetObserved, SetAlt,
|
||||
};
|
||||
|
||||
// Normalize a private CLI request, not a room-chat post. A single Companion
|
||||
// correlation prefix is echoed; nested prefixes never enter the allowlist.
|
||||
inline char* normalizeRoomClientPathCommand(char* command, char* reply) {
|
||||
char* end = command + strlen(command);
|
||||
while (end > command && (end[-1] == ' ' || end[-1] == '\t'
|
||||
|| end[-1] == '\r' || end[-1] == '\n')) *--end = 0;
|
||||
while (*command == ' ' || *command == '\t') command++;
|
||||
if (strlen(command) > 4 && command[2] == '|') {
|
||||
memcpy(reply, command, 3);
|
||||
command += 3;
|
||||
}
|
||||
while (*command == ' ' || *command == '\t') command++;
|
||||
cli::normalizeCommandVerb(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
inline RoomClientPathCommand classifyRoomClientPathCommand(const char* command) {
|
||||
if (strcmp(command, "get outpath") == 0) return RoomClientPathCommand::GetOut;
|
||||
if (strcmp(command, "get outpath path") == 0) return RoomClientPathCommand::GetObserved;
|
||||
if (strcmp(command, "get altpath") == 0) return RoomClientPathCommand::GetAlt;
|
||||
if (strcmp(command, "set outpath path") == 0) return RoomClientPathCommand::SetObserved;
|
||||
if (strcmp(command, "set outpath") == 0 || strncmp(command, "set outpath ", 12) == 0)
|
||||
return RoomClientPathCommand::SetOut;
|
||||
if (strcmp(command, "set altpath") == 0 || strncmp(command, "set altpath ", 12) == 0)
|
||||
return RoomClientPathCommand::SetAlt;
|
||||
return RoomClientPathCommand::None;
|
||||
}
|
||||
|
||||
inline void formatRoomClientPathReply(const uint8_t* path, uint8_t length,
|
||||
char* reply, size_t capacity) {
|
||||
if (length == 0xfe) { snprintf(reply, capacity, "> flood"); return; }
|
||||
if (length == 0xff) { snprintf(reply, capacity, "> unknown"); return; }
|
||||
if (!isValidEncodedClientPathLength(length, 64)) {
|
||||
snprintf(reply, capacity, "> invalid"); return;
|
||||
}
|
||||
const size_t hops = length & 63;
|
||||
if (hops == 0) { snprintf(reply, capacity, "> direct"); return; }
|
||||
const size_t width = (length >> 6) + 1;
|
||||
const size_t bytes = hops * width;
|
||||
// Compact fallback remains complete when comma separators would not fit.
|
||||
const bool separated = bytes * 2 + hops + 2 <= capacity;
|
||||
if (bytes * 2 + 3 > capacity) {
|
||||
snprintf(reply, capacity, "> path too long"); return;
|
||||
}
|
||||
static const char hex[] = "0123456789ABCDEF";
|
||||
size_t position = 0;
|
||||
reply[position++] = '>'; reply[position++] = ' ';
|
||||
for (size_t i = 0; i < bytes; i++) {
|
||||
if (separated && i != 0 && i % width == 0) reply[position++] = ',';
|
||||
reply[position++] = hex[path[i] >> 4];
|
||||
reply[position++] = hex[path[i] & 15];
|
||||
}
|
||||
reply[position] = 0;
|
||||
}
|
||||
|
||||
} // namespace mesh
|
||||
@@ -33,6 +33,16 @@ static void emit(MyMesh& room, const char* group, unsigned index, int version, b
|
||||
WirePresentation app{version};
|
||||
app.queueMessage(from, TXT_TYPE_SIGNED_PLAIN, &packet, sent.timestamp,
|
||||
sent.author, 4, sent.text.c_str(), false, 0);
|
||||
if (strcmp(group, "ordered") == 0) {
|
||||
assert(room.sent.size() == 2 * (index + 1));
|
||||
const auto& primary = room.sent[room.sent.size() - 2];
|
||||
assert(primary.path[0] == 0xA1 && sent.path[0] == 0xB2);
|
||||
assert(primary.retry_enabled && !sent.retry_enabled);
|
||||
WirePresentation primary_app{version};
|
||||
primary_app.queueMessage(from, TXT_TYPE_SIGNED_PLAIN, &packet, primary.timestamp,
|
||||
primary.author, 4, primary.text.c_str(), false, 0);
|
||||
assert(primary_app.frame == app.frame);
|
||||
}
|
||||
assert(app.frame.size() == sent.text.size() + (version >= 3 ? 20 : 17));
|
||||
printf("POST:%s:%u:%d:%d:%u:", group, index, version, int(flood), sent.timestamp);
|
||||
hex(sent.author, 4); printf(":");
|
||||
@@ -44,6 +54,8 @@ int main() {
|
||||
{
|
||||
MyMesh room; room.topic("Welcome");
|
||||
auto& client = room.acl.clients[0]; client.extra.room.sync_since = 50;
|
||||
client.out_path_len = client.alt_path_len = 1;
|
||||
client.out_path[0] = 0xA1; client.alt_path[0] = 0xB2;
|
||||
room.post(0, 100, "old-one"); room.post(1, 200, "old-two");
|
||||
room.post(2, 1100, "newer"); room.clock.now = 1200;
|
||||
room.tick(6000); assert(room.sent.back().text == "old-one");
|
||||
|
||||
Vendored
+196
@@ -0,0 +1,196 @@
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include "filesystem.h"
|
||||
#include <Packet.h>
|
||||
#include <helpers/RoomAccessPolicy.h>
|
||||
#include <helpers/RoomClientPathCommand.h>
|
||||
#include <helpers/ClientPathObservation.h>
|
||||
#include <helpers/ClientPathPersistence.h>
|
||||
#include <helpers/RemoteCliReplyCache.h>
|
||||
#include <helpers/RemoteCliRequest.h>
|
||||
#include <helpers/ClientACLResponse.h>
|
||||
#include <helpers/LazyPersistence.h>
|
||||
#include <helpers/LogicalMessageCache.h>
|
||||
#include <helpers/TxtDataHelpers.h>
|
||||
|
||||
@CONSTANTS@
|
||||
@STR_HELPER@
|
||||
#define MESH_CLIENT_REPEATER_ONLY 0
|
||||
namespace mesh {
|
||||
struct Identity { uint8_t pub_key[PUB_KEY_SIZE]; };
|
||||
struct Utils {
|
||||
static void sha256(uint8_t* out, size_t n, const uint8_t* left, size_t ln,
|
||||
const uint8_t* right, size_t rn) {
|
||||
memset(out, 0, n);
|
||||
for (size_t i = 0; i < ln; ++i) out[i % n] ^= left[i];
|
||||
for (size_t i = 0; i < rn; ++i) out[i % n] ^= right[i];
|
||||
}
|
||||
};
|
||||
@PACKET_METHODS@
|
||||
}
|
||||
@CLIENT_INFO@;
|
||||
|
||||
struct FakeACL {
|
||||
ClientInfo clients[2]{};
|
||||
ClientInfo durable[2]{};
|
||||
bool retained[2]{};
|
||||
bool save_ok = true;
|
||||
unsigned saves = 0;
|
||||
int getNumClients() const { return 2; }
|
||||
ClientInfo* getClientByIdx(int i) { assert(i >= 0 && i < 2); return &clients[i]; }
|
||||
bool save(MemoryFS* fs, bool (*filter)(ClientInfo*)) {
|
||||
++saves;
|
||||
assert(fs != nullptr && filter != nullptr);
|
||||
if (!save_ok) return false;
|
||||
for (unsigned i = 0; i < 2; ++i) {
|
||||
retained[i] = filter(&clients[i]);
|
||||
if (retained[i]) durable[i] = clients[i];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
struct FakeClock {
|
||||
uint32_t now = 1000;
|
||||
uint32_t serial = 0;
|
||||
uint32_t getCurrentTime() const { return now; }
|
||||
uint32_t getCurrentTimeUnique() { return now + ++serial; }
|
||||
};
|
||||
struct FakeRng {
|
||||
void random(uint8_t* data, size_t n) { memset(data, 0xB5, n); }
|
||||
};
|
||||
static uint32_t ticks = 100;
|
||||
static uint32_t millis() { return ticks; }
|
||||
struct Sent {
|
||||
mesh::Packet packet;
|
||||
uint8_t path[MAX_PATH_SIZE]{};
|
||||
uint8_t path_len = 0;
|
||||
unsigned long delay = 0;
|
||||
bool direct = true;
|
||||
bool retry_enabled = false;
|
||||
};
|
||||
struct MyMesh {
|
||||
FakeACL acl;
|
||||
MemoryFS policy_fs;
|
||||
MemoryFS* _fs = &policy_fs;
|
||||
mesh::RoomAccessPolicy room_access;
|
||||
mesh::RemoteCliReplyCache remote_cli_reply_cache;
|
||||
mesh::LogicalMessageCache<ROOM_MESSAGE_CACHE_SIZE> recent_room_posts;
|
||||
mesh::LogicalMessageCache<ROOM_MESSAGE_CACHE_SIZE> recent_room_polls;
|
||||
int matching_peer_indexes[2] = {0, 1};
|
||||
unsigned long dirty_contacts_expiry = 0;
|
||||
uint8_t contacts_save_failures = 0;
|
||||
FakeClock clock;
|
||||
FakeRng rng;
|
||||
uint8_t reply_data[MAX_PACKET_PAYLOAD]{};
|
||||
mesh::Packet pool[64];
|
||||
unsigned used = 0, allocations = 0, releases = 0, fail_allocation = UINT32_MAX;
|
||||
bool send_ok = true;
|
||||
unsigned general_commands = 0, posts = 0, retry_replacements = 0, _num_post_pushes = 0;
|
||||
uint32_t retry_timestamp = 0;
|
||||
mesh::Packet retry_packet;
|
||||
std::vector<Sent> sent;
|
||||
uint16_t default_scope = 0;
|
||||
struct Prefs {
|
||||
uint8_t direct_retry_enabled = 1;
|
||||
uint8_t path_hash_mode = 0;
|
||||
} _prefs;
|
||||
MyMesh() {
|
||||
metadata_filesystem = &policy_fs;
|
||||
assert(room_access.load(&policy_fs));
|
||||
for (unsigned i = 0; i < 2; ++i) {
|
||||
ClientInfo& c = acl.clients[i];
|
||||
memset(&c, 0, sizeof(c));
|
||||
memset(c.id.pub_key, i + 1, PUB_KEY_SIZE);
|
||||
memset(c.shared_secret, i + 0x40, PUB_KEY_SIZE);
|
||||
c.permissions = PERM_ACL_READ_WRITE;
|
||||
c.out_path_len = 1;
|
||||
c.out_path[0] = i + 0xA1;
|
||||
c.alt_path_len = OUT_PATH_UNKNOWN;
|
||||
c.observed_path_len = OUT_PATH_UNKNOWN;
|
||||
c.last_timestamp = 99;
|
||||
}
|
||||
}
|
||||
FakeClock* getRTCClock() { return &clock; }
|
||||
FakeRng* getRNG() { return &rng; }
|
||||
unsigned long futureMillis(unsigned long delay) { return ticks + delay; }
|
||||
mesh::Packet* obtainNewPacket() {
|
||||
if (++allocations == fail_allocation) return nullptr;
|
||||
assert(used < 64);
|
||||
return &pool[used++];
|
||||
}
|
||||
void releasePacket(mesh::Packet* p) { assert(p != nullptr); ++releases; }
|
||||
mesh::Packet* createDatagram(uint8_t type, const mesh::Identity&, const uint8_t*,
|
||||
const uint8_t* data, size_t n) {
|
||||
auto p = obtainNewPacket();
|
||||
if (p) {
|
||||
p->header = type << PH_TYPE_SHIFT;
|
||||
p->payload_len = n;
|
||||
memcpy(p->payload, data, n);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
mesh::Packet* createPathReturn(const mesh::Identity& id, const uint8_t* secret,
|
||||
const uint8_t*, uint8_t, uint8_t type, const uint8_t* data, size_t n) {
|
||||
return createDatagram(type, id, secret, data, n);
|
||||
}
|
||||
mesh::Packet* createAck(uint32_t ack) {
|
||||
mesh::Identity empty{}; uint8_t secret[PUB_KEY_SIZE]{};
|
||||
return createDatagram(PAYLOAD_TYPE_ACK, empty, secret, (const uint8_t*)&ack, 4);
|
||||
}
|
||||
mesh::Packet* createMultiAck(uint32_t ack, uint8_t) { return createAck(ack); }
|
||||
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;
|
||||
value.retry_enabled = _prefs.direct_retry_enabled != 0;
|
||||
memcpy(value.path, path, mesh::encodedClientPathByteLength(n));
|
||||
sent.push_back(value);
|
||||
return send_ok;
|
||||
}
|
||||
bool sendFloodReply(mesh::Packet* p, unsigned long delay, uint8_t hash_size) {
|
||||
Sent value; value.packet = *p; value.delay = delay; value.direct = false;
|
||||
value.path_len = (hash_size - 1) << 6;
|
||||
sent.push_back(value);
|
||||
return send_ok;
|
||||
}
|
||||
bool sendFloodScoped(uint16_t, mesh::Packet* p, unsigned long delay, uint8_t hash_size) {
|
||||
return sendFloodReply(p, delay, hash_size);
|
||||
}
|
||||
void replaceActiveMessageRetries(mesh::Packet* p, const uint8_t*, uint32_t timestamp) {
|
||||
++retry_replacements; retry_packet = *p; retry_timestamp = timestamp;
|
||||
}
|
||||
void handleCommand(uint32_t, char*, char* reply, int, uint8_t) {
|
||||
++general_commands; strcpy(reply, "OK admin");
|
||||
}
|
||||
bool addPost(ClientInfo*, const char*) { ++posts; return true; }
|
||||
void serviceRoomQuotas();
|
||||
uint8_t getUnsyncedCount(ClientInfo*) { return 0; }
|
||||
int getExtraAckTransmitCount() { return 0; }
|
||||
int handleRequest(ClientInfo*, uint32_t, uint8_t*, size_t, size_t) { return 0; }
|
||||
static bool saveFilter(ClientInfo*);
|
||||
bool handleClientPathCommand(ClientInfo*, char*, char*);
|
||||
bool sendClientReply(ClientInfo*, mesh::Packet*, unsigned long, uint8_t);
|
||||
bool pushRoomTextToClient(ClientInfo*, uint32_t, const mesh::Identity&, const char*, uint32_t);
|
||||
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) {
|
||||
std::vector<uint8_t> data(5 + strlen(text) + mesh::RemoteCliRequest::EXTENSION_SIZE + 1);
|
||||
memcpy(data.data(), ×tamp, 4); data[4] = flags << 2;
|
||||
memcpy(data.data() + 5, text, strlen(text));
|
||||
size_t length = 5 + strlen(text);
|
||||
if (logical) length = mesh::RemoteCliRequest::append(data.data(), data.size(), 5, strlen(text), logical);
|
||||
mesh::Packet packet; packet.header = ROUTE_TYPE_DIRECT;
|
||||
onPeerDataRecv(&packet, PAYLOAD_TYPE_TXT_MSG, sender, acl.clients[sender].shared_secret,
|
||||
data.data(), length);
|
||||
}
|
||||
std::string reply() const {
|
||||
if (sent.empty()) return "";
|
||||
const auto& p = sent.back().packet;
|
||||
return std::string((const char*)p.payload + 5, p.payload_len - 5);
|
||||
}
|
||||
};
|
||||
@PRODUCTION_METHODS@
|
||||
@TEST_CASES@
|
||||
+68
-9
@@ -6,6 +6,7 @@
|
||||
#include <vector>
|
||||
#include "../room_history_store/filesystem.h"
|
||||
#include <helpers/RoomAccessPolicy.h>
|
||||
#include <helpers/ClientPathPersistence.h>
|
||||
|
||||
#define PUB_KEY_SIZE 32
|
||||
#define MAX_PATH_SIZE 64
|
||||
@@ -37,7 +38,9 @@ struct Identity {
|
||||
};
|
||||
struct Packet {
|
||||
bool radio_reply = false;
|
||||
static bool isValidPathLen(uint8_t path) { return (path & 63) <= 20 && path < 0xc0; }
|
||||
uint8_t payload[MAX_PACKET_PAYLOAD] = {};
|
||||
size_t payload_len = 0;
|
||||
static bool isValidPathLen(uint8_t path);
|
||||
};
|
||||
struct Utils {
|
||||
static void sha256(uint8_t* out, size_t n, const uint8_t* a, size_t na,
|
||||
@@ -78,6 +81,9 @@ struct Sent {
|
||||
uint32_t timestamp;
|
||||
std::string text;
|
||||
uint8_t author[4];
|
||||
uint8_t path[MAX_PATH_SIZE] = {};
|
||||
uint8_t path_len = 0;
|
||||
bool direct = false, retry_enabled = false;
|
||||
};
|
||||
struct MyMesh {
|
||||
ACL acl;
|
||||
@@ -89,8 +95,10 @@ struct MyMesh {
|
||||
mesh::Store store;
|
||||
mesh::Store* _fs = &store;
|
||||
mesh::Packet packet;
|
||||
mesh::Packet alternate_packet;
|
||||
std::vector<Sent> sent;
|
||||
bool pool_available = true, queue_available = true;
|
||||
bool pool_available = true, queue_available = true, alternate_available = true;
|
||||
unsigned releases = 0;
|
||||
uint8_t reply_data[MAX_PACKET_PAYLOAD] = {};
|
||||
unsigned long next_push = 0, room_topic_ready_at = 0;
|
||||
bool room_topic_ready = true;
|
||||
@@ -101,7 +109,7 @@ struct MyMesh {
|
||||
PostInfo posts[MAX_UNSYNCED_POSTS] = {};
|
||||
uint32_t post_ready_at[MAX_UNSYNCED_POSTS] = {};
|
||||
uint32_t post_ready_mask = UINT32_MAX;
|
||||
struct { uint8_t path_hash_mode = 0; } _prefs;
|
||||
struct { uint8_t path_hash_mode = 0, direct_retry_enabled = 1; } _prefs;
|
||||
int default_scope = 0;
|
||||
MyMesh() {
|
||||
metadata_filesystem = &policy_fs;
|
||||
@@ -109,6 +117,7 @@ struct MyMesh {
|
||||
self_id.pub_key[0] = 77;
|
||||
acl.clients[0].id.pub_key[0] = 1;
|
||||
acl.clients[0].last_activity = 1000;
|
||||
for (auto& client : acl.clients) client.alt_path_len = OUT_PATH_UNKNOWN;
|
||||
}
|
||||
Clock* getRTCClock() { return &clock; }
|
||||
Random* getRNG() { return &rng; }
|
||||
@@ -117,24 +126,37 @@ struct MyMesh {
|
||||
assert(type == PAYLOAD_TYPE_TXT_MSG && len >= 9 && len <= 160);
|
||||
if (!pool_available) return nullptr;
|
||||
assert((data[4] >> 2) == TXT_TYPE_SIGNED_PLAIN);
|
||||
memcpy(packet.payload, data, len);
|
||||
packet.payload_len = len;
|
||||
return &packet;
|
||||
}
|
||||
mesh::Packet* obtainNewPacket() { return alternate_available ? &alternate_packet : nullptr; }
|
||||
void releasePacket(mesh::Packet* out) { assert(out == &alternate_packet); ++releases; }
|
||||
bool queue(mesh::Packet* out) {
|
||||
assert(out->radio_reply);
|
||||
if (!queue_available) return false;
|
||||
Sent entry;
|
||||
memcpy(&entry.timestamp, reply_data, 4);
|
||||
memcpy(entry.author, reply_data + 5, 4);
|
||||
entry.text = std::string(reinterpret_cast<char*>(reply_data + 9));
|
||||
// The production payload isn't terminated; test messages below are fixed
|
||||
// lengths or the whole buffer is cleared before each send by tick().
|
||||
memcpy(&entry.timestamp, out->payload, 4);
|
||||
memcpy(entry.author, out->payload + 5, 4);
|
||||
entry.text = std::string(reinterpret_cast<char*>(out->payload + 9), out->payload_len - 9);
|
||||
sent.push_back(entry);
|
||||
return true;
|
||||
}
|
||||
bool sendFloodScoped(int, mesh::Packet* out, unsigned long, uint8_t) { return queue(out); }
|
||||
bool sendDirect(mesh::Packet* out, const uint8_t*, uint8_t) { return queue(out); }
|
||||
bool sendFloodReply(mesh::Packet* out, unsigned long delay, uint8_t width) {
|
||||
return sendFloodScoped(default_scope, out, delay, width);
|
||||
}
|
||||
bool sendDirect(mesh::Packet* out, const uint8_t* path, uint8_t length, unsigned long = 0) {
|
||||
if (!queue(out)) return false;
|
||||
auto& entry = sent.back();
|
||||
entry.direct = true; entry.path_len = length;
|
||||
entry.retry_enabled = _prefs.direct_retry_enabled != 0;
|
||||
memcpy(entry.path, path, mesh::encodedClientPathByteLength(length));
|
||||
return true;
|
||||
}
|
||||
void replaceActiveMessageRetries(mesh::Packet*, const uint8_t*, uint32_t) {}
|
||||
bool pushPostToClient(ClientInfo*, PostInfo&);
|
||||
bool sendClientReply(ClientInfo*, mesh::Packet*, unsigned long, uint8_t);
|
||||
bool pushRoomTextToClient(ClientInfo*, uint32_t, const mesh::Identity&, const char*, uint32_t = 0);
|
||||
bool processAck(const uint8_t*);
|
||||
void activateRoomTopic();
|
||||
@@ -299,5 +321,42 @@ int main() {
|
||||
assert(memcmp(&ban_before, &banned, sizeof(banned)) == 0);
|
||||
assert(allowed.extra.room.pending_ack == 0 && allowed.extra.room.sync_since == 300);
|
||||
}
|
||||
{
|
||||
MyMesh m; auto& client = m.acl.clients[0];
|
||||
client.out_path_len = client.alt_path_len = 1;
|
||||
client.out_path[0] = 0xA1; client.alt_path[0] = 0xB2;
|
||||
m.topic("two-route topic"); m.post(0, 900, "two-route post");
|
||||
m.tick(6000);
|
||||
assert(m.sent.size() == 2 && m.sent[0].text == "two-route post");
|
||||
assert(m.sent[1].text == m.sent[0].text && m.sent[0].timestamp == m.sent[1].timestamp);
|
||||
assert(m.sent[0].path[0] == 0xA1 && m.sent[1].path[0] == 0xB2);
|
||||
assert(m.sent[0].retry_enabled && !m.sent[1].retry_enabled);
|
||||
assert(m._prefs.direct_retry_enabled == 1 && m._num_post_pushes == 1);
|
||||
assert(client.extra.room.ack_timeout == futureMillis(PUSH_TIMEOUT_BASE + 2 * PUSH_ACK_TIMEOUT_FACTOR));
|
||||
const uint32_t post_ack = client.extra.room.pending_ack;
|
||||
m.ack(); assert(client.extra.room.sync_since == 900);
|
||||
assert(!m.processAck(reinterpret_cast<const uint8_t*>(&post_ack)));
|
||||
m.tick(); assert(m.sent.size() == 4 && m.sent[2].text == "two-route topic");
|
||||
assert(m.sent[3].text == m.sent[2].text && m.sent[2].timestamp == m.sent[3].timestamp);
|
||||
assert(m._num_post_pushes == 2 && client.extra.room.sync_since == 900);
|
||||
m.ack(); assert(client.extra.room.sync_since == 900);
|
||||
assert(client.extra.room.topic_seen_revision == m.room_topic_revision);
|
||||
}
|
||||
for (bool alternate_available : {false, true}) {
|
||||
MyMesh m; auto& client = m.acl.clients[0];
|
||||
client.out_path_len = client.alt_path_len = 1;
|
||||
client.out_path[0] = 0xA1; client.alt_path[0] = 0xB2;
|
||||
m.alternate_available = alternate_available;
|
||||
m.queue_available = !alternate_available;
|
||||
m.topic("admission"); m.tick(6000);
|
||||
if (alternate_available) {
|
||||
assert(m.sent.empty() && m.releases == 1 && m._num_post_pushes == 0);
|
||||
assert(client.extra.room.pending_ack == 0);
|
||||
} else {
|
||||
assert(m.sent.size() == 1 && m.sent[0].retry_enabled && m._num_post_pushes == 1);
|
||||
m.ack();
|
||||
}
|
||||
assert(m._prefs.direct_retry_enabled == 1);
|
||||
}
|
||||
puts("room topic delivery regressions passed");
|
||||
}
|
||||
@@ -33,9 +33,13 @@ def room_outputs(work, compiler):
|
||||
state += "namespace mesh {\n" + extract_braced(clock, "class RTCClock") + ";\n}\n"
|
||||
(work / "state.inc").write_text(state, encoding="ascii")
|
||||
definitions = "\n".join(extract_braced(source, signature) for signature in (
|
||||
"bool MyMesh::sendClientReply(",
|
||||
"bool MyMesh::pushPostToClient(", "bool MyMesh::pushRoomTextToClient(",
|
||||
"bool MyMesh::processAck(", "void MyMesh::activateRoomTopic(",
|
||||
"bool MyMesh::handleRoomTopicCommand(", "void MyMesh::serviceRoomPush("))
|
||||
packet = (ROOT / "src/Packet.cpp").read_text()
|
||||
definitions = "namespace mesh {\n" + extract_braced(
|
||||
packet, "bool Packet::isValidPathLen(") + "\n}\n" + definitions
|
||||
(work / "production.inc").write_text(definitions, encoding="ascii")
|
||||
fixture = (ROOT / "test/fixtures/room_topic_delivery/test.cpp").read_text()
|
||||
fixture = fixture[:fixture.index("int main()")]
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Execute room per-client path commands and delivery against production bodies."""
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from test_replay_reset_integration import extract_braced
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FIXTURE = ROOT / "test/fixtures/room_client_paths.cpp"
|
||||
|
||||
CASES = r'''
|
||||
static void roleCases() {
|
||||
for (unsigned permissions = 0; permissions < 256; ++permissions) {
|
||||
for (bool logged_in : {false, true}) {
|
||||
MyMesh mesh;
|
||||
auto& own = mesh.acl.clients[0];
|
||||
own.permissions = permissions;
|
||||
own.last_activity = logged_in ? 800 : 0;
|
||||
const ClientInfo other = mesh.acl.clients[1];
|
||||
const uint8_t role = permissions & PERM_ACL_ROLE_MASK;
|
||||
const bool allowed = role == PERM_ACL_READ_ONLY || role == PERM_ACL_READ_WRITE
|
||||
|| role == PERM_ACL_ADMIN || (role == PERM_ACL_GUEST && logged_in);
|
||||
mesh.command("set altpath B1,B2");
|
||||
assert(mesh.general_commands == 0);
|
||||
assert(memcmp(&mesh.acl.clients[1], &other, sizeof(other)) == 0);
|
||||
assert(own.permissions == permissions && !own.permissions_are_explicit);
|
||||
if (allowed) {
|
||||
assert(own.alt_path_len == 2 && own.alt_path[0] == 0xB1 && own.alt_path[1] == 0xB2);
|
||||
assert(mesh.reply() == "> B1,B2");
|
||||
assert(mesh.acl.saves == (role == PERM_ACL_ADMIN ? 1U : 0U));
|
||||
} else {
|
||||
assert(own.alt_path_len == OUT_PATH_UNKNOWN && mesh.acl.saves == 0);
|
||||
if (role == PERM_ACL_GUEST) {
|
||||
assert(mesh.sent.empty() && own.last_timestamp == 99 && own.last_activity == 0);
|
||||
mesh.command("set altpath C1", 101);
|
||||
assert(own.alt_path_len == OUT_PATH_UNKNOWN && own.last_activity == 0);
|
||||
assert(mesh.sent.empty() && own.last_timestamp == 99);
|
||||
} else assert(mesh.reply().find("Err") != std::string::npos);
|
||||
}
|
||||
}
|
||||
}
|
||||
MyMesh mesh;
|
||||
char command[] = "get outpath", reply[180]{};
|
||||
assert(mesh.handleClientPathCommand(nullptr, command, reply));
|
||||
assert(strstr(reply, "Err") && mesh.acl.saves == 0);
|
||||
puts("256 permission combinations and live reader roles remain self-only passed");
|
||||
}
|
||||
|
||||
static void exactAllowlist() {
|
||||
const char* rejected[] = {
|
||||
"get prv.key", "erase", "set password owned", "set radio 915,500,5,8",
|
||||
"get outpathx", "get outpath path extra", "get altpath path", "set outpathx direct",
|
||||
"set altpath path", "get outpath;erase", "set outpath A1;erase",
|
||||
"AA|BB|set outpath direct", "set outpath direct;erase", "set altpath B1,,B2",
|
||||
"set outpath B1,", "set outpath B1,1234", "set outpath +B", "set outpath",
|
||||
};
|
||||
for (const char* text : rejected) {
|
||||
MyMesh mesh;
|
||||
const ClientInfo before = mesh.acl.clients[0], other = mesh.acl.clients[1];
|
||||
mesh.command(text);
|
||||
const auto& own = mesh.acl.clients[0];
|
||||
assert(mesh.general_commands == 0 && mesh.acl.saves == 0);
|
||||
assert(own.permissions == before.permissions && own.out_path_len == before.out_path_len);
|
||||
assert(memcmp(own.out_path, before.out_path, MAX_PATH_SIZE) == 0);
|
||||
assert(own.alt_path_len == before.alt_path_len);
|
||||
assert(memcmp(&mesh.acl.clients[1], &other, sizeof(other)) == 0);
|
||||
assert(mesh.reply().find("Err") != std::string::npos);
|
||||
}
|
||||
MyMesh normalized;
|
||||
normalized.command(" AA| SET outpath B1,B2 \t\r\n");
|
||||
assert(normalized.acl.clients[0].out_path_len == 2);
|
||||
assert(normalized.reply() == "AA|> B1,B2" && normalized.general_commands == 0);
|
||||
MyMesh chat;
|
||||
chat.command("set outpath B1", 100, 0, 0, TXT_TYPE_PLAIN);
|
||||
assert(chat.posts == 1 && chat.acl.clients[0].out_path[0] == 0xA1);
|
||||
MyMesh banned;
|
||||
assert(banned.room_access.addBan(&banned.policy_fs, banned.acl.clients[0].id.pub_key)
|
||||
== mesh::RoomAccessPolicy::BanResult::Saved);
|
||||
banned.command("set outpath B1");
|
||||
assert(banned.sent.empty() && banned.acl.clients[0].out_path[0] == 0xA1);
|
||||
puts("exact allowlist, correlation normalization, chat isolation, and ban gates passed");
|
||||
}
|
||||
|
||||
static void pathFormats() {
|
||||
struct Case { const char* command; uint8_t length; const char* response; };
|
||||
const Case cases[] = {
|
||||
{"set outpath B1,B2", 2, "> B1,B2"},
|
||||
{"set outpath b100, b200", 0x42, "> B100,B200"},
|
||||
{"set outpath B10011\tB20022", 0x82, "> B10011,B20022"},
|
||||
{"set outpath direct", 0, "> direct"},
|
||||
{"set outpath clear", OUT_PATH_UNKNOWN, "> outpath cleared"},
|
||||
{"set outpath flood", OUT_PATH_FORCE_FLOOD, "> flood"},
|
||||
{"set altpath direct", 0, "> direct"},
|
||||
{"set altpath clear", OUT_PATH_UNKNOWN, "> unknown"},
|
||||
};
|
||||
for (const auto& c : cases) {
|
||||
MyMesh mesh; mesh.command(c.command);
|
||||
const bool alt = strstr(c.command, "altpath") != nullptr;
|
||||
assert((alt ? mesh.acl.clients[0].alt_path_len : mesh.acl.clients[0].out_path_len) == c.length);
|
||||
assert(mesh.reply() == c.response);
|
||||
assert(mesh.acl.saves == 0 && !mesh.acl.clients[0].permissions_are_explicit);
|
||||
}
|
||||
MyMesh observed;
|
||||
auto& client = observed.acl.clients[0];
|
||||
mesh::beginObservedClientPath(client, OUT_PATH_UNKNOWN, ticks + 50);
|
||||
observed.command("get outpath path"); assert(observed.reply() == "> path pending");
|
||||
observed.command("set outpath path", 101); assert(observed.reply().find("pending") != std::string::npos);
|
||||
const uint8_t route[] = {0x12, 0x34, 0x56, 0x78};
|
||||
assert(mesh::captureObservedClientPath(client, route, 0x42, ticks));
|
||||
observed.command("get outpath path", 102); assert(observed.reply() == "> 1234,5678");
|
||||
assert(client.out_path_len == 1 && client.out_path[0] == 0xA1);
|
||||
observed.command("set outpath path", 103);
|
||||
assert(client.out_path_len == 0x42 && memcmp(client.out_path, route, sizeof(route)) == 0);
|
||||
MyMesh unavailable;
|
||||
unavailable.command("set outpath path"); assert(unavailable.reply().find("no path") != std::string::npos);
|
||||
assert(unavailable.acl.clients[0].out_path[0] == 0xA1);
|
||||
uint8_t path[MAX_PATH_SIZE]; memset(path, 0xAB, sizeof(path));
|
||||
for (unsigned length = 0; length < 256; ++length) {
|
||||
for (size_t capacity = 0; capacity <= 180; ++capacity) {
|
||||
uint8_t bounded[184]; memset(bounded, 0xCC, sizeof(bounded));
|
||||
mesh::formatRoomClientPathReply(path, length, (char*)bounded + 2, capacity);
|
||||
assert(bounded[0] == 0xCC && bounded[1] == 0xCC);
|
||||
for (size_t i = 2 + capacity; i < sizeof(bounded); ++i) assert(bounded[i] == 0xCC);
|
||||
if (capacity) assert(memchr(bounded + 2, 0, capacity) != nullptr);
|
||||
}
|
||||
}
|
||||
puts("one-to-three byte paths, route sentinels, observed paths, and bounded formatting passed");
|
||||
}
|
||||
|
||||
static void persistenceCases() {
|
||||
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)}) {
|
||||
for (bool explicit_role : {false, true}) {
|
||||
MyMesh mesh; auto& own = mesh.acl.clients[0];
|
||||
own.permissions = role; own.permissions_are_explicit = explicit_role; own.last_activity = 900;
|
||||
const uint8_t old_permissions = own.permissions;
|
||||
mesh.command("set outpath B1");
|
||||
const bool durable = explicit_role || role == PERM_ACL_ADMIN;
|
||||
assert(mesh.acl.saves == unsigned(durable));
|
||||
assert(own.out_path_is_persistable);
|
||||
assert(own.permissions_are_explicit == explicit_role && own.permissions == old_permissions);
|
||||
if (durable) {
|
||||
assert(mesh.acl.retained[0] && !mesh.acl.retained[1]);
|
||||
assert(mesh.acl.durable[0].out_path[0] == 0xB1);
|
||||
}
|
||||
mesh.command("set outpath B1", 101);
|
||||
assert(mesh.acl.saves == unsigned(durable)); // A no-op does not wear flash.
|
||||
mesh.command("set altpath C1", 102);
|
||||
assert(mesh.acl.saves == (durable ? 2U : 0U));
|
||||
mesh.command("set altpath C1", 103);
|
||||
assert(mesh.acl.saves == (durable ? 2U : 0U));
|
||||
}
|
||||
}
|
||||
for (const char* command_text : {"set outpath B1", "set altpath C1"}) {
|
||||
MyMesh mesh; auto& own = mesh.acl.clients[0];
|
||||
own.permissions = PERM_ACL_ADMIN; own.out_path_is_persistable = true;
|
||||
const ClientInfo before = own;
|
||||
mesh.acl.save_ok = false; mesh.dirty_contacts_expiry = 50000; mesh.contacts_save_failures = 3;
|
||||
char command[80], reply[180]{}; strcpy(command, command_text);
|
||||
assert(mesh.handleClientPathCommand(&own, command, reply));
|
||||
assert(strstr(reply, "save failed") && mesh.acl.saves == 1);
|
||||
assert(memcmp(&own, &before, sizeof(own)) == 0);
|
||||
assert(mesh.dirty_contacts_expiry == 50000 && mesh.contacts_save_failures == 3);
|
||||
}
|
||||
puts("transient roles stay transient, filtered durability, no-op writes, and save rollback passed");
|
||||
}
|
||||
|
||||
static void retryCases() {
|
||||
MyMesh mesh; auto& own = mesh.acl.clients[0];
|
||||
own.permissions_are_explicit = true;
|
||||
mesh.command("set outpath B1,B2", 100, 0, 777);
|
||||
assert(mesh.acl.saves == 1 && mesh.reply() == "> B1,B2");
|
||||
mesh.command("set outpath C1", 101);
|
||||
assert(mesh.acl.saves == 2 && own.out_path_len == 1 && own.out_path[0] == 0xC1);
|
||||
mesh.command("set outpath B1,B2", 102, 0, 777);
|
||||
assert(mesh.acl.saves == 2 && own.out_path_len == 1 && own.out_path[0] == 0xC1);
|
||||
assert(mesh.reply() == "> B1,B2"); // Recover the original result without repeating mutation.
|
||||
const size_t before_reject = mesh.sent.size();
|
||||
mesh.command("set outpath D1", 102); // Equal-time different request is not fresh.
|
||||
mesh.command("set outpath D1", 99); // Old uncached request is not fresh.
|
||||
assert(mesh.acl.saves == 2 && mesh.sent.size() == before_reject && own.out_path[0] == 0xC1);
|
||||
mesh.acl.clients[1].permissions_are_explicit = true;
|
||||
mesh.command("set outpath B1,B2", 100, 1, 777);
|
||||
assert(mesh.acl.saves == 3 && mesh.acl.clients[1].out_path_len == 2);
|
||||
// A prior Admin result must not become readable after the role is demoted.
|
||||
MyMesh demoted; demoted.acl.clients[0].permissions = PERM_ACL_ADMIN;
|
||||
demoted.command("get prv.key", 100, 0, 999);
|
||||
assert(demoted.general_commands == 1 && demoted.reply() == "OK admin");
|
||||
demoted.acl.clients[0].permissions = PERM_ACL_READ_WRITE;
|
||||
demoted.command("get prv.key", 101, 0, 999);
|
||||
assert(demoted.general_commands == 1 && demoted.reply() != "OK admin");
|
||||
for (uint8_t role : {uint8_t(PERM_ACL_FILTER_MGR), uint8_t(PERM_ACL_REGION_MGR), uint8_t(7)}) {
|
||||
MyMesh revoked; revoked.command("get outpath", 100, 0, 123);
|
||||
revoked.acl.clients[0].permissions = role;
|
||||
revoked.command("get outpath", 101, 0, 123);
|
||||
assert(revoked.reply().find("Err") != std::string::npos);
|
||||
}
|
||||
puts("cached setters execute once, sender isolation, replay denial, and demotion gates passed");
|
||||
}
|
||||
|
||||
static mesh::Packet* packet(MyMesh& mesh, uint8_t flags = TXT_TYPE_CLI_DATA) {
|
||||
mesh::Identity id{}; uint8_t secret[PUB_KEY_SIZE]{};
|
||||
const uint8_t data[] = {1, 2, 3, 4, uint8_t(flags << 2), 'r', 'e', 'p', 'l', 'y'};
|
||||
return mesh.createDatagram(PAYLOAD_TYPE_TXT_MSG, id, secret, data, sizeof(data));
|
||||
}
|
||||
static void alternateCases() {
|
||||
MyMesh mesh; auto& own = mesh.acl.clients[0]; own.alt_path_len = 1; own.alt_path[0] = 0xB1;
|
||||
auto p = packet(mesh); p->radio_reply = true;
|
||||
assert(mesh.sendClientReply(&own, p, 300, 2));
|
||||
assert(mesh.sent.size() == 2 && mesh.sent[0].retry_enabled && !mesh.sent[1].retry_enabled);
|
||||
assert(mesh.sent[0].path[0] == 0xA1 && mesh.sent[1].path[0] == 0xB1);
|
||||
assert(mesh.sent[0].packet.payload_len == mesh.sent[1].packet.payload_len);
|
||||
assert(memcmp(mesh.sent[0].packet.payload, mesh.sent[1].packet.payload, p->payload_len) == 0);
|
||||
assert(mesh.sent[1].packet.radio_reply && mesh._prefs.direct_retry_enabled == 1);
|
||||
for (unsigned mode = 0; mode < 4; ++mode) {
|
||||
MyMesh single; auto& c = single.acl.clients[0];
|
||||
if (mode == 0) { c.alt_path_len = 1; memcpy(c.alt_path, c.out_path, MAX_PATH_SIZE); c.alt_path[63] ^= 1; }
|
||||
if (mode == 1) c.alt_path_len = OUT_PATH_FORCE_FLOOD;
|
||||
if (mode == 2) { c.out_path_len = OUT_PATH_UNKNOWN; c.alt_path_len = 1; }
|
||||
if (mode == 3) { c.alt_path_len = 1; c.alt_path[0] = 0xB1; single.fail_allocation = 2; }
|
||||
assert(single.sendClientReply(&c, packet(single), 300, 2));
|
||||
assert(single.sent.size() == 1 && single.allocations <= 2);
|
||||
assert(single.sent[0].direct == (mode != 2));
|
||||
}
|
||||
MyMesh failed; auto& c = failed.acl.clients[0]; c.alt_path_len = 1; c.alt_path[0] = 0xB1;
|
||||
failed.send_ok = false;
|
||||
assert(!failed.sendClientReply(&c, packet(failed), 300, 2));
|
||||
assert(failed.sent.size() == 1 && failed.releases == 1 && failed._prefs.direct_retry_enabled == 1);
|
||||
MyMesh exhausted; exhausted.fail_allocation = 1; exhausted.command("set outpath B1");
|
||||
assert(exhausted.acl.clients[0].out_path[0] == 0xB1 && exhausted.sent.empty());
|
||||
MyMesh duplicate; duplicate.acl.clients[0].alt_path_len = 1; duplicate.acl.clients[0].alt_path[0] = 0xB1;
|
||||
duplicate.command("set outpath C1", 100, 0, 123);
|
||||
duplicate.command("set outpath C1", 101, 0, 123);
|
||||
assert(duplicate.sent.size() == 4 && duplicate.acl.saves == 0);
|
||||
assert(duplicate.sent[0].packet.payload_len == duplicate.sent[1].packet.payload_len);
|
||||
assert(memcmp(duplicate.sent[0].packet.payload, duplicate.sent[1].packet.payload,
|
||||
duplicate.sent[0].packet.payload_len) == 0);
|
||||
puts("alternate encrypted copies retain primary retries and tolerate admission or pool failure passed");
|
||||
}
|
||||
|
||||
static void pushCases() {
|
||||
MyMesh mesh; auto& c = mesh.acl.clients[0]; c.alt_path_len = 1; c.alt_path[0] = 0xB1;
|
||||
mesh::Identity author{}; memset(author.pub_key, 0x55, PUB_KEY_SIZE);
|
||||
assert(mesh.pushRoomTextToClient(&c, 700, author, "hello room", 22));
|
||||
assert(mesh.sent.size() == 2 && mesh.retry_replacements == 1 && mesh._num_post_pushes == 1);
|
||||
assert(mesh.sent[0].retry_enabled && !mesh.sent[1].retry_enabled && mesh.retry_timestamp == 700);
|
||||
assert(c.extra.room.push_post_timestamp == 700 && c.extra.room.pending_topic_revision == 22);
|
||||
assert(c.extra.room.pending_ack != 0 && c.extra.room.ack_timeout == ticks + PUSH_TIMEOUT_BASE + 2 * PUSH_ACK_TIMEOUT_FACTOR);
|
||||
assert(mesh.sent[0].packet.radio_reply && mesh.sent[1].packet.radio_reply);
|
||||
assert(memcmp(mesh.sent[0].packet.payload, mesh.sent[1].packet.payload, mesh.sent[0].packet.payload_len) == 0);
|
||||
MyMesh failed; failed.send_ok = false;
|
||||
assert(!failed.pushRoomTextToClient(&failed.acl.clients[0], 700, author, "hello room", 22));
|
||||
assert(failed.retry_replacements == 0 && failed._num_post_pushes == 0);
|
||||
assert(failed.acl.clients[0].extra.room.pending_ack == 0);
|
||||
puts("actual room post and topic pushes share copies, ACK state, and primary retry ownership passed");
|
||||
}
|
||||
int main(int argc, char** argv) {
|
||||
assert(argc == 2);
|
||||
if (!strcmp(argv[1], "roles")) roleCases();
|
||||
else if (!strcmp(argv[1], "allowlist")) exactAllowlist();
|
||||
else if (!strcmp(argv[1], "formats")) pathFormats();
|
||||
else if (!strcmp(argv[1], "persistence")) persistenceCases();
|
||||
else if (!strcmp(argv[1], "retries")) retryCases();
|
||||
else if (!strcmp(argv[1], "alternate")) alternateCases();
|
||||
else if (!strcmp(argv[1], "push")) pushCases();
|
||||
else assert(false);
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
def production_source():
|
||||
room = (ROOT / "examples/simple_room_server/MyMesh.cpp").read_text()
|
||||
header = (ROOT / "examples/simple_room_server/MyMesh.h").read_text()
|
||||
acl = (ROOT / "src/helpers/ClientACL.h").read_text()
|
||||
packet = (ROOT / "src/Packet.cpp").read_text()
|
||||
constants = "\n".join(line for line in acl.splitlines()
|
||||
if line.startswith(("#define PERM_ACL_", "#define OUT_PATH_")))
|
||||
sources = [room, header, (ROOT / "src/Mesh.h").read_text(),
|
||||
(ROOT / "src/helpers/TxtDataHelpers.h").read_text()]
|
||||
for name in ("ROOM_MESSAGE_CACHE_SIZE", "TXT_TYPE_PLAIN", "TXT_TYPE_CLI_DATA", "TXT_TYPE_CLI_COMMAND",
|
||||
"TXT_TYPE_SIGNED_PLAIN", "TXT_ACK_DELAY", "REPLY_DELAY_MILLIS", "REQ_TYPE_KEEP_ALIVE",
|
||||
"SERVER_RESPONSE_DELAY", "LAZY_CONTACTS_WRITE_DELAY", "PUSH_TIMEOUT_BASE",
|
||||
"PUSH_ACK_TIMEOUT_FACTOR", "PUSH_ACK_TIMEOUT_FLOOD"):
|
||||
found = None
|
||||
for text in sources:
|
||||
found = re.search(r"^\s*#define\s+" + name + r"\s+.*$", text, re.MULTILINE)
|
||||
if found:
|
||||
break
|
||||
if not found:
|
||||
raise AssertionError("missing production constant " + name)
|
||||
constants += "\n" + found[0]
|
||||
methods = "\n".join(extract_braced(room, signature) for signature in (
|
||||
"bool MyMesh::handleClientPathCommand(", "bool MyMesh::sendClientReply(",
|
||||
"void MyMesh::onPeerDataRecv(", "bool MyMesh::pushRoomTextToClient(",
|
||||
"void MyMesh::serviceRoomQuotas(", "bool MyMesh::saveFilter("))
|
||||
replacements = {
|
||||
"@CONSTANTS@": constants,
|
||||
"@STR_HELPER@": extract_braced((ROOT / "src/helpers/TxtDataHelpers.cpp").read_text(),
|
||||
"void StrHelper::strncpy("),
|
||||
"@CLIENT_INFO@": extract_braced(acl, "struct ClientInfo {"),
|
||||
"@PACKET_METHODS@": "\n".join(extract_braced(packet, signature) for signature in (
|
||||
"Packet::Packet()", "bool Packet::isValidPathLen(", "uint8_t Packet::copyPath(",
|
||||
"size_t Packet::writePath(")),
|
||||
"@PRODUCTION_METHODS@": methods,
|
||||
"@TEST_CASES@": CASES,
|
||||
}
|
||||
generated = FIXTURE.read_text()
|
||||
for marker, text in replacements.items():
|
||||
generated = generated.replace(marker, text)
|
||||
return generated
|
||||
|
||||
|
||||
class RoomClientPathTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def build(cls, generated, name):
|
||||
work = Path(cls.directory.name)
|
||||
source = work / (name + ".cpp")
|
||||
source.write_text(generated, encoding="ascii")
|
||||
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"),
|
||||
str(source), "-o", str(binary)]
|
||||
if sys.platform.startswith("linux"):
|
||||
command[1:1] = ["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
|
||||
"-fno-omit-frame-pointer", "-fno-pie", "-no-pie"]
|
||||
checked = subprocess.run(command, capture_output=True, text=True, timeout=60)
|
||||
if checked.returncode:
|
||||
raise AssertionError(checked.stdout + checked.stderr)
|
||||
return binary
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.compiler = shutil.which("g++") or shutil.which("clang++")
|
||||
if not cls.compiler:
|
||||
raise AssertionError("a host C++17 compiler is required")
|
||||
cls.directory = tempfile.TemporaryDirectory(prefix="room-client-paths-")
|
||||
cls.addClassCleanup(cls.directory.cleanup)
|
||||
cls.generated = production_source()
|
||||
cls.binary = cls.build(cls.generated, "room-paths")
|
||||
|
||||
def run_case(self, name, expected):
|
||||
checked = subprocess.run([str(self.binary), name], capture_output=True, text=True, timeout=20)
|
||||
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
|
||||
self.assertIn(expected, checked.stdout)
|
||||
|
||||
def test_all_roles_only_change_the_authenticated_clients_routes(self):
|
||||
self.run_case("roles", "256 permission combinations and live reader roles remain self-only passed")
|
||||
|
||||
def test_narrow_allowlist_prefixes_posts_bans_and_command_bypass_attempts(self):
|
||||
self.run_case("allowlist", "exact allowlist, correlation normalization, chat isolation, and ban gates passed")
|
||||
|
||||
def test_path_widths_observed_routes_and_bounded_formatting(self):
|
||||
self.run_case("formats", "one-to-three byte paths, route sentinels, observed paths, and bounded formatting passed")
|
||||
|
||||
def test_transient_sessions_filtered_storage_noop_and_rollback(self):
|
||||
self.run_case("persistence", "transient roles stay transient, filtered durability, no-op writes, and save rollback passed")
|
||||
|
||||
def test_replayed_mutations_cached_results_identity_and_revoked_permissions(self):
|
||||
self.run_case("retries", "cached setters execute once, sender isolation, replay denial, and demotion gates passed")
|
||||
|
||||
def test_alternate_packet_clones_primary_retries_and_allocation_failures(self):
|
||||
self.run_case("alternate", "alternate encrypted copies retain primary retries and tolerate admission or pool failure passed")
|
||||
|
||||
def test_actual_post_and_topic_pushes_share_ack_and_retry_state(self):
|
||||
self.run_case("push", "actual room post and topic pushes share copies, ACK state, and primary retry ownership passed")
|
||||
|
||||
def test_negative_controls_detect_rollback_cache_authorization_and_retry_regressions(self):
|
||||
controls = (
|
||||
("rollback", "memcpy(selected, previous, sizeof(previous));",
|
||||
"(void)previous;", "persistence"),
|
||||
("cache-permission", "if (cached_retry && !client->isAdmin() && !own_path_allowed) {",
|
||||
"if (false && cached_retry && !client->isAdmin() && !own_path_allowed) {", "retries"),
|
||||
("alternate-retries", "_prefs.direct_retry_enabled = 0;",
|
||||
"_prefs.direct_retry_enabled = 1;", "alternate"),
|
||||
)
|
||||
for name, before, after, case in controls:
|
||||
with self.subTest(control=name):
|
||||
self.assertEqual(self.generated.count(before), 1,
|
||||
"update the negative control to the production seam")
|
||||
binary = self.build(self.generated.replace(before, after), "negative-" + name)
|
||||
checked = subprocess.run([str(binary), case], capture_output=True, text=True, timeout=20)
|
||||
self.assertNotEqual(checked.returncode, 0,
|
||||
"negative control did not detect " + name)
|
||||
self.assertIn("Assertion", checked.stderr, checked.stdout + checked.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -25,12 +25,16 @@ HARNESS = r'''
|
||||
#include <helpers/RemoteCliRequest.h>
|
||||
#include <helpers/ClientACLResponse.h>
|
||||
#include <helpers/ClientPathPersistence.h>
|
||||
#include <helpers/ClientPathObservation.h>
|
||||
#include <helpers/RoomClientPathCommand.h>
|
||||
#include <helpers/TxtDataHelpers.h>
|
||||
#include <Packet.h>
|
||||
#include <helpers/RoomLoginAuthorization.h>
|
||||
#include <helpers/ClientLoginPersistence.h>
|
||||
#include <helpers/LazyPersistence.h>
|
||||
#include <helpers/LogicalMessageCache.h>
|
||||
@CONSTANTS@
|
||||
@STR_HELPER@
|
||||
#define MESH_CLIENT_REPEATER_ONLY 0
|
||||
namespace mesh {
|
||||
struct Identity { uint8_t pub_key[PUB_KEY_SIZE]; };
|
||||
@@ -86,6 +90,10 @@ struct FakeACL : ClientACL {
|
||||
bool known = false, allocate_ok = true;
|
||||
unsigned lookups = 0, authorizations = 0, allocations = 0;
|
||||
uint8_t authorized_role = 255;
|
||||
bool save(MemoryFS* fs, bool (*filter)(ClientInfo*)) {
|
||||
assert(fs != nullptr && filter != nullptr);
|
||||
return true;
|
||||
}
|
||||
int getNumClients() const { return known ? 1 : 0; }
|
||||
ClientInfo* getClientByIdx(int i) { assert(i == 0 && known); return &client; }
|
||||
ClientInfo* getClient(const uint8_t* key, size_t length) {
|
||||
@@ -107,6 +115,7 @@ struct FakeACL : ClientACL {
|
||||
client.id = sender;
|
||||
client.permissions = permissions;
|
||||
client.out_path_len = OUT_PATH_UNKNOWN;
|
||||
client.alt_path_len = OUT_PATH_UNKNOWN;
|
||||
client.out_path_is_persistable = true;
|
||||
known = true;
|
||||
return &client;
|
||||
@@ -130,6 +139,7 @@ static uint32_t millis() { return ticks; }
|
||||
struct MyMesh {
|
||||
FakeACL acl;
|
||||
MemoryFS policy_fs;
|
||||
MemoryFS* _fs = &policy_fs;
|
||||
mesh::RoomAccessPolicy room_access;
|
||||
MyMesh() { metadata_filesystem = &policy_fs; assert(room_access.load(&policy_fs)); }
|
||||
int matching_peer_indexes[1] = {0};
|
||||
@@ -137,6 +147,8 @@ struct MyMesh {
|
||||
mesh::LogicalMessageCache<ROOM_MESSAGE_CACHE_SIZE> recent_room_polls;
|
||||
void serviceRoomQuotas();
|
||||
static bool saveFilter(ClientInfo*);
|
||||
bool handleClientPathCommand(ClientInfo*, char*, char*);
|
||||
bool sendClientReply(ClientInfo*, mesh::Packet*, unsigned long, uint8_t);
|
||||
unsigned path_acks = 0;
|
||||
bool processAck(const uint8_t*) { ++path_acks; return true; }
|
||||
uint8_t getUnsyncedCount(ClientInfo*) { return 2; }
|
||||
@@ -151,13 +163,14 @@ struct MyMesh {
|
||||
char password[32] = "admin";
|
||||
char guest_password[32] = "guest";
|
||||
bool allow_read_only = false;
|
||||
uint8_t direct_retry_enabled = 1;
|
||||
} _prefs;
|
||||
FakeClock clock;
|
||||
FakeRng rng;
|
||||
unsigned long millis_now = 20000, dirty_contacts_expiry = 0, next_push = 0;
|
||||
uint8_t contacts_save_failures = 0, reply_data[32] = {};
|
||||
unsigned creations = 0, direct_sends = 0, flood_sends = 0, path_returns = 0;
|
||||
bool create_ok = true;
|
||||
bool create_ok = true, queue_ok = true;
|
||||
mesh::Packet response;
|
||||
uint8_t response_secret[PUB_KEY_SIZE] = {}, response_key[PUB_KEY_SIZE] = {};
|
||||
uint8_t returned_path[MAX_PATH_SIZE] = {}, direct_path[MAX_PATH_SIZE] = {};
|
||||
@@ -169,6 +182,8 @@ struct MyMesh {
|
||||
|
||||
FakeClock* getRTCClock() { return &clock; }
|
||||
FakeRng* getRNG() { return &rng; }
|
||||
mesh::Packet* obtainNewPacket() { return nullptr; }
|
||||
void releasePacket(mesh::Packet*) { assert(false); }
|
||||
unsigned long futureMillis(unsigned long delay) { return millis_now + delay; }
|
||||
mesh::Packet* createDatagram(uint8_t type, const mesh::Identity& sender,
|
||||
const uint8_t* secret, const uint8_t* data, size_t len) {
|
||||
@@ -188,18 +203,20 @@ struct MyMesh {
|
||||
memcpy(returned_path, path, (path_len & 63) * ((path_len >> 6) + 1));
|
||||
return createDatagram(type, sender, secret, data, len);
|
||||
}
|
||||
void sendDirect(mesh::Packet* packet, const uint8_t* path, uint8_t path_len,
|
||||
bool sendDirect(mesh::Packet* packet, const uint8_t* path, uint8_t path_len,
|
||||
unsigned long delay) {
|
||||
assert(packet == &response && (delay == SERVER_RESPONSE_DELAY || delay == TXT_ACK_DELAY));
|
||||
assert(mesh::Packet::isValidPathLen(path_len));
|
||||
++direct_sends;
|
||||
direct_path_len = path_len;
|
||||
memcpy(direct_path, path, (path_len & 63) * ((path_len >> 6) + 1));
|
||||
return queue_ok;
|
||||
}
|
||||
void sendFloodReply(mesh::Packet* packet, unsigned long delay, uint8_t hash_size) {
|
||||
bool sendFloodReply(mesh::Packet* packet, unsigned long delay, uint8_t hash_size) {
|
||||
assert(packet == &response && (delay == SERVER_RESPONSE_DELAY || delay == TXT_ACK_DELAY));
|
||||
++flood_sends;
|
||||
reply_hash_size = hash_size;
|
||||
return queue_ok;
|
||||
}
|
||||
bool store_ok = true;
|
||||
bool addPost(ClientInfo*, const char*) { if (!store_ok) return false; ++posts; return true; }
|
||||
@@ -232,6 +249,8 @@ struct MyMesh {
|
||||
@PATH_HANDLER@
|
||||
@QUOTA_SERVICE@
|
||||
@SAVE_FILTER@
|
||||
@CLIENT_PATH_HANDLER@
|
||||
@SEND_CLIENT_REPLY@
|
||||
|
||||
static mesh::Identity identity(uint8_t n = 1) {
|
||||
mesh::Identity sender{};
|
||||
@@ -247,6 +266,7 @@ static void seedKnown(MyMesh& mesh, uint8_t permissions = PERM_ACL_ADMIN) {
|
||||
client.last_timestamp = 99;
|
||||
client.last_activity = 777;
|
||||
client.out_path_len = 2;
|
||||
client.alt_path_len = OUT_PATH_UNKNOWN;
|
||||
client.out_path[0] = 0xA1;
|
||||
client.out_path[1] = 0xB2;
|
||||
client.out_path_is_persistable = false;
|
||||
@@ -486,6 +506,9 @@ static void sessionRefresh() {
|
||||
mesh.acl.client.out_path_len = route;
|
||||
login(mesh, "guest", 100, 81, flood);
|
||||
const auto& client = mesh.acl.client;
|
||||
assert(client.observed_path_len == OUT_PATH_UNKNOWN);
|
||||
assert(client.observed_path_pending == flood);
|
||||
assert(client.observed_path_expiry == (flood ? mesh.millis_now + 60000 : 0));
|
||||
assert(client.permissions == (PERM_ACL_REGION_MGR | 0x80));
|
||||
assert(client.last_timestamp == 100 && client.last_activity == mesh.clock.now);
|
||||
assert(client.extra.room.sync_since == 81 && client.extra.room.last_post_timestamp == 800);
|
||||
@@ -537,6 +560,64 @@ static void sessionRefresh() {
|
||||
puts("real-handler successful reconnect refreshes cursor, activity, secret, topic state, and route passed");
|
||||
}
|
||||
|
||||
static void observedLoginPath() {
|
||||
MyMesh mesh;
|
||||
seedKnown(mesh);
|
||||
mesh.acl.client.out_path_len = OUT_PATH_FORCE_FLOOD;
|
||||
login(mesh, "guest", 100, 80, true);
|
||||
auto& client = mesh.acl.client;
|
||||
assert(client.observed_path_pending && client.out_path_len == OUT_PATH_FORCE_FLOOD);
|
||||
uint8_t route[] = {0x12, 0x34, 0x56, 0x78}, ack[4]{};
|
||||
auto packet = incoming();
|
||||
// ACK and flood packets are not the reciprocal empty-custom login PATH.
|
||||
mesh.onPeerPathRecv(&packet, 0, client.shared_secret, route, 0x42,
|
||||
PAYLOAD_TYPE_ACK, ack, sizeof(ack));
|
||||
assert(client.observed_path_pending && mesh.path_acks == 1);
|
||||
auto flood_packet = incoming(true);
|
||||
mesh.onPeerPathRecv(&flood_packet, 0, client.shared_secret, route, 0x42, 0x0F, ack, 0);
|
||||
assert(client.observed_path_pending);
|
||||
assert(!mesh.onPeerPathRecv(&packet, 0, client.shared_secret, route, 0x42, 0x0F, ack, 0));
|
||||
assert(!client.observed_path_pending && client.observed_path_len == 0x42);
|
||||
assert(memcmp(client.observed_path, route, sizeof(route)) == 0);
|
||||
assert(client.out_path_len == OUT_PATH_FORCE_FLOOD); // An observed PATH is not an override.
|
||||
char get_command[] = "get outpath path", reply[180]{};
|
||||
assert(mesh.handleClientPathCommand(&client, get_command, reply));
|
||||
assert(strcmp(reply, "> 1234,5678") == 0);
|
||||
char set_command[] = "set outpath path";
|
||||
assert(mesh.handleClientPathCommand(&client, set_command, reply));
|
||||
assert(client.out_path_len == 0x42 && client.out_path_is_persistable);
|
||||
assert(memcmp(client.out_path, route, sizeof(route)) == 0);
|
||||
login(mesh, "guest", 101, 80, false);
|
||||
assert(!client.observed_path_pending && client.observed_path_len == OUT_PATH_UNKNOWN);
|
||||
login(mesh, "guest", 102, 80, true);
|
||||
mesh.onPeerPathRecv(&packet, 0, client.shared_secret, route, 0, 0x0F, ack, 0);
|
||||
assert(client.observed_path_len == 0 && !client.observed_path_pending);
|
||||
char zero_hop[] = "set outpath path";
|
||||
assert(mesh.handleClientPathCommand(&client, zero_hop, reply));
|
||||
assert(client.out_path_len == 0 && strcmp(reply, "> direct") == 0);
|
||||
// Timeout and an unrelated PATH cannot create an observed-route result.
|
||||
login(mesh, "guest", 103, 80, true);
|
||||
mesh.millis_now += 60000;
|
||||
mesh.onPeerPathRecv(&packet, 0, client.shared_secret, route, 0x42, 0x0F, ack, 0);
|
||||
assert(!client.observed_path_pending && client.observed_path_len == OUT_PATH_UNKNOWN);
|
||||
login(mesh, "guest", 104, 80, true);
|
||||
char explicitly_selected[] = "set outpath B1";
|
||||
assert(mesh.handleClientPathCommand(&client, explicitly_selected, reply));
|
||||
mesh.onPeerPathRecv(&packet, 0, client.shared_secret, route, 0x42, 0x0F, ack, 0);
|
||||
assert(client.observed_path_len == 0x42 && client.out_path_len == 1 && client.out_path[0] == 0xB1);
|
||||
// No reciprocal PATH is requested unless the login response was queued.
|
||||
for (bool allocation_fault : {false, true}) {
|
||||
MyMesh failed;
|
||||
failed.create_ok = !allocation_fault;
|
||||
failed.queue_ok = allocation_fault;
|
||||
login(failed, "guest", 100, 80, true);
|
||||
assert(failed.acl.known && failed.acl.client.last_timestamp == 100);
|
||||
assert(!failed.acl.client.observed_path_pending
|
||||
&& failed.acl.client.observed_path_len == OUT_PATH_UNKNOWN);
|
||||
}
|
||||
puts("actual flood login opens observed window, PATH captures, direct login clears, and expiry rejects passed");
|
||||
}
|
||||
|
||||
static void readOnlyPosting() {
|
||||
for (const char* password : {"", "wrong"}) {
|
||||
MyMesh mesh;
|
||||
@@ -692,6 +773,7 @@ int main(int argc, char** argv) {
|
||||
else if (!strcmp(argv[1], "moderation")) moderationCases();
|
||||
else if (!strcmp(argv[1], "quotas")) quotaCases();
|
||||
else if (!strcmp(argv[1], "requests")) allRequestQuotaCases();
|
||||
else if (!strcmp(argv[1], "observed")) observedLoginPath();
|
||||
else assert(false);
|
||||
}
|
||||
'''
|
||||
@@ -737,6 +819,8 @@ class RoomLoginIntegrationTests(unittest.TestCase):
|
||||
constants += "\n" + delay.group()
|
||||
replacements = {
|
||||
"@CONSTANTS@": constants,
|
||||
"@STR_HELPER@": extract_braced((ROOT / "src/helpers/TxtDataHelpers.cpp").read_text(),
|
||||
"void StrHelper::strncpy("),
|
||||
"@PACKET_CONSTRUCTOR@": extract_braced(packet, "Packet::Packet()"),
|
||||
"@PACKET_PATH_CHECK@": extract_braced(packet, "bool Packet::isValidPathLen("),
|
||||
"@CLIENT_INFO@": extract_braced(acl_header, "struct ClientInfo {"),
|
||||
@@ -745,6 +829,8 @@ class RoomLoginIntegrationTests(unittest.TestCase):
|
||||
"@PATH_HANDLER@": extract_braced(room, "bool MyMesh::onPeerPathRecv("),
|
||||
"@QUOTA_SERVICE@": extract_braced(room, "void MyMesh::serviceRoomQuotas("),
|
||||
"@SAVE_FILTER@": extract_braced(room, "bool MyMesh::saveFilter("),
|
||||
"@CLIENT_PATH_HANDLER@": extract_braced(room, "bool MyMesh::handleClientPathCommand("),
|
||||
"@SEND_CLIENT_REPLY@": extract_braced(room, "bool MyMesh::sendClientReply("),
|
||||
"@LOGIN_HANDLER@": extract_braced(room, "void MyMesh::onAnonDataRecv("),
|
||||
}
|
||||
generated = HARNESS
|
||||
@@ -783,6 +869,9 @@ class RoomLoginIntegrationTests(unittest.TestCase):
|
||||
def test_successful_relogin_refreshes_session_and_routes_without_resetting_post_floor(self):
|
||||
self.run_case("refresh", "real-handler successful reconnect refreshes cursor, activity, secret, topic state, and route passed")
|
||||
|
||||
def test_observed_route_uses_actual_login_and_path_handlers_with_timeout(self):
|
||||
self.run_case("observed", "actual flood login opens observed window, PATH captures, direct login clears, and expiry rejects passed")
|
||||
|
||||
def test_all_room_request_types_consume_poll_budget_and_preserve_exact_retries(self):
|
||||
self.run_case("requests", "actual request quota covers status, telemetry, ACL, board, and exact payload retries passed")
|
||||
|
||||
|
||||
@@ -27,9 +27,13 @@ class RoomTopicDeliveryTests(unittest.TestCase):
|
||||
clock = (ROOT / "src/MeshCore.h").read_text()
|
||||
generated += "namespace mesh {\n" + extract_braced(clock, "class RTCClock") + ";\n}\n"
|
||||
definitions = "\n".join(extract_braced(source, signature) for signature in (
|
||||
"bool MyMesh::sendClientReply(",
|
||||
"bool MyMesh::pushPostToClient(", "bool MyMesh::pushRoomTextToClient(",
|
||||
"bool MyMesh::processAck(", "void MyMesh::activateRoomTopic(",
|
||||
"bool MyMesh::handleRoomTopicCommand(", "void MyMesh::serviceRoomPush("))
|
||||
packet = (ROOT / "src/Packet.cpp").read_text()
|
||||
definitions = "namespace mesh {\n" + extract_braced(
|
||||
packet, "bool Packet::isValidPathLen(") + "\n}\n" + definitions
|
||||
with tempfile.TemporaryDirectory(prefix="room-topic-delivery-") as directory:
|
||||
work = Path(directory)
|
||||
(work / "state.inc").write_text(generated)
|
||||
|
||||
Reference in new issue
Block a user