mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-20 12:41:58 +00:00
Support atomic two-packet signed fleet commands
Automatically split longer signed envelopes into two bounded group-data packets, reserve both before queueing, and roll back exact packet copies and retries on admission failure. Collect fragments in either order with a fixed deadline and verify the complete command before durable replay reservation and dispatch. Raise whole-fleet protocol capacity to 230 bytes, preserve target-specific budgets and radio acknowledgement barriers, and reject rule-token and USB rescue-line truncation. Document existing app input limits and add real-signature, sanitizer, queue failure, timeout, replay, and compact-board regressions.
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
* **[Two LoRa profiles on one device](https://mikecarper.github.io/MeshCore/radio_profiles/).** Time-share one transceiver between two frequencies and/or modulation settings. Choose transmission profiles per contact or channel, enable or isolate crossover traffic, schedule profiles, and use a temporary update profile alongside the normal network. Includes timing calibration and separate R2 screen pages.
|
||||
* **[Firmware updates over LoRa](https://mikecarper.github.io/MeshCore/lora_ota_automation/).** Update repeaters, room servers, and sensors over LoRa on supported firmware and storage layouts.
|
||||
* **[Repeater forwarding policies](https://mikecarper.github.io/MeshCore/flood_filtering/).** Actions include dropping, rate limiting, assigning scopes, selecting retries, and controlling forwarding priority. Separate policies cover ordinary radio forwarding, bridges, and profile crossover.
|
||||
* **[Private fleet management](https://mikecarper.github.io/MeshCore/fleet_management/).** Deploy filtering, secondary-radio changes, radio schedules, and clock updates to a whole fleet, one node, or a comma-separated list of public-key targets. The publisher signs commands; receivers hold its public verification key, with replay protection and acknowledgements before radio handoff.
|
||||
* **[Private fleet management](https://mikecarper.github.io/MeshCore/fleet_management/).** Deploy filtering, secondary-radio changes, radio schedules, and clock updates to a whole fleet, one node, or a comma-separated list of public-key targets. The publisher signs each complete command once, splitting longer requests across two packets; receivers hold its public verification key, with replay protection and acknowledgements before radio handoff.
|
||||
* **[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.
|
||||
|
||||
+53
-17
@@ -61,6 +61,8 @@ last accepted sequence. It does not reveal the channel key. `set fleet.channel
|
||||
off` disables reception; `set fleet.controller off` revokes the publisher. Key
|
||||
changes use the existing individually authenticated admin path, never fleet
|
||||
commands.
|
||||
`get fleet.stats` includes `parts`, the number of fragments currently buffered
|
||||
for an incomplete two-packet command.
|
||||
|
||||
Nodes need a clock set to current UTC. Commands expire after ten minutes and may
|
||||
be at most one minute ahead of a receiver's clock. A bad clock fails closed;
|
||||
@@ -107,10 +109,9 @@ command only once, and the signature protects the entire list.
|
||||
On air, full keys use a 128-bit SHA-256 digest; prefixes use their original
|
||||
public-key bytes. Whole-fleet commands use the compact FMC2 format with no target
|
||||
records, whether the CLI target is omitted or explicitly `all`. This reduces the
|
||||
header from 29 to 14 bytes. Whole-fleet, multiple-target, and prefix commands
|
||||
require receivers that support FMC2. A single complete key keeps the legacy FMC1
|
||||
format, and updated receivers also accept legacy whole-fleet packets. Separate
|
||||
fleets use separate channel keys; named subgroups are not part of this first version.
|
||||
header from 29 to 14 bytes. A single complete key uses the smaller fixed-target
|
||||
FMC1 format. Separate fleets use separate channel keys; named subgroups are not
|
||||
part of this first version.
|
||||
|
||||
Only local commands on the publishing Companion can originate fleet sends. A
|
||||
remote CLI command cannot turn that Companion into a multicast signing proxy.
|
||||
@@ -124,18 +125,53 @@ sequence; the operation should be chosen to be idempotent.
|
||||
|
||||
## Supported controls
|
||||
|
||||
The command body is limited to **72 ASCII bytes**. Targets and command must also
|
||||
fit one signed **165-byte envelope**. An 8-character target uses 5 bytes, a
|
||||
12-character target uses 7, and a complete key uses 17. The fixed envelope and
|
||||
signature use 78 bytes, leaving 87 bytes for the target list and command together.
|
||||
For example, two complete keys leave 53 bytes for the command. Lists are capped
|
||||
at 17 entries; packet-size limits usually allow fewer. Omitting targets or using
|
||||
`all` saves 15 header bytes; the command limit remains 72 bytes. A single complete
|
||||
key uses the legacy 29-byte header and also allows a full 72-byte command.
|
||||
The local app/console's command-length limit also applies. Oversized or invalid
|
||||
lists are rejected as a whole, without sending or truncating any targets.
|
||||
The short legacy USB rescue console cannot fit a full-key command; use the app
|
||||
or normal terminal for longer lists.
|
||||
The Companion automatically uses **one or two LoRa packets**, depending on the
|
||||
signed command's size. Whole-fleet commands fit **87 ASCII bytes** in one packet
|
||||
or **230 bytes** across two. Targets consume some of that allowance:
|
||||
|
||||
| Targets | One packet: command bytes | Two packets: command bytes |
|
||||
| --- | ---: | ---: |
|
||||
| Omitted or `all` | 87 | 230 |
|
||||
| One 8-character public-key prefix | 82 | 225 |
|
||||
| One 12-character public-key prefix | 80 | 223 |
|
||||
| One complete 64-character public key | 72 | 215 |
|
||||
| Two 8-character public-key prefixes | 77 | 220 |
|
||||
| Two complete public keys | 53 | 196 |
|
||||
|
||||
Each prefix in a list uses 5 or 7 bytes; each complete key in a list uses 17.
|
||||
A single complete key uses the smaller fixed-target format with a 29-byte header,
|
||||
so its limits come from packet space. Lists are capped at 17 entries. Requests
|
||||
exceeding their target-specific budget are rejected before signing or sending,
|
||||
with no partial target list.
|
||||
|
||||
The existing app/console input limit also applies. The app's normal 176-byte
|
||||
command frame fits **161 command bytes** after `fleet send 3 `; a correlated
|
||||
request uses three more bytes, and explicit targets reduce the space further.
|
||||
The shorter USB rescue console has an 80-byte input buffer. The 230-byte limit
|
||||
is the fleet protocol's capacity, rather than a promise that every console can
|
||||
enter that much text. Oversized or invalid lists are rejected as a whole,
|
||||
without sending or truncating any targets.
|
||||
|
||||
### Two-packet delivery
|
||||
|
||||
Longer commands keep **one 64-byte signature over the entire command**, including
|
||||
its channel, targets, sequence and expiry. The signed envelope is split into two
|
||||
`FMP1` fragments. Each fragment has an 11-byte collection header and up to 154
|
||||
envelope bytes, within the existing 165-byte group-data limit. Together they can
|
||||
carry a 308-byte signed envelope. No third packet is used.
|
||||
|
||||
The Companion reserves both packets before sending. If either cannot be queued,
|
||||
it cancels the queued part and any radio copy, reports an error, and leaves the
|
||||
sequence available for a retry. Successful queueing still does not guarantee
|
||||
delivery over LoRa.
|
||||
|
||||
A receiver accepts either arrival order and identical duplicates. It executes
|
||||
and acknowledges **only after both parts arrive and the complete signature,
|
||||
target, clock and replay checks pass**. Missing or altered parts cannot execute
|
||||
a partial command. One bounded assembly is kept for up to five minutes from the
|
||||
first part; duplicates do not extend that deadline. A fresh command with a newer
|
||||
eligible sequence replaces an incomplete older transfer, so a lost part does
|
||||
not block retries. Reboot or changing enrollment discards the partial transfer.
|
||||
|
||||
| Control | Fleet commands |
|
||||
| --- | --- |
|
||||
@@ -212,7 +248,7 @@ move nodes off the normal network; use a temporary window or a planned recovery
|
||||
path for experiments. A forwarding policy can also partition the fleet.
|
||||
|
||||
The receiver is allocated lazily when fleet settings are present or queried, with
|
||||
one bounded mailbox. Cryptographic work and storage writes occur after the receive
|
||||
one bounded mailbox and one 308-byte fragment assembly. Cryptographic work and storage writes occur after the receive
|
||||
stack unwinds. Signature-validation
|
||||
attempts are bounded to protect the radio loop from matching-hash junk. A corrupt
|
||||
or unreadable fleet store denies commands rather than discarding its replay state.
|
||||
|
||||
@@ -1691,6 +1691,64 @@ bool MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pk
|
||||
}
|
||||
}
|
||||
|
||||
#if MESH_ENABLE_FLEET_CONTROL
|
||||
void MyMesh::onRadioProfileCopyQueued(mesh::Packet* packet, const mesh::Packet* original, uint8_t priority) {
|
||||
mesh::Mesh::onRadioProfileCopyQueued(packet, original, priority);
|
||||
if (original && original == _fleet_admission_original) _fleet_admission_copy = packet;
|
||||
}
|
||||
|
||||
bool MyMesh::sendFleetCommandData(mesh::GroupChannel& channel, const uint8_t* envelope, size_t length) {
|
||||
if (!envelope || !length || length > mesh::FleetCommand::MaxEnvelopeLength) return false;
|
||||
if (length <= mesh::FleetCommand::MaxPayloadLength) {
|
||||
return sendGroupData(channel, nullptr, OUT_PATH_UNKNOWN,
|
||||
mesh::FleetCommand::DataType, envelope, (int)length);
|
||||
}
|
||||
mesh::Packet* reserved[2] = {};
|
||||
uint8_t group[3 + mesh::FleetCommand::MaxPayloadLength];
|
||||
group[0] = (uint8_t)mesh::FleetCommand::DataType;
|
||||
group[1] = (uint8_t)(mesh::FleetCommand::DataType >> 8);
|
||||
// Reserve both encrypted packets before handing either to the dispatcher.
|
||||
for (uint8_t part = 0; part < 2; ++part) {
|
||||
const size_t bytes = mesh::FleetCommand::fragment(envelope, length, part,
|
||||
group + 3, sizeof(group) - 3);
|
||||
if (bytes) {
|
||||
group[2] = (uint8_t)bytes;
|
||||
reserved[part] = createGroupDatagram(PAYLOAD_TYPE_GRP_DATA, channel, group, 3 + bytes);
|
||||
}
|
||||
if (!reserved[part]) {
|
||||
if (reserved[0]) releasePacket(reserved[0]);
|
||||
memset(group, 0, sizeof(group));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
memset(group, 0, sizeof(group));
|
||||
_fleet_admission_original = reserved[0];
|
||||
_fleet_admission_copy = nullptr;
|
||||
const bool first_queued = sendFloodScoped(channel, reserved[0]);
|
||||
mesh::Packet* first_copy = _fleet_admission_copy;
|
||||
_fleet_admission_original = nullptr;
|
||||
_fleet_admission_copy = nullptr;
|
||||
if (!first_queued) {
|
||||
// sendFloodScoped/sendPacket owns and releases the failed first packet.
|
||||
releasePacket(reserved[1]);
|
||||
return false;
|
||||
}
|
||||
if (sendFloodScoped(channel, reserved[1])) return true;
|
||||
// A failed second send has already been released. No dispatcher loop runs
|
||||
// inside admission, so the first packet and its optional copy are still
|
||||
// queued and can be retired by exact pointer without touching other work.
|
||||
for (int i = _mgr->getOutboundTotal() - 1; i >= 0; --i) {
|
||||
const mesh::Packet* queued = _mgr->getOutboundByIdx(i);
|
||||
if (queued != reserved[0] && queued != first_copy) continue;
|
||||
mesh::Packet* cancelled = _mgr->removeOutboundByIdx(i);
|
||||
cancelOutboundRadioRetry(cancelled);
|
||||
onSendFail(cancelled); // Clear its pending flood retry metadata too.
|
||||
releasePacket(cancelled);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
|
||||
void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
|
||||
const char *text) {
|
||||
#if MESH_ENABLE_ONE_KEY_DM
|
||||
@@ -5264,7 +5322,10 @@ void MyMesh::resetUsbHostSessionInput() {
|
||||
// CLI rescue deliberately remains active across reconnects, but no bytes
|
||||
// typed by the old host may complete a command for the new one. This is
|
||||
// especially important for the rescue shell's erase/rm commands.
|
||||
if (_cli_rescue) memset(cli_command, 0, sizeof(cli_command));
|
||||
if (_cli_rescue) {
|
||||
memset(cli_command, 0, sizeof(cli_command));
|
||||
_cli_line_overflow = false;
|
||||
}
|
||||
}
|
||||
|
||||
void MyMesh::handleCmdFrame(size_t len) {
|
||||
@@ -9520,6 +9581,7 @@ void MyMesh::handleTerminalCommand(char* command) {
|
||||
void MyMesh::enterCLIRescue() {
|
||||
_cli_rescue = true;
|
||||
cli_command[0] = 0;
|
||||
_cli_line_overflow = false;
|
||||
mesh::usbTerminalPort().println("========= CLI Rescue =========");
|
||||
}
|
||||
|
||||
@@ -9784,16 +9846,15 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp,
|
||||
snprintf(reply, reply_capacity, "Error: wait for the next clock second before fleet send");
|
||||
return true;
|
||||
}
|
||||
uint8_t payload[mesh::FleetCommand::MaxPayloadLength];
|
||||
uint8_t payload[mesh::FleetCommand::MaxEnvelopeLength];
|
||||
const size_t length = mesh::FleetCommand::encode(
|
||||
self_id, channel.channel.secret, sequence,
|
||||
sequence + mesh::FleetCommand::MaxLifetime, targets, text,
|
||||
payload, sizeof(payload));
|
||||
if (!length) {
|
||||
snprintf(reply, reply_capacity,
|
||||
"Error: fleet targets and command exceed one packet; use fewer targets");
|
||||
} else if (!sendGroupData(channel.channel, nullptr, OUT_PATH_UNKNOWN,
|
||||
mesh::FleetCommand::DataType, payload, (int)length)) {
|
||||
"Error: fleet targets and command exceed two packets; use fewer targets");
|
||||
} else if (!sendFleetCommandData(channel.channel, payload, length)) {
|
||||
snprintf(reply, reply_capacity, "Error: could not queue fleet command");
|
||||
} else {
|
||||
last_fleet_sequence = sequence;
|
||||
@@ -10076,30 +10137,42 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp,
|
||||
void MyMesh::checkCLIRescueCmd() {
|
||||
Stream& output = mesh::usbTerminalPort();
|
||||
Stream& input = mesh::usbCompanionPort();
|
||||
int len = strlen(cli_command);
|
||||
// `cli_command` must stay NUL-terminated within its bounds. If it ever isn't,
|
||||
// strlen() above can return >= sizeof(cli_command) and the loop below would
|
||||
// then index past the buffer, so clamp defensively.
|
||||
if (len >= (int)sizeof(cli_command)) {
|
||||
size_t len = strnlen(cli_command, sizeof(cli_command));
|
||||
if (len == sizeof(cli_command)) {
|
||||
cli_command[0] = 0;
|
||||
len = 0;
|
||||
_cli_line_overflow = true;
|
||||
}
|
||||
while (input.available() && len < sizeof(cli_command)-1) {
|
||||
unsigned budget = sizeof(cli_command);
|
||||
while (budget-- && input.available()) {
|
||||
char c = input.read();
|
||||
if (c != '\n') {
|
||||
cli_command[len++] = c;
|
||||
cli_command[len] = 0;
|
||||
}
|
||||
output.print(c); // echo
|
||||
}
|
||||
if (len == sizeof(cli_command)-1) { // buffer full: treat as a completed line
|
||||
cli_command[sizeof(cli_command)-2] = '\r'; // place end-of-line marker inside the buffer
|
||||
cli_command[sizeof(cli_command)-1] = 0; // keep the buffer NUL-terminated
|
||||
if (c == '\r' || c == '\n') {
|
||||
if (_cli_line_overflow) {
|
||||
_cli_line_overflow = false;
|
||||
#if defined(STM32_PLATFORM)
|
||||
output.println(" Error: unknown command");
|
||||
#else
|
||||
output.print("ERR: too long\r\n");
|
||||
#endif
|
||||
} else if (len) goto complete_line;
|
||||
return;
|
||||
}
|
||||
if (_cli_line_overflow) continue;
|
||||
if (len == sizeof(cli_command)-1) {
|
||||
_cli_line_overflow = true;
|
||||
cli_command[0] = 0;
|
||||
len = 0;
|
||||
continue;
|
||||
}
|
||||
cli_command[len++] = c;
|
||||
cli_command[len] = 0;
|
||||
}
|
||||
|
||||
if (len > 0 && cli_command[len - 1] == '\r') { // received complete line
|
||||
cli_command[len - 1] = 0; // replace newline with C string null terminator
|
||||
return;
|
||||
|
||||
complete_line:
|
||||
{
|
||||
reply_buf[0] = 0;
|
||||
#if COMPANION_FEATURE_READER
|
||||
if (mesh::handleReaderCommand(cli_command, output)) {
|
||||
|
||||
@@ -355,6 +355,10 @@ protected:
|
||||
bool sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis);
|
||||
bool sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis=0) override;
|
||||
bool sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis=0) override;
|
||||
#if MESH_ENABLE_FLEET_CONTROL
|
||||
bool sendFleetCommandData(mesh::GroupChannel& channel, const uint8_t* envelope, size_t length);
|
||||
void onRadioProfileCopyQueued(mesh::Packet* packet, const mesh::Packet* original, uint8_t priority) override;
|
||||
#endif
|
||||
|
||||
void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override;
|
||||
#if MESH_PACKET_LOGGING && !MESH_PACKET_LOGGING_COMPACT
|
||||
@@ -466,6 +470,11 @@ public:
|
||||
bool canRecoverUsbLogging() const;
|
||||
|
||||
private:
|
||||
#if MESH_ENABLE_FLEET_CONTROL
|
||||
// Exact ownership of the optional radio copy during two-part admission.
|
||||
mesh::Packet* _fleet_admission_original = nullptr;
|
||||
mesh::Packet* _fleet_admission_copy = nullptr;
|
||||
#endif
|
||||
// Only snapshot the changed value: CompanionNodePrefs owns self-referencing
|
||||
// runtime adapters and must not be copied as a transaction snapshot.
|
||||
template <typename T> bool savePreference(T& preference, T value) {
|
||||
@@ -723,6 +732,7 @@ private:
|
||||
#endif
|
||||
bool send_unscoped; // force un-scoped flood (instead of using send_scope)
|
||||
char cli_command[80];
|
||||
bool _cli_line_overflow = false;
|
||||
char reply_buf[166];
|
||||
uint8_t app_target_ver;
|
||||
uint8_t *sign_data;
|
||||
|
||||
@@ -7956,12 +7956,12 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
|
||||
}
|
||||
}
|
||||
cursor = skipFloodFilterSpaces(cursor);
|
||||
if (strlen(cursor) >= 192) {
|
||||
char params[256];
|
||||
if (strlen(cursor) >= sizeof(params)) {
|
||||
strcpy(reply, "Err - rule parameters too long");
|
||||
return;
|
||||
}
|
||||
|
||||
char params[192];
|
||||
strcpy(params, cursor);
|
||||
char* tokens[18];
|
||||
int token_count = 0;
|
||||
@@ -8244,9 +8244,12 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
|
||||
}
|
||||
char rate_text[24];
|
||||
const bool compact = floodFilterAsciiStartsWith(tokens[i], "q=");
|
||||
StrHelper::strncpy(rate_text,
|
||||
tokens[i] + (compact ? 2 : strlen("rate=")),
|
||||
sizeof(rate_text));
|
||||
const char* value = tokens[i] + (compact ? 2 : strlen("rate="));
|
||||
if (strlen(value) >= sizeof(rate_text)) {
|
||||
strcpy(reply, "Err - rate value too long");
|
||||
return;
|
||||
}
|
||||
StrHelper::strncpy(rate_text, value, sizeof(rate_text));
|
||||
char* slash = strchr(rate_text, '/');
|
||||
if ((!compact && slash == NULL)
|
||||
|| (slash != NULL && !(strcmp(slash, "/min") == 0
|
||||
@@ -8574,12 +8577,16 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
|
||||
}
|
||||
}
|
||||
cursor = skipFloodFilterSpaces(cursor);
|
||||
if (strlen(cursor) >= 140) {
|
||||
#if MESH_ENABLE_FLEET_CONTROL
|
||||
char params[256];
|
||||
#else
|
||||
char params[140];
|
||||
#endif
|
||||
if (strlen(cursor) >= sizeof(params)) {
|
||||
strcpy(reply, "Err - filter parameters too long");
|
||||
return;
|
||||
}
|
||||
|
||||
char params[140];
|
||||
strcpy(params, cursor);
|
||||
char* tokens[7];
|
||||
int token_count = 0;
|
||||
@@ -10175,6 +10182,10 @@ void MyMesh::setFloodGroupModeration(const char* args, char* reply) {
|
||||
return;
|
||||
}
|
||||
char rate_text[24];
|
||||
if (strlen(option + 5) >= sizeof(rate_text)) {
|
||||
strcpy(reply, "Err - rate value too long");
|
||||
return;
|
||||
}
|
||||
StrHelper::strncpy(rate_text, option + 5, sizeof(rate_text));
|
||||
char* slash = strchr(rate_text, '/');
|
||||
if (slash == NULL || !(strcmp(slash, "/min") == 0 || strcmp(slash, "/m") == 0)) {
|
||||
|
||||
@@ -1275,12 +1275,12 @@ void FloodRuleEngine::set(const char* args, char* reply,
|
||||
}
|
||||
}
|
||||
cursor = skipSpaces(cursor);
|
||||
if (strlen(cursor) >= 192) {
|
||||
char params[256];
|
||||
if (strlen(cursor) >= sizeof(params)) {
|
||||
copyString(reply, "Err - rule parameters too long", 160);
|
||||
return;
|
||||
}
|
||||
|
||||
char params[192];
|
||||
copyString(params, cursor, sizeof(params));
|
||||
char* tokens[18];
|
||||
int token_count = 0;
|
||||
@@ -1564,9 +1564,12 @@ void FloodRuleEngine::set(const char* args, char* reply,
|
||||
}
|
||||
bool compact = asciiStartsWith(tokens[i], "q=");
|
||||
char rate_text[24];
|
||||
copyString(rate_text,
|
||||
tokens[i] + (compact ? 2 : strlen("rate=")),
|
||||
sizeof(rate_text));
|
||||
const char* value = tokens[i] + (compact ? 2 : strlen("rate="));
|
||||
if (strlen(value) >= sizeof(rate_text)) {
|
||||
copyString(reply, "Err - rate value too long", 160);
|
||||
return;
|
||||
}
|
||||
copyString(rate_text, value, sizeof(rate_text));
|
||||
char* slash = strchr(rate_text, '/');
|
||||
if ((!compact && slash == NULL)
|
||||
|| (slash != NULL && !(strcmp(slash, "/min") == 0
|
||||
|
||||
@@ -78,9 +78,10 @@ bool FleetChannel::handleConfig(const char* command, char* reply, size_t capacit
|
||||
snprintf(reply, capacity, "> %s", key);
|
||||
} else snprintf(reply, capacity, "> unset");
|
||||
} else if (!strcmp(command, "get fleet.stats")) {
|
||||
snprintf(reply, capacity, "> accepted=%lu rejected=%lu busy=%lu pending=%u ack=%u",
|
||||
snprintf(reply, capacity, "> accepted=%lu rejected=%lu busy=%lu pending=%u ack=%u parts=%u",
|
||||
(unsigned long)accepted_, (unsigned long)rejected_, (unsigned long)busy_,
|
||||
unsigned(pending_), unsigned(barrier_.waiting()));
|
||||
unsigned(pending_), unsigned(barrier_.waiting()),
|
||||
unsigned(bool(assembly_parts_ & 1)) + unsigned(bool(assembly_parts_ & 2)));
|
||||
} else if (!strncmp(command, "set fleet.channel ", 18)) {
|
||||
if (pending_ || barrier_.waiting()) {
|
||||
snprintf(reply, capacity, "Err - fleet command pending; retry later"); return true;
|
||||
@@ -96,6 +97,7 @@ bool FleetChannel::handleConfig(const char* command, char* reply, size_t capacit
|
||||
snprintf(reply, capacity, "Err - fleet settings not saved; store unavailable"); return true;
|
||||
}
|
||||
configured_ = enabled;
|
||||
clearAssembly();
|
||||
memset(channel_.secret, 0, sizeof(channel_.secret));
|
||||
memcpy(channel_.secret, key, sizeof(key));
|
||||
Utils::sha256(channel_.hash, sizeof(channel_.hash), key, sizeof(key));
|
||||
@@ -115,6 +117,7 @@ bool FleetChannel::handleConfig(const char* command, char* reply, size_t capacit
|
||||
}
|
||||
memcpy(controller_.pub_key, key, 32);
|
||||
controller_set_ = !zero(key, 32);
|
||||
clearAssembly();
|
||||
snprintf(reply, capacity, "OK - fleet publisher %s", controller_set_ ? "set" : "off");
|
||||
} else return false;
|
||||
return true;
|
||||
@@ -139,29 +142,82 @@ void FleetChannel::receive(Packet* packet, Mesh& mesh, const TransportKey* scope
|
||||
(void)mesh;
|
||||
}
|
||||
|
||||
bool FleetChannel::decode(Mesh& mesh, FleetCommand::Decoded& command) {
|
||||
void FleetChannel::clearAssembly() {
|
||||
memset(assembly_, 0, sizeof(assembly_));
|
||||
assembly_sequence_ = assembly_deadline_ = 0;
|
||||
assembly_length_ = 0;
|
||||
assembly_parts_ = 0;
|
||||
}
|
||||
|
||||
void FleetChannel::serviceAssemblyDeadline() {
|
||||
if (assembly_parts_ && int32_t(uint32_t(millis()) - assembly_deadline_) >= 0)
|
||||
clearAssembly();
|
||||
}
|
||||
|
||||
FleetChannel::DecodeResult FleetChannel::decode(Mesh& mesh, FleetCommand::Decoded& command) {
|
||||
const uint32_t now_ms = millis();
|
||||
if (uint32_t(now_ms - verify_window_) >= 1000) {
|
||||
verify_window_ = now_ms; verify_attempts_ = 0;
|
||||
}
|
||||
// A forged matching channel hash must not monopolize the radio loop with
|
||||
// Ed25519 work. Legitimate publishers can retry with a fresh sequence.
|
||||
if (verify_attempts_ >= 4) return false;
|
||||
if (verify_attempts_ >= 4) return DecodeResult::Rejected;
|
||||
++verify_attempts_;
|
||||
uint8_t data[MAX_PACKET_PAYLOAD];
|
||||
const int length = Utils::MACThenDecrypt(channel_.secret, data, incoming_ + 1, incoming_len_ - 1);
|
||||
if (length < 3 || data[0] != uint8_t(FleetCommand::DataType)
|
||||
|| data[1] != uint8_t(FleetCommand::DataType >> 8)) return false;
|
||||
|| data[1] != uint8_t(FleetCommand::DataType >> 8)) return DecodeResult::Rejected;
|
||||
const size_t payload_length = data[2];
|
||||
const size_t unpadded = 3 + payload_length;
|
||||
if (unpadded > (size_t)length || (unpadded + 15) / 16 * 16 != (size_t)length
|
||||
|| !zero(data + unpadded, length - unpadded)) return false;
|
||||
if (payload_length < FleetCommand::MinHeaderSize + FleetCommand::SignatureSize
|
||||
|| storage::readLE32(data + 7) <= last_sequence_) return false;
|
||||
if (!FleetCommand::decode(controller_, channel_.secret, data + 3, payload_length,
|
||||
mesh.getRTCClock()->getCurrentTime(), mesh.self_id.pub_key, command)) return false;
|
||||
if (payload_length > FleetCommand::MaxPayloadLength || unpadded > (size_t)length
|
||||
|| (unpadded + 15) / 16 * 16 != (size_t)length
|
||||
|| !zero(data + unpadded, length - unpadded)) return DecodeResult::Rejected;
|
||||
const uint8_t* envelope = data + 3;
|
||||
size_t envelope_length = payload_length;
|
||||
bool assembled = false;
|
||||
if (payload_length >= 4 && !memcmp(envelope, "FMP1", 4)) {
|
||||
FleetCommand::Fragment fragment;
|
||||
if (!FleetCommand::parseFragment(envelope, payload_length, fragment)
|
||||
|| fragment.sequence <= last_sequence_) return DecodeResult::Rejected;
|
||||
const uint32_t now = mesh.getRTCClock()->getCurrentTime();
|
||||
if (fragment.sequence < FleetCommand::MinEpoch
|
||||
|| (fragment.sequence > now && fragment.sequence - now > FleetCommand::MaxClockLead)
|
||||
|| (now > fragment.sequence && now - fragment.sequence > FleetCommand::MaxLifetime))
|
||||
return DecodeResult::Rejected;
|
||||
// A fresh retry must not wait five minutes for a lost second fragment.
|
||||
// Older transfers and conflicting fragments cannot disturb newer intent.
|
||||
if (assembly_parts_ && fragment.sequence > assembly_sequence_) clearAssembly();
|
||||
if (assembly_parts_ && (assembly_sequence_ != fragment.sequence
|
||||
|| assembly_length_ != fragment.total_length))
|
||||
return DecodeResult::Rejected;
|
||||
if (!assembly_parts_) {
|
||||
assembly_sequence_ = fragment.sequence;
|
||||
assembly_length_ = fragment.total_length;
|
||||
// A duplicate cannot extend this fixed five-minute collection window.
|
||||
assembly_deadline_ = now_ms + 300000UL;
|
||||
}
|
||||
const size_t offset = fragment.index * FleetCommand::FragmentDataLength;
|
||||
const uint8_t mask = uint8_t(1U << fragment.index);
|
||||
if (assembly_parts_ & mask) {
|
||||
return !memcmp(assembly_ + offset, fragment.data, fragment.length)
|
||||
? DecodeResult::Partial : DecodeResult::Rejected;
|
||||
}
|
||||
memcpy(assembly_ + offset, fragment.data, fragment.length);
|
||||
assembly_parts_ |= mask;
|
||||
if (assembly_parts_ != 3) return DecodeResult::Partial;
|
||||
envelope = assembly_;
|
||||
envelope_length = assembly_length_;
|
||||
assembled = true;
|
||||
}
|
||||
const bool valid = envelope_length >= FleetCommand::MinHeaderSize + FleetCommand::SignatureSize
|
||||
&& storage::readLE32(envelope + 4) > last_sequence_
|
||||
&& (!assembled || storage::readLE32(envelope + 4) == assembly_sequence_)
|
||||
&& FleetCommand::decode(controller_, channel_.secret, envelope, envelope_length,
|
||||
mesh.getRTCClock()->getCurrentTime(), mesh.self_id.pub_key, command);
|
||||
if (assembled || (valid && assembly_parts_ && command.sequence >= assembly_sequence_)) clearAssembly();
|
||||
if (!valid) return DecodeResult::Rejected;
|
||||
broadcast_ = command.broadcast;
|
||||
return true;
|
||||
return DecodeResult::Accepted;
|
||||
}
|
||||
|
||||
bool FleetChannel::acknowledge(Mesh& mesh, const char* name, uint32_t sequence,
|
||||
|
||||
@@ -54,6 +54,10 @@ class FleetChannel {
|
||||
bool broadcast_ = false;
|
||||
bool ack_profile_mutation_ = false;
|
||||
uint8_t incoming_[MAX_PACKET_PAYLOAD]{};
|
||||
uint8_t assembly_[FleetCommand::MaxEnvelopeLength]{};
|
||||
uint32_t assembly_sequence_ = 0, assembly_deadline_ = 0;
|
||||
uint16_t assembly_length_ = 0;
|
||||
uint8_t assembly_parts_ = 0;
|
||||
uint8_t incoming_len_ = 0, rx_profile_ = 0, path_hash_size_ = 1;
|
||||
uint32_t rx_generation_ = 0;
|
||||
TransportKey reply_scope_{};
|
||||
@@ -63,7 +67,10 @@ class FleetChannel {
|
||||
bool load();
|
||||
bool save(bool enabled, const uint8_t* key, const uint8_t* controller,
|
||||
uint32_t sequence);
|
||||
bool decode(Mesh& mesh, FleetCommand::Decoded& command);
|
||||
enum class DecodeResult { Rejected, Partial, Accepted };
|
||||
DecodeResult decode(Mesh& mesh, FleetCommand::Decoded& command);
|
||||
void clearAssembly();
|
||||
void serviceAssemblyDeadline();
|
||||
bool acknowledge(Mesh& mesh, const char* name, uint32_t sequence,
|
||||
const char* reply, bool radio_mutation);
|
||||
void finish(RadioProfileCLI& profiles, bool delivered);
|
||||
@@ -83,12 +90,15 @@ class FleetChannel {
|
||||
void service(Mesh& mesh, RadioProfileCLI& profiles, const char* name,
|
||||
Handler handle) {
|
||||
serviceDeadline(mesh, profiles);
|
||||
serviceAssemblyDeadline();
|
||||
if (!pending_) return;
|
||||
pending_ = false;
|
||||
if (barrier_.waiting() || profiles.hasReplyMutation()
|
||||
|| (hooks_ && hooks_->hasFleetReplyMutation())) { ++busy_; return; }
|
||||
FleetCommand::Decoded command;
|
||||
if (!decode(mesh, command)) { ++rejected_; return; }
|
||||
const DecodeResult decoded = decode(mesh, command);
|
||||
if (decoded == DecodeResult::Partial) return;
|
||||
if (decoded != DecodeResult::Accepted) { ++rejected_; return; }
|
||||
// Reserve before dispatch, including failed commands. A lost reply, reboot,
|
||||
// or retry can never repeat a mutation. A storage failure grants no control.
|
||||
if (!save(configured_, channel_.secret, controller_.pub_key, command.sequence)) {
|
||||
|
||||
@@ -7,11 +7,16 @@ namespace {
|
||||
|
||||
constexpr char Domain[] = "MeshCoreFleet1";
|
||||
constexpr size_t SignedMessageCapacity = sizeof(Domain) - 1
|
||||
+ FleetCommand::KeySize + FleetCommand::MaxPayloadLength
|
||||
+ FleetCommand::KeySize + FleetCommand::MaxEnvelopeLength
|
||||
- FleetCommand::SignatureSize;
|
||||
static_assert(FleetCommand::HeaderSize + FleetCommand::MaxCommandLength
|
||||
+ FleetCommand::SignatureSize <= FleetCommand::MaxPayloadLength,
|
||||
"Legacy fleet envelope must fit the shared packet budget");
|
||||
static_assert(FleetCommand::HeaderSize + FleetCommand::FixedTargetMaxCommandLength
|
||||
+ FleetCommand::SignatureSize <= FleetCommand::MaxEnvelopeLength,
|
||||
"Fixed-target fleet envelope must fit the two-packet budget");
|
||||
static_assert(FleetCommand::MinHeaderSize + FleetCommand::MaxCommandLength
|
||||
+ FleetCommand::SignatureSize <= FleetCommand::MaxEnvelopeLength,
|
||||
"Compact fleet envelope must fit the two-packet budget");
|
||||
static_assert(FleetCommand::MaxCommandLength <= UINT8_MAX,
|
||||
"Fleet command length must fit the signed length byte");
|
||||
constexpr uint8_t PublicKey[FleetCommand::KeySize] = {
|
||||
0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a,
|
||||
0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72
|
||||
@@ -355,7 +360,7 @@ size_t FleetCommand::encode(const LocalIdentity& publisher, const uint8_t* key,
|
||||
const size_t command_length = strlen(command);
|
||||
const size_t unsigned_length = HeaderSize + command_length;
|
||||
const size_t length = unsigned_length + SignatureSize;
|
||||
if (capacity < length) return 0;
|
||||
if (length > MaxEnvelopeLength || capacity < length) return 0;
|
||||
memcpy(output, "FMC1", 4);
|
||||
write32(output + 4, sequence);
|
||||
write32(output + 8, expires);
|
||||
@@ -389,7 +394,7 @@ size_t FleetCommand::encode(const LocalIdentity& publisher, const uint8_t* key,
|
||||
const size_t command_length = strlen(command);
|
||||
const size_t unsigned_length = MinHeaderSize + targets.length + command_length;
|
||||
const size_t length = unsigned_length + SignatureSize;
|
||||
if (length > MaxPayloadLength || capacity < length) return 0;
|
||||
if (length > MaxEnvelopeLength || capacity < length) return 0;
|
||||
memcpy(output, "FMC2", 4);
|
||||
write32(output + 4, sequence);
|
||||
write32(output + 8, expires);
|
||||
@@ -411,7 +416,7 @@ bool FleetCommand::decode(const Identity& publisher, const uint8_t* key,
|
||||
if (!privateKeyAllowed(key) || !payload || !self_public_key
|
||||
|| zero(publisher.pub_key, PUB_KEY_SIZE) || zero(self_public_key, PUB_KEY_SIZE)
|
||||
|| now < MinEpoch || length < MinHeaderSize + SignatureSize
|
||||
|| length > MaxPayloadLength) return false;
|
||||
|| length > MaxEnvelopeLength) return false;
|
||||
const size_t unsigned_length = length - SignatureSize;
|
||||
size_t command_offset;
|
||||
bool broadcast;
|
||||
@@ -458,4 +463,39 @@ bool FleetCommand::decode(const Identity& publisher, const uint8_t* key,
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t FleetCommand::fragment(const uint8_t* envelope, size_t length, uint8_t index,
|
||||
uint8_t* output, size_t capacity) {
|
||||
if (!envelope || !output || length <= MaxPayloadLength
|
||||
|| length > MaxEnvelopeLength || index > 1
|
||||
|| (memcmp(envelope, "FMC1", 4) && memcmp(envelope, "FMC2", 4))) return 0;
|
||||
const size_t offset = size_t(index) * FragmentDataLength;
|
||||
const size_t chunk_length = index ? length - offset : FragmentDataLength;
|
||||
const size_t fragment_length = FragmentHeaderSize + chunk_length;
|
||||
if (capacity < fragment_length) return 0;
|
||||
memcpy(output, "FMP1", 4);
|
||||
memcpy(output + 4, envelope + 4, 4); // The receiver checks this against signed UTC sequence.
|
||||
output[8] = uint8_t(length);
|
||||
output[9] = uint8_t(length >> 8);
|
||||
output[10] = index;
|
||||
memcpy(output + FragmentHeaderSize, envelope + offset, chunk_length);
|
||||
return fragment_length;
|
||||
}
|
||||
|
||||
bool FleetCommand::parseFragment(const uint8_t* payload, size_t length, Fragment& output) {
|
||||
output = {};
|
||||
if (!payload || length < FragmentHeaderSize || length > MaxPayloadLength
|
||||
|| memcmp(payload, "FMP1", 4)) return false;
|
||||
const uint16_t total = uint16_t(payload[8]) | (uint16_t(payload[9]) << 8);
|
||||
const uint8_t index = payload[10];
|
||||
if (total <= MaxPayloadLength || total > MaxEnvelopeLength || index > 1) return false;
|
||||
const size_t chunk_length = index ? total - FragmentDataLength : FragmentDataLength;
|
||||
if (length != FragmentHeaderSize + chunk_length) return false;
|
||||
output.sequence = read32(payload + 4);
|
||||
output.total_length = total;
|
||||
output.index = index;
|
||||
output.data = payload + FragmentHeaderSize;
|
||||
output.length = chunk_length;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace mesh
|
||||
|
||||
@@ -16,12 +16,17 @@ public:
|
||||
static constexpr size_t KeySize = 16;
|
||||
static constexpr size_t TargetSize = 16;
|
||||
static constexpr size_t SignatureSize = 64;
|
||||
static constexpr size_t MaxCommandLength = 72;
|
||||
static constexpr size_t HeaderSize = 29;
|
||||
static constexpr size_t MinHeaderSize = 14;
|
||||
static constexpr size_t MaxPayloadLength = 165;
|
||||
static constexpr size_t FragmentHeaderSize = 11;
|
||||
static constexpr size_t FragmentDataLength = MaxPayloadLength - FragmentHeaderSize;
|
||||
static constexpr size_t MaxEnvelopeLength = 2 * FragmentDataLength;
|
||||
static constexpr size_t SinglePacketMaxCommandLength = MaxPayloadLength - MinHeaderSize - SignatureSize;
|
||||
static constexpr size_t MaxCommandLength = MaxEnvelopeLength - MinHeaderSize - SignatureSize;
|
||||
static constexpr size_t FixedTargetMaxCommandLength = MaxEnvelopeLength - HeaderSize - SignatureSize;
|
||||
static constexpr size_t MaxTargetBytes = 86;
|
||||
static constexpr size_t MaxTargetCount = 17;
|
||||
static constexpr size_t MaxPayloadLength = 165;
|
||||
static constexpr uint32_t MinEpoch = 1735689600UL; // 2025-01-01 UTC
|
||||
static constexpr uint32_t MaxLifetime = 600;
|
||||
static constexpr uint32_t MaxClockLead = 60;
|
||||
@@ -41,6 +46,14 @@ public:
|
||||
uint8_t data[MaxTargetBytes];
|
||||
};
|
||||
|
||||
struct Fragment {
|
||||
uint32_t sequence;
|
||||
uint16_t total_length;
|
||||
uint8_t index;
|
||||
const uint8_t* data;
|
||||
size_t length;
|
||||
};
|
||||
|
||||
static bool privateKeyAllowed(const uint8_t* key);
|
||||
// "all" becomes the reserved zero target; otherwise require one complete
|
||||
// public key and derive the first 16 bytes of SHA-256, not a short node ID.
|
||||
@@ -59,7 +72,7 @@ public:
|
||||
|
||||
// Broadcast and prefix/list requests use FMC2. Zero records imply all;
|
||||
// a single complete key retains FMC1 encoding. Targets and command share
|
||||
// the packet budget, so valid lists can still be too large for a command.
|
||||
// the two-packet envelope budget, so lists reduce the command allowance.
|
||||
static size_t encode(const LocalIdentity& publisher, const uint8_t* key,
|
||||
uint32_t sequence, uint32_t expires,
|
||||
const Targets& targets, const char* command,
|
||||
@@ -71,6 +84,15 @@ public:
|
||||
static bool decode(const Identity& publisher, const uint8_t* key,
|
||||
const uint8_t* payload, size_t length, uint32_t now,
|
||||
const uint8_t* self_public_key, Decoded& output);
|
||||
|
||||
// Envelopes <= MaxPayloadLength travel unchanged. Longer envelopes use
|
||||
// exactly two FMP1 frames, each bounded by the group payload limit.
|
||||
static size_t fragment(const uint8_t* envelope, size_t length, uint8_t index,
|
||||
uint8_t* output, size_t capacity);
|
||||
// Fragment metadata is untrusted. The receiver must assemble both parts,
|
||||
// verify the entire envelope once, require its signed sequence to match,
|
||||
// and durably reserve replay state before executing any command.
|
||||
static bool parseFragment(const uint8_t* payload, size_t length, Fragment& output);
|
||||
};
|
||||
|
||||
} // namespace mesh
|
||||
|
||||
+5
-1
@@ -23,7 +23,7 @@
|
||||
namespace mesh {
|
||||
struct FleetCommand {
|
||||
static constexpr uint16_t DataType = 0xFF01;
|
||||
static constexpr size_t KeySize = 16, TargetSize = 16, MaxPayloadLength = 165;
|
||||
static constexpr size_t KeySize = 16, TargetSize = 16, MaxPayloadLength = 165, MaxEnvelopeLength = 308;
|
||||
static constexpr uint32_t MinEpoch = 1735689600UL, MaxLifetime = 600;
|
||||
struct Targets { uint8_t length = 0, count = 0, data[86] = {}; };
|
||||
static bool parseTargets(const char*, size_t, Targets&) { return false; }
|
||||
@@ -52,4 +52,8 @@ struct CompanionFleetFixture {
|
||||
assert(false && "fleet packet admission belongs in its dedicated suite");
|
||||
return false;
|
||||
}
|
||||
bool sendFleetCommandData(FleetFixtureChannel&, const uint8_t*, size_t) {
|
||||
assert(false && "fleet packet admission belongs in its dedicated suite");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -155,7 +155,7 @@ class CompanionExtraFsHilGuardTest(unittest.TestCase):
|
||||
self.assertNotIn("handleExtraFsHilCommand", generic)
|
||||
self.assertNotIn("hil extrafs", generic)
|
||||
self.assertNotIn("handleExtraFsHilCommand", on_air)
|
||||
self.assertIn("handleCommand(text, sender_timestamp, reply)", on_air)
|
||||
self.assertIn("handleCommand(text, sender_timestamp ? sender_timestamp : 1, reply)", on_air)
|
||||
|
||||
def test_filler_reports_only_read_verified_durable_bytes(self):
|
||||
body = function_body(
|
||||
|
||||
@@ -7,6 +7,7 @@ supplies real Ed25519 signing and verification through the Identity interface.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
@@ -24,10 +25,23 @@ HARNESS = r'''
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <helpers/FleetCommand.h>
|
||||
#include <helpers/CompanionFrameLimits.h>
|
||||
#include <openssl/evp.h>
|
||||
|
||||
struct RescueStream : Stream {
|
||||
std::string input, output;
|
||||
size_t position = 0, reads = 0;
|
||||
int available() override { return position < input.size(); }
|
||||
int read() override { ++reads; return input[position++]; }
|
||||
size_t write(uint8_t value) override { output += char(value); return 1; }
|
||||
};
|
||||
static RescueStream rescue_input, rescue_output;
|
||||
|
||||
namespace mesh {
|
||||
Stream& usbTerminalPort() { return rescue_output; }
|
||||
Stream& usbCompanionPort() { return rescue_input; }
|
||||
Identity::Identity() { memset(pub_key, 0, sizeof(pub_key)); }
|
||||
LocalIdentity::LocalIdentity() : Identity() {
|
||||
memset(prv_key, 0xAA, 32);
|
||||
@@ -62,23 +76,78 @@ bool Identity::verify(const uint8_t* signature, const uint8_t* data, int length)
|
||||
|
||||
#define MAX_GROUP_CHANNELS 4
|
||||
#define OUT_PATH_UNKNOWN 255
|
||||
namespace mesh {
|
||||
struct GroupChannel {
|
||||
uint8_t secret[32] = {};
|
||||
uint8_t tx_radio = 0;
|
||||
};
|
||||
struct Packet {
|
||||
uint8_t payload[168] = {};
|
||||
size_t payload_len = 0;
|
||||
uint8_t tx_radio = 0;
|
||||
uint16_t scope = 0;
|
||||
bool isRouteDirect() const { return false; }
|
||||
uint8_t getPayloadType() const { return 6; }
|
||||
};
|
||||
struct Mesh {
|
||||
unsigned base_copy_hooks = 0;
|
||||
void onRadioProfileCopyQueued(Packet*, const Packet*, uint8_t) { ++base_copy_hooks; }
|
||||
};
|
||||
}
|
||||
using GroupChannel = mesh::GroupChannel;
|
||||
#define PAYLOAD_TYPE_GRP_DATA 6
|
||||
#define PAYLOAD_TYPE_TRACE 9
|
||||
|
||||
struct PacketManager {
|
||||
mesh::Packet pool[64];
|
||||
bool used[64] = {};
|
||||
std::vector<mesh::Packet*> outbound;
|
||||
unsigned total = 0, allocations = 0, released = 0;
|
||||
unsigned fail_allocation = 0;
|
||||
mesh::Packet* allocNew() {
|
||||
if (++allocations == fail_allocation) return nullptr;
|
||||
for (unsigned i = 0; i < 64; ++i) if (!used[i]) {
|
||||
used[i] = true; pool[i] = {}; return &pool[i];
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
void free(mesh::Packet* packet) {
|
||||
const ptrdiff_t index = packet - pool;
|
||||
assert(index >= 0 && index < 64 && used[index]);
|
||||
used[index] = false; ++released;
|
||||
}
|
||||
unsigned freeCount() const { unsigned count = 0; for (bool value : used) count += !value; return count; }
|
||||
int getOutboundTotal() const { return int(total); }
|
||||
mesh::Packet* getOutboundByIdx(int index) { return outbound.at(index); }
|
||||
mesh::Packet* removeOutboundByIdx(int index) {
|
||||
auto* packet = outbound.at(index); outbound.erase(outbound.begin() + index); --total;
|
||||
return packet;
|
||||
}
|
||||
void queue(mesh::Packet* packet) { outbound.push_back(packet); ++total; }
|
||||
};
|
||||
struct ChannelDetails { GroupChannel channel; char name[32] = {}; };
|
||||
struct Clock { uint32_t now = mesh::FleetCommand::MinEpoch + 1000;
|
||||
uint32_t getCurrentTime() const { return now; } };
|
||||
class MyMesh {
|
||||
class MyMesh : public mesh::Mesh {
|
||||
public:
|
||||
mesh::LocalIdentity self_id;
|
||||
Clock clock;
|
||||
ChannelDetails channels[MAX_GROUP_CHANNELS];
|
||||
bool queue_ok = true;
|
||||
unsigned queued = 0;
|
||||
PacketManager manager;
|
||||
PacketManager* _mgr = &manager;
|
||||
unsigned& queued = manager.total;
|
||||
unsigned queue_calls = 0, fail_queue = 0, failed_hooks = 0, radio_cancellations = 0;
|
||||
bool radio_copy = false;
|
||||
mesh::Packet* _fleet_admission_original = nullptr;
|
||||
mesh::Packet* _fleet_admission_copy = nullptr;
|
||||
char cli_command[80] = {};
|
||||
bool _cli_line_overflow = false;
|
||||
bool _cli_rescue = false;
|
||||
std::vector<std::string> rescue_commands;
|
||||
uint8_t sent_policy = 0;
|
||||
uint16_t sent_type = 0;
|
||||
uint8_t sent[mesh::FleetCommand::MaxPayloadLength] = {};
|
||||
uint8_t sent[mesh::FleetCommand::MaxEnvelopeLength] = {};
|
||||
size_t sent_length = 0;
|
||||
MyMesh() {
|
||||
strcpy(channels[1].name, "private fleet");
|
||||
@@ -90,18 +159,64 @@ class MyMesh {
|
||||
out = channels[index]; return true;
|
||||
}
|
||||
Clock* getRTCClock() { return &clock; }
|
||||
mesh::Packet* createGroupDatagram(uint8_t type, const GroupChannel& channel,
|
||||
const uint8_t* data, size_t length) {
|
||||
assert(type == PAYLOAD_TYPE_GRP_DATA && length <= sizeof(mesh::Packet::payload));
|
||||
auto* packet = manager.allocNew();
|
||||
if (!packet) return nullptr;
|
||||
packet->tx_radio = channel.tx_radio; packet->payload_len = length;
|
||||
memcpy(packet->payload, data, length); return packet;
|
||||
}
|
||||
void releasePacket(mesh::Packet* packet) { manager.free(packet); }
|
||||
void onSendFail(mesh::Packet*) { ++failed_hooks; }
|
||||
void cancelOutboundRadioRetry(const mesh::Packet*) { ++radio_cancellations; }
|
||||
void onTracePacketQueuedForSend(mesh::Packet*) { assert(false); }
|
||||
bool queueOutboundPacket(mesh::Packet* packet, uint8_t, uint32_t) {
|
||||
if (++queue_calls == fail_queue || !queue_ok) return false;
|
||||
manager.queue(packet);
|
||||
sent_policy = packet->tx_radio;
|
||||
sent_type = uint16_t(packet->payload[0]) | uint16_t(packet->payload[1]) << 8;
|
||||
const uint8_t* blob = packet->payload + 3;
|
||||
const size_t length = packet->payload[2];
|
||||
assert(length + 3 == packet->payload_len && length <= mesh::FleetCommand::MaxPayloadLength);
|
||||
mesh::FleetCommand::Fragment part;
|
||||
if (mesh::FleetCommand::parseFragment(blob, length, part)) {
|
||||
memcpy(sent + part.index * mesh::FleetCommand::FragmentDataLength, part.data, part.length);
|
||||
sent_length = part.total_length;
|
||||
} else {
|
||||
memcpy(sent, blob, length); sent_length = length;
|
||||
}
|
||||
if (radio_copy) {
|
||||
auto* copy = manager.allocNew(); assert(copy); *copy = *packet;
|
||||
manager.queue(copy); onRadioProfileCopyQueued(copy, packet, 1);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
bool sendPacket(mesh::Packet*, uint8_t, uint32_t = 0);
|
||||
bool sendFloodScoped(const GroupChannel& channel, mesh::Packet* packet, uint32_t = 0) {
|
||||
assert(packet->tx_radio == channel.tx_radio);
|
||||
packet->scope = 0x4321; // Scope adapter; production helper preserves this channel route.
|
||||
return sendPacket(packet, 1);
|
||||
}
|
||||
bool sendGroupData(GroupChannel& channel, uint8_t* path, uint8_t path_length,
|
||||
uint16_t type, const uint8_t* data, int length) {
|
||||
assert(path == nullptr && path_length == OUT_PATH_UNKNOWN);
|
||||
assert(length > 0 && size_t(length) <= sizeof(sent));
|
||||
if (!queue_ok) return false;
|
||||
sent_policy = channel.tx_radio; sent_type = type;
|
||||
memcpy(sent, data, length); sent_length = length; ++queued;
|
||||
return true;
|
||||
assert(length > 0 && size_t(length) <= mesh::FleetCommand::MaxPayloadLength);
|
||||
uint8_t group[168] = {uint8_t(type), uint8_t(type >> 8), uint8_t(length)};
|
||||
memcpy(group + 3, data, length);
|
||||
auto* packet = createGroupDatagram(PAYLOAD_TYPE_GRP_DATA, channel, group, 3 + length);
|
||||
return packet && sendFloodScoped(channel, packet);
|
||||
}
|
||||
bool sendFleetCommandData(GroupChannel&, const uint8_t*, size_t);
|
||||
void onRadioProfileCopyQueued(mesh::Packet*, const mesh::Packet*, uint8_t);
|
||||
bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply);
|
||||
void checkCLIRescueCmd();
|
||||
void enterCLIRescue();
|
||||
void resetUsbHostSessionInput();
|
||||
};
|
||||
@METHOD@
|
||||
@SEND_HELPERS@
|
||||
@RESCUE_READER@
|
||||
|
||||
static void rejected(MyMesh& node, const char* text, uint32_t remote = 0) {
|
||||
char reply[160] = {};
|
||||
@@ -118,6 +233,37 @@ static std::string fullKey(uint8_t value) {
|
||||
return hex;
|
||||
}
|
||||
|
||||
static void checkedFragments(MyMesh& node, size_t first_index) {
|
||||
assert(node.sent_length > mesh::FleetCommand::MaxPayloadLength);
|
||||
uint8_t assembled[mesh::FleetCommand::MaxEnvelopeLength] = {};
|
||||
// Read in reverse order; the immutable part index determines placement.
|
||||
for (uint8_t index : {1, 0}) {
|
||||
auto* packet = node.manager.outbound.at(first_index + index);
|
||||
assert(packet->scope == 0x4321 && packet->tx_radio == 2);
|
||||
assert(packet->payload[0] == 1 && packet->payload[1] == 0xff);
|
||||
const size_t bytes = packet->payload[2];
|
||||
mesh::FleetCommand::Fragment part;
|
||||
assert(mesh::FleetCommand::parseFragment(packet->payload + 3, bytes, part));
|
||||
assert(part.index == index && part.total_length == node.sent_length);
|
||||
assert(part.sequence == node.clock.now && bytes <= mesh::FleetCommand::MaxPayloadLength);
|
||||
assert(part.length == (index == 0 ? 154 : node.sent_length - 154));
|
||||
assert(bytes == mesh::FleetCommand::FragmentHeaderSize + part.length);
|
||||
memcpy(assembled + index * 154, part.data, part.length);
|
||||
uint8_t recipient[32]; memset(recipient, 0x42, sizeof(recipient));
|
||||
mesh::FleetCommand::Decoded decoded;
|
||||
assert(!mesh::FleetCommand::decode(node.self_id, node.channels[1].channel.secret,
|
||||
packet->payload + 3, bytes, node.clock.now, recipient, decoded));
|
||||
}
|
||||
assert(!memcmp(assembled, node.sent, node.sent_length));
|
||||
uint8_t recipient[32]; memset(recipient, 0x42, sizeof(recipient));
|
||||
mesh::FleetCommand::Decoded decoded;
|
||||
assert(mesh::FleetCommand::decode(node.self_id, node.channels[1].channel.secret,
|
||||
assembled, node.sent_length, node.clock.now, recipient, decoded));
|
||||
assembled[node.sent_length - 1] ^= 1;
|
||||
assert(!mesh::FleetCommand::decode(node.self_id, node.channels[1].channel.secret,
|
||||
assembled, node.sent_length, node.clock.now, recipient, decoded));
|
||||
}
|
||||
|
||||
static void compactBroadcast(MyMesh& node, const char* command) {
|
||||
assert(!memcmp(node.sent, "FMC2", 4));
|
||||
assert(node.sent[12] == 0); // No target records means every authorized receiver.
|
||||
@@ -125,6 +271,8 @@ static void compactBroadcast(MyMesh& node, const char* command) {
|
||||
assert(mesh::FleetCommand::MinHeaderSize == 14);
|
||||
assert(node.sent_length == 14 + strlen(command) + mesh::FleetCommand::SignatureSize);
|
||||
assert(!memcmp(node.sent + 14, command, strlen(command)));
|
||||
if (node.sent_length > mesh::FleetCommand::MaxPayloadLength)
|
||||
checkedFragments(node, node.manager.outbound.size() - 2);
|
||||
for (uint8_t value : {0x42, 0x77}) {
|
||||
uint8_t recipient[32]; memset(recipient, value, sizeof(recipient));
|
||||
mesh::FleetCommand::Decoded decoded;
|
||||
@@ -225,12 +373,210 @@ int main(int argc, char** argv) {
|
||||
}
|
||||
const std::string prefix = "set flood.filter ";
|
||||
const std::string maximum = prefix + std::string(mesh::FleetCommand::MaxCommandLength - prefix.size(), '1');
|
||||
assert(maximum.size() == 72 && mesh::FleetCommand::commandAllowed(maximum.c_str()));
|
||||
assert(maximum.size() == 230 && mesh::FleetCommand::commandAllowed(maximum.c_str()));
|
||||
rejected(node, ("fleet send 1 " + maximum + "1").c_str());
|
||||
rejected(node, ("fleet send 1 all " + maximum + "1").c_str());
|
||||
assert(node.handleCommand(("fleet send 1 " + maximum).c_str(), 0, reply));
|
||||
compactBroadcast(node, maximum.c_str());
|
||||
assert(node.sent_length == 150 && node.queued == 1); // Compact header keeps the 72-byte cap.
|
||||
assert(node.sent_length == 308 && node.queued == 2);
|
||||
++node.clock.now;
|
||||
assert(node.handleCommand(("fleet send 1 all " + maximum).c_str(), 0, reply));
|
||||
compactBroadcast(node, maximum.c_str());
|
||||
assert(node.sent_length == 308 && node.queued == 4);
|
||||
} else if (scenario == "target_command_capacity") {
|
||||
struct Boundary { std::string targets; size_t command_length; };
|
||||
const Boundary boundaries[] = {
|
||||
{"42424242", 225}, {"424242424242", 223}, {fullKey(0x42), 215},
|
||||
{"42424242,51515151", 220}, {"42424242,515151515151", 218},
|
||||
{fullKey(0x42) + "," + fullKey(0x51), 196},
|
||||
};
|
||||
const std::string prefix = "set flood.filter ";
|
||||
for (const Boundary& boundary : boundaries) {
|
||||
const std::string fitting = prefix + std::string(boundary.command_length - prefix.size(), '1');
|
||||
const std::string oversize = fitting + '1';
|
||||
assert(mesh::FleetCommand::commandAllowed(oversize.c_str()));
|
||||
const std::string cli_prefix = "fleet send 1 " + boundary.targets + " ";
|
||||
const unsigned before = node.queued;
|
||||
rejected(node, (cli_prefix + oversize).c_str());
|
||||
// A valid target plus a long command must not become a broadcast, a
|
||||
// partial send, or a consumed sequence. A queue refusal is retryable too.
|
||||
assert(node.handleCommand((cli_prefix + oversize).c_str(), 0, reply));
|
||||
assert(strstr(reply, "exceed two packets") && node.queued == before);
|
||||
node.queue_ok = false;
|
||||
rejected(node, (cli_prefix + fitting).c_str());
|
||||
node.queue_ok = true;
|
||||
assert(node.handleCommand((cli_prefix + fitting).c_str(), 0, reply));
|
||||
assert(node.queued == before + 2 && node.sent_length == 308);
|
||||
assert(node.sent_policy == 2);
|
||||
uint8_t recipient[32]; memset(recipient, 0x42, sizeof(recipient));
|
||||
mesh::FleetCommand::Decoded decoded;
|
||||
assert(mesh::FleetCommand::decode(node.self_id, node.channels[1].channel.secret,
|
||||
node.sent, node.sent_length, node.clock.now, recipient, decoded));
|
||||
assert(decoded.sequence == node.clock.now && !strcmp(decoded.command, fitting.c_str()));
|
||||
assert(!memcmp(node.sent, boundary.targets.size() == 64 ? "FMC1" : "FMC2", 4));
|
||||
memset(recipient, 0x77, sizeof(recipient));
|
||||
assert(!mesh::FleetCommand::decode(node.self_id, node.channels[1].channel.secret,
|
||||
node.sent, node.sent_length, node.clock.now, recipient, decoded));
|
||||
rejected(node, (cli_prefix + fitting).c_str()); // Successful send consumes its second.
|
||||
++node.clock.now;
|
||||
}
|
||||
assert(node.queued == 12);
|
||||
} else if (scenario == "maximum_correlated_app_command") {
|
||||
const std::string prefix = "set flood.filter ";
|
||||
const std::string maximum = prefix + std::string(158 - prefix.size(), '1');
|
||||
const std::string local_cli = "AB|fleet send 1 " + maximum;
|
||||
// The app's local CLI frame adds opcode 66 and a trailing NUL. Its request
|
||||
// correlation prefix is already inside local_cli; all are ASCII bytes.
|
||||
assert(local_cli.size() + 2 == MAX_FRAME_SIZE);
|
||||
assert((local_cli + '1').size() + 2 > MAX_FRAME_SIZE);
|
||||
rejected(node, local_cli.c_str(), 1);
|
||||
node.queue_ok = false;
|
||||
rejected(node, local_cli.c_str());
|
||||
node.queue_ok = true;
|
||||
assert(node.handleCommand(local_cli.c_str(), 0, reply));
|
||||
assert(!strncmp(reply, "AB|OK - fleet command queued; seq=", 32));
|
||||
compactBroadcast(node, maximum.c_str());
|
||||
assert(node.sent_length == 236 && node.queued == 2);
|
||||
assert(node.sent_policy == 2);
|
||||
rejected(node, local_cli.c_str());
|
||||
++node.clock.now;
|
||||
const std::string unprefixed = prefix + std::string(161 - prefix.size(), '1');
|
||||
const std::string implicit = "fleet send 1 " + unprefixed;
|
||||
assert(implicit.size() + 2 == MAX_FRAME_SIZE);
|
||||
assert((implicit + '1').size() + 2 > MAX_FRAME_SIZE);
|
||||
assert(node.handleCommand(implicit.c_str(), 0, reply));
|
||||
compactBroadcast(node, unprefixed.c_str());
|
||||
assert(node.sent_length == 239 && node.queued == 4);
|
||||
} else if (scenario == "fragment_boundaries") {
|
||||
struct Boundary { std::string targets; size_t single_length; };
|
||||
const Boundary boundaries[] = {
|
||||
{"", 87}, {"42424242 ", 82}, {"424242424242 ", 80}, {fullKey(0x42) + " ", 72},
|
||||
};
|
||||
const std::string prefix = "set flood.filter ";
|
||||
for (const Boundary& boundary : boundaries) {
|
||||
std::string command = prefix + std::string(boundary.single_length - prefix.size(), '1');
|
||||
const std::string cli_prefix = "fleet send 1 " + boundary.targets;
|
||||
const size_t before = node.manager.outbound.size();
|
||||
assert(node.handleCommand((cli_prefix + command).c_str(), 0, reply));
|
||||
assert(node.queued == before + 1 && node.sent_length == 165);
|
||||
assert(!memcmp(node.manager.outbound.back()->payload + 3,
|
||||
boundary.targets.size() == 65 ? "FMC1" : "FMC2", 4));
|
||||
++node.clock.now; command += '1';
|
||||
assert(node.handleCommand((cli_prefix + command).c_str(), 0, reply));
|
||||
assert(node.queued == before + 3 && node.sent_length == 166);
|
||||
checkedFragments(node, before + 1);
|
||||
++node.clock.now;
|
||||
}
|
||||
assert(node.queued == 12);
|
||||
} else if (scenario == "fragment_atomic_admission") {
|
||||
unsigned scenario_index = 0;
|
||||
for (const char* failure : {"pool_first", "pool_second", "queue_first", "queue_second", "queue_second_copy"}) {
|
||||
MyMesh trial; trial.clock.now += ++scenario_index * 10;
|
||||
const std::string prefix = "set flood.filter ";
|
||||
const std::string command = prefix + std::string(161 - prefix.size(), '1');
|
||||
const std::string cli = "fleet send 1 " + command;
|
||||
auto* unrelated = trial.manager.allocNew(); assert(unrelated);
|
||||
unrelated->payload[0] = 99; trial.manager.queue(unrelated);
|
||||
const unsigned free_before = trial.manager.freeCount();
|
||||
if (!strcmp(failure, "pool_first")) trial.manager.fail_allocation = trial.manager.allocations + 1;
|
||||
if (!strcmp(failure, "pool_second")) trial.manager.fail_allocation = trial.manager.allocations + 2;
|
||||
if (!strcmp(failure, "queue_first")) trial.fail_queue = trial.queue_calls + 1;
|
||||
if (!strcmp(failure, "queue_second")) trial.fail_queue = trial.queue_calls + 2;
|
||||
if (!strcmp(failure, "queue_second_copy")) {
|
||||
trial.fail_queue = trial.queue_calls + 2; trial.radio_copy = true;
|
||||
}
|
||||
rejected(trial, cli.c_str());
|
||||
assert(trial.queued == 1 && trial.manager.outbound[0] == unrelated);
|
||||
assert(trial.manager.freeCount() == free_before);
|
||||
assert(!trial._fleet_admission_original && !trial._fleet_admission_copy);
|
||||
if (!strcmp(failure, "queue_first")) assert(trial.failed_hooks == 1);
|
||||
if (!strcmp(failure, "queue_second"))
|
||||
assert(trial.failed_hooks == 2 && trial.radio_cancellations == 1);
|
||||
if (!strcmp(failure, "queue_second_copy")) {
|
||||
assert(trial.failed_hooks == 3 && trial.radio_cancellations == 2);
|
||||
assert(trial.base_copy_hooks == 1); // The production override chains Mesh.
|
||||
}
|
||||
trial.manager.fail_allocation = 0; trial.fail_queue = 0; trial.radio_copy = false;
|
||||
assert(trial.handleCommand(cli.c_str(), 0, reply));
|
||||
assert(trial.queued == 3 && trial.manager.outbound[0] == unrelated);
|
||||
compactBroadcast(trial, command.c_str());
|
||||
assert(trial.manager.freeCount() == free_before - 2);
|
||||
}
|
||||
} else if (scenario == "rescue_overflow") {
|
||||
const std::string unsafe = "fleet send 1 set flood.rule.1 type=grp_txt drop hops=all in=any priority=00001 suspend=tempradio";
|
||||
assert(unsafe.size() > sizeof(node.cli_command));
|
||||
// The old forced-completion behavior would execute this valid prefix,
|
||||
// silently omitting the requested suspend=tempradio condition.
|
||||
const std::string truncated = unsafe.substr(0, 78);
|
||||
assert(mesh::FleetCommand::commandAllowed(truncated.c_str() + 13));
|
||||
rescue_input.input = unsafe + "\r\nfleet send 1 clock\r\n";
|
||||
while (rescue_input.available()) {
|
||||
const size_t before = rescue_input.reads;
|
||||
node.checkCLIRescueCmd();
|
||||
assert(rescue_input.reads - before <= sizeof(node.cli_command));
|
||||
}
|
||||
assert(node.rescue_commands.size() == 1 && node.rescue_commands[0] == "fleet send 1 clock");
|
||||
assert(node.queued == 1 && !node._cli_line_overflow && node.cli_command[0] == 0);
|
||||
assert(rescue_output.output.find("ERR: too long") != std::string::npos);
|
||||
compactBroadcast(node, "clock");
|
||||
} else if (scenario == "rescue_split_overflow") {
|
||||
rescue_input.input = std::string(400, 'x');
|
||||
node.checkCLIRescueCmd();
|
||||
assert(rescue_input.reads == sizeof(node.cli_command) && node._cli_line_overflow);
|
||||
assert(node.rescue_commands.empty() && node.queued == 0);
|
||||
while (rescue_input.available()) node.checkCLIRescueCmd();
|
||||
assert(node._cli_line_overflow && node.rescue_commands.empty());
|
||||
// Even a valid fleet suffix is discarded until the actual line ending.
|
||||
rescue_input.input += "fleet send 1 set radio2 off\nfleet send 1 clock\n";
|
||||
while (rescue_input.available()) node.checkCLIRescueCmd();
|
||||
assert(node.rescue_commands.size() == 1 && node.rescue_commands[0] == "fleet send 1 clock");
|
||||
assert(node.queued == 1 && !node._cli_line_overflow);
|
||||
} else if (scenario == "rescue_boundaries") {
|
||||
const std::string exact(79, 'x');
|
||||
rescue_input.input = exact;
|
||||
node.checkCLIRescueCmd();
|
||||
assert(node.rescue_commands.empty() && strlen(node.cli_command) == 79);
|
||||
rescue_input.input += "\r\nfirst\r\nsecond\nthird\r";
|
||||
while (rescue_input.available()) node.checkCLIRescueCmd();
|
||||
const std::vector<std::string> expected = {exact, "first", "second", "third"};
|
||||
assert(node.rescue_commands == expected && !node._cli_line_overflow);
|
||||
assert(node.cli_command[0] == 0);
|
||||
} else if (scenario == "rescue_unterminated") {
|
||||
memset(node.cli_command, 'x', sizeof(node.cli_command));
|
||||
rescue_input.input = "fleet send 1 set radio2 off\r\nfleet send 1 clock\r";
|
||||
while (rescue_input.available()) node.checkCLIRescueCmd();
|
||||
assert(node.rescue_commands.size() == 1 && node.rescue_commands[0] == "fleet send 1 clock");
|
||||
assert(node.queued == 1 && !node._cli_line_overflow);
|
||||
} else if (scenario == "rescue_session_reset") {
|
||||
node._cli_line_overflow = true; strcpy(node.cli_command, "old host prefix");
|
||||
node.enterCLIRescue();
|
||||
assert(node._cli_rescue && !node._cli_line_overflow && node.cli_command[0] == 0);
|
||||
rescue_input.input = "fleet send 1 clock\r";
|
||||
while (rescue_input.available()) node.checkCLIRescueCmd();
|
||||
assert(node.queued == 1);
|
||||
++node.clock.now;
|
||||
node._cli_line_overflow = true; strcpy(node.cli_command, "old host prefix");
|
||||
node.resetUsbHostSessionInput();
|
||||
assert(!node._cli_line_overflow);
|
||||
for (char value : node.cli_command) assert(value == 0);
|
||||
rescue_input.input += "fleet send 1 clock\n";
|
||||
while (rescue_input.available()) node.checkCLIRescueCmd();
|
||||
assert(node.queued == 2 && node.rescue_commands.size() == 2);
|
||||
} else if (scenario == "stm32_rescue") {
|
||||
rescue_input.input = std::string(400, 'x') + "fleet send 1 clock\r\nnext\r\n";
|
||||
while (rescue_input.available()) {
|
||||
const size_t before = rescue_input.reads;
|
||||
node.checkCLIRescueCmd();
|
||||
assert(rescue_input.reads - before <= sizeof(node.cli_command));
|
||||
}
|
||||
assert(node.rescue_commands == std::vector<std::string>{"next"});
|
||||
assert(rescue_output.output.find(" Error: unknown command") != std::string::npos);
|
||||
assert(rescue_output.output.find("ERR: too long") == std::string::npos);
|
||||
assert(node.queued == 0 && !node._cli_line_overflow);
|
||||
memset(node.cli_command, 'x', sizeof(node.cli_command));
|
||||
rescue_input.input += "suffix\nlast\n";
|
||||
while (rescue_input.available()) node.checkCLIRescueCmd();
|
||||
assert(node.rescue_commands == (std::vector<std::string>{"next", "last"}));
|
||||
} else if (scenario == "explicit_target_forms") {
|
||||
for (const std::string& targets : {std::string("42424242"), std::string("424242424242"),
|
||||
fullKey(0x42), std::string("42424242,515151515151,") + fullKey(0x63)}) {
|
||||
@@ -258,7 +604,7 @@ int main(int argc, char** argv) {
|
||||
"fleet send 1 11111111111111 set radio2 off", "fleet send 1 all get password",
|
||||
"fleet send 1 all set radio2 off\nreboot"}) rejected(node, command);
|
||||
rejected(node, ("fleet send 1 " + std::string(65, '1') + " set radio2 off").c_str());
|
||||
rejected(node, ("fleet send 1 all set flood.filter " + std::string(150, '1')).c_str());
|
||||
rejected(node, ("fleet send 1 all set flood.filter " + std::string(mesh::FleetCommand::MaxCommandLength, '1')).c_str());
|
||||
assert(node.handleCommand("fleet\tsend\t1\tall\tset radio2 off", 0, reply));
|
||||
assert(!strncmp(reply, "OK - fleet command queued", 25));
|
||||
} else if (scenario == "private_channel") {
|
||||
@@ -378,14 +724,16 @@ int main(int argc, char** argv) {
|
||||
}
|
||||
mesh::FleetCommand::Targets parsed;
|
||||
assert(mesh::FleetCommand::parseTargets(targets.c_str(), targets.size(), parsed));
|
||||
const std::string prefix = "set flood.filter ";
|
||||
const std::string too_long = prefix + std::string(mesh::FleetCommand::MaxCommandLength - prefix.size(), '1');
|
||||
assert(node.handleCommand(("fleet send 1 " + targets + " " + too_long).c_str(), 0, reply));
|
||||
assert(strstr(reply, "exceed two packets") && node.queued == 0);
|
||||
assert(node.handleCommand(("fleet send 1 " + targets + " set radio2 off").c_str(), 0, reply));
|
||||
assert(strstr(reply, "exceed one packet") && node.queued == 0);
|
||||
assert(node.handleCommand("fleet send 1 00112233 set radio2 off", 0, reply));
|
||||
assert(node.queued == 1);
|
||||
assert(node.queued == 2); // The valid list now fits one atomic two-packet envelope.
|
||||
++node.clock.now;
|
||||
targets += ",44556677"; // More than the entire target-record capacity.
|
||||
rejected(node, ("fleet send 1 " + targets + " set radio2 off").c_str());
|
||||
assert(node.queued == 1);
|
||||
assert(node.queued == 2);
|
||||
} else if (scenario == "scheduled_sets") {
|
||||
const char* relative[] = {
|
||||
"set radioat 910.5,500,5,5,+1,auto",
|
||||
@@ -462,9 +810,15 @@ int main(int argc, char** argv) {
|
||||
assert(mesh::FleetCommand::parseTargets(targets.c_str(), targets.size(), parsed));
|
||||
assert(mesh::FleetCommand::commandAllowed(scheduled.c_str()));
|
||||
assert(node.handleCommand(("fleet send 1 " + targets + " " + scheduled).c_str(), 0, reply));
|
||||
assert(strstr(reply, "exceed one packet") && node.queued == 0);
|
||||
assert(!strncmp(reply, "OK - fleet command queued", 25) && node.queued == 2);
|
||||
uint8_t recipient[32]; memset(recipient, 0x42, sizeof(recipient));
|
||||
mesh::FleetCommand::Decoded decoded;
|
||||
assert(mesh::FleetCommand::decode(node.self_id, node.channels[1].channel.secret,
|
||||
node.sent, node.sent_length, node.clock.now, recipient, decoded));
|
||||
assert(!strcmp(decoded.command, scheduled.c_str()));
|
||||
++node.clock.now;
|
||||
assert(node.handleCommand(("fleet send 1 42424242 " + scheduled).c_str(), 0, reply));
|
||||
assert(node.queued == 1 && node.sent_policy == 2);
|
||||
assert(node.queued == 3 && node.sent_policy == 2);
|
||||
} else if (scenario == "clock_controls") {
|
||||
uint8_t recipient[32]; memset(recipient, 0x42, sizeof(recipient));
|
||||
for (const char* clock_command : {"clock", "clock sync", "time 1800000000"}) {
|
||||
@@ -510,10 +864,45 @@ class CompanionFleetControlTests(unittest.TestCase):
|
||||
work = Path(cls.work.name)
|
||||
cls.binary = work / "companion-fleet"
|
||||
fixture = work / "fixture.cpp"
|
||||
fixture.write_text(HARNESS.replace("@METHOD@", cls.production))
|
||||
helpers = "\n".join(body(source, signature) for signature in (
|
||||
"void MyMesh::onRadioProfileCopyQueued(", "bool MyMesh::sendFleetCommandData(",
|
||||
))
|
||||
send_packet = body((ROOT / "src/Dispatcher.cpp").read_text(), "bool Dispatcher::sendPacket(")
|
||||
send_packet = send_packet.replace("bool Dispatcher::sendPacket(Packet*", "bool MyMesh::sendPacket(mesh::Packet*", 1)
|
||||
rescue = body(source, "void MyMesh::checkCLIRescueCmd()")
|
||||
# Keep the entire production reader/line-admission logic. Replace only
|
||||
# downstream rescue commands (filesystem/UI peripherals) with a record
|
||||
# and the real Companion fleet dispatcher used throughout this suite.
|
||||
rescue = rescue[:rescue.index(" reply_buf[0] = 0;")]
|
||||
# Print::println(text) is absent from the shared host Stream adapter.
|
||||
# Preserve STM32's actual shared error text and adapt only printing.
|
||||
rescue = rescue.replace('output.println(" Error: unknown command");',
|
||||
'output.print(" Error: unknown command"); output.println();')
|
||||
rescue += '''
|
||||
rescue_commands.emplace_back(cli_command);
|
||||
char reply[160] = {};
|
||||
handleCommand(cli_command, 0, reply);
|
||||
cli_command[0] = 0;
|
||||
}
|
||||
}
|
||||
'''
|
||||
reset = body(source, "void MyMesh::resetUsbHostSessionInput()")
|
||||
enter = body(source, "void MyMesh::enterCLIRescue()")
|
||||
# The shared Stream mock lacks Print::println(text); preserve the
|
||||
# actual entry-state reset and adapt only this banner's formatting.
|
||||
banner = 'mesh::usbTerminalPort().println("========= CLI Rescue =========");'
|
||||
assert enter.count(banner) == 1
|
||||
enter = enter.replace(banner, 'mesh::usbTerminalPort().print("========= CLI Rescue =========\\n");')
|
||||
cls.fixture = fixture
|
||||
fixture.write_text(HARNESS.replace("@METHOD@", cls.production)
|
||||
.replace("@SEND_HELPERS@", send_packet + "\n" + helpers)
|
||||
.replace("@RESCUE_READER@", rescue + "\n" + reset + "\n" + enter))
|
||||
sanitizer_flags = (["-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-no-pie"]
|
||||
if os.environ.get("MESHCORE_FLEET_SANITIZERS") == "1" else [])
|
||||
cls.sanitizer_flags = sanitizer_flags
|
||||
result = subprocess.run([
|
||||
compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
|
||||
"-Wno-unused-parameter", "-O2",
|
||||
"-Wno-unused-parameter", "-O2", *sanitizer_flags,
|
||||
"-fstack-usage", "-isystem", str(ROOT / "test/mocks"),
|
||||
"-I", str(ROOT / "src"), str(fixture),
|
||||
str(ROOT / "src/helpers/FleetCommand.cpp"), "-lcrypto", "-o", str(cls.binary),
|
||||
@@ -546,9 +935,50 @@ class CompanionFleetControlTests(unittest.TestCase):
|
||||
def test_unknown_or_malformed_explicit_targets_never_fall_back_to_broadcast(self):
|
||||
self.scenario("implicit_target_ambiguity")
|
||||
|
||||
def test_omitted_target_still_requires_canonical_allowlisted_command_and_72_byte_cap(self):
|
||||
def test_omitted_target_requires_canonical_allowlisted_command_and_230_byte_cap(self):
|
||||
self.scenario("implicit_command_controls")
|
||||
|
||||
def test_target_specific_exact_packet_limits_reject_atomically_and_allow_retry(self):
|
||||
self.scenario("target_command_capacity")
|
||||
|
||||
def test_maximum_correlated_implicit_command_fits_app_frame_and_remains_local_only(self):
|
||||
self.scenario("maximum_correlated_app_command")
|
||||
|
||||
def test_one_packet_boundary_automatically_becomes_two_signed_fragments(self):
|
||||
self.scenario("fragment_boundaries")
|
||||
|
||||
def test_two_part_admission_reserves_pool_and_rolls_back_exact_packets_and_radio_copy(self):
|
||||
self.scenario("fragment_atomic_admission")
|
||||
|
||||
def test_rescue_overflow_discards_full_fleet_rule_and_preserves_next_crlf_command(self):
|
||||
self.scenario("rescue_overflow")
|
||||
|
||||
def test_rescue_overflow_drains_bounded_chunks_and_never_executes_valid_suffix(self):
|
||||
self.scenario("rescue_split_overflow")
|
||||
|
||||
def test_rescue_exact_capacity_waits_for_real_terminator_and_keeps_lines_separate(self):
|
||||
self.scenario("rescue_boundaries")
|
||||
|
||||
def test_rescue_unterminated_buffer_is_bounded_and_discards_remainder(self):
|
||||
self.scenario("rescue_unterminated")
|
||||
|
||||
def test_rescue_entry_and_usb_host_recovery_reset_discard_state(self):
|
||||
self.scenario("rescue_session_reset")
|
||||
|
||||
def test_stm32_rescue_shared_error_still_drains_and_rejects_entire_overflow(self):
|
||||
binary = Path(self.work.name) / "companion-stm32-rescue"
|
||||
built = subprocess.run([
|
||||
self.compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
|
||||
"-Wno-unused-parameter", "-DSTM32_PLATFORM=1", "-O2", *self.sanitizer_flags,
|
||||
"-isystem", str(ROOT / "test/mocks"), "-I", str(ROOT / "src"),
|
||||
str(self.fixture), str(ROOT / "src/helpers/FleetCommand.cpp"),
|
||||
"-lcrypto", "-o", str(binary),
|
||||
], capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
|
||||
result = subprocess.run([str(binary), "stm32_rescue"],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_explicit_8_12_64_hex_and_list_targets_remain_restricted(self):
|
||||
self.scenario("explicit_target_forms")
|
||||
|
||||
@@ -576,7 +1006,7 @@ class CompanionFleetControlTests(unittest.TestCase):
|
||||
def test_invalid_lists_reject_atomically_without_consuming_sequence(self):
|
||||
self.scenario("invalid_target_lists")
|
||||
|
||||
def test_valid_list_exceeding_packet_capacity_never_partially_sends(self):
|
||||
def test_valid_list_exceeding_two_packet_capacity_never_partially_sends(self):
|
||||
self.scenario("target_capacity")
|
||||
|
||||
def test_four_schedule_set_forms_are_signed_without_rewriting(self):
|
||||
|
||||
@@ -279,13 +279,16 @@ static void enroll(FleetChannel& fleet,const LocalIdentity& publisher){
|
||||
assert(config(fleet,std::string("set fleet.controller ")+hex).rfind("OK",0)==0);
|
||||
}
|
||||
static Packet raw(const uint8_t* bytes,size_t length){
|
||||
Packet packet;Utils::sha256(packet.payload,1,channel_key,16);
|
||||
Packet packet;
|
||||
assert(1+CIPHER_MAC_SIZE+(length+15)/16*16<=sizeof(packet.payload));
|
||||
Utils::sha256(packet.payload,1,channel_key,16);
|
||||
packet.payload_len=1+Utils::encryptThenMAC(channel_key,packet.payload+1,bytes,int(length));
|
||||
packet.radio_profile=1;packet.radio_generation=17;packet.path_hash_size=2;
|
||||
return packet;
|
||||
}
|
||||
static Packet command(const LocalIdentity& publisher,uint32_t sequence,const char* text="set radio2 off",
|
||||
const char* target="all"){
|
||||
assert(strlen(text)<=FleetCommand::MaxPayloadLength-FleetCommand::HeaderSize-FleetCommand::SignatureSize);
|
||||
uint8_t payload[184]={},destination[16];assert(FleetCommand::parseTarget(target,destination));
|
||||
size_t length=FleetCommand::encode(publisher,channel_key,sequence,sequence+300,destination,text,payload+3,sizeof(payload)-3);
|
||||
assert(length);payload[0]=uint8_t(FleetCommand::DataType);payload[1]=uint8_t(FleetCommand::DataType>>8);payload[2]=uint8_t(length);
|
||||
@@ -321,7 +324,7 @@ static std::string publicHex(const Identity& identity){
|
||||
static Packet signedUnchecked(const LocalIdentity& publisher,uint32_t sequence,uint32_t expires,
|
||||
const char* text){
|
||||
uint8_t data[184]={},message[160]={};size_t text_size=strlen(text);
|
||||
assert(text_size<=FleetCommand::MaxCommandLength);
|
||||
assert(text_size<=FleetCommand::MaxPayloadLength-FleetCommand::HeaderSize-FleetCommand::SignatureSize);
|
||||
uint8_t* payload=data+3;memcpy(payload,"FMC1",4);
|
||||
storage::writeLE32(payload+4,sequence);storage::writeLE32(payload+8,expires);
|
||||
payload[28]=uint8_t(text_size);memcpy(payload+29,text,text_size);
|
||||
@@ -332,6 +335,20 @@ static Packet signedUnchecked(const LocalIdentity& publisher,uint32_t sequence,u
|
||||
data[0]=uint8_t(FleetCommand::DataType);data[1]=uint8_t(FleetCommand::DataType>>8);
|
||||
data[2]=uint8_t(29+text_size+64);return raw(data,3+data[2]);
|
||||
}
|
||||
static Packet compactUnchecked(const LocalIdentity& publisher,uint32_t sequence,const char* text){
|
||||
uint8_t data[184]={},message[160]={};const size_t text_size=strlen(text);
|
||||
// One byte over the valid limit still fits the cipher's final padded block.
|
||||
assert(text_size<=FleetCommand::SinglePacketMaxCommandLength+1);
|
||||
uint8_t* payload=data+3;memcpy(payload,"FMC2",4);
|
||||
storage::writeLE32(payload+4,sequence);storage::writeLE32(payload+8,sequence+300);
|
||||
payload[12]=0;payload[13]=uint8_t(text_size);memcpy(payload+14,text,text_size);
|
||||
constexpr const char* domain="MeshCoreFleet1";const size_t domain_size=strlen(domain);
|
||||
memcpy(message,domain,domain_size);memcpy(message+domain_size,channel_key,16);
|
||||
memcpy(message+domain_size+16,payload,14+text_size);
|
||||
publisher.sign(payload+14+text_size,message,int(domain_size+16+14+text_size));
|
||||
data[0]=uint8_t(FleetCommand::DataType);data[1]=uint8_t(FleetCommand::DataType>>8);
|
||||
data[2]=uint8_t(14+text_size+64);return raw(data,3+data[2]);
|
||||
}
|
||||
|
||||
static void bind(FleetChannel& fleet,Mesh& mesh,RadioProfileCLI& profiles){
|
||||
mesh.copying=[&](Packet* second,const Packet* first){fleet.copy(second,first);};
|
||||
@@ -351,8 +368,213 @@ static unsigned apply(FleetChannel& fleet,fs::FS& fs,Mesh& mesh,RadioProfileCLI&
|
||||
static void reseal(std::vector<uint8_t>& image){
|
||||
storage::writeLE32(image.data()+60,storage::updateCRC32(0xffffffff,image.data(),60));
|
||||
}
|
||||
static std::string longRule(){
|
||||
const std::string prefix="set flood.rule.1 type=grp_txt channel="+std::string(64,'1')
|
||||
+" prefix=AABBCC,DDEEFF,112233 hops=6+ priority=255 suspend=tempradio mode=radio hashbytes=3 in=any";
|
||||
const std::string action=" action=drop";
|
||||
assert(prefix.size()+action.size()<=FleetCommand::MaxCommandLength);
|
||||
return prefix+std::string(FleetCommand::MaxCommandLength-prefix.size()-action.size(),' ')+action;
|
||||
}
|
||||
static std::vector<uint8_t> envelope(const LocalIdentity& publisher,uint32_t sequence,
|
||||
const std::string& text,const std::string& target="all"){
|
||||
FleetCommand::Targets targets;assert(FleetCommand::parseTargets(target.c_str(),target.size(),targets));
|
||||
std::vector<uint8_t> bytes(FleetCommand::MaxEnvelopeLength);
|
||||
size_t length=FleetCommand::encode(publisher,channel_key,sequence,sequence+300,targets,
|
||||
text.c_str(),bytes.data(),bytes.size());
|
||||
assert(length);bytes.resize(length);return bytes;
|
||||
}
|
||||
static Packet part(const std::vector<uint8_t>& bytes,uint8_t index){
|
||||
uint8_t data[168]={};size_t length=FleetCommand::fragment(bytes.data(),bytes.size(),index,
|
||||
data+3,sizeof(data)-3);
|
||||
assert(length&&length<=FleetCommand::MaxPayloadLength);data[0]=uint8_t(FleetCommand::DataType);
|
||||
data[1]=uint8_t(FleetCommand::DataType>>8);data[2]=uint8_t(length);
|
||||
Packet result=raw(data,3+length);assert(result.payload_len<=MAX_PACKET_PAYLOAD);return result;
|
||||
}
|
||||
template<typename Change> static Packet changePart(const Packet& packet,Change change){
|
||||
uint8_t data[184]={};int length=Utils::MACThenDecrypt(channel_key,data,packet.payload+1,packet.payload_len-1);
|
||||
assert(length>0);change(data+3);return raw(data,size_t(length));
|
||||
}
|
||||
struct FragmentFixture {
|
||||
fs::FS disk;Mesh mesh;RadioProfileCLI profiles;FleetChannel fleet{&disk};LocalIdentity publisher;
|
||||
std::string expected=longRule();unsigned calls=0;bool mutation=false;
|
||||
FragmentFixture(){enroll(fleet,publisher);bind(fleet,mesh,profiles);}
|
||||
uint32_t reserved()const{return storage::readLE32(disk.files.at(Path).data()+56);}
|
||||
std::string stats(){return config(fleet,"get fleet.stats");}
|
||||
unsigned feed(Packet& packet,const TransportKey* scope=nullptr,uint32_t advance_ms=1000){
|
||||
fake_ms+=advance_ms;const unsigned before=calls;
|
||||
const auto image=disk.files.at(Path);const unsigned writes=disk.writes,verifies=verify_calls;
|
||||
fleet.receive(&packet,mesh,scope);
|
||||
assert(disk.files.at(Path)==image&&disk.writes==writes&&verify_calls==verifies);
|
||||
fleet.service(mesh,profiles,"long:rule",[&](uint32_t seq,const char* text,char* reply){
|
||||
++calls;assert(reserved()==seq&&profiles.command);
|
||||
assert(mesh.scoped_profile==packet.radio_profile&&mesh.scoped_generation==packet.radio_generation);
|
||||
assert(strlen(text)==expected.size()&&!strcmp(text,expected.c_str()));
|
||||
assert(text[expected.size()]==0);if(mutation)profiles.stage();strcpy(reply,"OK");
|
||||
});
|
||||
assert(!profiles.command&&mesh.scoped_profile==0&&mesh.scoped_generation==0);return calls-before;
|
||||
}
|
||||
void tick(uint32_t advance_ms){
|
||||
fake_ms+=advance_ms;fleet.service(mesh,profiles,"node",[](uint32_t,const char*,char*){assert(false);});
|
||||
}
|
||||
void partial(){assert(calls==0&&reserved()==0&&mesh.queued==0&&!profiles.mutation);
|
||||
assert(stats().find("accepted=0")!=std::string::npos&&stats().find("parts=1")!=std::string::npos);}
|
||||
};
|
||||
static int fragmentScenario(const std::string& scenario){
|
||||
FragmentFixture f;const uint32_t seq=f.mesh.clock.now;
|
||||
auto bytes=envelope(f.publisher,seq,f.expected);assert(bytes.size()==308&&f.expected.size()==230);
|
||||
Packet a=part(bytes,0),b=part(bytes,1);assert(a.payload_len==179&&b.payload_len==179);
|
||||
if(scenario=="fragments_orders"){
|
||||
for(unsigned reverse=0;reverse<2;++reverse){
|
||||
FragmentFixture x;auto encoded=envelope(x.publisher,seq,x.expected);
|
||||
Packet first=part(encoded,reverse?1:0),second=part(encoded,reverse?0:1);
|
||||
const auto old=x.disk.files.at(Path);const unsigned verifies=verify_calls,writes=x.disk.writes;
|
||||
assert(x.feed(first)==0);x.partial();assert(x.disk.files.at(Path)==old&&x.disk.writes==writes);
|
||||
assert(verify_calls==verifies&&x.stats().find("rejected=0")!=std::string::npos);
|
||||
assert(x.feed(second)==1&&verify_calls==verifies+1&&x.reserved()==seq&&x.mesh.queued==1);
|
||||
assert(x.stats().find("parts=0")!=std::string::npos&&last_jitter_max==60500);
|
||||
assert(x.feed(first)==0&&x.feed(second)==0&&x.calls==1);
|
||||
FleetChannel rebooted(&x.disk);unsigned calls=0;
|
||||
for(Packet* packet:{&first,&second}){fake_ms+=1000;rebooted.receive(packet,x.mesh);
|
||||
rebooted.service(x.mesh,x.profiles,"node",[&](uint32_t,const char*,char*){++calls;});}
|
||||
assert(calls==0&&x.reserved()==seq);
|
||||
}
|
||||
}else if(scenario=="fragments_duplicates"){
|
||||
const unsigned verifies=verify_calls,writes=f.disk.writes;
|
||||
assert(f.feed(b)==0&&f.feed(b)==0&&f.feed(b)==0);f.partial();
|
||||
assert(verify_calls==verifies&&f.disk.writes==writes&&f.stats().find("rejected=0")!=std::string::npos);
|
||||
assert(f.feed(a)==1&&f.feed(a)==0&&f.feed(b)==0&&f.calls==1&&f.mesh.queued==1);
|
||||
}else if(scenario=="fragments_minimum_size"){
|
||||
f.expected="set flood.rule.1 05 c="+std::string(64,'1')+" d";
|
||||
assert(f.expected.size()==88);auto small=envelope(f.publisher,seq,f.expected);
|
||||
assert(small.size()==166);Packet first=part(small,0),second=part(small,1);
|
||||
assert(first.payload_len==179&&second.payload_len==35);
|
||||
assert(f.feed(second)==0);f.partial();assert(f.feed(first)==1&&f.reserved()==seq);
|
||||
FleetCommand::Targets all;assert(FleetCommand::parseTargets("all",3,all));
|
||||
uint8_t output[308];memset(output,0xA5,sizeof(output));
|
||||
const std::string too_long=longRule()+" ";assert(too_long.size()==231);
|
||||
assert(!FleetCommand::encode(f.publisher,channel_key,seq+1,seq+301,all,too_long.c_str(),output,sizeof(output)));
|
||||
for(uint8_t byte:output)assert(byte==0xA5);
|
||||
}else if(scenario=="fragments_expired_during_collection"){
|
||||
assert(f.feed(a)==0);f.partial();f.mesh.clock.now+=301;
|
||||
assert(f.feed(b)==0&&f.reserved()==0&&f.mesh.queued==0&&f.calls==0);
|
||||
assert(f.stats().find("parts=0")!=std::string::npos);
|
||||
auto retry=envelope(f.publisher,f.mesh.clock.now,f.expected);
|
||||
Packet first=part(retry,0),second=part(retry,1);
|
||||
assert(f.feed(first)==0&&f.feed(second)==1&&f.reserved()==f.mesh.clock.now);
|
||||
}else if(scenario=="fragments_conflicts"){
|
||||
assert(f.feed(a)==0);f.partial();
|
||||
Packet changed=changePart(a,[](uint8_t* frame){frame[11+90]^=1;});
|
||||
assert(f.feed(changed)==0);f.partial();
|
||||
Packet length=changePart(a,[](uint8_t* frame){storage::writeLE16(frame+8,307);});
|
||||
assert(f.feed(length)==0);f.partial();
|
||||
Packet index=changePart(a,[](uint8_t* frame){frame[10]=2;});
|
||||
assert(f.feed(index)==0);f.partial();
|
||||
assert(f.feed(b)==1&&f.calls==1&&f.reserved()==seq);
|
||||
}else if(scenario=="fragments_fresh_retry"){
|
||||
assert(f.feed(a)==0);f.partial();auto newer=envelope(f.publisher,seq+1,f.expected);
|
||||
Packet fresh_first=part(newer,1),fresh_second=part(newer,0);
|
||||
assert(f.feed(fresh_first)==0);f.partial();
|
||||
assert(f.feed(b)==0);f.partial();assert(f.feed(fresh_second)==1&&f.reserved()==seq+1);
|
||||
assert(f.feed(a)==0&&f.feed(b)==0&&f.calls==1&&f.mesh.queued==1);
|
||||
}else if(scenario=="fragments_tamper"){
|
||||
Packet mac=a;mac.payload[1]^=1;assert(f.feed(mac)==0&&f.stats().find("parts=0")!=std::string::npos);
|
||||
assert(f.feed(a)==0);f.partial();const auto saved=f.disk.files.at(Path);
|
||||
Packet signature=changePart(b,[](uint8_t* frame){frame[11+153]^=1;});
|
||||
assert(f.feed(signature)==0&&f.calls==0&&f.disk.files.at(Path)==saved&&f.mesh.queued==0);
|
||||
assert(f.stats().find("parts=0")!=std::string::npos);
|
||||
assert(f.feed(b)==0&&f.feed(a)==1&&f.calls==1);
|
||||
}else if(scenario=="fragments_wrong_signer"){
|
||||
LocalIdentity stranger;auto forged=envelope(stranger,seq,f.expected);
|
||||
Packet first=part(forged,0),second=part(forged,1);
|
||||
assert(f.feed(first)==0);f.partial();assert(f.feed(second)==0&&f.reserved()==0&&f.mesh.queued==0);
|
||||
assert(f.stats().find("parts=0")!=std::string::npos&&f.feed(a)==0&&f.feed(b)==1);
|
||||
}else if(scenario=="fragments_fixed_timeout"){
|
||||
assert(f.feed(a)==0);f.partial();assert(f.feed(a,nullptr,299999)==0);f.partial();
|
||||
f.tick(1);assert(f.stats().find("parts=0")!=std::string::npos&&f.reserved()==0&&f.mesh.queued==0);
|
||||
assert(f.feed(b)==0);f.partial();assert(f.feed(a)==1);
|
||||
}else if(scenario=="fragments_timeout_wrap"){
|
||||
fake_ms=UINT32_MAX-200000;assert(f.feed(b,nullptr,0)==0);f.partial();f.tick(299999);f.partial();
|
||||
f.tick(1);assert(f.stats().find("parts=0")!=std::string::npos&&f.reserved()==0);
|
||||
assert(f.feed(a)==0&&f.feed(b)==1);
|
||||
}else if(scenario=="fragments_sequence_bounds"){
|
||||
for(uint32_t invalid:{FleetCommand::MinEpoch-1,seq+61,seq-601}){
|
||||
Packet changed=changePart(a,[&](uint8_t* frame){storage::writeLE32(frame+4,invalid);});
|
||||
assert(f.feed(changed)==0&&f.stats().find("parts=0")!=std::string::npos&&f.reserved()==0);
|
||||
}
|
||||
Packet mismatch_a=changePart(a,[&](uint8_t* frame){storage::writeLE32(frame+4,seq+1);});
|
||||
Packet mismatch_b=changePart(b,[&](uint8_t* frame){storage::writeLE32(frame+4,seq+1);});
|
||||
const unsigned verifies=verify_calls;assert(f.feed(mismatch_a)==0&&f.feed(mismatch_b)==0);
|
||||
assert(f.reserved()==0&&verify_calls==verifies&&f.stats().find("parts=0")!=std::string::npos);
|
||||
assert(f.feed(a)==0&&f.feed(b)==1);
|
||||
}else if(scenario=="fragments_target"){
|
||||
LocalIdentity stranger;f.expected="set flood.rule.1 any "+std::string(150,' ')+"drop";
|
||||
auto other=envelope(f.publisher,seq,f.expected,publicHex(stranger));
|
||||
Packet other_a=part(other,0),other_b=part(other,1);assert(f.feed(other_a)==0);f.partial();
|
||||
assert(f.feed(other_b)==0&&f.reserved()==0&&f.mesh.queued==0);
|
||||
auto mine=envelope(f.publisher,seq,f.expected,publicHex(f.mesh.self_id));
|
||||
Packet mine_a=part(mine,0),mine_b=part(mine,1);
|
||||
assert(f.feed(mine_b)==0&&f.feed(mine_a)==1&&last_jitter_max==1500);
|
||||
}else if(scenario=="fragments_config_reboot"){
|
||||
assert(f.feed(a)==0);f.partial();assert(config(f.fleet,std::string("set fleet.channel ")+Key).rfind("OK",0)==0);
|
||||
assert(f.stats().find("parts=0")!=std::string::npos&&f.feed(b)==0);f.partial();
|
||||
assert(config(f.fleet,"set fleet.controller "+publicHex(f.publisher)).rfind("OK",0)==0);
|
||||
assert(f.stats().find("parts=0")!=std::string::npos&&f.feed(a)==0);f.partial();
|
||||
FleetChannel rebooted(&f.disk);unsigned calls=0;fake_ms+=1000;rebooted.receive(&b,f.mesh);
|
||||
rebooted.service(f.mesh,f.profiles,"node",[&](uint32_t,const char*,char*){++calls;});
|
||||
assert(calls==0&&f.reserved()==0&&f.mesh.queued==0);
|
||||
fake_ms+=1000;rebooted.receive(&a,f.mesh);
|
||||
rebooted.service(f.mesh,f.profiles,"node",[&](uint32_t sequence,const char* text,char* reply){
|
||||
++calls;assert(sequence==seq&&!strcmp(text,f.expected.c_str()));strcpy(reply,"OK");});
|
||||
assert(calls==1&&f.reserved()==seq);
|
||||
}else if(scenario=="fragments_reserve_failure"){
|
||||
const auto saved=f.disk.files.at(Path);f.disk.fail_write=true;const unsigned writes=f.disk.writes;
|
||||
assert(f.feed(b)==0);f.partial();assert(f.disk.writes==writes&&f.disk.files.at(Path)==saved);
|
||||
assert(f.feed(a)==0&&f.disk.files.at(Path)==saved&&f.mesh.queued==0&&f.calls==0);
|
||||
assert(config(f.fleet,"get fleet.channel").find("store=error")!=std::string::npos);
|
||||
f.disk.fail_write=false;assert(f.feed(a)==0&&f.feed(b)==0);
|
||||
FleetChannel rebooted(&f.disk);unsigned calls=0;
|
||||
for(Packet* packet:{&a,&b}){fake_ms+=1000;rebooted.receive(packet,f.mesh);
|
||||
rebooted.service(f.mesh,f.profiles,"node",[&](uint32_t,const char*,char* reply){++calls;strcpy(reply,"OK");});}
|
||||
assert(calls==1&&f.reserved()==seq);
|
||||
}else if(scenario=="fragments_scoped_radio_ack"){
|
||||
f.mutation=true;f.mesh.fanout=true;TransportKey scope;memset(scope.key,0x12,sizeof(scope.key));
|
||||
a.radio_profile=0;a.radio_generation=9;b.radio_profile=1;b.radio_generation=27;b.path_hash_size=3;
|
||||
b.scoped=true;b.transport_codes[0]=scope.calcTransportCode(&b);
|
||||
assert(f.feed(a)==0);f.partial();assert(f.feed(b,&scope)==1&&f.fleet.waiting()&&f.profiles.mutation);
|
||||
assert(f.mesh.manager.outbound.size()==2&&f.mesh.ack_scoped&&f.mesh.ack_path_size==3);
|
||||
assert(f.mesh.ack_codes[0]==scope.calcTransportCode(f.mesh.manager.outbound[0]));
|
||||
assert(f.mesh.ack_codes[0]!=b.transport_codes[0]&&last_jitter_max==15500);
|
||||
f.fleet.complete(f.mesh.manager.removeOutboundByIdx(0),f.profiles);
|
||||
assert(f.fleet.waiting()&&f.profiles.mutation&&f.profiles.commits==0);
|
||||
f.fleet.fail(f.mesh.manager.removeOutboundByIdx(0),f.profiles);
|
||||
assert(!f.fleet.waiting()&&!f.profiles.mutation&&f.profiles.commits==1);
|
||||
assert(f.feed(a)==0&&f.feed(b,&scope)==0&&f.calls==1);
|
||||
}else if(scenario=="fragments_unknown_scope"){
|
||||
f.mutation=true;a.scoped=true;a.transport_codes[0]=123;
|
||||
assert(f.feed(b)==0&&f.feed(a)==1&&f.mesh.queued==0&&f.profiles.rollbacks==1);
|
||||
assert(f.reserved()==seq&&!f.fleet.waiting()&&!f.profiles.mutation);
|
||||
}else if(scenario=="fragments_single_interrupt"){
|
||||
assert(f.feed(a)==0);f.partial();f.expected="get radio2";
|
||||
Packet single=targetsCommand(f.publisher,seq+1,"all",f.expected.c_str());
|
||||
assert(f.feed(single)==1&&f.reserved()==seq+1&&f.stats().find("parts=0")!=std::string::npos);
|
||||
assert(f.feed(b)==0&&f.feed(a)==0&&f.calls==1&&f.mesh.queued==1);
|
||||
}else if(scenario=="fragments_malformed_frame"){
|
||||
for(unsigned damage=0;damage<4;++damage){
|
||||
Packet malformed=changePart(a,[&](uint8_t* frame){
|
||||
if(damage==0)storage::writeLE16(frame+8,309);
|
||||
else if(damage==1)storage::writeLE16(frame+8,165);
|
||||
else if(damage==2)frame[10]=2;
|
||||
else frame[-1]=uint8_t(FleetCommand::MaxPayloadLength-1);
|
||||
});assert(f.feed(malformed)==0&&f.stats().find("parts=0")!=std::string::npos);
|
||||
}
|
||||
assert(f.reserved()==0&&f.mesh.queued==0&&f.feed(a)==0&&f.feed(b)==1);
|
||||
}else assert(false);
|
||||
printf("fleet runtime %s passed\n",scenario.c_str());return 0;
|
||||
}
|
||||
int main(int argc,char** argv){
|
||||
assert(argc==2);const std::string scenario=argv[1];fs::FS fs;Mesh mesh;RadioProfileCLI profiles;
|
||||
assert(argc==2);const std::string scenario=argv[1];
|
||||
if(scenario.rfind("fragments_",0)==0)return fragmentScenario(scenario);
|
||||
fs::FS fs;Mesh mesh;RadioProfileCLI profiles;
|
||||
FleetChannel fleet(&fs);LocalIdentity publisher;bind(fleet,mesh,profiles);
|
||||
if(scenario=="enrollment"){
|
||||
Packet packet=command(publisher,mesh.clock.now);assert(apply(fleet,fs,mesh,profiles,packet)==0);
|
||||
@@ -588,6 +810,43 @@ int main(int argc,char** argv){
|
||||
assert(apply(fleet,fs,mesh,profiles,mutation)==0);
|
||||
FleetChannel rebooted(&fs);
|
||||
assert(apply(rebooted,fs,mesh,profiles,packet)==0&&apply(rebooted,fs,mesh,profiles,mutation)==0);
|
||||
}else if(scenario=="fmc2_maximum_command"){
|
||||
enroll(fleet,publisher);
|
||||
const std::string secret(64,'1');
|
||||
const std::string text="set flood.rule.1 5 c="+secret+" d";
|
||||
const std::string oversized="set flood.rule.1 05 c="+secret+" d";
|
||||
assert(text.size()==87&&text.size()==FleetCommand::SinglePacketMaxCommandLength);
|
||||
assert(oversized.size()==88&&FleetCommand::commandAllowed(oversized.c_str()));
|
||||
FleetCommand::Targets all;assert(FleetCommand::parseTargets("all",3,all));
|
||||
uint8_t output[184];memset(output,0xA5,sizeof(output));
|
||||
assert(FleetCommand::encode(publisher,channel_key,mesh.clock.now,mesh.clock.now+300,all,
|
||||
oversized.c_str(),output,sizeof(output))==166);
|
||||
assert(output[166]==0xA5);
|
||||
Packet packet=targetsCommand(publisher,mesh.clock.now,"all",text.c_str());
|
||||
uint8_t plaintext[184]={};
|
||||
const int size=Utils::MACThenDecrypt(channel_key,plaintext,packet.payload+1,packet.payload_len-1);
|
||||
assert(size==176&&packet.payload_len==179&&packet.payload_len<=MAX_PACKET_PAYLOAD);
|
||||
assert(plaintext[2]==FleetCommand::MaxPayloadLength&&plaintext[3+13]==87);
|
||||
assert(!memcmp(plaintext+3,"FMC2",4)&&plaintext[3+12]==0);
|
||||
assert(!memcmp(plaintext+3+14,text.data(),text.size()));
|
||||
for(unsigned i=3+plaintext[2];i<unsigned(size);++i)assert(plaintext[i]==0);
|
||||
const auto previous=fs.files.at(Path);unsigned calls=0;
|
||||
struct Capture {char text[88]{};char guard='!';} capture;
|
||||
const auto dispatch=[&](FleetChannel& receiver,Packet& request){
|
||||
const unsigned before=calls;receiver.receive(&request,mesh);
|
||||
receiver.service(mesh,profiles,"long-rule",[&](uint32_t seq,const char* command,char* reply){
|
||||
++calls;assert(storage::readLE32(fs.files.at(Path).data()+56)==seq);
|
||||
assert(strlen(command)==text.size()&&!strcmp(command,text.c_str())&&command[86]=='d'&&command[87]==0);
|
||||
memcpy(capture.text,command,text.size()+1);assert(capture.guard=='!');strcpy(reply,"OK");
|
||||
});return calls-before;
|
||||
};
|
||||
plaintext[3+14+86]='s';Packet changed=raw(plaintext,size);
|
||||
assert(dispatch(fleet,changed)==0&&fs.files.at(Path)==previous&&mesh.queued==0);
|
||||
Packet too_long=compactUnchecked(publisher,mesh.clock.now,oversized.c_str());
|
||||
assert(too_long.payload_len==179&&dispatch(fleet,too_long)==0&&fs.files.at(Path)==previous);
|
||||
assert(dispatch(fleet,packet)==1&&last_jitter_max==60500&&mesh.queued==1);
|
||||
assert(!strcmp(capture.text,text.c_str())&&capture.guard=='!');
|
||||
assert(dispatch(fleet,packet)==0);FleetChannel rebooted(&fs);assert(dispatch(rebooted,packet)==0);
|
||||
}else if(scenario=="fmc2_short_prefix"){
|
||||
enroll(fleet,publisher);Packet packet=targetsCommand(publisher,mesh.clock.now,publicHex(mesh.self_id).substr(0,8));
|
||||
uint8_t plaintext[184]={};assert(Utils::MACThenDecrypt(channel_key,plaintext,packet.payload+1,packet.payload_len-1)>0);
|
||||
@@ -770,6 +1029,9 @@ class SHA256 : public SHA256Base {{ public:
|
||||
def test_actual_zero_target_sender_reaches_receiver_and_keeps_ack_and_reboot_replay_safety(self):
|
||||
self.scenario("fmc2_encoded_all")
|
||||
|
||||
def test_single_packet_87_byte_body_is_intact_signed_and_88_bytes_require_fragmentation(self):
|
||||
self.scenario("fmc2_maximum_command")
|
||||
|
||||
def test_new_public_key_prefix_envelope_dispatches_and_staggers_possible_multiple_acks(self):
|
||||
self.scenario("fmc2_short_prefix")
|
||||
|
||||
@@ -785,6 +1047,60 @@ class SHA256 : public SHA256Base {{ public:
|
||||
def test_existing_remote_radio_mutation_blocks_fleet_without_reserving_sequence(self):
|
||||
self.scenario("existing_mutation")
|
||||
|
||||
def test_two_packets_deliver_exact_230_byte_command_in_either_order_once_across_reboot(self):
|
||||
self.scenario("fragments_orders")
|
||||
|
||||
def test_identical_fragments_never_repeat_execution_or_reserve_partial_requests(self):
|
||||
self.scenario("fragments_duplicates")
|
||||
|
||||
def test_smallest_fragmented_command_uses_a_short_second_packet_and_231_bytes_are_rejected(self):
|
||||
self.scenario("fragments_minimum_size")
|
||||
|
||||
def test_command_expiring_between_parts_never_dispatches_and_fresh_retry_can_succeed(self):
|
||||
self.scenario("fragments_expired_during_collection")
|
||||
|
||||
def test_conflicting_parts_cannot_destroy_a_valid_incomplete_transfer(self):
|
||||
self.scenario("fragments_conflicts")
|
||||
|
||||
def test_fresh_retry_preempts_missing_part_and_delayed_old_parts_cannot_pollute_it(self):
|
||||
self.scenario("fragments_fresh_retry")
|
||||
|
||||
def test_mac_and_last_signature_byte_tampering_never_authorize_partial_commands(self):
|
||||
self.scenario("fragments_tamper")
|
||||
|
||||
def test_fragmented_request_from_another_real_signing_identity_is_rejected(self):
|
||||
self.scenario("fragments_wrong_signer")
|
||||
|
||||
def test_fragment_deadline_is_fixed_despite_duplicates_and_missing_part(self):
|
||||
self.scenario("fragments_fixed_timeout")
|
||||
|
||||
def test_fragment_deadline_handles_millisecond_timer_wrap(self):
|
||||
self.scenario("fragments_timeout_wrap")
|
||||
|
||||
def test_fragment_sequence_plausibility_and_signed_sequence_match_precede_reservation(self):
|
||||
self.scenario("fragments_sequence_bounds")
|
||||
|
||||
def test_fragmented_nonmatching_target_preserves_sequence_for_matching_retry(self):
|
||||
self.scenario("fragments_target")
|
||||
|
||||
def test_channel_controller_changes_and_reboot_clear_partial_transfers(self):
|
||||
self.scenario("fragments_config_reboot")
|
||||
|
||||
def test_fragment_completion_storage_failure_has_no_dispatch_or_reply(self):
|
||||
self.scenario("fragments_reserve_failure")
|
||||
|
||||
def test_final_fragment_scope_and_radio_profile_feed_both_ack_copy_commit_barrier(self):
|
||||
self.scenario("fragments_scoped_radio_ack")
|
||||
|
||||
def test_fragmented_radio_mutation_with_unknown_final_scope_rolls_back(self):
|
||||
self.scenario("fragments_unknown_scope")
|
||||
|
||||
def test_new_valid_single_packet_can_interrupt_an_incomplete_two_packet_transfer(self):
|
||||
self.scenario("fragments_single_interrupt")
|
||||
|
||||
def test_malformed_fragment_size_index_and_padding_never_allocate_partial_intent(self):
|
||||
self.scenario("fragments_malformed_frame")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
#include <Ed25519.h>
|
||||
#include <helpers/FleetCommand.h>
|
||||
#include <SHA256.h>
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
@@ -36,7 +38,7 @@ protected:
|
||||
uint32_t sequence = Now,
|
||||
uint32_t expires = Now + 120,
|
||||
const uint8_t* target = nullptr) {
|
||||
std::vector<uint8_t> bytes(Fleet::MaxPayloadLength);
|
||||
std::vector<uint8_t> bytes(Fleet::MaxEnvelopeLength);
|
||||
const size_t size = Fleet::encode(publisher, ChannelKey, sequence, expires,
|
||||
target ? target : all.data(), command, bytes.data(), bytes.size());
|
||||
bytes.resize(size);
|
||||
@@ -50,7 +52,7 @@ protected:
|
||||
|
||||
std::vector<uint8_t> encodeTargets(const Fleet::Targets& targets,
|
||||
const char* command = "get radio2") {
|
||||
std::vector<uint8_t> bytes(Fleet::MaxPayloadLength);
|
||||
std::vector<uint8_t> bytes(Fleet::MaxEnvelopeLength);
|
||||
const size_t size = Fleet::encode(publisher, ChannelKey, Now, Now + 120,
|
||||
targets, command, bytes.data(), bytes.size());
|
||||
bytes.resize(size);
|
||||
@@ -153,7 +155,7 @@ TEST_F(FleetCommandTest, ScheduledCommandsRejectMalformedTimesFieldsAndSelectors
|
||||
+ std::string(Fleet::MaxCommandLength - prefix.size() - suffix.size(), '0') + suffix;
|
||||
ASSERT_EQ(Fleet::MaxCommandLength, maximum.size());
|
||||
EXPECT_TRUE(Fleet::commandAllowed(maximum.c_str()));
|
||||
EXPECT_TRUE(decode(encode(maximum.c_str())));
|
||||
EXPECT_TRUE(decode(encodeTargets(Fleet::Targets{}, maximum.c_str())));
|
||||
const std::string oversized = prefix + '0' + maximum.substr(prefix.size());
|
||||
EXPECT_FALSE(Fleet::commandAllowed(oversized.c_str()));
|
||||
}
|
||||
@@ -289,21 +291,28 @@ TEST_F(FleetCommandTest, FailedSignatureVerificationNeverReturnsCommand) {
|
||||
EXPECT_STREQ("", decoded.command);
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, FullPayloadLimitFitsWithoutOverflow) {
|
||||
TEST_F(FleetCommandTest, LegacyPayloadLimitFitsAndCannotBeRaisedByDestinationCapacity) {
|
||||
const std::string prefix = "set flood.rule.1 ";
|
||||
const std::string maximum = prefix + std::string(Fleet::MaxCommandLength - prefix.size(), 'a');
|
||||
const std::string maximum = prefix + std::string(Fleet::FixedTargetMaxCommandLength - prefix.size(), 'a');
|
||||
ASSERT_TRUE(Fleet::commandAllowed(maximum.c_str()));
|
||||
const auto bytes = encode(maximum.c_str());
|
||||
ASSERT_EQ(Fleet::MaxPayloadLength, bytes.size());
|
||||
ASSERT_EQ(Fleet::MaxEnvelopeLength, bytes.size());
|
||||
EXPECT_TRUE(decode(bytes));
|
||||
EXPECT_STREQ(maximum.c_str(), decoded.command);
|
||||
EXPECT_FALSE(Fleet::commandAllowed((maximum + "a").c_str()));
|
||||
EXPECT_TRUE(Fleet::commandAllowed((maximum + "a").c_str()));
|
||||
EXPECT_TRUE(encode((maximum + "a").c_str()).empty());
|
||||
std::array<uint8_t, Fleet::MaxPayloadLength + 2> output;
|
||||
std::array<uint8_t, Fleet::MaxEnvelopeLength + 64> output;
|
||||
output.fill(0xA5);
|
||||
EXPECT_EQ(0U, Fleet::encode(publisher, ChannelKey, Now, Now + 10, all.data(),
|
||||
maximum.c_str(), output.data() + 1, 164));
|
||||
maximum.c_str(), output.data() + 1, Fleet::MaxEnvelopeLength - 1));
|
||||
for (uint8_t value : output) EXPECT_EQ(0xA5, value);
|
||||
for (size_t command_length : {Fleet::FixedTargetMaxCommandLength + 1, Fleet::MaxCommandLength}) {
|
||||
const std::string oversized = prefix + std::string(command_length - prefix.size(), 'a');
|
||||
ASSERT_TRUE(Fleet::commandAllowed(oversized.c_str()));
|
||||
EXPECT_EQ(0U, Fleet::encode(publisher, ChannelKey, Now, Now + 10, all.data(),
|
||||
oversized.c_str(), output.data(), output.size()));
|
||||
for (uint8_t value : output) EXPECT_EQ(0xA5, value);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, RejectsTruncationTrailingPaddingAndWrongMagic) {
|
||||
@@ -439,7 +448,7 @@ TEST_F(FleetCommandTest, TargetRecordAndPayloadBudgetsAreSeparateAndBounded) {
|
||||
ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets));
|
||||
EXPECT_EQ(count, targets.count);
|
||||
EXPECT_EQ(count * 5, targets.length);
|
||||
EXPECT_EQ(count <= 15, !encodeTargets(targets).empty()) << count;
|
||||
EXPECT_FALSE(encodeTargets(targets).empty()) << count;
|
||||
}
|
||||
Fleet::Targets targets{};
|
||||
list += ",01020304";
|
||||
@@ -452,7 +461,7 @@ TEST_F(FleetCommandTest, TargetRecordAndPayloadBudgetsAreSeparateAndBounded) {
|
||||
ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets));
|
||||
EXPECT_EQ(count * 17, targets.length);
|
||||
}
|
||||
EXPECT_TRUE(encodeTargets(targets).empty());
|
||||
EXPECT_FALSE(encodeTargets(targets).empty());
|
||||
list += ',' + full;
|
||||
EXPECT_FALSE(Fleet::parseTargets(list.data(), list.size(), targets));
|
||||
}
|
||||
@@ -485,17 +494,25 @@ TEST_F(FleetCommandTest, CompactBroadcastCapacityAndCommandLimitRemainBounded) {
|
||||
const std::string prefix = "set flood.rule.1 ";
|
||||
const std::string maximum = prefix + std::string(Fleet::MaxCommandLength - prefix.size(), 'a');
|
||||
const auto bytes = encodeTargets(targets, maximum.c_str());
|
||||
ASSERT_EQ(150U, bytes.size());
|
||||
EXPECT_EQ(15U, encode(maximum.c_str()).size() - bytes.size());
|
||||
ASSERT_EQ(230U, maximum.size());
|
||||
ASSERT_EQ(Fleet::MaxEnvelopeLength, bytes.size());
|
||||
ASSERT_TRUE(Fleet::commandAllowed(maximum.c_str()));
|
||||
EXPECT_TRUE(decode(bytes));
|
||||
EXPECT_STREQ(maximum.c_str(), decoded.command);
|
||||
EXPECT_TRUE(decoded.broadcast);
|
||||
std::array<uint8_t, Fleet::MaxPayloadLength> output;
|
||||
std::array<uint8_t, Fleet::MaxEnvelopeLength> output;
|
||||
output.fill(0xA5);
|
||||
EXPECT_EQ(0U, Fleet::encode(publisher, ChannelKey, Now, Now + 120, targets,
|
||||
maximum.c_str(), output.data(), bytes.size() - 1));
|
||||
for (uint8_t byte : output) EXPECT_EQ(0xA5, byte);
|
||||
const std::string oversized = maximum + 'a';
|
||||
EXPECT_FALSE(Fleet::commandAllowed(oversized.c_str()));
|
||||
EXPECT_TRUE(encodeTargets(targets, oversized.c_str()).empty());
|
||||
for (const char invalid : {'\n', ';', char(0x80)}) {
|
||||
const std::string malformed = maximum.substr(0, maximum.size() - 1) + invalid;
|
||||
EXPECT_FALSE(Fleet::commandAllowed(malformed.c_str()));
|
||||
EXPECT_TRUE(encodeTargets(targets, malformed.c_str()).empty());
|
||||
}
|
||||
for (size_t length = 0; length < bytes.size(); ++length) {
|
||||
const std::vector<uint8_t> truncated(bytes.begin(), bytes.begin() + length);
|
||||
EXPECT_FALSE(decode(truncated));
|
||||
@@ -565,7 +582,7 @@ TEST_F(FleetCommandTest, AnyMatchingEntryIsAcceptedButMalformedLaterEntryIsRejec
|
||||
bytes[13 + targets.length] = 0;
|
||||
EXPECT_FALSE(decode(bytes));
|
||||
bytes = encodeTargets(targets);
|
||||
bytes[13 + targets.length] = 73;
|
||||
bytes[13 + targets.length] = Fleet::MaxCommandLength + 1;
|
||||
EXPECT_FALSE(decode(bytes));
|
||||
for (size_t length = 0; length < bytes.size(); ++length) {
|
||||
const std::vector<uint8_t> truncated(bytes.begin(), bytes.begin() + length);
|
||||
@@ -615,21 +632,52 @@ TEST_F(FleetCommandTest, InvalidExternalTargetStructuresNeverWriteOutput) {
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, LargestFmc2EnvelopeFitsAndOversizedCombinationWritesNothing) {
|
||||
const std::string prefix = "set flood.rule.1 ";
|
||||
const std::string maximum = prefix + std::string(Fleet::MaxCommandLength - prefix.size(), 'a');
|
||||
Fleet::Targets targets{};
|
||||
const char* three = "01020304,05060708,090A0B0C";
|
||||
ASSERT_TRUE(Fleet::parseTargets(three, strlen(three), targets));
|
||||
EXPECT_EQ(Fleet::MaxPayloadLength, encodeTargets(targets, maximum.c_str()).size());
|
||||
const std::string prefix = "set flood.rule.1 ";
|
||||
const std::string maximum = prefix + std::string(
|
||||
Fleet::MaxCommandLength - targets.length - prefix.size(), 'a');
|
||||
EXPECT_EQ(Fleet::MaxEnvelopeLength, encodeTargets(targets, maximum.c_str()).size());
|
||||
const char* four = "01020304,05060708,090A0B0C,01020304";
|
||||
ASSERT_TRUE(Fleet::parseTargets(four, strlen(four), targets));
|
||||
std::array<uint8_t, Fleet::MaxPayloadLength> output;
|
||||
std::array<uint8_t, Fleet::MaxEnvelopeLength> output;
|
||||
output.fill(0xA5);
|
||||
EXPECT_EQ(0U, Fleet::encode(publisher, ChannelKey, Now, Now + 120,
|
||||
targets, maximum.c_str(), output.data(), output.size()));
|
||||
for (uint8_t byte : output) EXPECT_EQ(0xA5, byte);
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, EveryTargetFormUsesItsExactCommandBudgetWithoutPartialWrites) {
|
||||
char full[PUB_KEY_SIZE * 2 + 1];
|
||||
mesh::Utils::toHex(full, publisher.pub_key, PUB_KEY_SIZE);
|
||||
const std::string short_key(full, 8), long_key(full, 12);
|
||||
const std::string prefix = "set flood.rule.1 ";
|
||||
const std::vector<std::pair<std::string, size_t>> cases = {
|
||||
{"all", 230}, {short_key, 225}, {long_key, 223}, {full, 215},
|
||||
{short_key + ',' + short_key, 220}, {short_key + ',' + long_key, 218},
|
||||
{std::string(full) + ',' + short_key, 208}, {std::string(full) + ',' + full, 196}
|
||||
};
|
||||
for (const auto& item : cases) {
|
||||
SCOPED_TRACE(item.first);
|
||||
Fleet::Targets targets{};
|
||||
ASSERT_TRUE(Fleet::parseTargets(item.first.data(), item.first.size(), targets));
|
||||
const std::string maximum = prefix + std::string(item.second - prefix.size(), 'a');
|
||||
ASSERT_TRUE(Fleet::commandAllowed(maximum.c_str()));
|
||||
const auto bytes = encodeTargets(targets, maximum.c_str());
|
||||
ASSERT_EQ(Fleet::MaxEnvelopeLength, bytes.size());
|
||||
ASSERT_TRUE(decode(bytes));
|
||||
EXPECT_STREQ(maximum.c_str(), decoded.command);
|
||||
const std::string oversized = maximum + 'a';
|
||||
EXPECT_TRUE(encodeTargets(targets, oversized.c_str()).empty());
|
||||
std::array<uint8_t, Fleet::MaxEnvelopeLength + 64> output;
|
||||
output.fill(0xA5);
|
||||
EXPECT_EQ(0U, Fleet::encode(publisher, ChannelKey, Now, Now + 120, targets,
|
||||
oversized.c_str(), output.data(), output.size()));
|
||||
for (uint8_t byte : output) EXPECT_EQ(0xA5, byte);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, UnrepresentableZeroDigestNeverBecomesAllDuringLegacyEncoding) {
|
||||
Fleet::Targets targets{};
|
||||
targets.count = 1;
|
||||
@@ -641,6 +689,129 @@ TEST_F(FleetCommandTest, UnrepresentableZeroDigestNeverBecomesAllDuringLegacyEnc
|
||||
EXPECT_FALSE(decode(bytes));
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, SinglePacketBoundariesRemainExactBeforeFragmentation) {
|
||||
char full[PUB_KEY_SIZE * 2 + 1];
|
||||
mesh::Utils::toHex(full, publisher.pub_key, PUB_KEY_SIZE);
|
||||
const std::string prefix = "set flood.rule.1 ";
|
||||
for (const auto& item : std::vector<std::pair<std::string, size_t>>{
|
||||
{"all", 87}, {std::string(full, 8), 82}, {std::string(full, 12), 80}, {full, 72}}) {
|
||||
Fleet::Targets targets{};
|
||||
ASSERT_TRUE(Fleet::parseTargets(item.first.data(), item.first.size(), targets));
|
||||
const std::string command = prefix + std::string(item.second - prefix.size(), 'a');
|
||||
const auto single = encodeTargets(targets, command.c_str());
|
||||
ASSERT_EQ(Fleet::MaxPayloadLength, single.size());
|
||||
std::array<uint8_t, Fleet::MaxPayloadLength> output;
|
||||
output.fill(0xA5);
|
||||
EXPECT_EQ(0U, Fleet::fragment(single.data(), single.size(), 0, output.data(), output.size()));
|
||||
for (uint8_t byte : output) EXPECT_EQ(0xA5, byte);
|
||||
const auto longer = encodeTargets(targets, (command + 'a').c_str());
|
||||
ASSERT_EQ(Fleet::MaxPayloadLength + 1, longer.size());
|
||||
EXPECT_EQ(Fleet::MaxPayloadLength,
|
||||
Fleet::fragment(longer.data(), longer.size(), 0, output.data(), output.size()));
|
||||
EXPECT_EQ(Fleet::FragmentHeaderSize + longer.size() - Fleet::FragmentDataLength,
|
||||
Fleet::fragment(longer.data(), longer.size(), 1, output.data(), output.size()));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, TwoExactFragmentsReassembleInEitherOrderAndVerifyOnlyWholeEnvelope) {
|
||||
const std::string prefix = "set flood.rule.1 ";
|
||||
for (size_t command_length : {88U, 89U, 229U, 230U}) {
|
||||
const std::string command = prefix + std::string(command_length - prefix.size(), 'a');
|
||||
const auto envelope = encodeTargets(Fleet::Targets{}, command.c_str());
|
||||
ASSERT_EQ(Fleet::MinHeaderSize + command_length + Fleet::SignatureSize, envelope.size());
|
||||
std::array<std::vector<uint8_t>, 2> fragments;
|
||||
for (uint8_t index = 0; index < 2; ++index) {
|
||||
fragments[index].resize(Fleet::MaxPayloadLength);
|
||||
const size_t size = Fleet::fragment(envelope.data(), envelope.size(), index,
|
||||
fragments[index].data(), fragments[index].size());
|
||||
ASSERT_GT(size, 0U);
|
||||
ASSERT_LE(size, Fleet::MaxPayloadLength);
|
||||
fragments[index].resize(size);
|
||||
}
|
||||
for (bool reverse : {false, true}) {
|
||||
std::vector<uint8_t> assembled(envelope.size());
|
||||
const unsigned before = g_mock_ed25519_verify_calls;
|
||||
for (unsigned step = 0; step < 2; ++step) {
|
||||
const uint8_t index = reverse ? 1 - step : step;
|
||||
Fleet::Fragment part;
|
||||
ASSERT_TRUE(Fleet::parseFragment(fragments[index].data(), fragments[index].size(), part));
|
||||
EXPECT_EQ(Now, part.sequence);
|
||||
EXPECT_EQ(envelope.size(), part.total_length);
|
||||
EXPECT_EQ(index, part.index);
|
||||
EXPECT_EQ(index ? envelope.size() - Fleet::FragmentDataLength : Fleet::FragmentDataLength,
|
||||
part.length);
|
||||
EXPECT_EQ(0, memcmp(part.data, envelope.data() + index * Fleet::FragmentDataLength, part.length));
|
||||
std::copy_n(part.data, part.length, assembled.begin() + index * Fleet::FragmentDataLength);
|
||||
EXPECT_FALSE(decode(fragments[index])); // A fragment cannot authorize a command.
|
||||
EXPECT_EQ(before, g_mock_ed25519_verify_calls);
|
||||
Fleet::Fragment duplicate;
|
||||
ASSERT_TRUE(Fleet::parseFragment(fragments[index].data(), fragments[index].size(), duplicate));
|
||||
EXPECT_EQ(part.data, duplicate.data);
|
||||
EXPECT_EQ(part.length, duplicate.length);
|
||||
}
|
||||
EXPECT_EQ(envelope, assembled);
|
||||
ASSERT_TRUE(decode(assembled));
|
||||
EXPECT_EQ(before + 1, g_mock_ed25519_verify_calls);
|
||||
EXPECT_STREQ(command.c_str(), decoded.command);
|
||||
EXPECT_TRUE(decoded.broadcast);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, FragmentEncoderRejectsInvalidInputBeforeWriting) {
|
||||
const std::string command = "set flood.rule.1 " + std::string(72, 'a');
|
||||
auto envelope = encodeTargets(Fleet::Targets{}, command.c_str());
|
||||
ASSERT_GT(envelope.size(), Fleet::MaxPayloadLength);
|
||||
std::array<uint8_t, Fleet::MaxPayloadLength + 64> output;
|
||||
output.fill(0xA5);
|
||||
for (size_t length : {size_t(0), Fleet::MaxPayloadLength, Fleet::MaxEnvelopeLength + 1})
|
||||
EXPECT_EQ(0U, Fleet::fragment(envelope.data(), length, 0, output.data(), output.size()));
|
||||
EXPECT_EQ(0U, Fleet::fragment(nullptr, envelope.size(), 0, output.data(), output.size()));
|
||||
EXPECT_EQ(0U, Fleet::fragment(envelope.data(), envelope.size(), 0, nullptr, output.size()));
|
||||
for (uint8_t index : {uint8_t(2), uint8_t(255)})
|
||||
EXPECT_EQ(0U, Fleet::fragment(envelope.data(), envelope.size(), index, output.data(), output.size()));
|
||||
EXPECT_EQ(0U, Fleet::fragment(envelope.data(), envelope.size(), 0, output.data(), 164));
|
||||
EXPECT_EQ(0U, Fleet::fragment(envelope.data(), envelope.size(), 1, output.data(),
|
||||
Fleet::FragmentHeaderSize + envelope.size() - Fleet::FragmentDataLength - 1));
|
||||
envelope[0] = 'X';
|
||||
EXPECT_EQ(0U, Fleet::fragment(envelope.data(), envelope.size(), 0, output.data(), output.size()));
|
||||
for (uint8_t byte : output) EXPECT_EQ(0xA5, byte);
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, FragmentParserRejectsMalformedLengthIndexAndMagicAndClearsOutput) {
|
||||
const std::string command = "set flood.rule.1 " + std::string(72, 'a');
|
||||
const auto envelope = encodeTargets(Fleet::Targets{}, command.c_str());
|
||||
std::vector<uint8_t> valid(Fleet::MaxPayloadLength);
|
||||
ASSERT_EQ(valid.size(), Fleet::fragment(envelope.data(), envelope.size(), 0, valid.data(), valid.size()));
|
||||
const auto reject = [&](const uint8_t* bytes, size_t length) {
|
||||
Fleet::Fragment part{123, 300, 1, valid.data(), 100};
|
||||
EXPECT_FALSE(Fleet::parseFragment(bytes, length, part));
|
||||
EXPECT_EQ(0U, part.sequence); EXPECT_EQ(0, part.total_length); EXPECT_EQ(0, part.index);
|
||||
EXPECT_EQ(nullptr, part.data); EXPECT_EQ(0U, part.length);
|
||||
};
|
||||
reject(nullptr, valid.size());
|
||||
for (size_t length = 0; length < valid.size(); ++length) reject(valid.data(), length);
|
||||
auto malformed = valid;
|
||||
malformed.push_back(0);
|
||||
reject(malformed.data(), malformed.size());
|
||||
malformed = valid; malformed[0] = 'X'; reject(malformed.data(), malformed.size());
|
||||
for (uint16_t total : {uint16_t(0), uint16_t(165), uint16_t(309), uint16_t(65535)}) {
|
||||
malformed = valid; malformed[8] = uint8_t(total); malformed[9] = uint8_t(total >> 8);
|
||||
reject(malformed.data(), malformed.size());
|
||||
}
|
||||
for (uint8_t index : {uint8_t(1), uint8_t(2), uint8_t(255)}) {
|
||||
malformed = valid; malformed[10] = index; reject(malformed.data(), malformed.size());
|
||||
}
|
||||
valid.resize(Fleet::MaxPayloadLength);
|
||||
valid.resize(Fleet::fragment(envelope.data(), envelope.size(), 1, valid.data(), valid.size()));
|
||||
ASSERT_FALSE(valid.empty());
|
||||
malformed = valid; malformed.pop_back(); reject(malformed.data(), malformed.size());
|
||||
malformed = valid; malformed.push_back(0); reject(malformed.data(), malformed.size());
|
||||
Fleet::Fragment part;
|
||||
ASSERT_TRUE(Fleet::parseFragment(valid.data(), valid.size(), part));
|
||||
EXPECT_EQ(envelope.size() - Fleet::FragmentDataLength, part.length);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
|
||||
@@ -34,6 +34,8 @@ FIXTURE = r'''
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
inline unsigned fleet_sign_calls = 0;
|
||||
|
||||
// The production Identity interface is retained. OpenSSL implements its
|
||||
// signatures here so these tests cannot inherit native's always-true mock.
|
||||
namespace mesh {
|
||||
@@ -46,6 +48,7 @@ LocalIdentity::LocalIdentity(RNG* rng) {
|
||||
EVP_PKEY_free(key);
|
||||
}
|
||||
void LocalIdentity::sign(uint8_t* signature, const uint8_t* message, int length) const {
|
||||
++fleet_sign_calls;
|
||||
EVP_PKEY* key = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, nullptr, prv_key, 32);
|
||||
EVP_MD_CTX* context = EVP_MD_CTX_new(); assert(key && context);
|
||||
assert(EVP_DigestSignInit(context, nullptr, nullptr, nullptr, key) == 1);
|
||||
@@ -84,26 +87,83 @@ void hex(const uint8_t* bytes, size_t size) {
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
using Fleet = mesh::FleetCommand;
|
||||
if (argc == 7 && std::string(argv[1]) == "encode") {
|
||||
const std::string operation = argc > 1 ? argv[1] : "";
|
||||
if (argc == 7 && (operation == "encode" || operation == "encode-wide")) {
|
||||
FixedRng rng; mesh::LocalIdentity publisher(&rng);
|
||||
const auto key = unhex(argv[2]); assert(key.size() == Fleet::KeySize);
|
||||
std::array<uint8_t, Fleet::TargetSize> target;
|
||||
if (!Fleet::parseTarget(argv[3], target.data())) { std::cout << "reject\n"; return 0; }
|
||||
std::array<uint8_t, Fleet::MaxPayloadLength> output;
|
||||
std::array<uint8_t, Fleet::MaxEnvelopeLength + 64> output;
|
||||
output.fill(0xA5);
|
||||
const unsigned signed_before = fleet_sign_calls;
|
||||
const size_t capacity = operation == "encode-wide" ? output.size() : Fleet::MaxEnvelopeLength;
|
||||
const size_t size = Fleet::encode(publisher, key.data(), uint32_t(std::stoul(argv[4])),
|
||||
uint32_t(std::stoul(argv[5])), target.data(), argv[6], output.data(), output.size());
|
||||
if (!size) std::cout << "reject\n"; else hex(output.data(), size);
|
||||
uint32_t(std::stoul(argv[5])), target.data(), argv[6], output.data(), capacity);
|
||||
assert(size <= Fleet::MaxEnvelopeLength);
|
||||
if (!size) {
|
||||
assert(fleet_sign_calls == signed_before);
|
||||
for (uint8_t byte : output) assert(byte == 0xA5);
|
||||
std::cout << "reject\n";
|
||||
} else {
|
||||
assert(fleet_sign_calls == signed_before + 1);
|
||||
for (size_t i = size; i < output.size(); ++i) assert(output[i] == 0xA5);
|
||||
hex(output.data(), size);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (argc == 7 && std::string(argv[1]) == "encode2") {
|
||||
if (argc == 7 && (operation == "encode2" || operation == "encode2-wide")) {
|
||||
FixedRng rng; mesh::LocalIdentity publisher(&rng);
|
||||
const auto key = unhex(argv[2]); assert(key.size() == Fleet::KeySize);
|
||||
Fleet::Targets targets;
|
||||
if (!Fleet::parseTargets(argv[3], strlen(argv[3]), targets)) { std::cout << "reject\n"; return 0; }
|
||||
std::array<uint8_t, Fleet::MaxPayloadLength> output;
|
||||
std::array<uint8_t, Fleet::MaxEnvelopeLength + 64> output;
|
||||
output.fill(0xA5);
|
||||
const unsigned signed_before = fleet_sign_calls;
|
||||
const size_t capacity = operation == "encode2-wide" ? output.size() : Fleet::MaxEnvelopeLength;
|
||||
const size_t size = Fleet::encode(publisher, key.data(), uint32_t(std::stoul(argv[4])),
|
||||
uint32_t(std::stoul(argv[5])), targets, argv[6], output.data(), output.size());
|
||||
if (!size) std::cout << "reject\n"; else hex(output.data(), size);
|
||||
uint32_t(std::stoul(argv[5])), targets, argv[6], output.data(), capacity);
|
||||
assert(size <= Fleet::MaxEnvelopeLength);
|
||||
if (!size) {
|
||||
assert(fleet_sign_calls == signed_before);
|
||||
for (uint8_t byte : output) assert(byte == 0xA5);
|
||||
std::cout << "reject\n";
|
||||
} else {
|
||||
assert(fleet_sign_calls == signed_before + 1);
|
||||
for (size_t i = size; i < output.size(); ++i) assert(output[i] == 0xA5);
|
||||
hex(output.data(), size);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (argc == 5 && operation == "fragment") {
|
||||
const auto envelope = unhex(argv[2]);
|
||||
const unsigned index = unsigned(std::stoul(argv[3]));
|
||||
assert(index <= 255);
|
||||
const size_t capacity = std::stoul(argv[4]);
|
||||
std::array<uint8_t, Fleet::MaxPayloadLength + 64> output;
|
||||
assert(capacity <= output.size()); output.fill(0xA5);
|
||||
const unsigned signed_before = fleet_sign_calls;
|
||||
const size_t size = Fleet::fragment(envelope.data(), envelope.size(), uint8_t(index),
|
||||
output.data(), capacity);
|
||||
assert(fleet_sign_calls == signed_before && size <= Fleet::MaxPayloadLength);
|
||||
if (!size) {
|
||||
for (uint8_t byte : output) assert(byte == 0xA5);
|
||||
std::cout << "reject\n";
|
||||
} else {
|
||||
for (size_t i = size; i < output.size(); ++i) assert(output[i] == 0xA5);
|
||||
hex(output.data(), size);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (argc == 3 && operation == "parse-fragment") {
|
||||
const auto payload = unhex(argv[2]);
|
||||
Fleet::Fragment output{123, 300, 1, payload.data(), 100};
|
||||
if (!Fleet::parseFragment(payload.data(), payload.size(), output)) {
|
||||
assert(!output.sequence && !output.total_length && !output.index && !output.data && !output.length);
|
||||
std::cout << "reject\n";
|
||||
} else {
|
||||
std::cout << output.sequence << '\n' << output.total_length << '\n' << unsigned(output.index) << '\n';
|
||||
hex(output.data, output.length);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (argc == 7 && (std::string(argv[1]) == "decode" || std::string(argv[1]) == "decode2")) {
|
||||
@@ -142,6 +202,13 @@ def envelope2(entries=(), command="get radio2", sequence=NOW, expires=NOW + 120,
|
||||
return unsigned + publisher.sign(DOMAIN + channel + unsigned)
|
||||
|
||||
|
||||
def fragment_frame(payload, index):
|
||||
"""Independent wire framing; the signature stays in the whole envelope."""
|
||||
sequence = struct.unpack("<I", payload[4:8])[0]
|
||||
return (b"FMP1" + struct.pack("<IHB", sequence, len(payload), index)
|
||||
+ payload[index * 154:(index + 1) * 154])
|
||||
|
||||
|
||||
class FleetCommandCryptoTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
@@ -299,8 +366,18 @@ class FleetCommandCryptoTests(unittest.TestCase):
|
||||
self.assertEqual(len(maximum), 72)
|
||||
self.assertNotEqual(self.decode(envelope(maximum)), "reject")
|
||||
self.assertNotEqual(self.decode(envelope2(entries, maximum)), "reject")
|
||||
oversized = prefix + "0" + maximum[len(prefix):]
|
||||
self.assertEqual(self.decode(envelope(oversized)), "reject")
|
||||
larger = prefix + "0" + maximum[len(prefix):]
|
||||
self.assertNotEqual(self.decode(envelope(larger)), "reject")
|
||||
self.assertNotEqual(self.decode(envelope2(entries, larger)), "reject")
|
||||
target_maximum = prefix + "0" * (218 - len(prefix) - len(suffix)) + suffix
|
||||
self.assertNotEqual(self.decode(envelope2(entries, target_maximum)), "reject")
|
||||
too_large = prefix + "0" + target_maximum[len(prefix):]
|
||||
self.assertEqual(self.decode(envelope2(entries, too_large)), "reject")
|
||||
broadcast_maximum = prefix + "0" * (230 - len(prefix) - len(suffix)) + suffix
|
||||
expected = envelope2(command=broadcast_maximum)
|
||||
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), "all", NOW, NOW + 120,
|
||||
broadcast_maximum), expected.hex())
|
||||
self.assertNotEqual(self.decode(expected), "reject")
|
||||
|
||||
def test_clock_management_has_real_signatures_and_no_prefix_aliases(self):
|
||||
for command in ["clock", "clock sync", "time 1735689600", "time 1800000000", "time 4294967295"]:
|
||||
@@ -333,12 +410,18 @@ class FleetCommandCryptoTests(unittest.TestCase):
|
||||
self.assertEqual(self.decode(forged), "reject")
|
||||
self.assertNotEqual(self.decode(original, now=NOW + 120), "reject")
|
||||
|
||||
def test_exact_and_oversized_command_boundaries(self):
|
||||
maximum = "set flood.rule.1 " + "a" * (72 - len("set flood.rule.1 "))
|
||||
def test_fixed_target_exact_limit_cannot_be_raised_by_destination_capacity(self):
|
||||
maximum = "set flood.rule.1 " + "a" * (215 - len("set flood.rule.1 "))
|
||||
accepted = envelope(maximum)
|
||||
self.assertEqual(len(accepted), 165)
|
||||
self.assertEqual(len(accepted), 308)
|
||||
self.assertNotEqual(self.decode(accepted), "reject")
|
||||
self.assertEqual(self.decode(envelope(maximum + "a")), "reject")
|
||||
for operation in ["encode", "encode-wide"]:
|
||||
self.assertEqual(self.run_tool(operation, CHANNEL.hex(), "all", NOW, NOW + 120, maximum),
|
||||
accepted.hex())
|
||||
for length in [216, 230, 231]:
|
||||
command = "set flood.rule.1 " + "a" * (length - len("set flood.rule.1 "))
|
||||
self.assertEqual(self.run_tool(operation, CHANNEL.hex(), "all", NOW, NOW + 120, command), "reject")
|
||||
|
||||
def test_valid_signature_does_not_override_clock_policy(self):
|
||||
for sequence, expires, now in [
|
||||
@@ -412,23 +495,193 @@ class FleetCommandCryptoTests(unittest.TestCase):
|
||||
self.assertEqual(self.decode(original, publisher=OTHER_PUBLIC), "reject")
|
||||
self.assertEqual(self.decode(original, channel=bytes(range(17, 33))), "reject")
|
||||
|
||||
def test_compact_broadcast_keeps_72_byte_command_and_packet_framing_limits(self):
|
||||
def test_compact_broadcast_fits_230_byte_signed_command_and_framing_limits(self):
|
||||
prefix = "set flood.rule.1 "
|
||||
maximum = prefix + "a" * (72 - len(prefix))
|
||||
maximum = prefix + "a" * (230 - len(prefix))
|
||||
expected = envelope2(command=maximum)
|
||||
actual = self.run_tool("encode2", CHANNEL.hex(), "all", NOW, NOW + 120, maximum)
|
||||
self.assertEqual(actual, expected.hex())
|
||||
self.assertEqual(len(expected), 150)
|
||||
self.assertEqual(len(envelope(maximum)) - len(expected), 15)
|
||||
self.assertNotEqual(self.decode(expected), "reject")
|
||||
self.assertEqual(len(expected), 308)
|
||||
PUBLISHER.public_key().verify(expected[-64:], DOMAIN + CHANNEL + expected[:-64])
|
||||
for node in [PUBLIC, OTHER_PUBLIC]:
|
||||
self.assertEqual(self.decode(expected, self_key=node, metadata=True),
|
||||
f"{NOW}\n{NOW + 120}\n{maximum}\n1")
|
||||
oversized = maximum + "a"
|
||||
self.assertEqual(self.decode(envelope2(command=oversized)), "reject")
|
||||
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), "all", NOW, NOW + 120, oversized), "reject")
|
||||
self.assertEqual(self.run_tool("encode2-wide", CHANNEL.hex(), "all", NOW, NOW + 120, oversized), "reject")
|
||||
for invalid in [b"\n", b";", b"\x80", b"\0"]:
|
||||
malformed = maximum[:-1].encode("ascii") + invalid
|
||||
self.assertEqual(self.decode(envelope2(command=malformed)), "reject")
|
||||
for length in [0, 12, 13, 14, len(expected) - 64, len(expected) - 1]:
|
||||
with self.subTest(length=length):
|
||||
self.assertEqual(self.decode(expected[:length]), "reject")
|
||||
self.assertEqual(self.decode(expected + b"\0"), "reject")
|
||||
|
||||
def test_each_target_form_has_exact_packet_budget_and_atomic_oversize_rejection(self):
|
||||
short, long = PUBLIC[:4].hex(), PUBLIC[:6].hex()
|
||||
digest = hashlib.sha256(PUBLIC).digest()[:16]
|
||||
for token, entries, maximum_length in [
|
||||
("all", [], 230), (short, [(4, PUBLIC[:4])], 225),
|
||||
(long, [(6, PUBLIC[:6])], 223), (PUBLIC.hex(), None, 215),
|
||||
(f"{short},{short}", [(4, PUBLIC[:4])] * 2, 220),
|
||||
(f"{short},{long}", [(4, PUBLIC[:4]), (6, PUBLIC[:6])], 218),
|
||||
(f"{PUBLIC.hex()},{short}", [(16, digest), (4, PUBLIC[:4])], 208),
|
||||
(f"{PUBLIC.hex()},{PUBLIC.hex()}", [(16, digest)] * 2, 196)]:
|
||||
with self.subTest(token=token, maximum=maximum_length):
|
||||
prefix = "set flood.rule.1 "
|
||||
maximum = prefix + "a" * (maximum_length - len(prefix))
|
||||
expected = (envelope(maximum, target=digest) if entries is None
|
||||
else envelope2(entries, maximum))
|
||||
self.assertEqual(len(expected), 308)
|
||||
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, maximum),
|
||||
expected.hex())
|
||||
self.assertNotEqual(self.decode(expected), "reject")
|
||||
# The fixture verifies no writes and no signature calls on
|
||||
# rejection, even when its output buffer exceeds packet size.
|
||||
oversized = maximum + "a"
|
||||
for operation in ["encode2", "encode2-wide"]:
|
||||
self.assertEqual(self.run_tool(operation, CHANNEL.hex(), token, NOW, NOW + 120,
|
||||
oversized), "reject")
|
||||
signed_oversized = (envelope(oversized, target=digest) if entries is None
|
||||
else envelope2(entries, oversized))
|
||||
self.assertEqual(self.decode(signed_oversized), "reject")
|
||||
altered = bytearray(expected)
|
||||
altered[-65] = ord("b")
|
||||
self.assertEqual(self.decode(altered), "reject")
|
||||
|
||||
def test_maximum_broadcast_signature_covers_final_byte_and_230_byte_length(self):
|
||||
prefix = "set flood.rule.1 "
|
||||
maximum = prefix + "a" * (230 - len(prefix))
|
||||
original = envelope2(command=maximum)
|
||||
self.assertNotEqual(self.decode(original), "reject")
|
||||
for command in [maximum[:-1] + "b", maximum[:-1]]:
|
||||
changed = envelope2(command=command)
|
||||
self.assertNotEqual(self.decode(changed), "reject")
|
||||
self.assertEqual(self.decode(changed[:-64] + original[-64:]), "reject")
|
||||
for length_byte in [0, 87, 229, 231, 255]:
|
||||
changed = bytearray(original)
|
||||
changed[13] = length_byte
|
||||
self.assertEqual(self.decode(changed), "reject")
|
||||
for offset in range(len(original) - 64, len(original)):
|
||||
with self.subTest(signature_byte=offset):
|
||||
changed = bytearray(original)
|
||||
changed[offset] ^= 1
|
||||
self.assertEqual(self.decode(changed), "reject")
|
||||
|
||||
def test_single_packet_boundaries_and_first_two_fragment_command(self):
|
||||
digest = hashlib.sha256(PUBLIC).digest()[:16]
|
||||
for token, entries, maximum in [
|
||||
("all", [], 87), (PUBLIC[:4].hex(), [(4, PUBLIC[:4])], 82),
|
||||
(PUBLIC[:6].hex(), [(6, PUBLIC[:6])], 80),
|
||||
(PUBLIC.hex(), None, 72)]:
|
||||
with self.subTest(token=token):
|
||||
prefix = "set flood.rule.1 "
|
||||
command = prefix + "a" * (maximum - len(prefix))
|
||||
single = (envelope(command, target=digest) if entries is None
|
||||
else envelope2(entries, command))
|
||||
self.assertEqual(len(single), 165)
|
||||
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
|
||||
command), single.hex())
|
||||
self.assertNotEqual(self.decode(single), "reject")
|
||||
for index in [0, 1]:
|
||||
self.assertEqual(self.run_tool("fragment", single.hex(), index, 165), "reject")
|
||||
longer = (envelope(command + "a", target=digest) if entries is None
|
||||
else envelope2(entries, command + "a"))
|
||||
self.assertEqual(len(longer), 166)
|
||||
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
|
||||
command + "a"), longer.hex())
|
||||
self.assertNotEqual(self.decode(longer), "reject")
|
||||
for index, size in [(0, 165), (1, 23)]:
|
||||
actual = bytes.fromhex(self.run_tool("fragment", longer.hex(), index, 165))
|
||||
self.assertEqual(actual, fragment_frame(longer, index))
|
||||
self.assertEqual(len(actual), size)
|
||||
|
||||
def test_two_fragments_reassemble_out_of_order_with_one_real_signature(self):
|
||||
for size in [166, 167, 307, 308]:
|
||||
with self.subTest(size=size):
|
||||
prefix = "set flood.rule.1 "
|
||||
command = prefix + "a" * (size - 78 - len(prefix))
|
||||
original = envelope2(command=command)
|
||||
self.assertEqual(len(original), size)
|
||||
# The fixture requires exactly one signature call for encode
|
||||
# and no signature calls while cutting either fragment.
|
||||
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), "all", NOW, NOW + 120,
|
||||
command), original.hex())
|
||||
parts = [bytes.fromhex(self.run_tool("fragment", original.hex(), index, 165))
|
||||
for index in [0, 1]]
|
||||
for index, part in enumerate(parts):
|
||||
self.assertEqual(part, fragment_frame(original, index))
|
||||
self.assertLessEqual(len(part), 165)
|
||||
self.assertEqual(self.run_tool("parse-fragment", part.hex()),
|
||||
f"{NOW}\n{size}\n{index}\n{part[11:].hex()}")
|
||||
self.assertEqual(self.decode(part), "reject")
|
||||
for order in [(0, 1), (1, 0), (0, 0, 1), (1, 1, 0)]:
|
||||
with self.subTest(order=order):
|
||||
assembly = bytearray(size)
|
||||
for index in order:
|
||||
part = parts[index]
|
||||
assembly[index * 154:index * 154 + len(part) - 11] = part[11:]
|
||||
assembly = bytes(assembly)
|
||||
self.assertEqual(assembly, original)
|
||||
PUBLISHER.public_key().verify(assembly[-64:], DOMAIN + CHANNEL + assembly[:-64])
|
||||
for node in [PUBLIC, OTHER_PUBLIC]:
|
||||
self.assertEqual(self.decode(assembly, self_key=node, metadata=True),
|
||||
f"{NOW}\n{NOW + 120}\n{command}\n1")
|
||||
|
||||
def test_fragment_codec_bounds_and_exact_shapes_reject_without_output_writes(self):
|
||||
prefix = "set flood.rule.1 "
|
||||
original = envelope2(command=prefix + "a" * (88 - len(prefix)))
|
||||
parts = [fragment_frame(original, index) for index in [0, 1]]
|
||||
for source in [b"", original[:165], original + b"a" * (309 - len(original)),
|
||||
b"FMX1" + original[4:], b"FMP1" + original[4:]]:
|
||||
with self.subTest(source_length=len(source), magic=source[:4]):
|
||||
self.assertEqual(self.run_tool("fragment", source.hex(), 0, 229), "reject")
|
||||
for index in [2, 255]:
|
||||
self.assertEqual(self.run_tool("fragment", original.hex(), index, 229), "reject")
|
||||
for index, required in [(0, 165), (1, 23)]:
|
||||
for capacity in [0, 10, required - 1]:
|
||||
self.assertEqual(self.run_tool("fragment", original.hex(), index, capacity), "reject")
|
||||
self.assertEqual(self.run_tool("fragment", original.hex(), index, 229), parts[index].hex())
|
||||
malformed = [b"", b"FMP1", parts[0][:10], parts[0][:11],
|
||||
parts[0][:-1], parts[0] + b"\0", parts[1][:-1], parts[1] + b"\0",
|
||||
b"FMX1" + parts[0][4:]]
|
||||
for total in [0, 154, 165, 309, 65535]:
|
||||
malformed.append(parts[0][:8] + struct.pack("<H", total) + parts[0][10:])
|
||||
for index in [1, 2, 255]:
|
||||
malformed.append(parts[0][:10] + bytes([index]) + parts[0][11:])
|
||||
malformed.append(parts[1][:10] + b"\0" + parts[1][11:])
|
||||
for part in malformed:
|
||||
with self.subTest(part=part.hex()):
|
||||
self.assertEqual(self.run_tool("parse-fragment", part.hex()), "reject")
|
||||
|
||||
def test_fragment_tampering_and_mixed_commands_cannot_forge_complete_signature(self):
|
||||
prefix = "set flood.rule.1 "
|
||||
command = prefix + "a" * (230 - len(prefix))
|
||||
original = envelope2(command=command)
|
||||
parts = [fragment_frame(original, index) for index in [0, 1]]
|
||||
self.assertNotEqual(self.decode(original), "reject")
|
||||
# Every chunk contains signed data. Matching frame metadata and an
|
||||
# allowed changed command cannot substitute for the full signature.
|
||||
for index, data_offset in [(0, 14 + len(prefix)), (1, 0), (1, 153)]:
|
||||
changed = bytearray(parts[index])
|
||||
changed[11 + data_offset] ^= 1
|
||||
self.assertNotEqual(self.run_tool("parse-fragment", changed.hex()), "reject")
|
||||
chunks = [parts[0][11:], parts[1][11:]]
|
||||
chunks[index] = changed[11:]
|
||||
self.assertEqual(self.decode(b"".join(chunks)), "reject")
|
||||
different = envelope2(command=prefix + "b" * (230 - len(prefix)))
|
||||
self.assertNotEqual(self.decode(different), "reject")
|
||||
different_parts = [fragment_frame(different, index) for index in [0, 1]]
|
||||
for combined in [parts[0][11:] + different_parts[1][11:],
|
||||
different_parts[0][11:] + parts[1][11:]]:
|
||||
self.assertEqual(self.decode(combined), "reject")
|
||||
# Frame metadata is deliberately unauthenticated and only admits a
|
||||
# bounded assembly. Runtime must compare it to the signed sequence.
|
||||
changed_sequence = parts[0][:4] + struct.pack("<I", NOW + 1) + parts[0][8:]
|
||||
self.assertTrue(self.run_tool("parse-fragment", changed_sequence.hex()).startswith(f"{NOW + 1}\n"))
|
||||
self.assertEqual(self.decode(changed_sequence), "reject")
|
||||
|
||||
def test_real_signature_round_trip_for_mixed_prefixes_and_full_keys(self):
|
||||
entries = [(4, PUBLIC[:4]), (6, OTHER_PUBLIC[:6]), (16, hashlib.sha256(PUBLIC).digest()[:16])]
|
||||
token = f"{PUBLIC[:4].hex()},{OTHER_PUBLIC[:6].hex()},{PUBLIC.hex()}"
|
||||
@@ -502,10 +755,10 @@ class FleetCommandCryptoTests(unittest.TestCase):
|
||||
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, "get radio2"), "reject")
|
||||
|
||||
def test_fmc2_packet_capacity_is_checked_before_signing_or_sending(self):
|
||||
maximum = "set flood.rule.1 " + "a" * (72 - len("set flood.rule.1 "))
|
||||
maximum = "set flood.rule.1 " + "a" * (215 - len("set flood.rule.1 "))
|
||||
entries = [(4, PUBLIC[:4])] * 3
|
||||
expected = envelope2(entries, maximum)
|
||||
self.assertEqual(len(expected), 165)
|
||||
self.assertEqual(len(expected), 308)
|
||||
token = ",".join([PUBLIC[:4].hex()] * 3)
|
||||
actual = self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, maximum)
|
||||
self.assertEqual(actual, expected.hex())
|
||||
@@ -516,7 +769,7 @@ class FleetCommandCryptoTests(unittest.TestCase):
|
||||
for count in [15, 16, 17]:
|
||||
token = ",".join([PUBLIC[:4].hex()] * count)
|
||||
result = self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, "get radio2")
|
||||
self.assertEqual(result == "reject", count > 15)
|
||||
self.assertNotEqual(result, "reject")
|
||||
|
||||
def test_fmc2_rejects_short_trailing_and_signed_forbidden_commands(self):
|
||||
original = envelope2([(4, PUBLIC[:4]), (6, OTHER_PUBLIC[:6])])
|
||||
|
||||
@@ -220,8 +220,8 @@ class Nrf52ExtraFsContractTest(unittest.TestCase):
|
||||
next_at = handler.index("cmd_frame[0] == CMD_SET_ADVERT_NAME", get_at)
|
||||
get_contacts = handler[get_at:next_at]
|
||||
incomplete_at = get_contacts.index("hasIncompleteContactLoad()")
|
||||
lock_at = get_contacts.index("_serial->lockReplyRoute()")
|
||||
self.assertLess(incomplete_at, lock_at)
|
||||
route_at = get_contacts.index("_iter_reply_route = _serial->captureReplyRoute()")
|
||||
self.assertLess(incomplete_at, route_at)
|
||||
self.assertIn("ERR_CODE_FILE_IO_ERROR", get_contacts)
|
||||
|
||||
base = (ROOT / "src" / "helpers" / "BaseChatMesh.cpp").read_text(
|
||||
|
||||
@@ -204,6 +204,48 @@ TEST_F(TransportFilter, InvalidModesAndActionsDoNotReplaceSavedRule) {
|
||||
EXPECT_FALSE(blocked(p, RULE_MODE_RADIO));
|
||||
}
|
||||
|
||||
TEST_F(TransportFilter, TwoPacketFleetRulePreservesActionAfterLongParameters) {
|
||||
const std::string prefix = "set flood.rule.1 type=grp_txt channel=" + std::string(64, '1')
|
||||
+ " prefix=AABBCC,DDEEFF,112233 hops=6+ priority=255 suspend=tempradio"
|
||||
" mode=radio hashbytes=3 in=any";
|
||||
const std::string action = " action=drop";
|
||||
ASSERT_LT(prefix.size() + action.size(), 230U);
|
||||
const std::string setter = prefix + std::string(230 - prefix.size() - action.size(), ' ') + action;
|
||||
ASSERT_EQ(230U, setter.size());
|
||||
ASSERT_GT(setter.size() - strlen("set flood.rule.1 "), 192U);
|
||||
command(setter.c_str()); ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
|
||||
const auto saved = fs.files["/flood_filter"];
|
||||
|
||||
mesh::Packet p;
|
||||
p.header = (PAYLOAD_TYPE_GRP_TXT << PH_TYPE_SHIFT) | ROUTE_TYPE_FLOOD;
|
||||
p.setPathHashSizeAndCount(3, 6);
|
||||
memset(p.path, 0, p.getPathByteLen());
|
||||
const uint8_t path[] = {0xAA,0xBB,0xCC,0xDD,0xEE,0xFF,0x11,0x22,0x33};
|
||||
memcpy(p.path, path, sizeof(path));
|
||||
uint8_t key[PUB_KEY_SIZE];memset(key, 0x11, sizeof(key));
|
||||
mesh::Utils::sha256(p.payload, 1, key, sizeof(key));
|
||||
const uint8_t text[] = "fleet: sample";
|
||||
p.payload_len = 1 + mesh::Utils::encryptThenMAC(key, p.payload + 1, text, sizeof(text));
|
||||
EXPECT_TRUE(blocked(p, RULE_MODE_RADIO));
|
||||
|
||||
std::string stop = setter;stop.replace(stop.size() - 4, 4, "stop");
|
||||
command(stop.c_str()); ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
|
||||
EXPECT_FALSE(blocked(p, RULE_MODE_RADIO));
|
||||
EXPECT_NE(saved, fs.files["/flood_filter"]);
|
||||
|
||||
const auto unchanged = fs.files["/flood_filter"];
|
||||
command(("set flood.rule.1 any " + std::string(256, ' ') + "drop").c_str());
|
||||
EXPECT_EQ(0, strncmp(reply, "Err - rule parameters too long", 29));
|
||||
EXPECT_EQ(unchanged, fs.files["/flood_filter"]);
|
||||
}
|
||||
|
||||
TEST_F(TransportFilter, OverlongRateTokenCannotDiscardItsInvalidSuffix) {
|
||||
install("radio");const auto saved = fs.files["/flood_filter"];
|
||||
command(("set flood.rule.3 any q=" + std::string(23, '0') + "x stop").c_str());
|
||||
EXPECT_EQ(0, strncmp(reply, "Err - rate value too long", 24)) << reply;
|
||||
EXPECT_EQ(saved, fs.files["/flood_filter"]);
|
||||
}
|
||||
|
||||
TEST_F(TransportFilter, CombinedModeSharesRateAndResetsAfterMinute) {
|
||||
command("set flood.rule.3 type=any mode=bridge,cross rate=1/min");
|
||||
ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
|
||||
@@ -490,6 +532,35 @@ TEST_F(TransportFilter, PrivateKeyReferencesCopyLocallyWithoutDisclosingKey) {
|
||||
EXPECT_EQ(empty, fs.files["/flood_filter"]);
|
||||
}
|
||||
|
||||
TEST_F(TransportFilter, WholeFleetMaximumRulePreservesItsFinalDropAction) {
|
||||
const std::string setter = "set flood.rule.1 5 c=" + std::string(64, '1') + " d";
|
||||
ASSERT_EQ(87U, setter.size());
|
||||
command(setter.c_str());
|
||||
ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
|
||||
|
||||
mesh::Packet p;
|
||||
p.header = (PAYLOAD_TYPE_GRP_TXT << PH_TYPE_SHIFT) | ROUTE_TYPE_FLOOD;
|
||||
p.setPathHashSizeAndCount(1, 0);
|
||||
uint8_t key[PUB_KEY_SIZE];
|
||||
memset(key, 0x11, sizeof(key));
|
||||
mesh::Utils::sha256(p.payload, 1, key, sizeof(key));
|
||||
const uint8_t text[] = "fleet: sample";
|
||||
p.payload_len = 1 + mesh::Utils::encryptThenMAC(key, p.payload + 1, text, sizeof(text));
|
||||
EXPECT_TRUE(blocked(p, RULE_MODE_RADIO));
|
||||
|
||||
command("get fr.1");
|
||||
const std::string formatted = reply;
|
||||
ASSERT_GE(formatted.size(), 2U);
|
||||
EXPECT_EQ(" d", formatted.substr(formatted.size() - 2));
|
||||
|
||||
// A stop action at the same final byte has different forwarding behavior.
|
||||
std::string stop = setter;
|
||||
stop.back() = 's';
|
||||
command(stop.c_str());
|
||||
ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
|
||||
EXPECT_FALSE(blocked(p, RULE_MODE_RADIO));
|
||||
}
|
||||
|
||||
TEST(TransportModes, KeyFingerprintCollisionsCannotSelectAnArbitrarySecret) {
|
||||
struct Row {
|
||||
bool active = true;
|
||||
|
||||
Reference in New Issue
Block a user