From 466eb008c7d01cf7003a1f984ea9dcf736f8d778 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 9 Oct 2026 01:32:12 -0700 Subject: [PATCH] Target signed fleet commands by region and GPS radius --- .github/workflows/run-unit-tests.yml | 1 + README.md | 2 +- docs/fleet_management.md | 118 ++++- examples/companion_radio/MyMesh.cpp | 6 +- examples/simple_repeater/MyMesh.cpp | 7 + examples/simple_repeater/MyMesh.h | 1 + examples/simple_room_server/MyMesh.cpp | 9 + examples/simple_room_server/MyMesh.h | 3 + src/helpers/CommonCLI_Management.cpp | 4 +- src/helpers/FleetChannel.cpp | 3 +- src/helpers/FleetChannel.h | 50 +- src/helpers/FleetCommand.cpp | 207 +++++++- src/helpers/FleetCommand.h | 31 +- src/helpers/FleetLocation.h | 29 ++ src/helpers/SensorManager.cpp | 22 +- src/helpers/SensorManager.h | 3 + test/test_companion_fleet_control.py | 455 ++++++++++++++++- test/test_fleet_channel_runtime.py | 380 ++++++++++++++- test/test_fleet_clock_control.py | 3 +- .../test_fleet_command/test_fleet_command.cpp | 459 +++++++++++++++++- test/test_fleet_command_crypto.py | 442 ++++++++++++++++- test/test_fleet_location.py | 243 ++++++++++ test/test_fleet_radio_schedules.py | 4 +- test/test_fleet_role_wiring.py | 72 ++- 24 files changed, 2447 insertions(+), 107 deletions(-) create mode 100644 src/helpers/FleetLocation.h create mode 100644 test/test_fleet_location.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 1d0a4e97..e6858333 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -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 diff --git a/README.md b/README.md index 2f31f859..b960dcbb 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/fleet_management.md b/docs/fleet_management.md index 0f9f02a5..eaaa81e3 100644 --- a/docs/fleet_management.md +++ b/docs/fleet_management.md @@ -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. diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 3dfb36f8..47ec2048 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -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 [all|key[,key...]] "); + "Error: use fleet send [all|key|region:name|home:name|gps:lat,lon:km[;target...]] "); 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; diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 07cf4cf1..72e75f3a 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -10,6 +10,9 @@ #include #include #include +#if MESH_ENABLE_FLEET_CONTROL +#include +#endif #if MESH_ENABLE_TELEMETRY_HISTORY #include #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; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 615a131c..f7e5dd25 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -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_; } diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index f5d63a66..4f9dcbc3 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -12,6 +12,9 @@ #include #endif #include +#if MESH_ENABLE_FLEET_CONTROL +#include +#endif #include #include #include @@ -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); } diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index acf7e6d5..589156d0 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -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); diff --git a/src/helpers/CommonCLI_Management.cpp b/src/helpers/CommonCLI_Management.cpp index 0c442fe1..0ffeb139 100644 --- a/src/helpers/CommonCLI_Management.cpp +++ b/src/helpers/CommonCLI_Management.cpp @@ -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; } diff --git a/src/helpers/FleetChannel.cpp b/src/helpers/FleetChannel.cpp index 06dc3990..adcbae90 100644 --- a/src/helpers/FleetChannel.cpp +++ b/src/helpers/FleetChannel.cpp @@ -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; diff --git a/src/helpers/FleetChannel.h b/src/helpers/FleetChannel.h index 21fc7c59..029afdf3 100644 --- a/src/helpers/FleetChannel.h +++ b/src/helpers/FleetChannel.h @@ -6,6 +6,8 @@ #include #include #include +#include +#include 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(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 @@ -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(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); diff --git a/src/helpers/FleetCommand.cpp b/src/helpers/FleetCommand.cpp index 1d065c3d..ba7301f7 100644 --- a/src/helpers/FleetCommand.cpp +++ b/src/helpers/FleetCommand.cpp @@ -1,5 +1,6 @@ #include "FleetCommand.h" #include +#include #include 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(data + consumed), length)) return false; + broadcast = true; + if (self && matcher && matcher(context, type == FleetCommand::RegionRecordType + ? FleetCommand::RegionTarget::Configured : FleetCommand::RegionTarget::Home, + reinterpret_cast(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; diff --git a/src/helpers/FleetCommand.h b/src/helpers/FleetCommand.h index cad7f97b..f3cad1b5 100644 --- a/src/helpers/FleetCommand.h +++ b/src/helpers/FleetCommand.h @@ -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. diff --git a/src/helpers/FleetLocation.h b/src/helpers/FleetLocation.h new file mode 100644 index 00000000..0153b767 --- /dev/null +++ b/src/helpers/FleetLocation.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +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 +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(lround(latitude * 1000000.0)); + const int32_t lon = static_cast(lround(longitude * 1000000.0)); + if (lat == 0 && lon == 0) return false; + latitude_e6 = lat; + longitude_e6 = lon; + return true; +} + +} // namespace mesh diff --git a/src/helpers/SensorManager.cpp b/src/helpers/SensorManager.cpp index 08c00e73..82f140b2 100644 --- a/src/helpers/SensorManager.cpp +++ b/src/helpers/SensorManager.cpp @@ -1,4 +1,21 @@ #include "SensorManager.h" +#include + +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(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); diff --git a/src/helpers/SensorManager.h b/src/helpers/SensorManager.h index e21d29a8..16004870 100644 --- a/src/helpers/SensorManager.h +++ b/src/helpers/SensorManager.h @@ -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 diff --git a/test/test_companion_fleet_control.py b/test/test_companion_fleet_control.py index 0dfee3b5..c63fe703 100644 --- a/test/test_companion_fleet_control.py +++ b/test/test_companion_fleet_control.py @@ -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 configured; + std::vector 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(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(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{"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{"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") diff --git a/test/test_fleet_channel_runtime.py b/test/test_fleet_channel_runtime.py index c535426b..59d9a5a0 100644 --- a/test/test_fleet_channel_runtime.py +++ b/test/test_fleet_channel_runtime.py @@ -220,6 +220,39 @@ inline Dispatcher::ReceiveProfileScope::~ReceiveProfileScope(){mesh_.scoped_prof } ''' +REGION_MAP = r''' +#pragma once +#include +#include +#include +#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(countid: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& 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& bytes){ + constexpr const char* domain="MeshCoreFleet1";const size_t domain_size=strlen(domain); + assert(bytes.size()>=FleetCommand::SignatureSize); + std::vector 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{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 \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") diff --git a/test/test_fleet_clock_control.py b/test/test_fleet_clock_control.py index fe67fba6..6a2d6c99 100644 --- a/test/test_fleet_clock_control.py +++ b/test/test_fleet_clock_control.py @@ -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 \n', "Arduino.h": '#pragma once\n#include \n', "Adafruit_LittleFS.h": r''' diff --git a/test/test_fleet_command/test_fleet_command.cpp b/test/test_fleet_command/test_fleet_command.cpp index f7b05adb..917a1311 100644 --- a/test/test_fleet_command/test_fleet_command.cpp +++ b/test/test_fleet_command/test_fleet_command.cpp @@ -23,6 +23,38 @@ public: } }; +struct RegionContext { + std::vector configured; + std::vector home; + std::vector> seen; + + static bool match(void* context, Fleet::RegionTarget kind, const char* name, size_t length) { + auto& regions = *static_cast(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(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& 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& 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 encodeTargets(const Fleet::Targets& targets, const char* command = "get radio2") { std::vector 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>{ + {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(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(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 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 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{ + {"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 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 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(part.begin(), part.end()), location)); +} + +TEST_F(FleetCommandTest, ExternalGeographicRecordBoundsRejectBeforeAnyOutputWrite) { + std::array 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 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 output; output.fill(0xA5); @@ -655,8 +1080,8 @@ TEST_F(FleetCommandTest, EveryTargetFormUsesItsExactCommandBudgetWithoutPartialW const std::string prefix = "set flood.rule.1 "; const std::vector> 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); diff --git a/test/test_fleet_command_crypto.py b/test/test_fleet_command_crypto.py index 65a606a6..4ab6df52 100644 --- a/test/test_fleet_command_crypto.py +++ b/test/test_fleet_command_crypto.py @@ -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 configured, home; + static bool match(void* context, mesh::FleetCommand::RegionTarget kind, + const char* name, size_t length) { + const auto& regions = *static_cast(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(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 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(" +#include +#include +#include +#include +#include +#include +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::quiet_NaN(); + const double inf = std::numeric_limits::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{0,0}, {0.0000001,-0.0000001}, + {std::numeric_limits::quiet_NaN(),0}, {0,std::numeric_limits::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{0,0}, {91,0}, {-91,0}, {0,181}, {0,-181}, + {std::numeric_limits::quiet_NaN(),0}, {0,std::numeric_limits::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() diff --git a/test/test_fleet_radio_schedules.py b/test/test_fleet_radio_schedules.py index 40c10e79..1af823b0 100644 --- a/test/test_fleet_radio_schedules.py +++ b/test/test_fleet_radio_schedules.py @@ -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 ','#include \n#include ') 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 \n', "Packet.h":'#pragma once\n#include \n', "Arduino.h":'#pragma once\n#include \n#include \n'}.items(): diff --git a/test/test_fleet_role_wiring.py b/test/test_fleet_role_wiring.py index 59176c30..7dc4b224 100644 --- a/test/test_fleet_role_wiring.py +++ b/test/test_fleet_role_wiring.py @@ -20,6 +20,7 @@ HARNESS = r''' #include #include #include +#include #define MESH_ENABLE_CLOCK_SYNC 0 #define MAX_TKS_ENTRIES 16 static std::vector 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 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 #include 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)