Target signed fleet commands by region and GPS radius

This commit is contained in:
mikecarper committed 2026-10-09 01:32:12 -07:00
1 parent fdad79eaf7
commit 466eb008c7
24 files changed
+2447 -107

No files matched your search

+1
View File
@@ -107,6 +107,7 @@ jobs:
python3 -B test/test_companion_fleet_control.py -v
python3 -B test/test_fleet_channel_runtime.py -v
python3 -B test/test_fleet_role_wiring.py -v
python3 -B test/test_fleet_location.py -v
python3 -B test/test_fleet_radio_schedules.py -v
python3 -B test/test_fleet_clock_control.py -v
+1 -1
View File
@@ -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 each complete command once, splitting longer requests across two packets; 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, existing configured or home regions, a GPS center and radius, one node, or a semicolon-separated target list. 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.
+110 -8
View File
@@ -11,8 +11,8 @@ title: Private fleet management
Fleet control lets one publisher send filtering, radio schedules, secondary-radio
changes, and clock commands to
all enrolled infrastructure nodes, one node, or a list of nodes over a private
LoRa channel.
all enrolled infrastructure nodes, regions, a geographic radius, one node, or a list of targets over a
private LoRa channel.
It is disabled until configured. It is supported on repeaters and room servers;
fixed-size STM32 images omit it because of their flash limit.
Both the publishing Companion and receivers need firmware built with fleet-control
@@ -90,13 +90,13 @@ private fleet channel. Explicit `all` still works, for example
To address particular nodes, insert **8 or 12 hexadecimal characters from the
start of a node's public key**, or its **complete 64-character public key**, before
the command. Separate multiple targets with commas, without spaces. Lengths can
the command. Separate multiple targets with semicolons, without spaces. Lengths can
be mixed:
```text
fleet send 3 1257aee5 get radio2
fleet send 3 1257aee5a754,9abc0123 get radio2
fleet send 3 1257aee5,9abc01234567 set radio2.cross auto
fleet send 3 1257aee5a754;9abc0123 get radio2
fleet send 3 1257aee5;9abc01234567 set radio2.cross auto
```
These are example public-key prefixes; substitute your nodes' actual keys.
@@ -106,12 +106,107 @@ fleet. Names and short LoRa path IDs are not accepted as target identities.
`all` must appear alone. A node that matches several entries still executes the
command only once, and the signature protects the entire list.
### Target existing regions
Fleet commands read each receiver's **existing region map**. Two selectors are
available, and they can be mixed with public-key targets:
| Selector | Which receivers execute |
| --- | --- |
| `sea` or `region:sea` | Receivers with an exact `sea` entry in their configured region list |
| `home:sea` | Receivers whose home region is `sea`, or whose home has `sea` as a parent or ancestor |
```text
fleet send 3 sea get radio2
fleet send 3 sea;pdx clock sync
fleet send 3 region:sea set radio2.cross auto
fleet send 3 home:sea get tempradio2
fleet send 3 home:sea;home:pdx clock sync
fleet send 3 sea;home:pdx;1257aee5 get radio2
```
The semicolon-separated list is an **OR**: a receiver that matches any entry executes
once, even if it matches several entries. Names match exactly and are case
sensitive; `sea` does not match `seattle`. One leading `#` is an alias, as in the
existing region commands. `$` remains significant for private region names.
Names may contain up to 30 printable ASCII bytes using region-name characters.
Use `region:` or `home:` explicitly for names with 8 or 12 characters, names
made entirely of hexadecimal characters, or reserved command words such as
`get`, `set`, `del`, `time` and `clock`. Bare `all` still means the entire fleet;
`region:all` selects a region actually named `all`.
Inspect the existing configuration with `region` and `region home`. To choose a
home from regions already defined on that receiver, use:
```text
region home sea
region save
```
List matching includes a configured region even when forwarding for it is
denied. Home matching follows the selected home's ancestry; a wildcard or unset
home matches no named home target. The default transmit scope and the incoming
packet's scope do not determine target membership. Region selection is checked
against the live map when the complete command is received.
Targets select which nodes execute. Delivery still follows the Companion's
channel scope and the intervening forwarding rules. Unknown names match no
receivers and never become a whole-fleet request.
### Target a GPS center and radius
Use `gps:latitude,longitude:radius-km` to select receivers within a circle. For
example, this selects nodes within **25 km** of the given Seattle coordinates:
```text
fleet send 3 gps:47.6062,-122.3321:25 get radio2
fleet send 3 gps:47.6062,-122.3321:25 set radio2.cross auto
fleet send 3 sea;home:pdx;gps:47.6062,-122.3321:25;1257aee5 clock sync
```
GPS, region, home and key selectors can share the same semicolon-separated list.
They use the same OR rule: any matching entry selects the node, and the command
executes once. The center, radius and complete target list are signed.
The comma is only for the coordinate pair; `;` is the only target-list separator.
Coordinates use ordinary decimal degrees, with latitude from -90 to 90 and
longitude from -180 to 180, and at most six decimal places. Radius is in
kilometers, from **0.001 to 20050**, with at most three decimal places. For
example, `0.5` means 500 meters. Scientific notation, excess decimal places,
missing fields and out-of-range values are rejected without sending. Distance
uses the great-circle calculation, including across the date line and near the
poles; a node on the radius boundary is included.
Each receiver prefers its configured `lat` and `lon`. A stationary radio can
have a fixed position even without GPS hardware; configure it through its
normal administration connection:
```text
set lat 47.6062
set lon -122.3321
get lat
get lon
```
When both configured coordinates are zero, the receiver can use an existing
valid GPS cache no older than 12 hours. Matching never wakes GPS, takes a UART
or requests telemetry. A missing, expired, invalid or default `(0,0)` position
matches no GPS selector; it can still match another entry in the list. Invalid
nonzero configured coordinates are rejected rather than falling back to GPS.
For a stationary radio that lets GPS sleep for longer than 12 hours, configure
its fixed coordinates.
Private fleet matching uses this position even when location is hidden from
public adverts. It reads the position when the complete command arrives,
including after the second fragment of a two-packet command, so a moving node
is selected using its position at completion.
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. 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.
FMC1 format. Region, home and GPS selectors use separate signed record types in FMC2.
Separate fleets use separate channel keys.
Only local commands on the publishing Companion can originate fleet sends. A
remote CLI command cannot turn that Companion into a multicast signing proxy.
@@ -135,12 +230,19 @@ or **230 bytes** across two. Targets consume some of that allowance:
| One 8-character public-key prefix | 82 | 225 |
| One 12-character public-key prefix | 80 | 223 |
| One complete 64-character public key | 72 | 215 |
| One three-character region or home name, such as `sea` | 82 | 225 |
| Two three-character region or home names, such as `sea;pdx` | 77 | 220 |
| One GPS center and radius | 74 | 217 |
| 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.
Each region or home selector uses two bytes plus its canonical name length.
Each GPS selector uses 13 bytes: its type, latitude and longitude in millionths
of a degree, and radius in meters.
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
so its limits come from packet space. Lists are capped at 17 entries and 86 bytes
of encoded target records; for example, at most six GPS selectors fit. Requests
exceeding their target-specific budget are rejected before signing or sending,
with no partial target list.
+3 -3
View File
@@ -9781,7 +9781,7 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp,
if (strncmp(text, "send", 4)
|| (text[4] != ' ' && text[4] != '\t')) {
snprintf(reply, reply_capacity,
"Error: use fleet send <channel-index> [all|key[,key...]] <command>");
"Error: use fleet send <channel> [all|key|region:name|home:name|gps:lat,lon:km[;target...]] <command>");
return true;
}
text += 4;
@@ -9808,11 +9808,11 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp,
// or list must never silently broaden a request to the whole fleet.
if (!mesh::FleetCommand::commandAllowed(text)) {
const size_t target_length = strcspn(text, " \t");
// A list is one atomic packet; it must never become partial fan-out.
// A target list is one signed request; never admit a partial list.
if (text[target_length] == 0
|| !mesh::FleetCommand::parseTargets(text, target_length, targets)) {
snprintf(reply, reply_capacity,
"Error: fleet targets must be all or comma-separated 8/12/64-hex keys");
"Error: targets: all, 8/12/64-hex keys, region names, home:name or gps:lat,lon:km; semicolon-separated");
return true;
}
text += target_length;
+7
View File
@@ -10,6 +10,9 @@
#include <helpers/UsbLogging.h>
#include <helpers/HilStartupTrace.h>
#include <helpers/FileRead.h>
#if MESH_ENABLE_FLEET_CONTROL
#include <helpers/FleetLocation.h>
#endif
#if MESH_ENABLE_TELEMETRY_HISTORY
#include <helpers/FilePresence.h>
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
@@ -4862,6 +4865,10 @@ void MyMesh::deleteScheduledRadioParams(bool temporary, const char* selector, ch
}
#if MESH_ENABLE_FLEET_CONTROL
bool MyMesh::getFleetLocation(int32_t& latitude_e6, int32_t& longitude_e6) const {
return mesh::readFleetLocation(_prefs, sensors, latitude_e6, longitude_e6);
}
void MyMesh::finishFleetReplyMutation(bool delivered) {
if (!fleet_schedule_pending_) return;
fleet_schedule_delivered_ = delivered;
+1
View File
@@ -1103,6 +1103,7 @@ public:
// CommonCLICallbacks
bool supportsFleetControl() const override { return true; }
#if MESH_ENABLE_FLEET_CONTROL
bool getFleetLocation(int32_t& latitude_e6, int32_t& longitude_e6) const override;
void beginFleetCommand() override { fleet_command_ = true; }
void endFleetCommand() override { fleet_command_ = false; }
bool hasFleetReplyMutation() const override { return fleet_schedule_pending_; }
+9
View File
@@ -12,6 +12,9 @@
#include <helpers/ota/OtaContext.h>
#endif
#include <helpers/FileRead.h>
#if MESH_ENABLE_FLEET_CONTROL
#include <helpers/FleetLocation.h>
#endif
#include <helpers/radiolib/RxBoostedGainDefaults.h>
#include <helpers/CLICommandUtils.h>
#include <helpers/ClientACLCLI.h>
@@ -1915,6 +1918,12 @@ void MyMesh::startRegionsLoad() {
region_load_active = true;
}
#if MESH_ENABLE_FLEET_CONTROL
bool MyMesh::getFleetLocation(int32_t& latitude_e6, int32_t& longitude_e6) const {
return mesh::readFleetLocation(_prefs, sensors, latitude_e6, longitude_e6);
}
#endif
bool MyMesh::saveRegions() {
return region_map.save(_fs);
}
+3
View File
@@ -438,6 +438,9 @@ protected:
public:
bool supportsFleetControl() const override { return true; }
#if MESH_ENABLE_FLEET_CONTROL
bool getFleetLocation(int32_t& latitude_e6, int32_t& longitude_e6) const override;
#endif
MyMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables);
void begin(FILESYSTEM* fs);
+2 -2
View File
@@ -248,7 +248,7 @@ void CommonCLI::beginManagement(mesh::Mesh& mesh, FILESYSTEM* fs) {
#if MESH_ENABLE_FLEET_CONTROL
if (_callbacks->supportsFleetControl()
&& (fs->exists("/fleet_channel") || fs->exists("/fleet_channel.bak"))) {
_fleet_channel = new (std::nothrow) mesh::FleetChannel(fs, _callbacks);
_fleet_channel = new (std::nothrow) mesh::FleetChannel(fs, _callbacks, _region_map);
}
#endif
if (!_data_route) {
@@ -279,7 +279,7 @@ bool CommonCLI::handleFleetCommand(const char* command, char* reply) {
if (!_callbacks->supportsFleetControl()) {
strcpy(reply, "Err - fleet control is available on infrastructure roles"); return true;
}
if (!_fleet_channel) _fleet_channel = new (std::nothrow) mesh::FleetChannel(_management_fs, _callbacks);
if (!_fleet_channel) _fleet_channel = new (std::nothrow) mesh::FleetChannel(_management_fs, _callbacks, _region_map);
if (!_fleet_channel) {
strcpy(reply, "Err - fleet control memory unavailable"); return true;
}
+2 -1
View File
@@ -213,7 +213,8 @@ FleetChannel::DecodeResult FleetChannel::decode(Mesh& mesh, FleetCommand::Decode
&& 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);
mesh.getRTCClock()->getCurrentTime(), mesh.self_id.pub_key, command,
matchesFleetRegion, regions_, matchesFleetLocation, hooks_);
if (assembled || (valid && assembly_parts_ && command.sequence >= assembly_sequence_)) clearAssembly();
if (!valid) return DecodeResult::Rejected;
broadcast_ = command.broadcast;
+48 -2
View File
@@ -6,6 +6,8 @@
#include <helpers/TempRadioReplyBarrier.h>
#include <helpers/FleetCommand.h>
#include <helpers/TransportKeyStore.h>
#include <helpers/RegionMap.h>
#include <helpers/RegionNameUtils.h>
namespace mesh {
@@ -16,8 +18,19 @@ struct FleetReplyHooks {
virtual void endFleetCommand() {}
virtual bool hasFleetReplyMutation() const { return false; }
virtual void finishFleetReplyMutation(bool) {}
virtual bool getFleetLocation(int32_t&, int32_t&) const { return false; }
};
inline bool matchesFleetLocation(void* context, int32_t latitude_e6,
int32_t longitude_e6, uint32_t radius_meters) {
auto* hooks = static_cast<FleetReplyHooks*>(context);
int32_t local_latitude_e6 = 0, local_longitude_e6 = 0;
if (!hooks || !hooks->getFleetLocation(local_latitude_e6, local_longitude_e6)
|| (!local_latitude_e6 && !local_longitude_e6)) return false;
return FleetCommand::withinRadius(latitude_e6, longitude_e6, radius_meters,
local_latitude_e6, local_longitude_e6);
}
// Scope codes authenticate the original packet's payload. A new reply needs
// fresh codes from the exact matching region key, including rotated keys.
template <typename Regions, typename Region>
@@ -35,12 +48,44 @@ bool captureFleetReplyScope(Regions& regions, const Region* region,
return false;
}
// Fleet selectors read the current region map without changing routing.
// A configured-list selector includes denied regions too; home selectors
// follow only the selected home's parent chain, never the default TX scope.
inline bool matchesFleetRegion(void* context, FleetCommand::RegionTarget kind,
const char* name, size_t length) {
auto* regions = static_cast<RegionMap*>(context);
if (!regions || !name || !length || length > FleetCommand::MaxRegionNameLength) return false;
const auto matches = [name, length](const RegionEntry* entry) {
if (!entry || entry->isWildcard()) return false;
const char* canonical = RegionNameUtils::canonical(entry->name);
const size_t available = sizeof(entry->name) - size_t(canonical - entry->name);
const size_t actual = strnlen(canonical, available);
return actual < available && actual == length && !memcmp(canonical, name, length);
};
const int count = regions->getCount();
if (count < 0 || count > MAX_REGION_ENTRIES) return false;
if (kind == FleetCommand::RegionTarget::Configured) {
for (int i = 0; i < count; ++i) {
if (matches(regions->getByIdx(i))) return true;
}
} else if (kind == FleetCommand::RegionTarget::Home) {
const RegionEntry* entry = regions->getHomeRegion();
// Bound traversal even if a damaged in-memory map has a parent cycle.
for (int depth = 0; entry && !entry->isWildcard() && depth < count; ++depth) {
if (matches(entry)) return true;
entry = regions->findById(entry->parent);
}
}
return false;
}
// Optional infrastructure-only receiver. The publisher's private key is never
// enrolled here. The ordinary channel key provides privacy; signatures grant
// the deliberately narrower fleet capability.
class FleetChannel {
FILESYSTEM* fs_;
FleetReplyHooks* hooks_;
RegionMap* regions_;
GroupChannel channel_{};
Identity controller_;
uint32_t last_sequence_ = 0;
@@ -77,8 +122,9 @@ class FleetChannel {
void serviceDeadline(Mesh& mesh, RadioProfileCLI& profiles);
public:
explicit FleetChannel(FILESYSTEM* fs, FleetReplyHooks* hooks = nullptr)
: fs_(fs), hooks_(hooks) { healthy_ = load(); }
explicit FleetChannel(FILESYSTEM* fs, FleetReplyHooks* hooks = nullptr,
RegionMap* regions = nullptr)
: fs_(fs), hooks_(hooks), regions_(regions) { healthy_ = load(); }
bool handleConfig(const char* command, char* reply, size_t capacity);
void receive(Packet* packet, Mesh& mesh, const TransportKey* scope = nullptr);
void complete(Packet* packet, RadioProfileCLI& profiles);
+188 -19
View File
@@ -1,5 +1,6 @@
#include "FleetCommand.h"
#include <SHA256.h>
#include <math.h>
#include <string.h>
namespace mesh {
@@ -49,11 +50,93 @@ void targetHash(const uint8_t* full_key, uint8_t* target) {
hash.finalize(target, FleetCommand::TargetSize);
}
bool regionNameAllowed(const char* name, size_t length) {
if (!length || length > FleetCommand::MaxRegionNameLength) return false;
for (size_t i = 0; i < length; ++i) {
const unsigned char value = name[i];
// Match RegionMap's name characters within printable ASCII only.
if (value != '-' && value != '$' && value != '#'
&& !(value >= '0' && value <= '9')
&& !(value >= 'A' && value <= '~')) return false;
}
return true;
}
bool coordinatesAllowed(int32_t latitude_e6, int32_t longitude_e6) {
return latitude_e6 >= -90000000 && latitude_e6 <= 90000000
&& longitude_e6 >= -180000000 && longitude_e6 <= 180000000;
}
bool geoAllowed(int32_t latitude_e6, int32_t longitude_e6, uint32_t radius) {
return coordinatesAllowed(latitude_e6, longitude_e6)
&& radius >= FleetCommand::MinRadiusMeters && radius <= FleetCommand::MaxRadiusMeters;
}
// Convert bounded decimal text to fixed point without floating-point rounding,
// exponent aliases, silently dropped precision, or intermediate overflow.
bool scaledDecimal(const char* text, size_t length, unsigned precision,
uint32_t maximum, bool signed_value, int32_t& result) {
if (!length) return false;
size_t cursor = 0;
bool negative = false;
if (text[cursor] == '+' || text[cursor] == '-') {
if (!signed_value || ++cursor == length) return false;
negative = text[0] == '-';
}
if (text[cursor] < '0' || text[cursor] > '9') return false;
if (text[cursor] == '0' && cursor + 1 < length
&& text[cursor + 1] >= '0' && text[cursor + 1] <= '9') return false;
uint32_t scale = 1;
for (unsigned i = 0; i < precision; ++i) scale *= 10;
const uint32_t maximum_whole = maximum / scale;
uint32_t whole = 0;
while (cursor < length && text[cursor] >= '0' && text[cursor] <= '9') {
const uint32_t digit = unsigned(text[cursor++] - '0');
if (digit > maximum_whole || whole > (maximum_whole - digit) / 10) return false;
whole = whole * 10 + digit;
}
uint32_t fraction = 0;
unsigned digits = 0;
if (cursor < length) {
if (text[cursor++] != '.' || cursor == length) return false;
while (cursor < length) {
if (text[cursor] < '0' || text[cursor] > '9' || digits == precision) return false;
fraction = fraction * 10 + unsigned(text[cursor++] - '0');
++digits;
}
}
while (digits++ < precision) fraction *= 10;
const uint32_t magnitude = whole * scale + fraction;
if (magnitude > maximum) return false;
result = negative ? -int32_t(magnitude) : int32_t(magnitude);
return true;
}
bool parseGeo(const char* token, size_t length, int32_t& latitude_e6,
int32_t& longitude_e6, uint32_t& radius) {
size_t cursor = 4; // gps:
int32_t values[3];
for (unsigned i = 0; i < 3; ++i) {
const size_t start = cursor;
const char separator = i == 0 ? ',' : ':';
while (cursor < length && token[cursor] != separator) ++cursor;
if ((i < 2 && cursor == length) || (i == 2 && cursor != length)
|| !scaledDecimal(token + start, cursor - start, i == 2 ? 3 : 6,
i == 0 ? 90000000 : i == 1 ? 180000000 : FleetCommand::MaxRadiusMeters,
i != 2, values[i])) return false;
if (cursor < length) ++cursor;
}
latitude_e6 = values[0]; longitude_e6 = values[1]; radius = uint32_t(values[2]);
return geoAllowed(latitude_e6, longitude_e6, radius);
}
// Validate every record, including records after one that matches this node.
// An absent self key is used by encode to validate externally supplied lists.
bool targetRecords(const uint8_t* data, size_t available, uint8_t count,
const uint8_t* self, size_t& consumed, bool& matched,
bool& broadcast) {
bool& broadcast, FleetCommand::RegionMatcher matcher = nullptr,
void* context = nullptr, FleetCommand::GeoMatcher geo_matcher = nullptr,
void* geo_context = nullptr) {
consumed = 0;
matched = count == 0;
broadcast = count == 0 || count > 1;
@@ -63,6 +146,30 @@ bool targetRecords(const uint8_t* data, size_t available, uint8_t count,
for (unsigned i = 0; i < count; ++i) {
if (consumed >= available) return false;
const uint8_t type = data[consumed++];
if (type == FleetCommand::GeoRecordType) {
constexpr size_t fields = FleetCommand::GeoRecordLength - 1;
if (fields > available - consumed || consumed + fields > FleetCommand::MaxTargetBytes) return false;
const int32_t latitude_e6 = int32_t(read32(data + consumed));
const int32_t longitude_e6 = int32_t(read32(data + consumed + 4));
const uint32_t radius = read32(data + consumed + 8);
if (!geoAllowed(latitude_e6, longitude_e6, radius)) return false;
broadcast = true;
if (self && geo_matcher && geo_matcher(geo_context, latitude_e6, longitude_e6, radius)) matched = true;
consumed += fields;
continue;
}
if (type == FleetCommand::RegionRecordType || type == FleetCommand::HomeRecordType) {
if (consumed >= available) return false;
const uint8_t length = data[consumed++];
if (length > available - consumed || consumed + length > FleetCommand::MaxTargetBytes
|| !regionNameAllowed(reinterpret_cast<const char*>(data + consumed), length)) return false;
broadcast = true;
if (self && matcher && matcher(context, type == FleetCommand::RegionRecordType
? FleetCommand::RegionTarget::Configured : FleetCommand::RegionTarget::Home,
reinterpret_cast<const char*>(data + consumed), length)) matched = true;
consumed += length;
continue;
}
if (type != 4 && type != 6 && type != FleetCommand::TargetSize) return false;
if (type > available - consumed
|| consumed + type > FleetCommand::MaxTargetBytes) return false;
@@ -265,30 +372,89 @@ bool FleetCommand::parseTargets(const char* text, size_t length, Targets& target
size_t cursor = 0;
while (cursor < length) {
const size_t start = cursor;
while (cursor < length && text[cursor] != ',') ++cursor;
// A semicolon always terminates a target. GPS owns its one coordinate
// comma, so malformed coordinates cannot consume another target.
while (cursor < length && text[cursor] != ';') ++cursor;
const size_t token_length = cursor - start;
if (token_length != 8 && token_length != 12 && token_length != PUB_KEY_SIZE * 2) return fail();
const size_t type = token_length == PUB_KEY_SIZE * 2 ? TargetSize : token_length / 2;
if (targets.count == MaxTargetCount || targets.length + 1 + type > MaxTargetBytes) return fail();
uint8_t full_key[PUB_KEY_SIZE];
const size_t byte_length = token_length / 2;
for (size_t i = 0; i < byte_length; ++i) {
const int upper = hexDigit(text[start + i * 2]);
const int lower = hexDigit(text[start + i * 2 + 1]);
if (upper < 0 || lower < 0) return fail();
full_key[i] = uint8_t((upper << 4) | lower);
if (!token_length || targets.count == MaxTargetCount) return fail();
const char* token = text + start;
const bool gps = token_length >= 4 && !memcmp(token, "gps:", 4);
const bool region = token_length >= 7 && !memcmp(token, "region:", 7);
const bool home = token_length >= 5 && !memcmp(token, "home:", 5);
const bool explicit_name = region || home;
const bool key = !explicit_name && !gps
&& (token_length == 8 || token_length == 12 || token_length == PUB_KEY_SIZE * 2);
if (gps) {
int32_t latitude_e6, longitude_e6;
uint32_t radius;
if (targets.length + GeoRecordLength > MaxTargetBytes
|| !parseGeo(token, token_length, latitude_e6, longitude_e6, radius)) return fail();
targets.data[targets.length++] = GeoRecordType;
write32(targets.data + targets.length, uint32_t(latitude_e6));
write32(targets.data + targets.length + 4, uint32_t(longitude_e6));
write32(targets.data + targets.length + 8, radius);
targets.length += GeoRecordLength - 1;
} else if (key) {
const size_t type = token_length == PUB_KEY_SIZE * 2 ? TargetSize : token_length / 2;
if (targets.length + 1 + type > MaxTargetBytes) return fail();
uint8_t full_key[PUB_KEY_SIZE];
const size_t byte_length = token_length / 2;
for (size_t i = 0; i < byte_length; ++i) {
const int upper = hexDigit(token[i * 2]);
const int lower = hexDigit(token[i * 2 + 1]);
if (upper < 0 || lower < 0) return fail();
full_key[i] = uint8_t((upper << 4) | lower);
}
if (type == TargetSize && zero(full_key, PUB_KEY_SIZE)) return fail();
targets.data[targets.length++] = uint8_t(type);
if (type == TargetSize) targetHash(full_key, targets.data + targets.length);
else memcpy(targets.data + targets.length, full_key, type);
targets.length += uint8_t(type);
} else {
if (!explicit_name) {
if (keyEquals(token, token_length, "all") || keyEquals(token, token_length, "get")
|| keyEquals(token, token_length, "set") || keyEquals(token, token_length, "del")
|| keyEquals(token, token_length, "time") || keyEquals(token, token_length, "clock")) return fail();
bool all_hex = true;
for (size_t i = 0; i < token_length; ++i) all_hex = all_hex && hexDigit(token[i]) >= 0;
if (all_hex) return fail();
}
const size_t prefix = region ? 7 : home ? 5 : 0;
const char* name = token + prefix;
size_t name_length = token_length - prefix;
if (name_length && *name == '#') { ++name; --name_length; }
if (!regionNameAllowed(name, name_length)
|| targets.length + 2 + name_length > MaxTargetBytes) return fail();
targets.data[targets.length++] = home ? HomeRecordType : RegionRecordType;
targets.data[targets.length++] = uint8_t(name_length);
memcpy(targets.data + targets.length, name, name_length);
targets.length += uint8_t(name_length);
}
if (type == TargetSize && zero(full_key, PUB_KEY_SIZE)) return fail();
targets.data[targets.length++] = uint8_t(type);
if (type == TargetSize) targetHash(full_key, targets.data + targets.length);
else memcpy(targets.data + targets.length, full_key, type);
targets.length += uint8_t(type);
++targets.count;
if (cursor < length && ++cursor == length) return fail();
}
return true;
}
bool FleetCommand::withinRadius(int32_t center_latitude_e6, int32_t center_longitude_e6,
uint32_t radius_meters, int32_t local_latitude_e6,
int32_t local_longitude_e6) {
if (!geoAllowed(center_latitude_e6, center_longitude_e6, radius_meters)
|| !coordinatesAllowed(local_latitude_e6, local_longitude_e6)) return false;
constexpr double radians_per_e6_degree = 0.000001 * 0.017453292519943295769;
const double center_latitude = double(center_latitude_e6) * radians_per_e6_degree;
const double local_latitude = double(local_latitude_e6) * radians_per_e6_degree;
const double latitude_half = (double(local_latitude_e6) - center_latitude_e6) * radians_per_e6_degree / 2;
const double longitude_half = (double(local_longitude_e6) - center_longitude_e6) * radians_per_e6_degree / 2;
const double latitude_sine = sin(latitude_half), longitude_sine = sin(longitude_half);
double haversine = latitude_sine * latitude_sine
+ cos(center_latitude) * cos(local_latitude) * longitude_sine * longitude_sine;
if (haversine < 0) haversine = 0;
else if (haversine > 1) haversine = 1;
const double distance = 2 * 6371008.8 * atan2(sqrt(haversine), sqrt(1 - haversine));
return distance <= double(radius_meters);
}
bool FleetCommand::commandAllowed(const char* command) {
if (!command) return false;
size_t length = 0;
@@ -411,7 +577,9 @@ size_t FleetCommand::encode(const LocalIdentity& publisher, const uint8_t* key,
bool FleetCommand::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) {
const uint8_t* self_public_key, Decoded& output,
RegionMatcher matcher, void* context,
GeoMatcher geo_matcher, void* geo_context) {
memset(&output, 0, sizeof(output));
if (!privateKeyAllowed(key) || !payload || !self_public_key
|| zero(publisher.pub_key, PUB_KEY_SIZE) || zero(self_public_key, PUB_KEY_SIZE)
@@ -433,7 +601,8 @@ bool FleetCommand::decode(const Identity& publisher, const uint8_t* key,
size_t consumed;
bool matched;
if (!targetRecords(payload + 13, unsigned_length - 13, payload[12],
self_public_key, consumed, matched, broadcast)
self_public_key, consumed, matched, broadcast, matcher, context,
geo_matcher, geo_context)
|| !matched) return false;
command_offset = MinHeaderSize + consumed;
if (command_offset > unsigned_length) return false;
+28 -3
View File
@@ -27,6 +27,13 @@ public:
static constexpr size_t FixedTargetMaxCommandLength = MaxEnvelopeLength - HeaderSize - SignatureSize;
static constexpr size_t MaxTargetBytes = 86;
static constexpr size_t MaxTargetCount = 17;
static constexpr uint8_t RegionRecordType = 0x20;
static constexpr uint8_t HomeRecordType = 0x21;
static constexpr uint8_t GeoRecordType = 0x22;
static constexpr size_t GeoRecordLength = 13;
static constexpr size_t MaxRegionNameLength = 30;
static constexpr uint32_t MinRadiusMeters = 1;
static constexpr uint32_t MaxRadiusMeters = 20050000;
static constexpr uint32_t MinEpoch = 1735689600UL; // 2025-01-01 UTC
static constexpr uint32_t MaxLifetime = 600;
static constexpr uint32_t MaxClockLead = 60;
@@ -43,9 +50,20 @@ public:
uint8_t count;
// Each record is a length/type byte (4, 6, or 16) followed by that many
// bytes. Prefixes are raw public-key bytes; complete keys are SHA-256/128.
// Region/home records are type 0x20/0x21, name length, then canonical name.
// GPS records are type 0x22, signed latitude/longitude E6, radius metres LE32.
uint8_t data[MaxTargetBytes];
};
enum class RegionTarget { Configured, Home };
// Names are length-delimited and case-sensitive, with one leading '#' alias
// removed by the encoder. The role matches against its existing RegionMap.
// This bounded, read-only callback must not execute or mutate any state.
using RegionMatcher = bool (*)(void* context, RegionTarget target,
const char* name, size_t length);
using GeoMatcher = bool (*)(void* context, int32_t latitude_e6,
int32_t longitude_e6, uint32_t radius_meters);
struct Fragment {
uint32_t sequence;
uint16_t total_length;
@@ -58,10 +76,15 @@ public:
// "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.
static bool parseTarget(const char* text, uint8_t* target);
// Parse one bounded token containing comma-separated 8-, 12-, or 64-digit
// public keys. "all" is accepted only alone and produces zero records.
// Parse one bounded token containing semicolon-separated public keys and named
// regions. Bare key-length tokens never fall back to region names; explicit
// region:/home: selectors permit ambiguous names. GPS circles use
// gps:latitude,longitude:radius-km. "all" is accepted alone.
static bool parseTargets(const char* text, size_t length, Targets& targets);
static bool commandAllowed(const char* command);
static bool withinRadius(int32_t center_latitude_e6, int32_t center_longitude_e6,
uint32_t radius_meters, int32_t local_latitude_e6,
int32_t local_longitude_e6);
// Return the exact envelope size, or zero on invalid input. The signature
// binds the domain, private channel key, complete header, and command bytes.
@@ -83,7 +106,9 @@ public:
// any mutation, and defer execution until the receive stack has unwound.
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);
const uint8_t* self_public_key, Decoded& output,
RegionMatcher matcher = nullptr, void* context = nullptr,
GeoMatcher geo_matcher = nullptr, void* geo_context = nullptr);
// Envelopes <= MaxPayloadLength travel unchanged. Longer envelopes use
// exactly two FMP1 frames, each bounded by the group payload limit.
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#include <math.h>
#include <stdint.h>
namespace mesh {
// Private fleet matching uses a configured fixed position first. Only the
// default/unset pair falls back to an existing GPS cache; never wake GPS here.
template<class Prefs, class Sensors>
bool readFleetLocation(const Prefs& prefs, const Sensors& sensors,
int32_t& latitude_e6, int32_t& longitude_e6) {
latitude_e6 = longitude_e6 = 0;
double latitude = prefs.node_lat;
double longitude = prefs.node_lon;
if (latitude == 0 && longitude == 0
&& !sensors.getCachedGpsPosition(latitude, longitude)) return false;
if (!isfinite(latitude) || !isfinite(longitude)
|| latitude < -90.0 || latitude > 90.0
|| longitude < -180.0 || longitude > 180.0) return false;
const int32_t lat = static_cast<int32_t>(lround(latitude * 1000000.0));
const int32_t lon = static_cast<int32_t>(lround(longitude * 1000000.0));
if (lat == 0 && lon == 0) return false;
latitude_e6 = lat;
longitude_e6 = lon;
return true;
}
} // namespace mesh
+21 -1
View File
@@ -1,4 +1,21 @@
#include "SensorManager.h"
#include <math.h>
bool SensorManager::getCachedGpsPosition(double& latitude, double& longitude) const {
latitude = longitude = 0;
#if ENV_INCLUDE_GPS
if (!gpsTelemetryCacheFresh(millis())
|| !isfinite(gps_cache_lat) || !isfinite(gps_cache_lon)
|| gps_cache_lat < -90.0f || gps_cache_lat > 90.0f
|| gps_cache_lon < -180.0f || gps_cache_lon > 180.0f
|| (gps_cache_lat == 0 && gps_cache_lon == 0)) return false;
latitude = gps_cache_lat;
longitude = gps_cache_lon;
return true;
#else
return false;
#endif
}
#if ENV_INCLUDE_GPS
#ifndef GPS_TELEMETRY_CACHE_INTERVAL_SEC
@@ -37,7 +54,7 @@ bool SensorManager::gpsTelemetryHoldActive(unsigned long now) const {
bool SensorManager::gpsTelemetryCacheFresh(unsigned long now) const {
return gps_cache_valid &&
(unsigned long)(now - gps_cache_updated_at) <= GPS_TELEMETRY_MAX_STALE_SEC * 1000UL;
static_cast<uint32_t>(now - gps_cache_updated_at) <= GPS_TELEMETRY_MAX_STALE_SEC * 1000UL;
}
void SensorManager::updateGpsTelemetryCache(float lat, float lon, float altitude, unsigned long now) {
@@ -176,6 +193,9 @@ void SensorManager::processGpsTelemetryFix(float lat, float lon, float altitude,
}
void SensorManager::loopGpsTelemetry(unsigned long now) {
// Expire the validity bit even while a bridge owns the UART. A stale fix
// must not become fresh again when the 32-bit millis counter cycles.
if (gps_cache_valid && !gpsTelemetryCacheFresh(now)) gps_cache_valid = false;
if (!gps_transport_available) return;
if (!gps_user_enabled && !gpsTelemetryHoldActive(now) && !gps_acquiring) {
maybeStopGpsForTelemetry(now);
+3
View File
@@ -109,6 +109,9 @@ public:
}
virtual void loop() { }
virtual void setTelemetryLocationAccessAvailable(bool available);
// Read the existing fresh cache without acquiring GPS, changing power, or
// taking a UART. Sleeping/off receivers may retain a recent valid fix.
bool getCachedGpsPosition(double& latitude, double& longitude) const;
bool requestGpsTelemetryTimeSync(uint64_t min_interval_secs) {
#if ENV_INCLUDE_GPS
// A denied request must not consume the provider's throttle or reclaim a
+436 -19
View File
@@ -233,7 +233,64 @@ static std::string fullKey(uint8_t value) {
return hex;
}
static void checkedFragments(MyMesh& node, size_t first_index) {
struct RegionMembership {
std::vector<std::string> configured;
std::vector<std::string> home_ancestors;
};
// Receiver membership is a role-owned read-only callback. These explicit
// memberships isolate sender serialization from actual RegionMap behavior.
static bool regionMatches(void* context, mesh::FleetCommand::RegionTarget target,
const char* name, size_t length) {
assert(context && length > 0 && length <= mesh::FleetCommand::MaxRegionNameLength);
const auto& membership = *static_cast<const RegionMembership*>(context);
const auto& names = target == mesh::FleetCommand::RegionTarget::Configured
? membership.configured : membership.home_ancestors;
for (const auto& candidate : names)
if (candidate.size() == length && !memcmp(candidate.data(), name, length)) return true;
return false;
}
static bool decodedRegion(MyMesh& node, RegionMembership& membership,
mesh::FleetCommand::Decoded& decoded, uint8_t node_prefix = 0x77) {
uint8_t recipient[32]; memset(recipient, node_prefix, sizeof(recipient));
return mesh::FleetCommand::decode(node.self_id, node.channels[1].channel.secret,
node.sent, node.sent_length, node.clock.now, recipient, decoded,
regionMatches, &membership);
}
struct GeoPosition {
int32_t latitude_e6 = 47606200, longitude_e6 = -122332100;
bool available = true;
};
static bool geoMatches(void* context, int32_t latitude_e6, int32_t longitude_e6,
uint32_t radius_meters) {
assert(context);
const auto& position = *static_cast<const GeoPosition*>(context);
return position.available && mesh::FleetCommand::withinRadius(
latitude_e6, longitude_e6, radius_meters, position.latitude_e6, position.longitude_e6);
}
static bool decodedGeo(MyMesh& node, GeoPosition& position,
mesh::FleetCommand::Decoded& decoded,
RegionMembership* regions = nullptr, uint8_t node_prefix = 0x77) {
uint8_t recipient[32]; memset(recipient, node_prefix, sizeof(recipient));
return mesh::FleetCommand::decode(node.self_id, node.channels[1].channel.secret,
node.sent, node.sent_length, node.clock.now, recipient, decoded,
regions ? regionMatches : nullptr, regions, geoMatches, &position);
}
static uint32_t encoded32(const uint8_t* bytes) {
return uint32_t(bytes[0]) | uint32_t(bytes[1]) << 8
| uint32_t(bytes[2]) << 16 | uint32_t(bytes[3]) << 24;
}
static void checkedFragments(MyMesh& node, size_t first_index,
mesh::FleetCommand::RegionMatcher matcher = nullptr,
void* context = nullptr,
mesh::FleetCommand::GeoMatcher geo_matcher = nullptr,
void* geo_context = nullptr) {
assert(node.sent_length > mesh::FleetCommand::MaxPayloadLength);
uint8_t assembled[mesh::FleetCommand::MaxEnvelopeLength] = {};
// Read in reverse order; the immutable part index determines placement.
@@ -258,10 +315,12 @@ static void checkedFragments(MyMesh& node, size_t first_index) {
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, node.clock.now, recipient, decoded,
matcher, context, geo_matcher, geo_context));
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));
assembled, node.sent_length, node.clock.now, recipient, decoded,
matcher, context, geo_matcher, geo_context));
}
static void compactBroadcast(MyMesh& node, const char* command) {
@@ -293,7 +352,7 @@ int main(int argc, char** argv) {
assert(!node.handleCommand("fleet send 1 all set radio2 off", 0, nullptr));
assert(!node.handleCommand("fleetish", 0, reply));
rejected(node, "fleet send 1 all set radio2 off", 1);
rejected(node, "fleet send 1 00112233,445566778899 set radio2 off", 1);
rejected(node, "fleet send 1 00112233;445566778899 set radio2 off", 1);
rejected(node, "fleet", 1);
rejected(node, "fleet send 1 all reboot");
rejected(node, "fleet send 1 all get prv.key");
@@ -352,9 +411,10 @@ int main(int argc, char** argv) {
compactBroadcast(node, "clock sync");
assert(node.queued == 3);
} else if (scenario == "implicit_target_ambiguity") {
for (const char* targets : {"nonsense", "All", "ALL", "all,42424242", "42424242,all",
"allall", "4242424", "424242424", "4242424242424", "GG424242",
"42424242,,51515151", "42424242,", ",42424242", "set", "get", "clock"}) {
for (const char* targets : {"nonsense", "all;42424242", "42424242;all",
"4242424", "424242424", "4242424242424", "GG424242",
"42424242;;51515151", "42424242;", ";42424242", "region:", "home:",
"get", "set", "del", "time", "clock"}) {
rejected(node, (std::string("fleet send 1 ") + targets + " set radio2 off").c_str());
}
rejected(node, ("fleet send 1 " + fullKey(0) + " set radio2 off").c_str());
@@ -387,8 +447,8 @@ int main(int argc, char** argv) {
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},
{"42424242;51515151", 220}, {"42424242;515151515151", 218},
{fullKey(0x42) + ";" + fullKey(0x51), 196},
};
const std::string prefix = "set flood.filter ";
for (const Boundary& boundary : boundaries) {
@@ -577,9 +637,330 @@ int main(int argc, char** argv) {
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 == "region_target_modes") {
struct Selection { const char* token; const char* name; uint8_t type; };
const Selection selections[] = {
{"sea", "sea", 0x20}, {"pdx", "pdx", 0x20}, {"region:sea", "sea", 0x20},
{"#sea", "sea", 0x20}, {"region:#sea", "sea", 0x20},
{"home:sea", "sea", 0x21}, {"home:#sea", "sea", 0x21},
{"$sea", "$sea", 0x20}, {"home:$sea", "$sea", 0x21},
// Bare command verbs are reserved. Explicit selectors and # aliases
// can still select identically named regions without command ambiguity.
{"region:get", "get", 0x20}, {"home:get", "get", 0x21}, {"#get", "get", 0x20},
{"region:clock", "clock", 0x20}, {"home:time", "time", 0x21}, {"ALL", "ALL", 0x20},
{"region:00112233", "00112233", 0x20},
};
for (const auto& selection : selections) {
const std::string cli = std::string("fleet send 1 ") + selection.token + " get radio2";
rejected(node, cli.c_str(), 1);
const unsigned before = node.queued;
assert(node.handleCommand(cli.c_str(), 0, reply));
assert(node.queued == before + 1 && node.sent_policy == 2);
assert(!memcmp(node.sent, "FMC2", 4) && node.sent[12] == 1);
assert(node.sent[13] == selection.type && node.sent[14] == strlen(selection.name));
assert(!memcmp(node.sent + 15, selection.name, strlen(selection.name)));
assert(node.sent_length == 78 + 2 + strlen(selection.name) + strlen("get radio2"));
RegionMembership selected, wrong;
auto& names = selection.type == 0x20 ? selected.configured : selected.home_ancestors;
names.push_back(selection.name);
mesh::FleetCommand::Decoded decoded;
assert(decodedRegion(node, selected, decoded));
assert(decoded.broadcast && !strcmp(decoded.command, "get radio2"));
assert(!decodedRegion(node, wrong, decoded));
// Configured-list and home modes cannot borrow each other's membership.
auto& opposite = selection.type == 0x20 ? wrong.home_ancestors : wrong.configured;
opposite.push_back(selection.name);
assert(!decodedRegion(node, wrong, decoded));
uint8_t recipient[32]; 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.c_str()); // A successful target consumes this second.
++node.clock.now;
}
// Omitting a target still depends on a complete allowlisted command.
assert(node.handleCommand("fleet send 1 get radio2", 0, reply));
compactBroadcast(node, "get radio2");
++node.clock.now;
rejected(node, "fleet send 1 get clock");
assert(node.handleCommand("fleet send 1 region:get clock", 0, reply));
RegionMembership membership{{"get"}, {}};
mesh::FleetCommand::Decoded decoded;
assert(decodedRegion(node, membership, decoded) && !strcmp(decoded.command, "clock"));
membership.configured.clear();
assert(!decodedRegion(node, membership, decoded)); // An explicitly named verb remains targeted.
rejected(node, "fleet send 1 all get clock"); // It never becomes a new command alias.
} else if (scenario == "region_mixed_targets") {
const std::string cli = "AB|fleet\tsend\t1\tsea;pdx;home:sea;42424242\tset radio2 off";
rejected(node, cli.c_str(), 123);
node.queue_ok = false;
rejected(node, cli.c_str());
node.queue_ok = true;
assert(node.handleCommand(cli.c_str(), 0, reply));
assert(!strncmp(reply, "AB|OK - fleet command queued; seq=", 32));
assert(node.queued == 1 && node.sent_policy == 2 && node.sent[12] == 4);
assert(node.sent_length == 78 + 20 + strlen("set radio2 off"));
mesh::FleetCommand::Decoded decoded;
for (auto membership : {RegionMembership{{"sea"}, {}}, RegionMembership{{"pdx"}, {}},
RegionMembership{{}, {"bel", "sea", "w-wa"}}}) {
assert(decodedRegion(node, membership, decoded));
assert(decoded.broadcast && !strcmp(decoded.command, "set radio2 off"));
}
RegionMembership none{{"Sea", "seattle"}, {"pdx"}};
assert(!decodedRegion(node, none, decoded));
assert(decodedRegion(node, none, decoded, 0x42)); // Selected node prefix is independent.
rejected(node, cli.c_str());
++node.clock.now;
assert(node.handleCommand("fleet send 1 unknown-place clock", 0, reply));
assert(!decodedRegion(node, none, decoded)); // Unknown names never mean all.
} else if (scenario == "region_packet_budgets") {
const std::string name30(30, 'z');
struct Boundary { std::string token; size_t records; RegionMembership membership; };
const Boundary boundaries[] = {
{"sea", 5, {{"sea"}, {}}}, {"home:sea", 5, {{}, {"bel", "sea"}}},
{"region:" + name30, 32, {{name30}, {}}},
{"home:" + name30, 32, {{}, {name30}}},
{"sea;pdx;home:sea;42424242", 20, {{"pdx"}, {}}},
};
const std::string prefix = "set flood.filter ";
for (const auto& boundary : boundaries) {
const size_t maximum = mesh::FleetCommand::MaxCommandLength - boundary.records;
std::string command = prefix + std::string(maximum - prefix.size(), '1');
const std::string cli = "fleet send 1 " + boundary.token + " ";
const size_t before = node.manager.outbound.size();
rejected(node, (cli + command + '1').c_str());
assert(node.handleCommand((cli + command + '1').c_str(), 0, reply));
assert(strstr(reply, "exceed two packets") && node.queued == before);
node.queue_ok = false;
rejected(node, (cli + command).c_str());
node.queue_ok = true;
assert(node.handleCommand((cli + command).c_str(), 0, reply));
assert(node.sent_length == 308 && node.queued == before + 2 && node.sent_policy == 2);
auto membership = boundary.membership;
checkedFragments(node, before, regionMatches, &membership);
mesh::FleetCommand::Decoded decoded;
assert(decodedRegion(node, membership, decoded));
assert(!strcmp(decoded.command, command.c_str()));
++node.clock.now;
// The exact single-packet boundary changes admission to two packets
// when one additional command byte is supplied.
const size_t single = mesh::FleetCommand::SinglePacketMaxCommandLength - boundary.records;
command = prefix + std::string(single - prefix.size(), '1');
const size_t single_before = node.manager.outbound.size();
assert(node.handleCommand((cli + command).c_str(), 0, reply));
assert(node.sent_length == 165 && node.queued == single_before + 1);
assert(decodedRegion(node, membership, decoded));
++node.clock.now;
command += '1';
assert(node.handleCommand((cli + command).c_str(), 0, reply));
assert(node.sent_length == 166 && node.queued == single_before + 3);
checkedFragments(node, single_before + 1, regionMatches, &membership);
++node.clock.now;
}
} else if (scenario == "region_signature_binding") {
const std::string prefix = "set flood.filter ";
for (size_t length : {size_t(20), size_t(83)}) {
const std::string command = prefix + std::string(length - prefix.size(), '1');
const size_t before = node.manager.outbound.size();
assert(node.handleCommand(("fleet send 1 sea " + command).c_str(), 0, reply));
RegionMembership membership{{"sea", "tea"}, {"sea", "tea"}};
mesh::FleetCommand::Decoded decoded;
assert(decodedRegion(node, membership, decoded));
if (length == 83) checkedFragments(node, before, regionMatches, &membership);
// Both the readable name and its configured-vs-home selector are signed.
node.sent[15] = 't';
assert(!decodedRegion(node, membership, decoded));
node.sent[15] = 's';
node.sent[13] = mesh::FleetCommand::HomeRecordType;
assert(!decodedRegion(node, membership, decoded));
node.sent[13] = mesh::FleetCommand::RegionRecordType;
assert(decodedRegion(node, membership, decoded));
++node.clock.now;
}
} else if (scenario == "region_invalid_targets") {
for (const std::string& targets : {std::string("all;sea"), std::string("sea;all"),
std::string("sea;;pdx"), std::string("sea;"), std::string(",sea"),
std::string("region:"), std::string("home:"), std::string("region:#"),
std::string("home:*"), std::string("region:sea:pdx"), std::string("unknown:sea"),
std::string("region:") + std::string(31, 'z'), std::string("home:") + std::string(31, 'z'),
std::string("sea;GG112233"), std::string("sea;0011223"), std::string("sea;001122334"),
std::string("home:sea;0011223344556"), std::string("sea;00112233;44556677889Z"),
std::string("sea;region:\x7f"), std::string("sea;region:\xc3\xa9"),
std::string("sea;") + fullKey(0), std::string("sea;") + std::string(65, '1')}) {
rejected(node, ("fleet send 1 " + targets + " set radio2 off").c_str());
assert(node.queued == 0);
}
assert(node.handleCommand("fleet send 1 sea clock", 0, reply));
assert(node.queued == 1); // No malformed target consumed the successful second.
RegionMembership membership{{"sea"}, {}};
mesh::FleetCommand::Decoded decoded;
assert(decodedRegion(node, membership, decoded) && !strcmp(decoded.command, "clock"));
} else if (scenario == "rescue_region_overflow") {
const std::string oversized = "fleet send 1 sea;pdx;home:sea;" + fullKey(0x42)
+ " set radio2 off";
assert(oversized.size() > sizeof(node.cli_command));
rescue_input.input = oversized + "\r\nfleet send 1 sea clock\r\n";
while (rescue_input.available()) node.checkCLIRescueCmd();
assert(node.rescue_commands == std::vector<std::string>{"fleet send 1 sea clock"});
assert(node.queued == 1 && !node._cli_line_overflow);
assert(rescue_output.output.find("ERR: too long") != std::string::npos);
RegionMembership membership{{"sea"}, {}};
mesh::FleetCommand::Decoded decoded;
assert(decodedRegion(node, membership, decoded));
assert(!strcmp(decoded.command, "clock") && node.sent[12] == 1);
} else if (scenario == "gps_target") {
struct Circle { const char* text; int32_t lat, lon; uint32_t radius; };
const Circle circles[] = {
{"gps:47.6062,-122.3321:25", 47606200, -122332100, 25000},
{"gps:+47.606200,-122.332100:25.125", 47606200, -122332100, 25125},
{"gps:47.6062,-122.3321:0.001", 47606200, -122332100, 1},
};
GeoPosition position;
for (const auto& circle : circles) {
const std::string cli = std::string("fleet send 1 ") + circle.text + " get radio2";
rejected(node, cli.c_str(), 1);
assert(node.handleCommand(cli.c_str(), 0, reply));
assert(node.sent_policy == 2 && node.sent[12] == 1 && node.sent[13] == 0x22);
assert(int32_t(encoded32(node.sent + 14)) == circle.lat);
assert(int32_t(encoded32(node.sent + 18)) == circle.lon);
assert(encoded32(node.sent + 22) == circle.radius);
assert(node.sent_length == 78 + 13 + strlen("get radio2"));
mesh::FleetCommand::Decoded decoded;
assert(decodedGeo(node, position, decoded));
assert(decoded.broadcast && !strcmp(decoded.command, "get radio2"));
position.available = false;
assert(!decodedGeo(node, position, decoded));
position.available = true;
uint8_t recipient[32]; 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.c_str());
++node.clock.now;
}
} else if (scenario == "gps_mixed_targets") {
const std::string cli = "CD|fleet\tsend\t1\tsea;gps:47.6062,-122.3321:25;home:pdx;42424242\tclock sync";
rejected(node, cli.c_str(), 99);
assert(node.handleCommand(cli.c_str(), 0, reply));
assert(!strncmp(reply, "CD|OK - fleet command queued; seq=", 32));
assert(node.sent_policy == 2 && node.queued == 1 && node.sent[12] == 4);
assert(node.sent_length == 78 + 28 + strlen("clock sync"));
GeoPosition position; RegionMembership regions;
mesh::FleetCommand::Decoded decoded;
assert(decodedGeo(node, position, decoded, &regions)); // Seattle circle.
position.latitude_e6 = 47610100; position.longitude_e6 = -122201500;
assert(decodedGeo(node, position, decoded, &regions)); // Bellevue is inside25km.
position.latitude_e6 = 45523000; position.longitude_e6 = -122676000;
assert(!decodedGeo(node, position, decoded, &regions)); // Portland is outside.
regions.configured = {"sea"};
assert(decodedGeo(node, position, decoded, &regions)); // Configured-region OR.
regions.configured.clear(); regions.home_ancestors = {"suburb", "pdx"};
assert(decodedGeo(node, position, decoded, &regions)); // Home-ancestor OR.
regions.home_ancestors.clear(); position.available = false;
assert(decodedGeo(node, position, decoded, &regions, 0x42)); // Node-prefix OR.
assert(!decodedGeo(node, position, decoded, &regions));
rejected(node, cli.c_str());
} else if (scenario == "gps_packet_budgets") {
struct Boundary { const char* targets; size_t records; };
const Boundary boundaries[] = {
{"gps:47.6062,-122.3321:25", 13},
{"sea;gps:47.6062,-122.3321:25;home:pdx;42424242", 28},
};
const std::string prefix = "set flood.filter ";
GeoPosition position;
for (const auto& boundary : boundaries) {
const std::string cli = std::string("fleet send 1 ") + boundary.targets + " ";
const size_t maximum = mesh::FleetCommand::MaxCommandLength - boundary.records;
std::string command = prefix + std::string(maximum - prefix.size(), '1');
const size_t before = node.manager.outbound.size();
rejected(node, (cli + command + '1').c_str());
assert(node.handleCommand((cli + command + '1').c_str(), 0, reply));
assert(strstr(reply, "exceed two packets") && node.queued == before);
node.fail_queue = node.queue_calls + 2;
rejected(node, (cli + command).c_str());
assert(node.queued == before); // Neither half remains after second admission fails.
node.fail_queue = 0;
assert(node.handleCommand((cli + command).c_str(), 0, reply));
assert(node.sent_length == 308 && node.queued == before + 2 && node.sent_policy == 2);
checkedFragments(node, before, nullptr, nullptr, geoMatches, &position);
mesh::FleetCommand::Decoded decoded;
assert(decodedGeo(node, position, decoded) && !strcmp(decoded.command, command.c_str()));
++node.clock.now;
const size_t single = mesh::FleetCommand::SinglePacketMaxCommandLength - boundary.records;
command = prefix + std::string(single - prefix.size(), '1');
const size_t single_before = node.manager.outbound.size();
assert(node.handleCommand((cli + command).c_str(), 0, reply));
assert(node.sent_length == 165 && node.queued == single_before + 1);
assert(decodedGeo(node, position, decoded));
++node.clock.now; command += '1';
assert(node.handleCommand((cli + command).c_str(), 0, reply));
assert(node.sent_length == 166 && node.queued == single_before + 3);
checkedFragments(node, single_before + 1, nullptr, nullptr, geoMatches, &position);
++node.clock.now;
}
} else if (scenario == "gps_signature_binding") {
const std::string prefix = "set flood.filter "; GeoPosition position;
for (size_t length : {size_t(20), size_t(75)}) {
const std::string command = prefix + std::string(length - prefix.size(), '1');
const size_t before = node.manager.outbound.size();
assert(node.handleCommand(("fleet send 1 gps:47.6062,-122.3321:25 " + command).c_str(), 0, reply));
mesh::FleetCommand::Decoded decoded;
assert(decodedGeo(node, position, decoded));
if (length == 75) checkedFragments(node, before, nullptr, nullptr, geoMatches, &position);
for (size_t field : {size_t(14), size_t(18), size_t(22)}) {
node.sent[field] ^= 1; // Still valid and near the selected receiver.
assert(!decodedGeo(node, position, decoded));
node.sent[field] ^= 1;
assert(decodedGeo(node, position, decoded));
}
++node.clock.now;
}
} else if (scenario == "gps_invalid_targets") {
for (const char* target : {"gps:", "gps:47,-122", "gps:47,-122:", "gps:::-1",
"gps:47:-122:25", "gps:47,,122:25", "gps:47,-122:25;", ";gps:47,-122:25",
"sea;;gps:47,-122:25", "gps:47,-122:25;all", "all;gps:47,-122:25",
"gps:47,-122:25;clock", "gps:47,-122:25;sea;", "sea;gps:47;-122:25",
"gps:47,-122:25:1", "gps:91,-122:25", "gps:-91,-122:25", "gps:47,181:25",
"gps:47,-181:25", "gps:47,-122:0", "gps:47,-122:-1", "gps:47,-122:+1",
"gps:47,-122:0.0001", "gps:47,-122:20050.001", "gps:NaN,-122:25",
"gps:47,inf:25", "gps:47,-122:1e3", "gps:.5,-122:25", "gps:47.,-122:25",
"gps:47.6062001,-122:25", "gps:47,-122.3321001:25", "gps:047,-122:25",
"gps:47,-122:025", "gps:47,-122:429496729600000000000", "gps:47,-122:25,,sea",
"all,gps:47,-122:25", "gps:47,-122:25,all", "gps:47,-122:25,GG112233"}) {
rejected(node, (std::string("fleet send 1 ") + target + " clock").c_str());
assert(node.queued == 0);
}
std::string circles;
for (unsigned i = 0; i < 6; ++i) {
if (i) circles += ';';
circles += "gps:47.6062,-122.3321:25";
}
mesh::FleetCommand::Targets parsed;
assert(mesh::FleetCommand::parseTargets(circles.c_str(), circles.size(), parsed));
assert(parsed.count == 6 && parsed.length == 78);
rejected(node, ("fleet send 1 " + circles + ";gps:47,-122:25 clock").c_str());
assert(node.queued == 0); // Target-record overflow never signs a partial list.
assert(node.handleCommand(("fleet send 1 " + circles + " clock").c_str(), 0, reply));
assert(node.sent_length == 161 && node.queued == 1);
GeoPosition position; mesh::FleetCommand::Decoded decoded;
assert(decodedGeo(node, position, decoded) && !strcmp(decoded.command, "clock"));
} else if (scenario == "comma_target_lists_rejected") {
const std::string comma_lists[] = {
"42424242,515151515151", fullKey(0x42) + "," + fullKey(0x63),
"sea,pdx", "home:sea,home:pdx", "sea;home:pdx,region:sea",
"sea,gps:47.6062,-122.3321:25", "gps:47.6062,-122.3321:25,sea",
"gps:47.6062,-122.3321:25;sea,pdx",
"gps:47.6062,-122.3321:25,gps:45.5,-122.6:25",
};
for (const auto& targets : comma_lists) {
rejected(node, ("fleet send 1 " + targets + " clock").c_str());
assert(node.queued == 0);
}
assert(node.handleCommand("fleet send 1 sea;gps:47.6062,-122.3321:25;home:pdx;42424242 clock", 0, reply));
assert(node.queued == 1 && node.sent[12] == 4);
GeoPosition position; mesh::FleetCommand::Decoded decoded;
assert(decodedGeo(node, position, decoded) && !strcmp(decoded.command, "clock"));
} 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)}) {
fullKey(0x42), std::string("42424242;515151515151;") + fullKey(0x63)}) {
assert(node.handleCommand(("fleet send 1 " + targets + " set radio2 off").c_str(), 0, reply));
uint8_t recipient[32]; memset(recipient, 0x42, sizeof(recipient));
mesh::FleetCommand::Decoded decoded;
@@ -661,7 +1042,7 @@ int main(int argc, char** argv) {
assert(!mesh::FleetCommand::decode(node.self_id, node.channels[1].channel.secret,
node.sent, node.sent_length, node.clock.now, recipient, decoded));
} else if (scenario == "mixed_targets") {
const std::string command = "fleet send 1 42424242,515151515151," + fullKey(0x63)
const std::string command = "fleet send 1 42424242;515151515151;" + fullKey(0x63)
+ " set radio2 off";
assert(node.handleCommand(command.c_str(), 0, reply));
assert(node.queued == 1 && node.sent_policy == 2);
@@ -692,7 +1073,7 @@ int main(int argc, char** argv) {
memset(recipient, 0x77, sizeof(recipient));
assert(!matched());
} else if (scenario == "multiple_full_targets") {
const std::string command = "fleet send 1 " + fullKey(0x42) + "," + fullKey(0x63)
const std::string command = "fleet send 1 " + fullKey(0x42) + ";" + fullKey(0x63)
+ " set radio2 off";
assert(node.handleCommand(command.c_str(), 0, reply));
assert(node.queued == 1);
@@ -708,9 +1089,9 @@ int main(int argc, char** argv) {
node.sent, node.sent_length, node.clock.now, recipient, decoded));
}
} else if (scenario == "invalid_target_lists") {
for (const char* targets : {"all,00112233", "00112233,all", "00112233,,44556677",
",00112233", "00112233,", "0011223", "001122334", "0011223344556",
"GG112233", "00112233,44556677889Z"}) {
for (const char* targets : {"all;00112233", "00112233;all", "00112233;;44556677",
";00112233", "00112233;", "0011223", "001122334", "0011223344556",
"GG112233", "00112233;44556677889Z"}) {
rejected(node, (std::string("fleet send 1 ") + targets + " set radio2 off").c_str());
}
assert(node.handleCommand("fleet send 1 00112233 set radio2 off", 0, reply));
@@ -719,7 +1100,7 @@ int main(int argc, char** argv) {
std::string targets;
for (unsigned i = 1; i <= 17; ++i) {
char prefix[9]; snprintf(prefix, sizeof(prefix), "%08x", i);
if (!targets.empty()) targets += ',';
if (!targets.empty()) targets += ';';
targets += prefix;
}
mesh::FleetCommand::Targets parsed;
@@ -731,7 +1112,7 @@ int main(int argc, char** argv) {
assert(node.handleCommand(("fleet send 1 " + targets + " set radio2 off").c_str(), 0, reply));
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.
targets += ";44556677"; // More than the entire target-record capacity.
rejected(node, ("fleet send 1 " + targets + " set radio2 off").c_str());
assert(node.queued == 2);
} else if (scenario == "scheduled_sets") {
@@ -804,8 +1185,8 @@ int main(int argc, char** argv) {
assert(node.queued == 1);
} else if (scenario == "schedule_packet_budget") {
const std::string scheduled = "set tempradioat2 910.5,500,5,5,rxtx,+1,+2,auto";
std::string targets = fullKey(0x42) + "," + fullKey(0x51) + ","
+ fullKey(0x63) + "," + fullKey(0x74);
std::string targets = fullKey(0x42) + ";" + fullKey(0x51) + ";"
+ fullKey(0x63) + ";" + fullKey(0x74);
mesh::FleetCommand::Targets parsed;
assert(mesh::FleetCommand::parseTargets(targets.c_str(), targets.size(), parsed));
assert(mesh::FleetCommand::commandAllowed(scheduled.c_str()));
@@ -979,6 +1360,42 @@ class CompanionFleetControlTests(unittest.TestCase):
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_bare_and_explicit_region_targets_preserve_distinct_configured_and_home_modes(self):
self.scenario("region_target_modes")
def test_mixed_regions_home_and_node_prefix_are_signed_without_broadcast_fallback(self):
self.scenario("region_mixed_targets")
def test_region_records_share_exact_one_and_two_packet_budgets_and_allow_safe_retry(self):
self.scenario("region_packet_budgets")
def test_region_name_and_configured_or_home_selector_are_signature_bound(self):
self.scenario("region_signature_binding")
def test_invalid_region_and_keylike_lists_reject_atomically_without_consuming_sequence(self):
self.scenario("region_invalid_targets")
def test_rescue_overflow_never_executes_truncated_region_target_list(self):
self.scenario("rescue_region_overflow")
def test_signed_gps_circle_uses_exact_fixed_point_coordinates_and_requires_location(self):
self.scenario("gps_target")
def test_gps_region_home_and_node_targets_form_one_authorized_or_list(self):
self.scenario("gps_mixed_targets")
def test_gps_exact_packet_budgets_reject_and_roll_back_without_consuming_sequence(self):
self.scenario("gps_packet_budgets")
def test_gps_latitude_longitude_and_radius_are_bound_by_real_signature(self):
self.scenario("gps_signature_binding")
def test_malformed_gps_and_overcapacity_lists_never_sign_or_queue_partial_requests(self):
self.scenario("gps_invalid_targets")
def test_only_semicolons_separate_targets_and_gps_coordinate_comma_is_not_a_list(self):
self.scenario("comma_target_lists_rejected")
def test_explicit_8_12_64_hex_and_list_targets_remain_restricted(self):
self.scenario("explicit_target_forms")
+377 -3
View File
@@ -220,6 +220,39 @@ inline Dispatcher::ReceiveProfileScope::~ReceiveProfileScope(){mesh_.scoped_prof
}
'''
REGION_MAP = r'''
#pragma once
#include <cstdint>
#include <cstring>
#include <cassert>
#define MAX_REGION_ENTRIES 32
#define REGION_DENY_FLOOD 0x01
struct RegionEntry {
uint16_t id=0,parent=0;uint8_t flags=0;char name[31]={};
bool isWildcard()const{return id==0;}
};
class RegionMap {public:
RegionEntry entries[MAX_REGION_ENTRIES]{},wildcard{};
unsigned count=0;uint16_t home_id=0,default_id=0;
RegionEntry* put(const char* name,uint16_t parent=0){
assert(count<MAX_REGION_ENTRIES&&strlen(name)<sizeof(entries[0].name));
RegionEntry& row=entries[count++];row.id=uint16_t(count);row.parent=parent;
strcpy(row.name,name);return &row;
}
int getCount()const{return int(count);}
const RegionEntry* getByIdx(int index)const{return &entries[index];}
RegionEntry* findById(uint16_t id){
if(!id)return &wildcard;for(unsigned i=0;i<count;++i)if(entries[i].id==id)return &entries[i];
return nullptr;
}
RegionEntry* getHomeRegion(){return home_id?findById(home_id):nullptr;}
RegionEntry* getDefaultRegion(){return default_id?findById(default_id):nullptr;}
RegionEntry& getWildcard(){return wildcard;}
void setHomeRegion(const RegionEntry* row){home_id=row?row->id:0;}
void setDefaultRegion(const RegionEntry* row){default_id=row?row->id:0;}
};
'''
PROFILE = r'''
#pragma once
namespace mesh {
@@ -571,8 +604,282 @@ static int fragmentScenario(const std::string& scenario){
}else assert(false);
printf("fleet runtime %s passed\n",scenario.c_str());return 0;
}
static Packet whole(const std::vector<uint8_t>& bytes){
assert(bytes.size()<=FleetCommand::MaxPayloadLength);uint8_t data[168]={};
data[0]=uint8_t(FleetCommand::DataType);data[1]=uint8_t(FleetCommand::DataType>>8);
data[2]=uint8_t(bytes.size());memcpy(data+3,bytes.data(),bytes.size());return raw(data,3+bytes.size());
}
static void resign(const LocalIdentity& publisher,std::vector<uint8_t>& bytes){
constexpr const char* domain="MeshCoreFleet1";const size_t domain_size=strlen(domain);
assert(bytes.size()>=FleetCommand::SignatureSize);
std::vector<uint8_t> message(domain_size+16+bytes.size()-FleetCommand::SignatureSize);
memcpy(message.data(),domain,domain_size);memcpy(message.data()+domain_size,channel_key,16);
memcpy(message.data()+domain_size+16,bytes.data(),bytes.size()-FleetCommand::SignatureSize);
publisher.sign(bytes.data()+bytes.size()-FleetCommand::SignatureSize,message.data(),int(message.size()));
}
struct LocationHooks :FleetReplyHooks {
bool valid=false;int32_t latitude=0,longitude=0;mutable unsigned reads=0;
bool getFleetLocation(int32_t& lat_e6,int32_t& lon_e6)const override {
++reads;if(!valid)return false;lat_e6=latitude;lon_e6=longitude;return true;
}
};
struct RegionFixture {
fs::FS disk;Mesh mesh;RadioProfileCLI profiles;RegionMap regions;LocationHooks location;
FleetChannel fleet{&disk,&location,&regions};LocalIdentity publisher;
std::string expected="get radio2";unsigned calls=0,issued=0;
RegionEntry* usa=nullptr;RegionEntry* sea=nullptr;RegionEntry* pdx=nullptr;RegionEntry* north=nullptr;
RegionFixture(){
usa=regions.put("#usa");sea=regions.put("#sea",usa->id);pdx=regions.put("#pdx",usa->id);
north=regions.put("#north",sea->id);regions.put("seaside");regions.put("$private",usa->id);
regions.setHomeRegion(north);regions.setDefaultRegion(pdx);
enroll(fleet,publisher);bind(fleet,mesh,profiles);
}
uint32_t reserved()const{return storage::readLE32(disk.files.at(Path).data()+56);}
unsigned feed(FleetChannel& receiver,Packet& packet,const TransportKey* scope=nullptr){
fake_ms+=1000;const unsigned before=calls,verifies=verify_calls,writes=disk.writes,reads=location.reads;
const auto saved=disk.files.at(Path);receiver.receive(&packet,mesh,scope);
assert(location.reads==reads);
assert(verify_calls==verifies&&disk.writes==writes&&disk.files.at(Path)==saved);
receiver.service(mesh,profiles,"region:node",[&](uint32_t sequence,const char* text,char* reply){
++calls;assert(reserved()==sequence&&!strcmp(text,expected.c_str()));
assert(mesh.scoped_profile==packet.radio_profile&&mesh.scoped_generation==packet.radio_generation);
assert(profiles.command);strcpy(reply,"OK");
});
assert(!profiles.command&&mesh.scoped_profile==0&&mesh.scoped_generation==0);return calls-before;
}
unsigned feed(Packet& packet,const TransportKey* scope=nullptr){return feed(fleet,packet,scope);}
unsigned send(const std::string& target){
Packet packet=targetsCommand(publisher,mesh.clock.now+issued++,target,expected.c_str());return feed(packet);
}
};
static int regionScenario(const std::string& scenario){
RegionFixture f;
if(scenario=="regions_target_delimiter"){
FleetCommand::Targets parsed;
const auto saved=f.disk.files.at(Path);
for(const char* invalid:{"sea,pdx","sea,,pdx","sea;",";sea","sea;;pdx",
"gps:47.6062:-122.3321:25","gps:47.6062,-122.3321:25,sea"}){
assert(!FleetCommand::parseTargets(invalid,strlen(invalid),parsed));
assert(parsed.count==0&&parsed.length==0);
}
assert(f.disk.files.at(Path)==saved&&f.calls==0&&f.mesh.queued==0);
assert(f.send("sea;pdx;home:sea")==1&&f.calls==1);
}else if(scenario=="regions_list_home"){
f.sea->flags=REGION_DENY_FLOOD;f.pdx->flags=REGION_DENY_FLOOD;
assert(f.send("sea")==1&&f.send("region:pdx")==1); // Configured membership ignores routing flags.
assert(f.send("home:sea")==1&&f.send("home:north")==1&&f.send("home:usa")==1);
assert(f.send("home:pdx")==0&&f.send("home:seaside")==0);
assert(f.send("SEA")==0&&f.send("region:se")==0&&f.send("home:NORTH")==0);
assert(f.send("#sea")==1&&f.send("region:#pdx")==1&&f.send("home:#sea")==1);
assert(last_jitter_max==60500); // Region requests may have multiple matching radios.
}else if(scenario=="regions_private_names"){
assert(f.send("region:$private")==1&&f.send("private")==0);
RegionEntry* private_row=f.regions.findById(6);f.regions.setHomeRegion(private_row);
assert(f.send("home:$private")==1&&f.send("home:private")==0&&f.send("home:usa")==1);
}else if(scenario=="regions_home_not_default_or_scope"){
f.regions.setHomeRegion(f.pdx);f.regions.setDefaultRegion(f.sea);
Packet packet=targetsCommand(f.publisher,f.mesh.clock.now,"home:sea");
TransportKey incoming;memset(incoming.key,0x12,sizeof(incoming.key));
packet.scoped=true;packet.transport_codes[0]=incoming.calcTransportCode(&packet);
const auto before=f.disk.files.at(Path);assert(f.feed(packet,&incoming)==0&&f.disk.files.at(Path)==before);
assert(f.send("home:pdx")==1); // Only home and ancestors qualify, not the default.
f.regions.setHomeRegion(nullptr);f.regions.setDefaultRegion(f.sea);
assert(f.send("home:sea")==0&&f.send("sea")==1);
f.regions.setHomeRegion(&f.regions.getWildcard());strcpy(f.regions.wildcard.name,"#sea");
assert(f.send("home:sea")==0);
f.regions.home_id=99;assert(f.send("home:sea")==0); // Missing home IDs fail closed.
}else if(scenario=="regions_wildcard_and_cycles"){
RegionEntry* wildcard=f.regions.put("wildcard");wildcard->id=0;
assert(f.send("region:wildcard")==0&&f.send("home:wildcard")==0);
f.north->parent=f.sea->id;f.sea->parent=f.north->id;
assert(f.send("home:pdx")==0); // Bounded parent walk cannot hang on a damaged cycle.
}else if(scenario=="regions_damaged_map_bounds"){
const unsigned count=f.regions.count;const auto saved=f.disk.files.at(Path);
f.regions.count=MAX_REGION_ENTRIES+1;
assert(f.send("sea")==0&&f.send("home:sea")==0&&f.disk.files.at(Path)==saved);
f.regions.count=UINT32_MAX;
assert(f.send("sea")==0&&f.send("home:sea")==0&&f.disk.files.at(Path)==saved);
f.regions.count=count;memset(f.sea->name,'G',sizeof(f.sea->name));
assert(f.send(std::string(30,'G'))==0); // An unterminated row cannot alias a truncated name.
f.regions.setHomeRegion(f.sea);assert(f.send("home:"+std::string(30,'G'))==0);
f.sea->parent=99;assert(f.send("home:usa")==0&&f.disk.files.at(Path)==saved);
}else if(scenario=="regions_missing_map"){
FleetChannel no_map(&f.disk);const auto before=f.disk.files.at(Path);
Packet list=targetsCommand(f.publisher,f.mesh.clock.now,"sea");
assert(f.feed(no_map,list)==0&&f.disk.files.at(Path)==before);
Packet home=targetsCommand(f.publisher,f.mesh.clock.now,"home:sea");
assert(f.feed(no_map,home)==0&&f.disk.files.at(Path)==before);
Packet individual=targetsCommand(f.publisher,f.mesh.clock.now,"sea;"+publicHex(f.mesh.self_id));
assert(f.feed(no_map,individual)==1&&f.reserved()==f.mesh.clock.now);
}else if(scenario=="regions_overlap_once"){
const std::string key=publicHex(f.mesh.self_id);
assert(f.send("sea;home:sea;"+key.substr(0,8)+";"+key)==1);
assert(f.calls==1&&f.mesh.queued==1&&last_jitter_max==60500);
assert(config(f.fleet,"get fleet.stats").find("accepted=1")!=std::string::npos);
LocalIdentity another;const auto before=f.disk.files.at(Path);
assert(f.send("home:pdx;"+publicHex(another))==0&&f.disk.files.at(Path)==before);
}else if(scenario=="regions_signature_tamper"){
auto good=envelope(f.publisher,f.mesh.clock.now,"get radio2","sea");
assert(!memcmp(good.data(),"FMC2",4)&&good[12]==1&&good[13]==0x20&&good[14]==3);
const auto saved=f.disk.files.at(Path);auto changed=good;changed[13]=0x21; // Also matches, but signature differs.
Packet bad_type=whole(changed);assert(f.feed(bad_type)==0&&f.disk.files.at(Path)==saved);
changed=good;changed[17]='x';Packet bad_name=whole(changed);
assert(f.feed(bad_name)==0&&f.disk.files.at(Path)==saved&&f.mesh.queued==0);
Packet valid=whole(good);assert(f.feed(valid)==1&&f.calls==1);
}else if(scenario=="regions_malformed_after_match"){
auto good=envelope(f.publisher,f.mesh.clock.now,"get radio2","sea;pdx");
assert(good[12]==2&&good[13]==0x20&&good[18]==0x20&&good[19]==3);
const auto saved=f.disk.files.at(Path);
for(unsigned damage=0;damage<4;++damage){
auto broken=good;
if(damage==0)broken[18]=0x23; // Unknown type after an already-matching record.
else if(damage==1)broken[19]=0;
else if(damage==2)broken[19]=31;
else broken[20]=',';
resign(f.publisher,broken);Packet packet=whole(broken);
assert(f.feed(packet)==0&&f.disk.files.at(Path)==saved&&f.mesh.queued==0);
}
Packet valid=whole(good);assert(f.feed(valid)==1&&f.calls==1);
}else if(scenario=="regions_durable_replay"){
Packet packet=targetsCommand(f.publisher,f.mesh.clock.now,"home:sea");
assert(f.feed(packet)==1);const auto persisted=f.disk.files.at(Path);
assert(persisted.size()==64&&!memcmp(persisted.data(),"FCS1",4));
assert(f.feed(packet)==0&&f.disk.files.at(Path)==persisted);
FleetChannel rebooted(&f.disk,nullptr,&f.regions);assert(f.feed(rebooted,packet)==0);
assert(f.disk.files.at(Path)==persisted&&f.calls==1&&f.mesh.queued==1);
}else if(scenario=="regions_fragments_membership_changes"){
f.expected="set flood.rule.1 any "+std::string(150,' ')+"drop";
auto bytes=envelope(f.publisher,f.mesh.clock.now,f.expected,"home:sea");
assert(bytes.size()>FleetCommand::MaxPayloadLength);Packet first=part(bytes,0),second=part(bytes,1);
const auto saved=f.disk.files.at(Path);unsigned verifies=verify_calls;
assert(f.feed(first)==0&&f.disk.files.at(Path)==saved&&verify_calls==verifies&&f.mesh.queued==0);
f.regions.setHomeRegion(f.pdx);
assert(f.feed(second)==0&&f.disk.files.at(Path)==saved&&f.calls==0&&f.mesh.queued==0);
f.regions.setHomeRegion(f.north); // Rejected nonmember command did not consume its sequence.
assert(f.feed(second)==0&&f.feed(first)==1&&f.reserved()==f.mesh.clock.now);
assert(f.feed(first)==0&&f.feed(second)==0&&f.calls==1);
}else if(scenario=="regions_fragments_tamper"){
f.expected="set flood.rule.1 any "+std::string(150,' ')+"drop";
auto bytes=envelope(f.publisher,f.mesh.clock.now,f.expected,"sea");
Packet first=part(bytes,0),second=part(bytes,1);
const auto saved=f.disk.files.at(Path);assert(f.feed(first)==0);
Packet tampered=changePart(second,[](uint8_t* frame){frame[11+1]^=1;});
assert(f.feed(tampered)==0&&f.calls==0&&f.disk.files.at(Path)==saved&&f.mesh.queued==0);
assert(f.feed(second)==0&&f.feed(first)==1&&f.calls==1);
FleetChannel rebooted(&f.disk,nullptr,&f.regions);
assert(f.feed(rebooted,first)==0&&f.feed(rebooted,second)==0&&f.calls==1);
}else assert(false);
printf("fleet runtime %s passed\n",scenario.c_str());return 0;
}
static constexpr const char* Seattle="gps:47.6062,-122.3321:25";
static int gpsScenario(const std::string& scenario){
RegionFixture f;f.location.valid=true;f.location.latitude=47606200;f.location.longitude=-122332100;
if(scenario=="gps_inside_outside"){
assert(f.send(Seattle)==1&&f.location.reads==1&&last_jitter_max==60500);
f.location.latitude=47700000;f.location.longitude=-122300000;assert(f.send(Seattle)==1);
f.location.latitude=45515200;f.location.longitude=-122678400;
const auto saved=f.disk.files.at(Path);assert(f.send(Seattle)==0&&f.disk.files.at(Path)==saved);
assert(f.send("gps:45.5152,-122.6784:1")==1);
}else if(scenario=="gps_unknown_and_bad_fixes"){
f.location.valid=false;f.location.latitude=0;f.location.longitude=0;
const auto saved=f.disk.files.at(Path);assert(f.send("gps:0,0:10")==0&&f.disk.files.at(Path)==saved);
f.location.valid=true;assert(f.send("gps:0,0:10")==0&&f.disk.files.at(Path)==saved);
f.location.valid=false;
f.location.latitude=47606200;f.location.longitude=-122332100;
assert(f.send(Seattle)==0); // A stale/invalid provider value is never substituted for a known fix.
f.location.valid=true;
for(const auto& coords:{std::pair<int32_t,int32_t>{90000001,0},{-90000001,0},
{0,180000001},{0,-180000001},{INT32_MIN,INT32_MAX}}){
f.location.latitude=coords.first;f.location.longitude=coords.second;
assert(f.send(Seattle)==0&&f.disk.files.at(Path)==saved);
}
FleetChannel no_provider(&f.disk,nullptr,&f.regions);Packet packet=targetsCommand(f.publisher,f.mesh.clock.now,Seattle);
assert(f.feed(no_provider,packet)==0&&f.disk.files.at(Path)==saved&&f.mesh.queued==0);
}else if(scenario=="gps_boundaries_and_globe"){
assert(FleetCommand::withinRadius(0,0,1,0,8));
assert(!FleetCommand::withinRadius(0,0,1,0,10));
assert(FleetCommand::withinRadius(0,0,1000,0,8990));
assert(!FleetCommand::withinRadius(0,0,1000,0,9000));
assert(FleetCommand::withinRadius(90000000,0,1,90000000,180000000));
f.location.latitude=0;f.location.longitude=-179900000;
assert(f.send("gps:0,179.9:25")==1&&f.send("gps:0,179.9:22")==0);
f.location.latitude=89900000;f.location.longitude=180000000;
assert(f.send("gps:89.9,0:25")==1&&f.send("gps:89.9,0:22")==0);
f.location.latitude=90000000;f.location.longitude=180000000;
assert(f.send("gps:90,0:0.001")==1);
}else if(scenario=="gps_mixed_targets_once"){
const std::string key=publicHex(f.mesh.self_id);
assert(f.send(std::string(Seattle)+";sea;home:sea;"+key)==1);
assert(f.calls==1&&f.mesh.queued==1&&last_jitter_max==60500);
assert(f.send(std::string("gps:0,0:1;")+Seattle+";home:pdx")==1);
assert(f.send(key+";"+Seattle)==1); // Internal GPS comma remains distinct from target-list separators.
f.location.valid=false;assert(f.send(std::string(Seattle)+";sea")==1); // OR semantics retain valid region matches.
const auto saved=f.disk.files.at(Path);LocalIdentity stranger;
assert(f.send(std::string(Seattle)+";home:pdx;"+publicHex(stranger))==0&&f.disk.files.at(Path)==saved);
}else if(scenario=="gps_signed_metadata_tamper"){
auto good=envelope(f.publisher,f.mesh.clock.now,f.expected,Seattle);
assert(good[12]==1&&good[13]==FleetCommand::GeoRecordType);
const auto saved=f.disk.files.at(Path);
for(unsigned field:{14U,18U,22U,13U}){
auto altered=good;altered[field]^=1;Packet changed=whole(altered);
assert(f.feed(changed)==0&&f.disk.files.at(Path)==saved&&f.mesh.queued==0);
}
Packet valid=whole(good);assert(f.feed(valid)==1&&f.calls==1);
}else if(scenario=="gps_malformed_after_matching_region"){
auto good=envelope(f.publisher,f.mesh.clock.now,f.expected,std::string("sea;")+Seattle);
assert(good[12]==2&&good[13]==0x20&&good[18]==FleetCommand::GeoRecordType);
const auto saved=f.disk.files.at(Path);
for(unsigned damage=0;damage<5;++damage){
auto broken=good;
if(damage==0)storage::writeLE32(broken.data()+19,90000001);
else if(damage==1)storage::writeLE32(broken.data()+23,180000001);
else if(damage==2)storage::writeLE32(broken.data()+27,0);
else if(damage==3)storage::writeLE32(broken.data()+27,FleetCommand::MaxRadiusMeters+1);
else broken[18]=0x23;
resign(f.publisher,broken);Packet packet=whole(broken);
assert(f.feed(packet)==0&&f.disk.files.at(Path)==saved&&f.mesh.queued==0);
}
Packet valid=whole(good);assert(f.feed(valid)==1&&f.calls==1);
}else if(scenario=="gps_durable_replay"){
Packet packet=targetsCommand(f.publisher,f.mesh.clock.now,Seattle);
assert(f.feed(packet)==1);const auto persisted=f.disk.files.at(Path);
assert(persisted.size()==64&&!memcmp(persisted.data(),"FCS1",4)&&f.reserved()==f.mesh.clock.now);
assert(f.feed(packet)==0&&f.disk.files.at(Path)==persisted);
FleetChannel rebooted(&f.disk,&f.location,&f.regions);
assert(f.feed(rebooted,packet)==0&&f.disk.files.at(Path)==persisted&&f.calls==1&&f.mesh.queued==1);
}else if(scenario=="gps_fragments_fix_changes"){
f.expected="set flood.rule.1 any "+std::string(150,' ')+"drop";
auto bytes=envelope(f.publisher,f.mesh.clock.now,f.expected,Seattle);
assert(bytes.size()>FleetCommand::MaxPayloadLength);Packet first=part(bytes,0),second=part(bytes,1);
const auto saved=f.disk.files.at(Path);const unsigned reads=f.location.reads;
assert(f.feed(first)==0&&f.disk.files.at(Path)==saved&&f.location.reads==reads&&f.mesh.queued==0);
f.location.latitude=45515200;f.location.longitude=-122678400;
assert(f.feed(second)==0&&f.disk.files.at(Path)==saved&&f.calls==0&&f.mesh.queued==0);
f.location.latitude=47606200;f.location.longitude=-122332100;
assert(f.feed(second)==0&&f.feed(first)==1&&f.reserved()==f.mesh.clock.now);
assert(f.feed(first)==0&&f.feed(second)==0&&f.calls==1);
}else if(scenario=="gps_fragments_invalid_final_fix"){
f.expected="set flood.rule.1 any "+std::string(150,' ')+"drop";
auto bytes=envelope(f.publisher,f.mesh.clock.now,f.expected,Seattle);Packet first=part(bytes,0),second=part(bytes,1);
const auto saved=f.disk.files.at(Path);assert(f.feed(second)==0);f.location.valid=false;
assert(f.feed(first)==0&&f.disk.files.at(Path)==saved&&f.calls==0&&f.mesh.queued==0);
f.location.valid=true;
Packet tampered=changePart(second,[](uint8_t* frame){frame[11+1]^=1;});
assert(f.feed(first)==0&&f.feed(tampered)==0&&f.calls==0&&f.disk.files.at(Path)==saved);
assert(f.feed(second)==0&&f.feed(first)==1&&f.calls==1);
FleetChannel rebooted(&f.disk,&f.location,&f.regions);
assert(f.feed(rebooted,first)==0&&f.feed(rebooted,second)==0&&f.calls==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];
if(scenario.rfind("gps_",0)==0)return gpsScenario(scenario);
if(scenario.rfind("regions_",0)==0)return regionScenario(scenario);
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);
@@ -856,16 +1163,16 @@ int main(int argc,char** argv){
FleetChannel rebooted(&fs);assert(apply(rebooted,fs,mesh,profiles,packet)==0);
}else if(scenario=="fmc2_overlap_matches_once"){
enroll(fleet,publisher);const std::string mine=publicHex(mesh.self_id);
Packet packet=targetsCommand(publisher,mesh.clock.now,mine.substr(0,8)+","+mine);
Packet packet=targetsCommand(publisher,mesh.clock.now,mine.substr(0,8)+";"+mine);
assert(apply(fleet,fs,mesh,profiles,packet)==1);assert(mesh.queued==1&&last_jitter_max==60500);
assert(config(fleet,"get fleet.stats").find("accepted=1")!=std::string::npos);
}else if(scenario=="fmc2_nonmatching_no_reserve"){
enroll(fleet,publisher);const auto previous=fs.files.at(Path);LocalIdentity stranger;
char different_prefix[9];uint8_t changed[4];memcpy(changed,mesh.self_id.pub_key,4);changed[0]^=1;
Utils::toHex(different_prefix,changed,4);
Packet packet=targetsCommand(publisher,mesh.clock.now,std::string(different_prefix)+","+publicHex(stranger));
Packet packet=targetsCommand(publisher,mesh.clock.now,std::string(different_prefix)+";"+publicHex(stranger));
assert(apply(fleet,fs,mesh,profiles,packet)==0);assert(mesh.queued==0&&fs.files.at(Path)==previous);
packet=targetsCommand(publisher,mesh.clock.now,publicHex(stranger)+","+publicHex(mesh.self_id));
packet=targetsCommand(publisher,mesh.clock.now,publicHex(stranger)+";"+publicHex(mesh.self_id));
assert(apply(fleet,fs,mesh,profiles,packet)==1);assert(last_jitter_max==60500);
}else if(scenario=="fmc2_target_tamper"){
enroll(fleet,publisher);const auto previous=fs.files.at(Path);
@@ -896,6 +1203,7 @@ class FleetChannelRuntimeTests(unittest.TestCase):
(work / "helpers").mkdir()
for name, content in {"Utils.h": UTILS, "FS.h": FILESYSTEM, "Mesh.h": MESH,
"helpers/RadioProfileCLI.h": PROFILE,
"helpers/RegionMap.h": REGION_MAP,
"Packet.h": '#pragma once\n#include <Mesh.h>\n',
"Adafruit_LittleFS.h": '''
#pragma once
@@ -960,6 +1268,72 @@ class SHA256 : public SHA256Base {{ public:
result = subprocess.run([str(binary), name], capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_gps_center_radius_targets_inside_seattle_and_excludes_portland(self):
self.scenario("gps_inside_outside")
def test_gps_unknown_invalid_out_of_range_and_absent_location_providers_fail_closed(self):
self.scenario("gps_unknown_and_bad_fixes")
def test_gps_small_distance_boundary_antimeridian_and_poles_use_spherical_distance(self):
self.scenario("gps_boundaries_and_globe")
def test_gps_region_home_and_node_target_matches_use_or_semantics_and_execute_once(self):
self.scenario("gps_mixed_targets_once")
def test_gps_latitude_longitude_radius_and_record_type_are_signed(self):
self.scenario("gps_signed_metadata_tamper")
def test_signed_malformed_gps_record_after_matching_region_cannot_authorize_execution(self):
self.scenario("gps_malformed_after_matching_region")
def test_gps_command_replay_survives_reboot_with_the_existing_64_byte_storage_format(self):
self.scenario("gps_durable_replay")
def test_two_packet_gps_target_uses_location_at_completion_before_reserving_sequence(self):
self.scenario("gps_fragments_fix_changes")
def test_two_packet_gps_rejects_invalid_final_fix_tampering_and_reboot_replays(self):
self.scenario("gps_fragments_invalid_final_fix")
def test_semicolon_is_the_only_target_separator_with_comma_reserved_for_gps_coordinates(self):
self.scenario("regions_target_delimiter")
def test_existing_configured_regions_and_home_ancestors_are_distinct_exact_targets(self):
self.scenario("regions_list_home")
def test_private_region_dollar_identity_is_preserved_in_list_and_home_targets(self):
self.scenario("regions_private_names")
def test_default_received_scope_and_missing_or_wildcard_home_never_grant_membership(self):
self.scenario("regions_home_not_default_or_scope")
def test_wildcard_rows_and_damaged_parent_cycles_fail_closed_without_hanging(self):
self.scenario("regions_wildcard_and_cycles")
def test_invalid_map_counts_unterminated_names_and_missing_parent_ids_fail_closed(self):
self.scenario("regions_damaged_map_bounds")
def test_receiver_without_region_map_refuses_regions_but_can_still_match_node_keys(self):
self.scenario("regions_missing_map")
def test_mixed_list_home_and_node_targets_execute_once_with_nonmembers_unreserved(self):
self.scenario("regions_overlap_once")
def test_region_name_and_mode_are_bound_to_the_real_publisher_signature(self):
self.scenario("regions_signature_tamper")
def test_signed_malformed_trailing_region_record_rejects_despite_prior_matching_region(self):
self.scenario("regions_malformed_after_match")
def test_region_target_replay_survives_reboot_without_changing_existing_storage_format(self):
self.scenario("regions_durable_replay")
def test_region_membership_is_rechecked_when_second_fragment_arrives_before_reservation(self):
self.scenario("regions_fragments_membership_changes")
def test_region_fragment_tampering_and_reboot_replay_never_repeat_execution(self):
self.scenario("regions_fragments_tamper")
def test_private_channel_and_public_only_publisher_enrollment(self):
self.scenario("enrollment")
+2 -1
View File
@@ -15,7 +15,7 @@ import tempfile
import unittest
from cpp_source import body
from test_fleet_channel_runtime import FILESYSTEM, HARNESS as RUNTIME, MESH, PROFILE, UTILS
from test_fleet_channel_runtime import FILESYSTEM, HARNESS as RUNTIME, MESH, PROFILE, REGION_MAP, UTILS
ROOT = Path(__file__).resolve().parents[1]
@@ -243,6 +243,7 @@ class FleetClockControlTests(unittest.TestCase):
for name, content in {
"Utils.h": UTILS, "FS.h": FILESYSTEM, "Mesh.h": mesh,
"helpers/RadioProfileCLI.h": PROFILE,
"helpers/RegionMap.h": REGION_MAP,
"Packet.h": '#pragma once\n#include <Mesh.h>\n',
"Arduino.h": '#pragma once\n#include <Mesh.h>\n',
"Adafruit_LittleFS.h": r'''
+442 -17
View File
@@ -23,6 +23,38 @@ public:
}
};
struct RegionContext {
std::vector<std::string> configured;
std::vector<std::string> home;
std::vector<std::pair<Fleet::RegionTarget, std::string>> seen;
static bool match(void* context, Fleet::RegionTarget kind, const char* name, size_t length) {
auto& regions = *static_cast<RegionContext*>(context);
const std::string label(name, length);
regions.seen.emplace_back(kind, label);
const auto& names = kind == Fleet::RegionTarget::Configured ? regions.configured : regions.home;
return std::find(names.begin(), names.end(), label) != names.end();
}
};
struct GeoContext {
bool known = true;
int32_t latitude_e6 = 47606200, longitude_e6 = -122332100;
unsigned calls = 0;
int32_t received_latitude = 0, received_longitude = 0;
uint32_t received_radius = 0;
static bool match(void* context, int32_t latitude, int32_t longitude, uint32_t radius) {
auto& location = *static_cast<GeoContext*>(context);
++location.calls;
location.received_latitude = latitude;
location.received_longitude = longitude;
location.received_radius = radius;
return location.known && Fleet::withinRadius(latitude, longitude, radius,
location.latitude_e6, location.longitude_e6);
}
};
class FleetCommandTest : public ::testing::Test {
protected:
FixedRng rng;
@@ -50,6 +82,18 @@ protected:
now, publisher.pub_key, decoded);
}
bool decodeRegions(const std::vector<uint8_t>& bytes, RegionContext& regions) {
return Fleet::decode(publisher, ChannelKey, bytes.data(), bytes.size(), Now,
publisher.pub_key, decoded, RegionContext::match, &regions);
}
bool decodeGeo(const std::vector<uint8_t>& bytes, GeoContext& location,
RegionContext* regions = nullptr) {
return Fleet::decode(publisher, ChannelKey, bytes.data(), bytes.size(), Now,
publisher.pub_key, decoded, regions ? RegionContext::match : nullptr,
regions, GeoContext::match, &location);
}
std::vector<uint8_t> encodeTargets(const Fleet::Targets& targets,
const char* command = "get radio2") {
std::vector<uint8_t> bytes(Fleet::MaxEnvelopeLength);
@@ -395,7 +439,7 @@ TEST_F(FleetCommandTest, UnixRangeBoundaryDoesNotWrap) {
TEST_F(FleetCommandTest, BoundedTargetTokenParsesMixedPrefixWidthsAndCompleteKeys) {
Fleet::Targets targets{};
const std::string full = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
const std::string token = "A1B2C3D4,010203040506," + full;
const std::string token = "A1B2C3D4;010203040506;" + full;
const std::string joined = token + " get radio2";
ASSERT_TRUE(Fleet::parseTargets(joined.data(), token.size(), targets));
EXPECT_EQ(3, targets.count);
@@ -419,14 +463,14 @@ TEST_F(FleetCommandTest, BoundedTargetTokenParsesMixedPrefixWidthsAndCompleteKey
TEST_F(FleetCommandTest, RejectsMalformedWholeTargetListsWithoutPartialResults) {
for (const std::string& token : {
std::string(""), std::string("all,01020304"), std::string("01020304,all"),
std::string("all,all"), std::string("ALL"), std::string(",01020304"),
std::string("01020304,"), std::string("01020304,,05060708"),
std::string("01020304,0506070g"), std::string("01020304 05060708"),
std::string("01020304, 05060708"), std::string("01020304\n"),
std::string(""), std::string("all;01020304"), std::string("01020304;all"),
std::string("all;all"), std::string(";01020304"),
std::string("01020304;"), std::string("01020304;;05060708"),
std::string("01020304;0506070g"), std::string("01020304 05060708"),
std::string("01020304; 05060708"), std::string("01020304\n"),
std::string("01"), std::string(10, '1'), std::string(16, '1'),
std::string(32, '1'), std::string(63, '1'), std::string(65, '1'),
std::string(64, '0'), std::string("01020304,") + std::string(64, '0')}) {
std::string(64, '0'), std::string("01020304;") + std::string(64, '0')}) {
Fleet::Targets targets;
memset(&targets, 0xA5, sizeof(targets));
EXPECT_FALSE(Fleet::parseTargets(token.data(), token.size(), targets)) << token;
@@ -442,7 +486,7 @@ TEST_F(FleetCommandTest, RejectsMalformedWholeTargetListsWithoutPartialResults)
TEST_F(FleetCommandTest, TargetRecordAndPayloadBudgetsAreSeparateAndBounded) {
std::string list;
for (unsigned count = 1; count <= 17; ++count) {
if (!list.empty()) list += ',';
if (!list.empty()) list += ';';
list += "01020304";
Fleet::Targets targets{};
ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets));
@@ -451,18 +495,18 @@ TEST_F(FleetCommandTest, TargetRecordAndPayloadBudgetsAreSeparateAndBounded) {
EXPECT_FALSE(encodeTargets(targets).empty()) << count;
}
Fleet::Targets targets{};
list += ",01020304";
list += ";01020304";
EXPECT_FALSE(Fleet::parseTargets(list.data(), list.size(), targets));
list.clear();
const std::string full = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
for (unsigned count = 1; count <= 5; ++count) {
if (!list.empty()) list += ',';
if (!list.empty()) list += ';';
list += full;
ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets));
EXPECT_EQ(count * 17, targets.length);
}
EXPECT_FALSE(encodeTargets(targets).empty());
list += ',' + full;
list += ';' + full;
EXPECT_FALSE(Fleet::parseTargets(list.data(), list.size(), targets));
}
@@ -551,7 +595,7 @@ TEST_F(FleetCommandTest, PrefixesAndListsUseFmc2WithSignedFraming) {
EXPECT_TRUE(decode(bytes));
EXPECT_TRUE(decoded.broadcast);
}
const std::string list = std::string(full) + ',' + full;
const std::string list = std::string(full) + ';' + full;
Fleet::Targets targets{};
ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets));
const auto bytes = encodeTargets(targets);
@@ -565,7 +609,7 @@ TEST_F(FleetCommandTest, AnyMatchingEntryIsAcceptedButMalformedLaterEntryIsRejec
mesh::Utils::toHex(full, publisher.pub_key, PUB_KEY_SIZE);
const std::string prefix(full, 8);
Fleet::Targets targets{};
const std::string list = prefix + ",01020304," + full;
const std::string list = prefix + ";01020304;" + full;
ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets));
auto bytes = encodeTargets(targets);
ASSERT_TRUE(decode(bytes));
@@ -613,6 +657,387 @@ TEST_F(FleetCommandTest, RawPrefixesIncludeLegitimateZeroPrefixesAndCollisions)
Now, first, decoded));
}
TEST_F(FleetCommandTest, NamedRegionAndHomeRecordsPreserveCaseAndCanonicalHashAlias) {
const std::string list = "sea;pdx;#Sea;region:all;region:dead;region:zzzzzzzz;home:$Private";
Fleet::Targets targets{};
ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets));
ASSERT_EQ(7, targets.count);
size_t cursor = 0;
for (const auto& item : std::vector<std::pair<uint8_t, std::string>>{
{Fleet::RegionRecordType, "sea"}, {Fleet::RegionRecordType, "pdx"},
{Fleet::RegionRecordType, "Sea"}, {Fleet::RegionRecordType, "all"},
{Fleet::RegionRecordType, "dead"}, {Fleet::RegionRecordType, "zzzzzzzz"},
{Fleet::HomeRecordType, "$Private"}}) {
EXPECT_EQ(item.first, targets.data[cursor++]);
EXPECT_EQ(item.second.size(), targets.data[cursor++]);
EXPECT_EQ(item.second, std::string(reinterpret_cast<char*>(targets.data + cursor), item.second.size()));
cursor += item.second.size();
}
EXPECT_EQ(cursor, targets.length);
Fleet::Targets canonical{}, alias{};
ASSERT_TRUE(Fleet::parseTargets("home:#sea", 9, alias));
ASSERT_TRUE(Fleet::parseTargets("home:sea", 8, canonical));
EXPECT_EQ(encodeTargets(canonical), encodeTargets(alias));
ASSERT_TRUE(Fleet::parseTargets("ALL", 3, targets));
EXPECT_EQ(Fleet::RegionRecordType, targets.data[0]);
EXPECT_EQ("ALL", std::string(reinterpret_cast<char*>(targets.data + 2), 3));
}
TEST_F(FleetCommandTest, MalformedKeysNeverFallBackToNamedRegionTargets) {
for (const std::string& token : {
std::string("zzzzzzzz"), std::string("not-a-key-id"), std::string(12, 'z'),
std::string(64, 'g'), std::string("a"), std::string("dead"), std::string(16, 'a'),
std::string("region:"), std::string("home:"), std::string("#"), std::string("region:#"),
std::string("region:*"), std::string("home:*"), std::string("region:with.space"),
std::string("region:with space"), std::string("region:with:colon"),
std::string("region:bad\x7f"), std::string("region:bad\x80"),
std::string("region:bad\n"), std::string("sea;all"), std::string("all;sea"),
std::string("get"), std::string("set"), std::string("del"), std::string("time"), std::string("clock"),
std::string("region:") + std::string(31, 'z'), std::string("home:") + std::string(31, 'z')}) {
Fleet::Targets targets;
memset(&targets, 0xA5, sizeof(targets));
EXPECT_FALSE(Fleet::parseTargets(token.data(), token.size(), targets)) << token;
EXPECT_EQ(0, targets.length);
EXPECT_EQ(0, targets.count);
}
for (const char* token : {"region:a", "region:dead", "region:zzzzzzzz", "region:all",
"home:a", "home:zzzzzzzz", "home:all", "region:get", "home:get", "#get",
"region:set", "home:del", "region:time", "home:clock"}) {
Fleet::Targets targets{};
EXPECT_TRUE(Fleet::parseTargets(token, strlen(token), targets)) << token;
}
}
TEST_F(FleetCommandTest, NamedTargetsRequireExactReadOnlyMatchingAndRemainBroadcast) {
Fleet::Targets targets{};
ASSERT_TRUE(Fleet::parseTargets("sea", 3, targets));
const auto bytes = encodeTargets(targets);
ASSERT_FALSE(bytes.empty());
EXPECT_FALSE(decode(bytes)); // A role without RegionMap matching grants no named capability.
for (const char* label : {"sea", "Sea", "se", "seattle", "$sea", "pdx"}) {
RegionContext regions{{label}, {"sea"}, {}};
EXPECT_EQ(!strcmp(label, "sea"), decodeRegions(bytes, regions)) << label;
if (!strcmp(label, "sea")) EXPECT_TRUE(decoded.broadcast);
else EXPECT_FALSE(decoded.broadcast);
ASSERT_EQ(1U, regions.seen.size());
EXPECT_EQ(Fleet::RegionTarget::Configured, regions.seen[0].first);
EXPECT_EQ("sea", regions.seen[0].second);
}
ASSERT_TRUE(Fleet::parseTargets("home:#sea", 9, targets));
const auto home = encodeTargets(targets);
RegionContext configured_only{{"sea"}, {}, {}};
EXPECT_FALSE(decodeRegions(home, configured_only));
RegionContext home_only{{}, {"sea"}, {}};
EXPECT_TRUE(decodeRegions(home, home_only));
EXPECT_TRUE(decoded.broadcast);
EXPECT_EQ(Fleet::RegionTarget::Home, home_only.seen[0].first);
EXPECT_FALSE(Fleet::parseTargets("$Private", 8, targets)); // Raw key-length tokens are never labels.
ASSERT_TRUE(Fleet::parseTargets("region:$Private", 15, targets));
RegionContext private_name{{"$Private"}, {}, {}};
EXPECT_TRUE(decodeRegions(encodeTargets(targets), private_name));
}
TEST_F(FleetCommandTest, MixedHomeRegionsAndPublicKeysMatchOnceButValidateAllRecords) {
char full[PUB_KEY_SIZE * 2 + 1];
mesh::Utils::toHex(full, publisher.pub_key, PUB_KEY_SIZE);
const std::string list = "sea;home:pdx;" + std::string(full, 8) + ";region:$fleet";
Fleet::Targets targets{};
ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets));
const auto bytes = encodeTargets(targets);
EXPECT_TRUE(decode(bytes)); // The exact key prefix remains sufficient without a region matcher.
EXPECT_TRUE(decoded.broadcast);
RegionContext regions{{"sea", "$fleet"}, {"pdx"}, {}};
ASSERT_TRUE(decodeRegions(bytes, regions));
EXPECT_EQ(3U, regions.seen.size());
EXPECT_STREQ("get radio2", decoded.command);
auto malformed = bytes;
const size_t final_name = 13 + 5 + 5 + 5 + 2;
malformed[final_name] = '*'; // Invalid final label after a matching region and key.
EXPECT_FALSE(decodeRegions(malformed, regions));
EXPECT_FALSE(decoded.broadcast);
g_mock_ed25519_verify_result = false;
EXPECT_FALSE(decodeRegions(bytes, regions));
EXPECT_FALSE(decoded.broadcast);
}
TEST_F(FleetCommandTest, NamedTargetCountsAndBytesConsumeTheSameBoundedBudget) {
std::string list;
Fleet::Targets targets{};
for (unsigned count = 1; count <= 17; ++count) {
if (!list.empty()) list += ';';
list += "sea";
ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets));
EXPECT_EQ(count * 5, targets.length);
}
list += ";sea";
EXPECT_FALSE(Fleet::parseTargets(list.data(), list.size(), targets));
list = "region:" + std::string(30, 'z') + ";home:" + std::string(30, 'Y')
+ ";region:" + std::string(20, 'x');
ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets));
EXPECT_EQ(Fleet::MaxTargetBytes, targets.length);
const std::string prefix = "set flood.rule.1 ";
const std::string command = prefix + std::string(Fleet::MaxCommandLength - targets.length - prefix.size(), 'a');
const auto bytes = encodeTargets(targets, command.c_str());
ASSERT_EQ(Fleet::MaxEnvelopeLength, bytes.size());
RegionContext regions{{std::string(30, 'z')}, {}, {}};
EXPECT_TRUE(decodeRegions(bytes, regions));
EXPECT_TRUE(decoded.broadcast);
EXPECT_TRUE(encodeTargets(targets, (command + 'a').c_str()).empty());
std::array<uint8_t, Fleet::MaxPayloadLength> first{}, second{};
ASSERT_EQ(first.size(), Fleet::fragment(bytes.data(), bytes.size(), 0, first.data(), first.size()));
ASSERT_EQ(second.size(), Fleet::fragment(bytes.data(), bytes.size(), 1, second.data(), second.size()));
Fleet::Fragment part{};
ASSERT_TRUE(Fleet::parseFragment(second.data(), second.size(), part));
EXPECT_EQ(154U, part.length);
list += 'x';
EXPECT_FALSE(Fleet::parseTargets(list.data(), list.size(), targets));
EXPECT_EQ(0, targets.length);
}
TEST_F(FleetCommandTest, InvalidExternalNamedRecordsNeverWriteOutput) {
std::array<uint8_t, Fleet::MaxEnvelopeLength> output;
output.fill(0xA5);
for (unsigned invalid = 0; invalid < 8; ++invalid) {
Fleet::Targets targets{};
targets.count = 1;
targets.length = 5;
targets.data[0] = Fleet::RegionRecordType;
targets.data[1] = 3;
memcpy(targets.data + 2, "sea", 3);
if (invalid == 0) targets.data[1] = 0;
if (invalid == 1) targets.data[1] = 31;
if (invalid == 2) targets.data[2] = '*';
if (invalid == 3) targets.data[2] = 0;
if (invalid == 4) targets.data[2] = 0x80;
if (invalid == 5) targets.data[2] = ';';
if (invalid == 6) targets.length = 1;
if (invalid == 7) targets.data[0] = 0x22;
EXPECT_EQ(0U, Fleet::encode(publisher, ChannelKey, Now, Now + 120,
targets, "get radio2", output.data(), output.size()));
for (uint8_t byte : output) EXPECT_EQ(0xA5, byte);
}
}
TEST_F(FleetCommandTest, GpsTargetsUseExactSignedMicrodegreesAndMetres) {
struct Case { const char* token; int32_t latitude, longitude; uint32_t radius; };
for (const auto& item : std::vector<Case>{
{"gps:47.6062,-122.3321:10", 47606200, -122332100, 10000},
{"gps:+90,-180:0.001", 90000000, -180000000, 1},
{"gps:-90,+180:20050.000", -90000000, 180000000, 20050000},
{"gps:0.000001,-0.000001:1.001", 1, -1, 1001},
{"gps:0,0:1", 0, 0, 1000}}) {
SCOPED_TRACE(item.token);
Fleet::Targets targets{};
ASSERT_TRUE(Fleet::parseTargets(item.token, strlen(item.token), targets));
ASSERT_EQ(1, targets.count);
ASSERT_EQ(Fleet::GeoRecordLength, targets.length);
EXPECT_EQ(Fleet::GeoRecordType, targets.data[0]);
const uint32_t values[] = {uint32_t(item.latitude), uint32_t(item.longitude), item.radius};
for (unsigned field = 0; field < 3; ++field)
for (unsigned byte = 0; byte < 4; ++byte)
EXPECT_EQ(uint8_t(values[field] >> (byte * 8)), targets.data[1 + field * 4 + byte]);
EXPECT_FALSE(decode(encodeTargets(targets))); // No location provider means no geographic match.
GeoContext location;
location.latitude_e6 = item.latitude; location.longitude_e6 = item.longitude;
ASSERT_TRUE(decodeGeo(encodeTargets(targets), location));
EXPECT_TRUE(decoded.broadcast);
EXPECT_EQ(item.latitude, location.received_latitude);
EXPECT_EQ(item.longitude, location.received_longitude);
EXPECT_EQ(item.radius, location.received_radius);
}
}
TEST_F(FleetCommandTest, SemicolonsSeparateTargetsAndGpsOwnsOnlyItsCoordinateComma) {
const std::string gps = "gps:47.6062,-122.3321:25";
for (const std::string& token : {gps + ";sea;home:pdx", "sea;" + gps + ";home:pdx",
"sea;home:pdx;" + gps}) {
SCOPED_TRACE(token);
Fleet::Targets targets{};
const std::string input = token + " get radio2";
ASSERT_TRUE(Fleet::parseTargets(input.data(), token.size(), targets));
EXPECT_EQ(3, targets.count);
EXPECT_EQ(23, targets.length);
GeoContext location;
RegionContext regions{{"sea"}, {"pdx"}, {}};
ASSERT_TRUE(decodeGeo(encodeTargets(targets), location, &regions));
EXPECT_EQ(1U, location.calls);
EXPECT_EQ(25000U, location.received_radius);
EXPECT_EQ(2U, regions.seen.size());
EXPECT_TRUE(decoded.broadcast);
}
// The explicit substring bound must hold even without a terminating NUL.
const std::vector<char> nonterminated(gps.begin(), gps.end());
for (size_t length = 4; length <= nonterminated.size(); ++length) {
Fleet::Targets targets;
memset(&targets, 0xA5, sizeof(targets));
const bool complete_radius = length == gps.size() || length == gps.size() - 1;
EXPECT_EQ(complete_radius,
Fleet::parseTargets(nonterminated.data(), length, targets)) << length;
if (!complete_radius) {
EXPECT_EQ(0, targets.count);
EXPECT_EQ(0, targets.length);
}
}
}
TEST_F(FleetCommandTest, OldCommaListsAndMalformedGpsSeparatorsRejectTheEntireList) {
for (const char* token : {
"sea,pdx", "01020304,05060708", "region:sea,home:pdx", "sea;home:pdx,sea",
"gps:47.6062:-122.3321:25", "gps:47.6062;-122.3321:25",
"gps:47.6062,-122.3321,25", "gps:47.6062,,-122.3321:25",
"gps:47.6062,-122.3321,:25", "gps:47.6062,-122.3321:25,sea",
";gps:47.6062,-122.3321:25", "gps:47.6062,-122.3321:25;",
"sea;;gps:47.6062,-122.3321:25", "sea;gps:47.6062,-122.3321:25;;home:pdx",
"sea;gps:47.6062;home:pdx", "sea;gps:47.6062,-122.3321;home:pdx",
"sea;gps:47.6062,-122.3321:;home:pdx", "sea;gps:47.6062,-122.3321:25;home:pdx,sea"}) {
SCOPED_TRACE(token);
Fleet::Targets targets;
memset(&targets, 0xA5, sizeof(targets));
EXPECT_FALSE(Fleet::parseTargets(token, strlen(token), targets));
EXPECT_EQ(0, targets.count);
EXPECT_EQ(0, targets.length);
for (uint8_t byte : targets.data) EXPECT_EQ(0, byte);
}
}
TEST_F(FleetCommandTest, SemicolonMixedGpsListsEnforceRecordAndByteBudgets) {
std::string list = "gps:47.6062,-122.3321:25";
Fleet::Targets targets{};
for (unsigned count = 2; count <= 17; ++count) {
list += ";z";
ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets));
EXPECT_EQ(count, targets.count);
EXPECT_EQ(13 + (count - 1) * 3, targets.length);
}
list += ";z";
EXPECT_FALSE(Fleet::parseTargets(list.data(), list.size(), targets));
EXPECT_EQ(0, targets.count);
list = "gps:47.6062,-122.3321:25";
for (unsigned count = 0; count < 14; ++count) list += ";sea";
ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets));
EXPECT_EQ(83, targets.length);
list += ";sea";
EXPECT_FALSE(Fleet::parseTargets(list.data(), list.size(), targets));
EXPECT_EQ(0, targets.length);
}
TEST_F(FleetCommandTest, GpsDecimalGrammarAndBoundsRejectWithoutPartialTargets) {
for (const char* token : {
"gps:", "gps:0", "gps:0:0", "gps:0,0:", "gps:,0:1", "gps:0,:1",
"gps:0,0:1:2", "gps:0,0:0", "gps:0,0:0.000", "gps:0,0:0.0001",
"gps:0,0:20050.001", "gps:90.000001,0:1", "gps:-90.000001,0:1",
"gps:0,180.000001:1", "gps:0,-180.000001:1", "gps:0.0000001,0:1",
"gps:0,0.0000001:1", "gps:0,0:1.0001", "gps:0,0:+1", "gps:0,0:-1",
"gps:NaN,0:1", "gps:0,Inf:1", "gps:0,0:1e3", "gps:1e1,0:1",
"gps:.1,0:1", "gps:1.,0:1", "gps:0,0:.1", "gps:0,0:1.",
"gps:01,0:1", "gps:0,00:1", "gps:0,0:01", "gps:--1,0:1",
"gps:++1,0:1", "gps:+,0:1", "gps:0,0:4294967296", "gps:999999999999999999,0:1",
"gps:0,0:1m", "gps:0,0:1\n", "gps: 0:0:1", "gps:0,0:1;all"}) {
Fleet::Targets targets;
memset(&targets, 0xA5, sizeof(targets));
EXPECT_FALSE(Fleet::parseTargets(token, strlen(token), targets)) << token;
EXPECT_EQ(0, targets.count);
EXPECT_EQ(0, targets.length);
}
}
TEST_F(FleetCommandTest, GeographicCircleIsDefensiveAtPolesDatelineAndAntipodes) {
EXPECT_TRUE(Fleet::withinRadius(0, 0, 1, 0, 0));
EXPECT_FALSE(Fleet::withinRadius(0, 0, 0, 0, 0));
EXPECT_FALSE(Fleet::withinRadius(0, 0, Fleet::MaxRadiusMeters + 1, 0, 0));
EXPECT_FALSE(Fleet::withinRadius(90000001, 0, 1, 0, 0));
EXPECT_FALSE(Fleet::withinRadius(0, 180000001, 1, 0, 0));
EXPECT_FALSE(Fleet::withinRadius(0, 0, 1, -90000001, 0));
EXPECT_FALSE(Fleet::withinRadius(0, 0, 1, 0, -180000001));
EXPECT_FALSE(Fleet::withinRadius(0, 0, 111195, 0, 1000000));
EXPECT_TRUE(Fleet::withinRadius(0, 0, 111196, 0, 1000000));
EXPECT_TRUE(Fleet::withinRadius(0, 179999999, 1, 0, -179999999));
EXPECT_TRUE(Fleet::withinRadius(90000000, 0, 1, 90000000, 180000000));
EXPECT_FALSE(Fleet::withinRadius(0, 0, 20015114, 0, 180000000));
EXPECT_TRUE(Fleet::withinRadius(0, 0, 20015115, 0, 180000000));
EXPECT_TRUE(Fleet::withinRadius(47606200, -122332100, 10000, 47610000, -122330000));
EXPECT_FALSE(Fleet::withinRadius(47606200, -122332100, 10000, 45515200, -122678400));
}
TEST_F(FleetCommandTest, MixedGeographicRegionHomeAndKeyTargetsAreBoundedOrMatches) {
char full[PUB_KEY_SIZE * 2 + 1];
mesh::Utils::toHex(full, publisher.pub_key, PUB_KEY_SIZE);
const std::string token = "gps:47.6062,-122.3321:1;sea;home:pdx;" + std::string(full, 8);
Fleet::Targets targets{};
ASSERT_TRUE(Fleet::parseTargets(token.data(), token.size(), targets));
ASSERT_EQ(4, targets.count);
EXPECT_EQ(13 + 5 + 5 + 5, targets.length);
const auto bytes = encodeTargets(targets);
EXPECT_TRUE(decode(bytes)); // Public-key prefix can match without either callback.
GeoContext location;
EXPECT_TRUE(decodeGeo(bytes, location));
EXPECT_TRUE(decoded.broadcast);
RegionContext regions{{"sea"}, {"pdx"}, {}};
EXPECT_TRUE(decodeGeo(bytes, location, &regions));
EXPECT_EQ(2U, regions.seen.size());
EXPECT_STREQ("get radio2", decoded.command);
const char* geo_only = "gps:47.6062,-122.3321:1";
ASSERT_TRUE(Fleet::parseTargets(geo_only, strlen(geo_only), targets));
const auto geo_bytes = encodeTargets(targets);
location.known = false;
EXPECT_FALSE(decodeGeo(geo_bytes, location));
location.known = true; location.latitude_e6 = 45515200; location.longitude_e6 = -122678400;
EXPECT_FALSE(decodeGeo(geo_bytes, location));
// Invalid geometry after a matching key still rejects the complete request.
const std::string reversed = std::string(full, 8) + ';' + geo_only;
ASSERT_TRUE(Fleet::parseTargets(reversed.data(), reversed.size(), targets));
auto malformed = encodeTargets(targets);
memset(malformed.data() + 13 + 5 + 1 + 8, 0, 4); // Zero-radius final record.
EXPECT_FALSE(decode(malformed));
EXPECT_FALSE(decoded.broadcast);
}
TEST_F(FleetCommandTest, GeographicRecordUsesThirteenBytesAndTwoFragmentCommandBudget) {
const char* token = "gps:47.6062,-122.3321:1";
Fleet::Targets targets{};
ASSERT_TRUE(Fleet::parseTargets(token, strlen(token), targets));
const std::string prefix = "set flood.rule.1 ";
const std::string maximum = prefix + std::string(217 - prefix.size(), 'a');
auto bytes = encodeTargets(targets, maximum.c_str());
ASSERT_EQ(Fleet::MaxEnvelopeLength, bytes.size());
GeoContext location;
EXPECT_TRUE(decodeGeo(bytes, location));
EXPECT_TRUE(encodeTargets(targets, (maximum + 'a').c_str()).empty());
std::string list;
for (unsigned count = 1; count <= 6; ++count) {
if (!list.empty()) list += ';';
list += token;
ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets));
EXPECT_EQ(count * 13, targets.length);
}
list += ';' + std::string(token);
EXPECT_FALSE(Fleet::parseTargets(list.data(), list.size(), targets));
EXPECT_EQ(0, targets.count);
std::array<uint8_t, Fleet::MaxPayloadLength> part{};
ASSERT_EQ(part.size(), Fleet::fragment(bytes.data(), bytes.size(), 0, part.data(), part.size()));
Fleet::Fragment parsed{};
ASSERT_TRUE(Fleet::parseFragment(part.data(), part.size(), parsed));
EXPECT_FALSE(decodeGeo(std::vector<uint8_t>(part.begin(), part.end()), location));
}
TEST_F(FleetCommandTest, ExternalGeographicRecordBoundsRejectBeforeAnyOutputWrite) {
std::array<uint8_t, Fleet::MaxEnvelopeLength> output;
output.fill(0xA5);
for (unsigned invalid = 0; invalid < 5; ++invalid) {
Fleet::Targets targets{};
const char* token = "gps:0,0:1";
ASSERT_TRUE(Fleet::parseTargets(token, strlen(token), targets));
if (invalid == 0) targets.length = 12;
if (invalid == 1) memset(targets.data + 9, 0, 4);
if (invalid == 2) memset(targets.data + 1, 0x7f, 4);
if (invalid == 3) memset(targets.data + 5, 0x7f, 4);
if (invalid == 4) memset(targets.data + 9, 0xff, 4);
EXPECT_EQ(0U, Fleet::encode(publisher, ChannelKey, Now, Now + 120,
targets, "get radio2", output.data(), output.size()));
for (uint8_t byte : output) EXPECT_EQ(0xA5, byte);
}
}
TEST_F(FleetCommandTest, InvalidExternalTargetStructuresNeverWriteOutput) {
std::array<uint8_t, Fleet::MaxPayloadLength> output;
output.fill(0xA5);
@@ -633,13 +1058,13 @@ TEST_F(FleetCommandTest, InvalidExternalTargetStructuresNeverWriteOutput) {
TEST_F(FleetCommandTest, LargestFmc2EnvelopeFitsAndOversizedCombinationWritesNothing) {
Fleet::Targets targets{};
const char* three = "01020304,05060708,090A0B0C";
const char* three = "01020304;05060708;090A0B0C";
ASSERT_TRUE(Fleet::parseTargets(three, strlen(three), targets));
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";
const char* four = "01020304;05060708;090A0B0C;01020304";
ASSERT_TRUE(Fleet::parseTargets(four, strlen(four), targets));
std::array<uint8_t, Fleet::MaxEnvelopeLength> output;
output.fill(0xA5);
@@ -655,8 +1080,8 @@ TEST_F(FleetCommandTest, EveryTargetFormUsesItsExactCommandBudgetWithoutPartialW
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}
{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);
+423 -19
View File
@@ -2,6 +2,7 @@
"""Exercise the production fleet envelope with real Ed25519, never native mocks."""
import hashlib
import math
from pathlib import Path
import struct
import subprocess
@@ -85,6 +86,31 @@ void hex(const uint8_t* bytes, size_t size) {
std::cout << '\n';
}
struct RegionNames {
std::vector<std::string> configured, home;
static bool match(void* context, mesh::FleetCommand::RegionTarget kind,
const char* name, size_t length) {
const auto& regions = *static_cast<RegionNames*>(context);
const auto& names = kind == mesh::FleetCommand::RegionTarget::Configured
? regions.configured : regions.home;
for (const auto& candidate : names) {
const char* canonical = candidate.c_str() + (!candidate.empty() && candidate[0] == '#');
if (strlen(canonical) == length && !memcmp(canonical, name, length)) return true;
}
return false;
}
};
struct GeoLocation {
bool known = false;
int32_t latitude = 0, longitude = 0;
static bool match(void* context, int32_t latitude, int32_t longitude, uint32_t radius) {
const auto& location = *static_cast<GeoLocation*>(context);
return location.known && mesh::FleetCommand::withinRadius(latitude, longitude, radius,
location.latitude, location.longitude);
}
};
int main(int argc, char** argv) {
using Fleet = mesh::FleetCommand;
const std::string operation = argc > 1 ? argv[1] : "";
@@ -115,7 +141,13 @@ int main(int argc, char** argv) {
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; }
memset(&targets, 0xA5, sizeof(targets));
if (!Fleet::parseTargets(argv[3], strlen(argv[3]), targets)) {
assert(!targets.count && !targets.length);
for (uint8_t byte : targets.data) assert(byte == 0);
assert(fleet_sign_calls == 0);
std::cout << "reject\n"; return 0;
}
std::array<uint8_t, Fleet::MaxEnvelopeLength + 64> output;
output.fill(0xA5);
const unsigned signed_before = fleet_sign_calls;
@@ -166,17 +198,47 @@ int main(int argc, char** argv) {
}
return 0;
}
if (argc == 7 && (std::string(argv[1]) == "decode" || std::string(argv[1]) == "decode2")) {
if (argc == 7 && operation == "within-radius") {
std::cout << int(Fleet::withinRadius(int32_t(std::stol(argv[2])), int32_t(std::stol(argv[3])),
uint32_t(std::stoul(argv[4])), int32_t(std::stol(argv[5])), int32_t(std::stol(argv[6])))) << '\n';
return 0;
}
if ((argc == 7 && (operation == "decode" || operation == "decode2"))
|| (argc == 8 && operation == "decode-regions")
|| (argc == 11 && operation == "decode-targets")) {
const auto public_key = unhex(argv[2]), key = unhex(argv[3]), self = unhex(argv[4]), bytes = unhex(argv[6]);
assert(public_key.size() == PUB_KEY_SIZE && self.size() == PUB_KEY_SIZE && key.size() == Fleet::KeySize);
mesh::Identity publisher(public_key.data()); Fleet::Decoded output;
RegionNames regions;
if (operation == "decode-regions" || operation == "decode-targets") {
const std::string selected(argv[7]);
size_t cursor = 0;
while (cursor < selected.size()) {
const size_t end = selected.find(',', cursor);
const std::string name = selected.substr(cursor, end == std::string::npos ? end : end - cursor);
if (!name.compare(0, 7, "region:")) regions.configured.push_back(name.substr(7));
else { assert(!name.compare(0, 5, "home:")); regions.home.push_back(name.substr(5)); }
if (end == std::string::npos) break;
cursor = end + 1;
}
}
GeoLocation location;
if (operation == "decode-targets") {
assert(!strcmp(argv[8], "0") || !strcmp(argv[8], "1"));
location.known = !strcmp(argv[8], "1");
location.latitude = int32_t(std::stol(argv[9]));
location.longitude = int32_t(std::stol(argv[10]));
}
if (!Fleet::decode(publisher, key.data(), bytes.data(), bytes.size(),
uint32_t(std::stoul(argv[5])), self.data(), output)) {
uint32_t(std::stoul(argv[5])), self.data(), output,
operation == "decode-regions" || operation == "decode-targets"
? RegionNames::match : nullptr, &regions,
operation == "decode-targets" ? GeoLocation::match : nullptr, &location)) {
assert(output.sequence == 0 && output.expires == 0 && !output.broadcast && output.command[0] == 0);
std::cout << "reject\n";
} else {
std::cout << output.sequence << '\n' << output.expires << '\n' << output.command << '\n';
if (std::string(argv[1]) == "decode2") std::cout << int(output.broadcast) << '\n';
if (operation != "decode") std::cout << int(output.broadcast) << '\n';
}
return 0;
}
@@ -195,7 +257,8 @@ def envelope(command="get radio2", sequence=NOW, expires=NOW + 120,
def envelope2(entries=(), command="get radio2", sequence=NOW, expires=NOW + 120,
channel=CHANNEL, publisher=PUBLISHER, count=None):
command = command.encode("ascii") if isinstance(command, str) else command
records = b"".join(bytes([kind]) + value for kind, value in entries)
records = b"".join(bytes([kind]) + (bytes([len(value)]) if kind in (0x20, 0x21) else b"")
+ value for kind, value in entries)
count = len(entries) if count is None else count
unsigned = (b"FMC2" + struct.pack("<II", sequence, expires) + bytes([count])
+ records + bytes([len(command)]) + command)
@@ -240,6 +303,18 @@ class FleetCommandCryptoTests(unittest.TestCase):
return self.run_tool("decode2" if metadata else "decode",
publisher.hex(), channel.hex(), self_key.hex(), now, payload.hex())
def decode_regions(self, payload, configured=(), home=(), channel=CHANNEL,
publisher=PUBLIC, self_key=PUBLIC, now=NOW):
names = ["region:" + name for name in configured] + ["home:" + name for name in home]
return self.run_tool("decode-regions", publisher.hex(), channel.hex(), self_key.hex(),
now, payload.hex(), ",".join(names))
def decode_geo(self, payload, latitude_e6=47606200, longitude_e6=-122332100, known=True,
configured=(), home=(), channel=CHANNEL, publisher=PUBLIC, self_key=PUBLIC, now=NOW):
names = ["region:" + name for name in configured] + ["home:" + name for name in home]
return self.run_tool("decode-targets", publisher.hex(), channel.hex(), self_key.hex(),
now, payload.hex(), ",".join(names), int(known), latitude_e6, longitude_e6)
def test_cpp_and_python_signatures_match_exactly(self):
expected = envelope()
actual = self.run_tool("encode", CHANNEL.hex(), "all", NOW, NOW + 120, "get radio2")
@@ -353,7 +428,7 @@ class FleetCommandCryptoTests(unittest.TestCase):
def test_multitarget_scheduled_request_is_signed_and_body_bounded(self):
command = "set tempradioat2 910.5,500,5,5,rxtx,+1,+2,auto"
entries = [(4, PUBLIC[:4]), (6, OTHER_PUBLIC[:6])]
token = f"{PUBLIC[:4].hex()},{OTHER_PUBLIC[:6].hex()}"
token = f"{PUBLIC[:4].hex()};{OTHER_PUBLIC[:6].hex()}"
expected = envelope2(entries, command)
actual = self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, command)
self.assertEqual(actual, expected.hex())
@@ -524,10 +599,10 @@ class FleetCommandCryptoTests(unittest.TestCase):
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)]:
(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))
@@ -684,7 +759,7 @@ class FleetCommandCryptoTests(unittest.TestCase):
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()}"
token = f"{PUBLIC[:4].hex()};{OTHER_PUBLIC[:6].hex()};{PUBLIC.hex()}"
expected = envelope2(entries)
actual = self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, "get radio2")
self.assertEqual(actual, expected.hex())
@@ -693,6 +768,335 @@ class FleetCommandCryptoTests(unittest.TestCase):
unrelated = hashlib.sha256(b"unrelated fleet node").digest()
self.assertEqual(self.decode(expected, self_key=unrelated), "reject")
def test_region_and_home_targets_have_distinct_cross_language_signed_records(self):
entries = [(0x20, b"sea"), (0x20, b"pdx"), (0x21, b"Sea"),
(0x20, b"$Private"), (4, PUBLIC[:4])]
token = f"sea;pdx;home:#Sea;region:$Private;{PUBLIC[:4].hex()}"
expected = envelope2(entries)
actual = self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, "get radio2")
self.assertEqual(actual, expected.hex())
PUBLISHER.public_key().verify(expected[-64:], DOMAIN + CHANNEL + expected[:-64])
self.assertTrue(self.decode(expected, metadata=True).endswith("\n1"))
# Each target is OR: configured and home matches select the intended
# callback domain independently of whether the public-key prefix fits.
for configured, home in [(["sea"], []), (["#pdx"], []), ([], ["#Sea"]),
(["$Private"], [])]:
with self.subTest(configured=configured, home=home):
self.assertEqual(self.decode_regions(expected, configured, home, self_key=OTHER_PUBLIC),
f"{NOW}\n{NOW + 120}\nget radio2\n1")
self.assertEqual(self.decode(expected, self_key=OTHER_PUBLIC), "reject")
self.assertEqual(self.decode_regions(expected, ["Sea"], ["sea"], self_key=OTHER_PUBLIC), "reject")
def test_region_exact_matching_preserves_case_private_names_and_hash_aliases(self):
for token, kind, canonical in [
("sea", 0x20, "sea"), ("#Sea", 0x20, "Sea"),
("region:#sea", 0x20, "sea"), ("home:#Sea", 0x21, "Sea"),
("region:$Private", 0x20, "$Private"), ("home:$Private", 0x21, "$Private")]:
with self.subTest(token=token):
expected = envelope2([(kind, canonical.encode("ascii"))])
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
"get radio2"), expected.hex())
self.assertEqual(self.decode(expected), "reject")
configured, home = ([canonical], []) if kind == 0x20 else ([], [canonical])
self.assertTrue(self.decode_regions(expected, configured, home).endswith("\n1"))
configured, home = ([], [canonical]) if kind == 0x20 else ([canonical], [])
self.assertEqual(self.decode_regions(expected, configured, home), "reject")
for wrong in [canonical.swapcase(), canonical + "-other", canonical[:-1]]:
configured, home = ([wrong], []) if kind == 0x20 else ([], [wrong])
self.assertEqual(self.decode_regions(expected, configured, home), "reject")
canonical = self.run_tool("encode2", CHANNEL.hex(), "home:sea", NOW, NOW + 120, "get radio2")
alias = self.run_tool("encode2", CHANNEL.hex(), "home:#sea", NOW, NOW + 120, "get radio2")
self.assertEqual(canonical, alias)
def test_named_target_parser_never_reinterprets_malformed_keys_or_command_verbs(self):
for token in ["zzzzzzzz", "z" * 12, "g" * 64, "a", "dead", "a" * 16,
"all;sea", "sea;all", "region:", "home:", "#", "region:#", "home:#",
"region:*", "home:*", "region:bad.name", "home:bad:name", "sea;pdx;",
"region:bad space", "region:bad\n", "region:bad\x7f", "region:badé",
"region:" + "z" * 31, "home:" + "z" * 31,
"get", "set", "del", "time", "clock"]:
with self.subTest(token=token):
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
"get radio2"), "reject")
# Explicit selectors remove any ambiguity with public-key formats and
# CLI verbs. Uppercase names retain the RegionMap's case sensitivity.
for token, kind, name in [
("region:all", 0x20, "all"), ("region:dead", 0x20, "dead"),
("region:zzzzzzzz", 0x20, "zzzzzzzz"), ("home:a", 0x21, "a"),
("region:get", 0x20, "get"), ("home:get", 0x21, "get"),
("#get", 0x20, "get"), ("region:clock", 0x20, "clock"),
("home:time", 0x21, "time"), ("ALL", 0x20, "ALL")]:
with self.subTest(token=token):
expected = envelope2([(kind, name.encode("ascii"))])
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
"get radio2"), expected.hex())
self.assertEqual(self.decode(expected), "reject")
configured, home = ([name], []) if kind == 0x20 else ([], [name])
self.assertNotEqual(self.decode_regions(expected, configured, home), "reject")
def test_region_record_schema_is_validated_after_an_earlier_matching_key(self):
matching = (4, PUBLIC[:4])
for kind in [0x20, 0x21]:
for name in [b"", b"z" * 31, b"*", b"bad.name", b"bad:name", b"bad/name",
b"bad\0", b"bad\n", b"bad\x7f", b"bad\x80", b"bad;name", b"bad name"]:
with self.subTest(kind=kind, name=name):
signed = envelope2([matching, (kind, name)])
self.assertEqual(self.decode_regions(signed, ["sea"], ["pdx"]), "reject")
expected = envelope2([matching, (0x20, b"sea"), (0x21, b"pdx")])
self.assertNotEqual(self.decode_regions(expected, ["sea"], ["pdx"]), "reject")
# The declared length is signed as part of the record, and it must
# still frame every later entry and the command exactly.
for value in [0, 1, 4, 30, 31, 255]:
changed = bytearray(expected)
changed[19] = value
self.assertEqual(self.decode_regions(changed, ["sea"], ["pdx"]), "reject")
unknown = envelope2([matching, (0x22, b"\3sea")])
self.assertEqual(self.decode_regions(unknown, ["sea"], ["pdx"]), "reject")
def test_signature_binds_region_name_and_configured_versus_home_selector(self):
original = envelope2([(0x20, b"sea")])
for changed in [envelope2([(0x20, b"pdx")]), envelope2([(0x21, b"sea")]),
envelope2([(0x20, b"Sea")]), envelope2([(0x20, b"sea"), (0x21, b"sea")])]:
with self.subTest(changed=changed.hex()):
self.assertNotEqual(self.decode_regions(changed, ["sea", "pdx", "Sea"], ["sea"]), "reject")
self.assertEqual(self.decode_regions(changed[:-64] + original[-64:],
["sea", "pdx", "Sea"], ["sea"]), "reject")
self.assertEqual(self.decode_regions(original, ["sea"], publisher=OTHER_PUBLIC), "reject")
self.assertEqual(self.decode_regions(original, ["sea"], channel=bytes(range(17, 33))), "reject")
self.assertEqual(self.decode_regions(original, ["sea"], now=0), "reject")
self.assertEqual(self.decode_regions(original, ["sea"], now=NOW + 121), "reject")
def test_named_targets_share_byte_count_and_fragment_budgets_without_partial_output(self):
for count in [1, 16, 17]:
token = ";".join(["sea"] * count)
expected = envelope2([(0x20, b"sea")] * count)
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
"get radio2"), expected.hex())
self.assertTrue(self.decode_regions(expected, ["sea"]).endswith("\n1"))
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), ";".join(["sea"] * 18), NOW,
NOW + 120, "get radio2"), "reject")
records = [(0x20, b"z" * 30), (0x21, b"Y" * 30), (0x20, b"x" * 20)]
token = "region:" + "z" * 30 + ";home:" + "Y" * 30 + ";region:" + "x" * 20
prefix = "set flood.rule.1 "
maximum = prefix + "a" * (144 - len(prefix))
expected = envelope2(records, maximum)
self.assertEqual(len(expected), 308) # Exactly86 bytes of target records.
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
maximum), expected.hex())
self.assertTrue(self.decode_regions(expected, ["z" * 30]).endswith("\n1"))
for operation in ["encode2", "encode2-wide"]:
self.assertEqual(self.run_tool(operation, CHANNEL.hex(), token, NOW, NOW + 120,
maximum + "a"), "reject")
self.assertEqual(self.run_tool(operation, CHANNEL.hex(), token + "x", NOW, NOW + 120,
"get radio2"), "reject")
parts = [bytes.fromhex(self.run_tool("fragment", expected.hex(), index, 165)) for index in [0, 1]]
for index, part in enumerate(parts):
self.assertEqual(part, fragment_frame(expected, index))
self.assertEqual(len(part), 165)
self.assertEqual(self.decode_regions(part, ["z" * 30]), "reject")
assembled = parts[0][11:] + parts[1][11:]
PUBLISHER.public_key().verify(assembled[-64:], DOMAIN + CHANNEL + assembled[:-64])
self.assertTrue(self.decode_regions(assembled, ["z" * 30]).endswith("\n1"))
# Tamper with a later home name while the first configured name still
# matches. The full reconstructed signature must reject this change.
changed = bytearray(parts[0])
changed[11 + 13 + 32 + 2] = ord("Z")
self.assertEqual(self.decode_regions(changed[11:] + parts[1][11:], ["z" * 30]), "reject")
def test_gps_cross_language_signatures_use_microdegrees_and_metres_exactly(self):
for token, latitude, longitude, radius in [
("gps:47.6062,-122.3321:10", 47606200, -122332100, 10000),
("gps:+90,-180:0.001", 90000000, -180000000, 1),
("gps:-90,+180:20050.000", -90000000, 180000000, 20050000),
("gps:0.000001,-0.000001:1.001", 1, -1, 1001),
("gps:0,0:1", 0, 0, 1000)]:
with self.subTest(token=token):
record = struct.pack("<iiI", latitude, longitude, radius)
expected = envelope2([(0x22, record)])
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
"get radio2"), expected.hex())
self.assertEqual(expected[13:26], b"\x22" + record)
self.assertEqual(len(expected), 101)
PUBLISHER.public_key().verify(expected[-64:], DOMAIN + CHANNEL + expected[:-64])
self.assertEqual(self.decode(expected), "reject")
self.assertEqual(self.decode_geo(expected, latitude, longitude),
f"{NOW}\n{NOW + 120}\nget radio2\n1")
self.assertEqual(self.decode_geo(expected, latitude, longitude, known=False), "reject")
def test_semicolon_target_lists_preserve_gps_at_first_middle_and_last(self):
gps = (0x22, struct.pack("<iiI", 47606200, -122332100, 25000))
circle = "gps:47.6062,-122.3321:25"
for token, entries in [
(f"{circle};sea;home:pdx", [gps, (0x20, b"sea"), (0x21, b"pdx")]),
(f"sea;{circle};home:pdx", [(0x20, b"sea"), gps, (0x21, b"pdx")]),
(f"sea;home:pdx;{circle}", [(0x20, b"sea"), (0x21, b"pdx"), gps]),
(f"{circle};{circle}", [gps, gps])]:
with self.subTest(token=token):
expected = envelope2(entries)
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
"get radio2"), expected.hex())
PUBLISHER.public_key().verify(expected[-64:], DOMAIN + CHANNEL + expected[:-64])
self.assertTrue(self.decode_geo(expected, self_key=OTHER_PUBLIC).endswith("\n1"))
if len(entries) == 3:
self.assertTrue(self.decode_geo(expected, known=False, configured=["sea"],
home=["pdx"], self_key=OTHER_PUBLIC).endswith("\n1"))
else:
self.assertEqual(self.decode_geo(expected, known=False), "reject")
def test_truncated_gps_tokens_only_accept_a_complete_nonempty_radius(self):
token = "gps:47.6062,-122.3321:25"
for length in range(4, len(token) + 1):
with self.subTest(length=length):
actual = self.run_tool("encode2", CHANNEL.hex(), token[:length], NOW, NOW + 120,
"get radio2")
if length in (len(token) - 1, len(token)):
radius = int(token[:length].rsplit(":", 1)[1]) * 1000
expected = envelope2([(0x22, struct.pack("<iiI", 47606200, -122332100, radius))])
self.assertEqual(actual, expected.hex())
else:
self.assertEqual(actual, "reject")
def test_comma_lists_and_malformed_gps_delimiters_cannot_send_partial_commands(self):
for token in [
"sea,pdx", f"{PUBLIC[:4].hex()},{OTHER_PUBLIC[:4].hex()}",
"region:sea,home:pdx", "sea;home:pdx,sea", "gps:47.6062:-122.3321:25",
"gps:47.6062;-122.3321:25", "gps:47.6062,-122.3321,25",
"gps:47.6062,,-122.3321:25", "gps:47.6062,-122.3321,:25",
"gps:47.6062,-122.3321:25,sea", ";gps:47.6062,-122.3321:25",
"gps:47.6062,-122.3321:25;", "sea;;gps:47.6062,-122.3321:25",
"sea;gps:47.6062,-122.3321:25;;home:pdx", "sea;gps:47.6062;home:pdx",
"sea;gps:47.6062,-122.3321;home:pdx", "sea;gps:47.6062,-122.3321:;home:pdx",
"sea;gps:47.6062,-122.3321:25;home:pdx,sea"]:
with self.subTest(token=token):
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
"get radio2"), "reject")
def test_semicolon_mixed_geographic_lists_keep_record_and_byte_limits(self):
circle = "gps:47.6062,-122.3321:25"
gps = (0x22, struct.pack("<iiI", 47606200, -122332100, 25000))
for token, entries in [
(";".join([circle] + ["z"] * 16), [gps] + [(0x20, b"z")] * 16),
(";".join([circle] + ["sea"] * 14), [gps] + [(0x20, b"sea")] * 14)]:
with self.subTest(token=token):
expected = envelope2(entries)
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
"get radio2"), expected.hex())
self.assertTrue(self.decode_geo(expected).endswith("\n1"))
for names in [["z"] * 17, ["sea"] * 15]:
token = ";".join([circle] + names)
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
"get radio2"), "reject")
def test_malformed_gps_decimal_tokens_never_fall_back_to_region_names(self):
for token in [
"gps:", "gps:0", "gps:0:0", "gps:0,0:", "gps:,0:1", "gps:0,:1", "gps:0,0:1:2",
"gps:0,0:0", "gps:0,0:0.000", "gps:0,0:0.0001", "gps:0,0:20050.001",
"gps:90.000001,0:1", "gps:-90.000001,0:1", "gps:0,180.000001:1",
"gps:0,-180.000001:1", "gps:0.0000001,0:1", "gps:0,0.0000001:1",
"gps:0,0:1.0001", "gps:0,0:+1", "gps:0,0:-1", "gps:NaN,0:1", "gps:0,Inf:1",
"gps:0,0:1e3", "gps:1e1,0:1", "gps:.1,0:1", "gps:1.,0:1", "gps:0,0:.1",
"gps:0,0:1.", "gps:01,0:1", "gps:0,00:1", "gps:0,0:01", "gps:--1,0:1",
"gps:++1,0:1", "gps:+,0:1", "gps:0,0:4294967296", "gps:999999999999999999,0:1",
"gps:0,0:1m", "gps:0,0:1\n", "gps: 0:0:1", "gps:0,0:1;all"]:
with self.subTest(token=token):
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
"get radio2"), "reject")
def test_geographic_math_matches_independent_distance_at_boundaries(self):
cases = [
(0, 0, 0, 0), (0, 0, 0, 1000000),
(0, 179999999, 0, -179999999), (90000000, 0, 90000000, 180000000),
(0, 0, 0, 180000000), (47606200, -122332100, 47610000, -122330000),
(47606200, -122332100, 45515200, -122678400),
(-89999999, -180000000, -89999999, 180000000)]
for latitude, longitude, local_latitude, local_longitude in cases:
center = math.radians(latitude / 1000000)
local = math.radians(local_latitude / 1000000)
half_latitude = math.radians((local_latitude - latitude) / 1000000) / 2
half_longitude = math.radians((local_longitude - longitude) / 1000000) / 2
value = (math.sin(half_latitude) ** 2 + math.cos(center) * math.cos(local)
* math.sin(half_longitude) ** 2)
value = max(0, min(1, value))
distance = 2 * 6371008.8 * math.atan2(math.sqrt(value), math.sqrt(1 - value))
radii = {1, max(1, int(math.floor(distance))), max(1, int(math.floor(distance)) + 1), 20050000}
for radius in radii:
with self.subTest(center=(latitude, longitude), local=(local_latitude, local_longitude), radius=radius):
actual = self.run_tool("within-radius", latitude, longitude, radius,
local_latitude, local_longitude)
self.assertEqual(actual, str(int(distance <= radius)))
for latitude, longitude, radius, local_latitude, local_longitude in [
(90000001, 0, 1, 0, 0), (0, 180000001, 1, 0, 0),
(0, 0, 1, -90000001, 0), (0, 0, 1, 0, -180000001),
(0, 0, 0, 0, 0), (0, 0, 20050001, 0, 0),
(2147483647, -2147483648, 4294967295, 0, 0)]:
self.assertEqual(self.run_tool("within-radius", latitude, longitude, radius,
local_latitude, local_longitude), "0")
def test_geographic_region_home_and_key_records_are_independent_signed_or_matches(self):
record = struct.pack("<iiI", 47606200, -122332100, 1000)
entries = [(0x22, record), (0x20, b"sea"), (0x21, b"pdx"), (4, PUBLIC[:4])]
token = f"gps:47.6062,-122.3321:1;sea;home:pdx;{PUBLIC[:4].hex()}"
expected = envelope2(entries)
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
"get radio2"), expected.hex())
self.assertTrue(self.decode(expected, metadata=True).endswith("\n1"))
self.assertTrue(self.decode_geo(expected, self_key=OTHER_PUBLIC).endswith("\n1"))
self.assertTrue(self.decode_geo(expected, known=False, configured=["sea"], self_key=OTHER_PUBLIC).endswith("\n1"))
self.assertTrue(self.decode_geo(expected, known=False, home=["pdx"], self_key=OTHER_PUBLIC).endswith("\n1"))
self.assertEqual(self.decode_geo(expected, known=False, self_key=OTHER_PUBLIC), "reject")
self.assertEqual(self.decode_geo(expected, 45515200, -122678400, self_key=OTHER_PUBLIC), "reject")
self.assertEqual(self.decode_geo(expected, 90000001, 0, self_key=OTHER_PUBLIC), "reject")
# Invalid later geographic records reject even after a matching key.
for latitude, longitude, radius in [
(90000001, 0, 1000), (-90000001, 0, 1000), (0, 180000001, 1000),
(0, -180000001, 1000), (0, 0, 0), (0, 0, 20050001), (0, 0, 4294967295)]:
invalid = envelope2([(4, PUBLIC[:4]), (0x22, struct.pack("<iiI", latitude, longitude, radius))])
self.assertEqual(self.decode_geo(invalid), "reject")
for missing in range(12):
invalid = envelope2([(4, PUBLIC[:4]), (0x22, record[:missing])])
self.assertEqual(self.decode_geo(invalid), "reject")
def test_signature_binds_every_geographic_field_and_fragmented_circle_command(self):
record = struct.pack("<iiI", 47606200, -122332100, 10000)
original = envelope2([(0x22, record)])
for changed in [
envelope2([(0x22, struct.pack("<iiI", 47606201, -122332100, 10000))]),
envelope2([(0x22, struct.pack("<iiI", 47606200, -122332101, 10000))]),
envelope2([(0x22, struct.pack("<iiI", 47606200, -122332100, 10001))]),
envelope2([(0x22, record), (0x20, b"sea")])]:
with self.subTest(changed=changed.hex()):
self.assertNotEqual(self.decode_geo(changed), "reject")
self.assertEqual(self.decode_geo(changed[:-64] + original[-64:]), "reject")
self.assertEqual(self.decode_geo(original, publisher=OTHER_PUBLIC), "reject")
self.assertEqual(self.decode_geo(original, channel=bytes(range(17, 33))), "reject")
self.assertEqual(self.decode_geo(original, now=0), "reject")
self.assertEqual(self.decode_geo(original, now=NOW + 121), "reject")
token = "gps:47.6062,-122.3321:10"
prefix = "set flood.rule.1 "
maximum = prefix + "a" * (217 - len(prefix))
expected = envelope2([(0x22, record)], maximum)
self.assertEqual(len(expected), 308)
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
maximum), expected.hex())
self.assertTrue(self.decode_geo(expected).endswith("\n1"))
for operation in ["encode2", "encode2-wide"]:
self.assertEqual(self.run_tool(operation, CHANNEL.hex(), token, NOW, NOW + 120,
maximum + "a"), "reject")
parts = [bytes.fromhex(self.run_tool("fragment", expected.hex(), index, 165)) for index in [0, 1]]
assembled = parts[0][11:] + parts[1][11:]
self.assertEqual(assembled, expected)
PUBLISHER.public_key().verify(assembled[-64:], DOMAIN + CHANNEL + assembled[:-64])
self.assertNotEqual(self.decode_geo(assembled), "reject")
for part in parts:
self.assertEqual(self.decode_geo(part), "reject")
for count in [1, 6]:
token_list = ";".join([token] * count)
signed = envelope2([(0x22, record)] * count)
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token_list, NOW, NOW + 120,
"get radio2"), signed.hex())
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), ";".join([token] * 7), NOW,
NOW + 120, "get radio2"), "reject")
def test_single_raw_prefix_is_a_broadcast_capability_for_matching_collisions(self):
first = PUBLIC
second = PUBLIC[:4] + bytes([PUBLIC[4] ^ 1]) + PUBLIC[5:]
@@ -746,11 +1150,11 @@ class FleetCommandCryptoTests(unittest.TestCase):
self.assertEqual(self.decode(envelope2([matching], count=2)), "reject")
def test_new_target_parser_refuses_partial_or_mixed_all_lists(self):
for token in ["", "all," + PUBLIC[:4].hex(), PUBLIC[:4].hex() + ",all",
PUBLIC[:4].hex() + ",", "," + PUBLIC[:4].hex(),
PUBLIC[:4].hex() + ",," + OTHER_PUBLIC[:4].hex(),
PUBLIC[:4].hex() + ",zzzzzzzz", "1" * 10, "1" * 16,
",".join([PUBLIC.hex()] * 6), ",".join([PUBLIC[:4].hex()] * 18)]:
for token in ["", "all;" + PUBLIC[:4].hex(), PUBLIC[:4].hex() + ";all",
PUBLIC[:4].hex() + ";", ";" + PUBLIC[:4].hex(),
PUBLIC[:4].hex() + ";;" + OTHER_PUBLIC[:4].hex(),
PUBLIC[:4].hex() + ";zzzzzzzz", "1" * 10, "1" * 16,
";".join([PUBLIC.hex()] * 6), ";".join([PUBLIC[:4].hex()] * 18)]:
with self.subTest(token=token):
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, "get radio2"), "reject")
@@ -759,15 +1163,15 @@ class FleetCommandCryptoTests(unittest.TestCase):
entries = [(4, PUBLIC[:4])] * 3
expected = envelope2(entries, maximum)
self.assertEqual(len(expected), 308)
token = ",".join([PUBLIC[:4].hex()] * 3)
token = ";".join([PUBLIC[:4].hex()] * 3)
actual = self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, maximum)
self.assertEqual(actual, expected.hex())
self.assertNotEqual(self.decode(expected), "reject")
token += "," + PUBLIC[:4].hex()
token += ";" + PUBLIC[:4].hex()
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, maximum), "reject")
self.assertEqual(self.decode(envelope2(entries * 2, maximum)), "reject")
for count in [15, 16, 17]:
token = ",".join([PUBLIC[:4].hex()] * count)
token = ";".join([PUBLIC[:4].hex()] * count)
result = self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, "get radio2")
self.assertNotEqual(result, "reject")
+243
View File
@@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""Exercise fleet location selection and the real read-only GPS cache accessor."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
from test_gps_upstream_adaptations import ARDUINO, CAYENNE, MESH
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <cassert>
#include <cmath>
#include <cstdint>
#include <limits>
#include <string>
#include <helpers/SensorManager.h>
#include <helpers/FleetLocation.h>
void LocationProvider::sendSentence(const char*) {}
struct Prefs { double node_lat = 0, node_lon = 0; unsigned advert_loc_policy = 0; };
struct Cached {
bool valid = true;
double latitude = 47.6062, longitude = -122.3321;
mutable unsigned reads = 0;
bool getCachedGpsPosition(double& lat, double& lon) const {
++reads; lat = latitude; lon = longitude; return valid;
}
};
#if ENV_INCLUDE_GPS
struct Sensors : SensorManager {
bool active = true;
unsigned starts = 0, stops = 0, providers = 0, queries = 0;
bool telemetryGpsDetected() const override { return true; }
bool telemetryGpsActive() const override { return active; }
void telemetryGpsStart() override { ++starts; active = true; }
void telemetryGpsStop() override { ++stops; active = false; }
LocationProvider* getLocationProvider() override { ++providers; return nullptr; }
bool querySensors(uint8_t, CayenneLPP&) override { ++queries; return false; }
void seed(float lat, float lon) {
setGpsTelemetryUserEnabled(true);
processGpsTelemetryFix(lat, lon, 5, millis());
setGpsTelemetryUserEnabled(false);
}
void block(bool blocked) { setGpsTelemetryTransportAvailable(!blocked); }
bool receiving() const { return gpsTelemetryReceiverRequired(millis()); }
void service() { loopGpsTelemetry(millis()); }
};
#endif
int main(int argc, char** argv) {
assert(argc == 2);
const std::string scenario = argv[1];
Prefs prefs; Cached cache; int32_t lat = 1, lon = 1;
if (scenario == "configured") {
prefs.node_lat = 47.6062; prefs.node_lon = -122.3321;
cache.valid = false;
for (unsigned policy : {0, 1, 2}) {
prefs.advert_loc_policy = policy;
assert(mesh::readFleetLocation(prefs, cache, lat, lon));
assert(lat == 47606200 && lon == -122332100 && cache.reads == 0);
}
prefs.node_lat = 0; prefs.node_lon = 180;
assert(mesh::readFleetLocation(prefs, cache, lat, lon) && lat == 0 && lon == 180000000);
prefs.node_lat = -90; prefs.node_lon = 0;
assert(mesh::readFleetLocation(prefs, cache, lat, lon) && lat == -90000000 && lon == 0);
prefs.node_lat = -0.0000015; prefs.node_lon = 0.0000015;
assert(mesh::readFleetLocation(prefs, cache, lat, lon) && lat == -2 && lon == 2);
assert(cache.reads == 0);
} else if (scenario == "invalid_configured") {
const double nan = std::numeric_limits<double>::quiet_NaN();
const double inf = std::numeric_limits<double>::infinity();
const double pairs[][2] = {{nan,0}, {0,nan}, {inf,1}, {1,-inf}, {90.1,0},
{-90.1,0}, {0,180.1}, {0,-180.1}, {0.0000001,-0.0000001}};
for (const auto& pair : pairs) {
prefs.node_lat = pair[0]; prefs.node_lon = pair[1]; lat = lon = 123;
assert(!mesh::readFleetLocation(prefs, cache, lat, lon));
assert(lat == 0 && lon == 0 && cache.reads == 0);
}
} else if (scenario == "fallback") {
for (unsigned policy : {0, 1, 2}) {
prefs.advert_loc_policy = policy;
assert(mesh::readFleetLocation(prefs, cache, lat, lon));
assert(lat == 47606200 && lon == -122332100);
}
assert(cache.reads == 3);
cache.valid = false; lat = lon = 123;
assert(!mesh::readFleetLocation(prefs, cache, lat, lon) && lat == 0 && lon == 0);
cache.valid = true;
for (const auto& pair : {std::pair<double,double>{0,0}, {0.0000001,-0.0000001},
{std::numeric_limits<double>::quiet_NaN(),0}, {0,std::numeric_limits<double>::infinity()},
{91,0}, {0,-181}}) {
cache.latitude = pair.first; cache.longitude = pair.second; lat = lon = 123;
assert(!mesh::readFleetLocation(prefs, cache, lat, lon) && lat == 0 && lon == 0);
}
#if ENV_INCLUDE_GPS
} else if (scenario == "cache_readonly") {
now_ms = 1000; Sensors sensors; sensors.powersaving_enabled = true;
double latitude = 1, longitude = 1;
assert(!sensors.getCachedGpsPosition(latitude, longitude));
assert(latitude == 0 && longitude == 0 && sensors.starts == 0 && sensors.stops == 0);
sensors.seed(47.6062f, -122.3321f);
sensors.block(true); // Cached reading must not reclaim a bridge-owned UART.
const unsigned starts = sensors.starts, stops = sensors.stops;
const bool active = sensors.active, receiving = sensors.receiving();
for (unsigned i = 0; i < 100; ++i) {
assert(sensors.getCachedGpsPosition(latitude, longitude));
assert(std::abs(latitude - 47.6062) < 0.00001);
assert(std::abs(longitude + 122.3321) < 0.00001);
assert(mesh::readFleetLocation(prefs, sensors, lat, lon));
}
assert(sensors.starts == starts && sensors.stops == stops && sensors.active == active);
assert(sensors.receiving() == receiving && sensors.providers == 0 && sensors.queries == 0);
assert(sensors.powersaving_enabled && !sensors.active && !sensors.receiving());
} else if (scenario == "cache_freshness" || scenario == "cache_wrap") {
const uint32_t start = scenario == "cache_wrap" ? UINT32_MAX - 1000 : 0;
now_ms = start; Sensors sensors; sensors.seed(47.6f, -122.3f);
double latitude = 1, longitude = 1;
now_ms = start + 12U * 3600000U;
assert(sensors.getCachedGpsPosition(latitude, longitude));
++now_ms; latitude = longitude = 123;
assert(!sensors.getCachedGpsPosition(latitude, longitude));
assert(latitude == 0 && longitude == 0);
assert(!mesh::readFleetLocation(prefs, sensors, lat, lon) && lat == 0 && lon == 0);
// Configured fixed locations remain usable while GPS cache is stale.
prefs.node_lat = 47.6; prefs.node_lon = -122.3;
assert(mesh::readFleetLocation(prefs, sensors, lat, lon));
} else if (scenario == "cache_full_cycle") {
const uint32_t original = 1000;
now_ms = original; Sensors sensors; sensors.seed(47.6f, -122.3f);
sensors.block(true);
double latitude = 1, longitude = 1;
assert(sensors.getCachedGpsPosition(latitude, longitude));
now_ms = original + 12U * 3600000U + 1;
sensors.service(); // Expiry must persist even while another owner has the UART.
assert(!sensors.getCachedGpsPosition(latitude, longitude));
// These low32 bits recur after one complete millis cycle. A cache that
// was observed expired must never become fresh again without a new fix.
now_ms = original;
assert(!sensors.getCachedGpsPosition(latitude, longitude));
assert(!mesh::readFleetLocation(prefs, sensors, lat, lon));
assert(latitude == 0 && longitude == 0 && lat == 0 && lon == 0);
sensors.block(false);
++now_ms; sensors.seed(47.7f, -122.4f);
assert(sensors.getCachedGpsPosition(latitude, longitude));
assert(std::abs(latitude - 47.7) < 0.00001 && std::abs(longitude + 122.4) < 0.00001);
} else if (scenario == "invalid_cache") {
for (const auto& pair : {std::pair<float,float>{0,0}, {91,0}, {-91,0}, {0,181}, {0,-181},
{std::numeric_limits<float>::quiet_NaN(),0}, {0,std::numeric_limits<float>::infinity()}}) {
now_ms = 100; Sensors sensors; sensors.seed(pair.first, pair.second);
double latitude = 1, longitude = 1;
assert(!sensors.getCachedGpsPosition(latitude, longitude));
assert(latitude == 0 && longitude == 0);
}
#else
} else if (scenario == "no_gps") {
SensorManager sensors; double latitude = 1, longitude = 1;
assert(!sensors.getCachedGpsPosition(latitude, longitude));
assert(latitude == 0 && longitude == 0);
assert(!mesh::readFleetLocation(prefs, sensors, lat, lon) && lat == 0 && lon == 0);
prefs.node_lat = 47.6062; prefs.node_lon = -122.3321;
assert(mesh::readFleetLocation(prefs, sensors, lat, lon));
assert(lat == 47606200 && lon == -122332100);
#endif
} else assert(false && "unknown scenario");
}
'''
class FleetLocationTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
compiler = shutil.which("g++") or shutil.which("clang++")
if compiler is None:
raise unittest.SkipTest("host C++17 compiler required")
cls.work = tempfile.TemporaryDirectory(prefix="meshcore-fleet-location-")
path = Path(cls.work.name)
for name, content in {"Arduino.h": ARDUINO, "Mesh.h": MESH,
"CayenneLPP.h": CAYENNE,
"Wire.h": "#pragma once\nclass TwoWire {};\n"}.items():
(path / name).write_text(content)
cpp = path / "fixture.cpp"
cpp.write_text(HARNESS)
cls.binaries = {}
sanitizers = (["-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-no-pie"]
if os.environ.get("MESHCORE_FLEET_SANITIZERS") == "1" else [])
for gps in (0, 1):
binary = path / f"gps-{gps}"
result = subprocess.run([
compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-O2",
"-Wno-unused-parameter", *sanitizers, f"-DENV_INCLUDE_GPS={gps}",
"-I", str(path), "-I", str(ROOT / "src"), str(cpp),
str(ROOT / "src/helpers/SensorManager.cpp"), "-o", str(binary),
], capture_output=True, text=True, timeout=60)
if result.returncode:
cls.work.cleanup()
raise AssertionError(result.stdout + result.stderr)
cls.binaries[gps] = binary
@classmethod
def tearDownClass(cls):
cls.work.cleanup()
def scenario(self, name, gps=1):
result = subprocess.run([str(self.binaries[gps]), name], capture_output=True,
text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_configured_position_preferred_and_independent_of_public_advertisement_policy(self):
self.scenario("configured")
def test_invalid_configured_position_fails_closed_without_gps_fallback(self):
self.scenario("invalid_configured")
def test_only_unset_configured_pair_falls_back_to_valid_cached_position(self):
self.scenario("fallback")
def test_real_cached_accessor_never_acquires_or_changes_sleep_or_uart_ownership(self):
self.scenario("cache_readonly")
def test_real_cache_twelve_hour_boundary_and_configured_position_after_expiry(self):
self.scenario("cache_freshness")
def test_real_cache_freshness_survives_millis_wrap(self):
self.scenario("cache_wrap")
def test_expired_cache_never_becomes_fresh_after_full_millis_cycle_while_uart_blocked(self):
self.scenario("cache_full_cycle")
def test_real_cached_accessor_rejects_nonfinite_out_of_range_and_unset_positions(self):
self.scenario("invalid_cache")
def test_gps_disabled_profiles_can_use_configured_position_without_receiver(self):
self.scenario("no_gps", gps=0)
if __name__ == "__main__":
unittest.main()
+3 -1
View File
@@ -14,7 +14,7 @@ import tempfile
import unittest
from cpp_source import body
from test_fleet_channel_runtime import UTILS, FILESYSTEM, MESH, HARNESS as RUNTIME_HARNESS
from test_fleet_channel_runtime import UTILS, FILESYSTEM, MESH, REGION_MAP, HARNESS as RUNTIME_HARNESS
from test_repeater_radio_timing_integration import HARNESS as PRIMARY_HARNESS
ROOT = Path(__file__).resolve().parents[1]
@@ -313,10 +313,12 @@ class FleetRadioSchedulesTests(unittest.TestCase):
compiler=shutil.which("g++") or shutil.which("clang++")
if not compiler:raise unittest.SkipTest("host C++ compiler required")
cls.work=tempfile.TemporaryDirectory(prefix="fleet-radio-schedules-");work=Path(cls.work.name)
(work/"helpers").mkdir()
mesh=MESH.replace('#include <Identity.h>','#include <Hardware.h>\n#include <Identity.h>')
mesh=mesh.replace('struct Clock {','struct Clock :RTCClock {').replace('uint32_t getCurrentTime() const{return now;}','uint32_t getCurrentTime()override{return now;}')
utils=UTILS.replace('class Utils { public:', 'class Utils { public:\n static int parseTextParts(char*,const char*[],int,char);')
for name,content in {"Utils.h":utils,"FS.h":FILESYSTEM,"Mesh.h":mesh,"Hardware.h":HARDWARE,
"helpers/RegionMap.h":REGION_MAP,
"Dispatcher.h":'#pragma once\n#include <Mesh.h>\n',
"Packet.h":'#pragma once\n#include <Mesh.h>\n',
"Arduino.h":'#pragma once\n#include <Mesh.h>\n#include <cstdlib>\n'}.items():
+65 -7
View File
@@ -20,6 +20,7 @@ HARNESS = r'''
#include <vector>
#include <algorithm>
#include <SHA256.h>
#include <helpers/FleetLocation.h>
#define MESH_ENABLE_CLOCK_SYNC 0
#define MAX_TKS_ENTRIES 16
static std::vector<int> events;
@@ -34,6 +35,7 @@ struct Packet {
struct Mesh {
virtual ~Mesh() = default;
virtual bool supportsFleetControl() const { return false; }
virtual bool getFleetLocation(int32_t&, int32_t&) const { return false; }
virtual void onSendComplete(Packet*) { events.push_back(1); }
virtual void onSendFail(Packet*) { events.push_back(3); }
virtual void onRadioProfileCopyQueued(Packet*, const Packet*, uint8_t) { events.push_back(5); }
@@ -55,6 +57,14 @@ struct Regions {
}
};
@SCOPE_HELPERS@
struct CachedSensors {
bool valid = false;
double latitude = 45.5152, longitude = -122.6784;
mutable unsigned reads = 0;
bool getCachedGpsPosition(double& lat, double& lon) const {
++reads; lat = latitude; lon = longitude; return valid;
}
};
struct Profiles {};
struct Fleet {
unsigned received = 0, serviced = 0;
@@ -93,7 +103,11 @@ class Repeater : public mesh::Mesh {
Regions region_map;
Region region;
Region* recv_pkt_region = &region;
struct { const char* node_name = "fleet node"; } _prefs;
struct { const char* node_name = "fleet node"; double node_lat = 0, node_lon = 0; } _prefs;
CachedSensors sensors;
#if MESH_ENABLE_FLEET_CONTROL
bool getFleetLocation(int32_t&, int32_t&) const override;
#endif
unsigned commands = 0;
uint32_t timestamp = 0;
@REPEATER_CAPABILITY@
@@ -112,7 +126,11 @@ class Room : public mesh::Mesh {
Regions region_map;
Region region;
Region* recv_pkt_region = &region;
struct { const char* node_name = "fleet node"; } _prefs;
struct { const char* node_name = "fleet node"; double node_lat = 0, node_lon = 0; } _prefs;
CachedSensors sensors;
#if MESH_ENABLE_FLEET_CONTROL
bool getFleetLocation(int32_t&, int32_t&) const override;
#endif
unsigned commands = 0;
uint32_t timestamp = 0;
@ROOM_CAPABILITY@
@@ -133,6 +151,20 @@ class Room : public mesh::Mesh {
template<typename Role>
void checkRole() {
Role role;
int32_t latitude = 0, longitude = 0;
assert(!role.getFleetLocation(latitude, longitude));
#if MESH_ENABLE_FLEET_CONTROL
assert(latitude == 0 && longitude == 0 && role.sensors.reads == 1);
role.sensors.valid = true;
role._prefs.node_lat = 47.6062; role._prefs.node_lon = -122.3321;
assert(role.getFleetLocation(latitude, longitude));
assert(latitude == 47606200 && longitude == -122332100 && role.sensors.reads == 1);
role._prefs.node_lat = role._prefs.node_lon = 0;
assert(role.getFleetLocation(latitude, longitude));
assert(latitude == 45515200 && longitude == -122678400 && role.sensors.reads == 2);
#else
assert(role.sensors.reads == 0);
#endif
mesh::Packet packet;
TransportKey original, rotated;
memset(original.key, 0x11, sizeof(original.key));
@@ -220,9 +252,14 @@ AUTHORIZATION = r'''
#include <new>
#include <initializer_list>
static unsigned configurations = 0, allocations = 0;
struct RegionMap {};
static RegionMap* enrolled_regions = nullptr;
struct FS { bool present = false; bool exists(const char*) const { return present; } };
namespace mesh {
struct FleetChannel {
explicit FleetChannel(void*, void* = nullptr) { ++allocations; }
explicit FleetChannel(void*, void* = nullptr, RegionMap* regions = nullptr) {
assert(regions); enrolled_regions = regions; ++allocations;
}
bool handleConfig(const char*, char* reply, size_t capacity) {
assert(capacity == 160); ++configurations; strcpy(reply, "OK"); return true;
}
@@ -237,9 +274,12 @@ class CommonCLI {
Callbacks callbacks;
Callbacks* _callbacks = &callbacks;
void* _management_fs = nullptr;
RegionMap regions;
RegionMap* _region_map = &regions;
mesh::FleetChannel* _fleet_channel = nullptr;
~CommonCLI() { delete _fleet_channel; }
bool handleFleetCommand(const char*, char*);
void loadExisting(FS* fs) { @BOOT_FLEET@ }
};
struct ClientInfo {
enum Role { Guest, Admin, RegionManager, FilterManager } role;
@@ -272,7 +312,7 @@ int main() {
dispatch(cli, nullptr, command, reply);
assert(!strcmp(reply, "OK"));
}
assert(allocations == 1 && configurations == 10);
assert(allocations == 1 && configurations == 10 && enrolled_regions == cli._region_map);
assert(isFilterMgrAllowed("set flood.filter.1 group-text 4"));
assert(isRegionMgrAllowed("set flood.channel.scope public #local"));
assert(!isFilterMgrAllowed("set radio2 off"));
@@ -283,6 +323,18 @@ int main() {
assert(strstr(reply, "infrastructure roles"));
assert(allocations == 1 && configurations == 10);
assert(!cli.handleFleetCommand("set fleeter.channel off", reply));
FS disk;
CommonCLI no_existing;
no_existing.loadExisting(&disk);
assert(allocations == 1 && no_existing._fleet_channel == nullptr);
disk.present = true;
CommonCLI existing;
existing.loadExisting(&disk);
assert(allocations == 2 && existing._fleet_channel && enrolled_regions == existing._region_map);
CommonCLI disabled;
disabled.callbacks.supported = false;
disabled.loadExisting(&disk);
assert(allocations == 2 && disabled._fleet_channel == nullptr);
puts("Fleet enrollment remains restricted to local/admin infrastructure dispatch");
}
'''
@@ -301,10 +353,13 @@ class FleetRoleWiringTests(unittest.TestCase):
guard_start = handler.rindex("if (sender && !sender->isAdmin())", 0,
handler.index("mesh::cli::handleACLGet("))
guard = body(handler[guard_start:], "if (sender && !sender->isAdmin())")
common = body((ROOT / "src/helpers/CommonCLI_Management.cpp").read_text(),
"bool CommonCLI::handleFleetCommand(")
management = (ROOT / "src/helpers/CommonCLI_Management.cpp").read_text()
common = body(management, "bool CommonCLI::handleFleetCommand(")
boot = body(body(management, "void CommonCLI::beginManagement("),
"if (_callbacks->supportsFleetControl()")
harness = AUTHORIZATION.replace("@MANAGER_HELPERS@", helpers)
harness = harness.replace("@COMMON_FLEET@", common).replace("@MANAGER_GUARD@", guard)
harness = harness.replace("@BOOT_FLEET@", boot)
with tempfile.TemporaryDirectory(prefix="meshcore-fleet-authorization-") as directory:
work = Path(directory)
source = work / "fixture.cpp"
@@ -332,6 +387,9 @@ class FleetRoleWiringTests(unittest.TestCase):
header = (ROOT / "examples" / folder / "MyMesh.h").read_text()
capabilities[role] = body(header, "bool supportsFleetControl() const override")
methods.append(body(source, "void MyMesh::onGroupPacketRecv(").replace("MyMesh::", role + "::", 1))
methods.append("#if MESH_ENABLE_FLEET_CONTROL\n"
+ body(source, "bool MyMesh::getFleetLocation(").replace("MyMesh::", role + "::", 1)
+ "\n#endif\n")
loop = body(source, "void MyMesh::loop()")
fleet = body(loop, "if (_cli.fleetChannel())")
methods.append("void " + role + "::serviceFleet() {\n#if MESH_ENABLE_FLEET_CONTROL\n"
@@ -362,7 +420,7 @@ class FleetRoleWiringTests(unittest.TestCase):
executable = work / str(enabled)
built = subprocess.run([
compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-isystem", str(ROOT / "test/mocks"),
"-isystem", str(ROOT / "test/mocks"), "-I", str(ROOT / "src"),
"-DMESH_ENABLE_FLEET_CONTROL=" + str(enabled),
str(fixture), "-o", str(executable),
], capture_output=True, text=True, timeout=60)