Target signed fleet commands by region and GPS radius
This commit is contained in:
1 parent
fdad79eaf7
commit
466eb008c7
24 files changed
+2447
-107
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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_; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ®ions)); // Seattle circle.
|
||||
position.latitude_e6 = 47610100; position.longitude_e6 = -122201500;
|
||||
assert(decodedGeo(node, position, decoded, ®ions)); // Bellevue is inside25km.
|
||||
position.latitude_e6 = 45523000; position.longitude_e6 = -122676000;
|
||||
assert(!decodedGeo(node, position, decoded, ®ions)); // Portland is outside.
|
||||
regions.configured = {"sea"};
|
||||
assert(decodedGeo(node, position, decoded, ®ions)); // Configured-region OR.
|
||||
regions.configured.clear(); regions.home_ancestors = {"suburb", "pdx"};
|
||||
assert(decodedGeo(node, position, decoded, ®ions)); // Home-ancestor OR.
|
||||
regions.home_ancestors.clear(); position.available = false;
|
||||
assert(decodedGeo(node, position, decoded, ®ions, 0x42)); // Node-prefix OR.
|
||||
assert(!decodedGeo(node, position, decoded, ®ions));
|
||||
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")
|
||||
|
||||
|
||||
@@ -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,®ions};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")
|
||||
|
||||
|
||||
@@ -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'''
|
||||
|
||||
@@ -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, ®ions);
|
||||
}
|
||||
|
||||
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, ®ions));
|
||||
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, ®ions));
|
||||
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);
|
||||
|
||||
@@ -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, ®ions,
|
||||
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")
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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():
|
||||
|
||||
@@ -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 = ®ion;
|
||||
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 = ®ion;
|
||||
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 = ®ions;
|
||||
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)
|
||||
|
||||
Reference in new issue
Block a user