Target signed fleet commands by region and GPS radius

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

No files matched your search

+436 -19
View File
@@ -233,7 +233,64 @@ static std::string fullKey(uint8_t value) {
return hex;
}
static void checkedFragments(MyMesh& node, size_t first_index) {
struct RegionMembership {
std::vector<std::string> configured;
std::vector<std::string> home_ancestors;
};
// Receiver membership is a role-owned read-only callback. These explicit
// memberships isolate sender serialization from actual RegionMap behavior.
static bool regionMatches(void* context, mesh::FleetCommand::RegionTarget target,
const char* name, size_t length) {
assert(context && length > 0 && length <= mesh::FleetCommand::MaxRegionNameLength);
const auto& membership = *static_cast<const RegionMembership*>(context);
const auto& names = target == mesh::FleetCommand::RegionTarget::Configured
? membership.configured : membership.home_ancestors;
for (const auto& candidate : names)
if (candidate.size() == length && !memcmp(candidate.data(), name, length)) return true;
return false;
}
static bool decodedRegion(MyMesh& node, RegionMembership& membership,
mesh::FleetCommand::Decoded& decoded, uint8_t node_prefix = 0x77) {
uint8_t recipient[32]; memset(recipient, node_prefix, sizeof(recipient));
return mesh::FleetCommand::decode(node.self_id, node.channels[1].channel.secret,
node.sent, node.sent_length, node.clock.now, recipient, decoded,
regionMatches, &membership);
}
struct GeoPosition {
int32_t latitude_e6 = 47606200, longitude_e6 = -122332100;
bool available = true;
};
static bool geoMatches(void* context, int32_t latitude_e6, int32_t longitude_e6,
uint32_t radius_meters) {
assert(context);
const auto& position = *static_cast<const GeoPosition*>(context);
return position.available && mesh::FleetCommand::withinRadius(
latitude_e6, longitude_e6, radius_meters, position.latitude_e6, position.longitude_e6);
}
static bool decodedGeo(MyMesh& node, GeoPosition& position,
mesh::FleetCommand::Decoded& decoded,
RegionMembership* regions = nullptr, uint8_t node_prefix = 0x77) {
uint8_t recipient[32]; memset(recipient, node_prefix, sizeof(recipient));
return mesh::FleetCommand::decode(node.self_id, node.channels[1].channel.secret,
node.sent, node.sent_length, node.clock.now, recipient, decoded,
regions ? regionMatches : nullptr, regions, geoMatches, &position);
}
static uint32_t encoded32(const uint8_t* bytes) {
return uint32_t(bytes[0]) | uint32_t(bytes[1]) << 8
| uint32_t(bytes[2]) << 16 | uint32_t(bytes[3]) << 24;
}
static void checkedFragments(MyMesh& node, size_t first_index,
mesh::FleetCommand::RegionMatcher matcher = nullptr,
void* context = nullptr,
mesh::FleetCommand::GeoMatcher geo_matcher = nullptr,
void* geo_context = nullptr) {
assert(node.sent_length > mesh::FleetCommand::MaxPayloadLength);
uint8_t assembled[mesh::FleetCommand::MaxEnvelopeLength] = {};
// Read in reverse order; the immutable part index determines placement.
@@ -258,10 +315,12 @@ static void checkedFragments(MyMesh& node, size_t first_index) {
uint8_t recipient[32]; memset(recipient, 0x42, sizeof(recipient));
mesh::FleetCommand::Decoded decoded;
assert(mesh::FleetCommand::decode(node.self_id, node.channels[1].channel.secret,
assembled, node.sent_length, node.clock.now, recipient, decoded));
assembled, node.sent_length, node.clock.now, recipient, decoded,
matcher, context, geo_matcher, geo_context));
assembled[node.sent_length - 1] ^= 1;
assert(!mesh::FleetCommand::decode(node.self_id, node.channels[1].channel.secret,
assembled, node.sent_length, node.clock.now, recipient, decoded));
assembled, node.sent_length, node.clock.now, recipient, decoded,
matcher, context, geo_matcher, geo_context));
}
static void compactBroadcast(MyMesh& node, const char* command) {
@@ -293,7 +352,7 @@ int main(int argc, char** argv) {
assert(!node.handleCommand("fleet send 1 all set radio2 off", 0, nullptr));
assert(!node.handleCommand("fleetish", 0, reply));
rejected(node, "fleet send 1 all set radio2 off", 1);
rejected(node, "fleet send 1 00112233,445566778899 set radio2 off", 1);
rejected(node, "fleet send 1 00112233;445566778899 set radio2 off", 1);
rejected(node, "fleet", 1);
rejected(node, "fleet send 1 all reboot");
rejected(node, "fleet send 1 all get prv.key");
@@ -352,9 +411,10 @@ int main(int argc, char** argv) {
compactBroadcast(node, "clock sync");
assert(node.queued == 3);
} else if (scenario == "implicit_target_ambiguity") {
for (const char* targets : {"nonsense", "All", "ALL", "all,42424242", "42424242,all",
"allall", "4242424", "424242424", "4242424242424", "GG424242",
"42424242,,51515151", "42424242,", ",42424242", "set", "get", "clock"}) {
for (const char* targets : {"nonsense", "all;42424242", "42424242;all",
"4242424", "424242424", "4242424242424", "GG424242",
"42424242;;51515151", "42424242;", ";42424242", "region:", "home:",
"get", "set", "del", "time", "clock"}) {
rejected(node, (std::string("fleet send 1 ") + targets + " set radio2 off").c_str());
}
rejected(node, ("fleet send 1 " + fullKey(0) + " set radio2 off").c_str());
@@ -387,8 +447,8 @@ int main(int argc, char** argv) {
struct Boundary { std::string targets; size_t command_length; };
const Boundary boundaries[] = {
{"42424242", 225}, {"424242424242", 223}, {fullKey(0x42), 215},
{"42424242,51515151", 220}, {"42424242,515151515151", 218},
{fullKey(0x42) + "," + fullKey(0x51), 196},
{"42424242;51515151", 220}, {"42424242;515151515151", 218},
{fullKey(0x42) + ";" + fullKey(0x51), 196},
};
const std::string prefix = "set flood.filter ";
for (const Boundary& boundary : boundaries) {
@@ -577,9 +637,330 @@ int main(int argc, char** argv) {
rescue_input.input += "suffix\nlast\n";
while (rescue_input.available()) node.checkCLIRescueCmd();
assert(node.rescue_commands == (std::vector<std::string>{"next", "last"}));
} else if (scenario == "region_target_modes") {
struct Selection { const char* token; const char* name; uint8_t type; };
const Selection selections[] = {
{"sea", "sea", 0x20}, {"pdx", "pdx", 0x20}, {"region:sea", "sea", 0x20},
{"#sea", "sea", 0x20}, {"region:#sea", "sea", 0x20},
{"home:sea", "sea", 0x21}, {"home:#sea", "sea", 0x21},
{"$sea", "$sea", 0x20}, {"home:$sea", "$sea", 0x21},
// Bare command verbs are reserved. Explicit selectors and # aliases
// can still select identically named regions without command ambiguity.
{"region:get", "get", 0x20}, {"home:get", "get", 0x21}, {"#get", "get", 0x20},
{"region:clock", "clock", 0x20}, {"home:time", "time", 0x21}, {"ALL", "ALL", 0x20},
{"region:00112233", "00112233", 0x20},
};
for (const auto& selection : selections) {
const std::string cli = std::string("fleet send 1 ") + selection.token + " get radio2";
rejected(node, cli.c_str(), 1);
const unsigned before = node.queued;
assert(node.handleCommand(cli.c_str(), 0, reply));
assert(node.queued == before + 1 && node.sent_policy == 2);
assert(!memcmp(node.sent, "FMC2", 4) && node.sent[12] == 1);
assert(node.sent[13] == selection.type && node.sent[14] == strlen(selection.name));
assert(!memcmp(node.sent + 15, selection.name, strlen(selection.name)));
assert(node.sent_length == 78 + 2 + strlen(selection.name) + strlen("get radio2"));
RegionMembership selected, wrong;
auto& names = selection.type == 0x20 ? selected.configured : selected.home_ancestors;
names.push_back(selection.name);
mesh::FleetCommand::Decoded decoded;
assert(decodedRegion(node, selected, decoded));
assert(decoded.broadcast && !strcmp(decoded.command, "get radio2"));
assert(!decodedRegion(node, wrong, decoded));
// Configured-list and home modes cannot borrow each other's membership.
auto& opposite = selection.type == 0x20 ? wrong.home_ancestors : wrong.configured;
opposite.push_back(selection.name);
assert(!decodedRegion(node, wrong, decoded));
uint8_t recipient[32]; memset(recipient, 0x77, sizeof(recipient));
assert(!mesh::FleetCommand::decode(node.self_id, node.channels[1].channel.secret,
node.sent, node.sent_length, node.clock.now, recipient, decoded));
rejected(node, cli.c_str()); // A successful target consumes this second.
++node.clock.now;
}
// Omitting a target still depends on a complete allowlisted command.
assert(node.handleCommand("fleet send 1 get radio2", 0, reply));
compactBroadcast(node, "get radio2");
++node.clock.now;
rejected(node, "fleet send 1 get clock");
assert(node.handleCommand("fleet send 1 region:get clock", 0, reply));
RegionMembership membership{{"get"}, {}};
mesh::FleetCommand::Decoded decoded;
assert(decodedRegion(node, membership, decoded) && !strcmp(decoded.command, "clock"));
membership.configured.clear();
assert(!decodedRegion(node, membership, decoded)); // An explicitly named verb remains targeted.
rejected(node, "fleet send 1 all get clock"); // It never becomes a new command alias.
} else if (scenario == "region_mixed_targets") {
const std::string cli = "AB|fleet\tsend\t1\tsea;pdx;home:sea;42424242\tset radio2 off";
rejected(node, cli.c_str(), 123);
node.queue_ok = false;
rejected(node, cli.c_str());
node.queue_ok = true;
assert(node.handleCommand(cli.c_str(), 0, reply));
assert(!strncmp(reply, "AB|OK - fleet command queued; seq=", 32));
assert(node.queued == 1 && node.sent_policy == 2 && node.sent[12] == 4);
assert(node.sent_length == 78 + 20 + strlen("set radio2 off"));
mesh::FleetCommand::Decoded decoded;
for (auto membership : {RegionMembership{{"sea"}, {}}, RegionMembership{{"pdx"}, {}},
RegionMembership{{}, {"bel", "sea", "w-wa"}}}) {
assert(decodedRegion(node, membership, decoded));
assert(decoded.broadcast && !strcmp(decoded.command, "set radio2 off"));
}
RegionMembership none{{"Sea", "seattle"}, {"pdx"}};
assert(!decodedRegion(node, none, decoded));
assert(decodedRegion(node, none, decoded, 0x42)); // Selected node prefix is independent.
rejected(node, cli.c_str());
++node.clock.now;
assert(node.handleCommand("fleet send 1 unknown-place clock", 0, reply));
assert(!decodedRegion(node, none, decoded)); // Unknown names never mean all.
} else if (scenario == "region_packet_budgets") {
const std::string name30(30, 'z');
struct Boundary { std::string token; size_t records; RegionMembership membership; };
const Boundary boundaries[] = {
{"sea", 5, {{"sea"}, {}}}, {"home:sea", 5, {{}, {"bel", "sea"}}},
{"region:" + name30, 32, {{name30}, {}}},
{"home:" + name30, 32, {{}, {name30}}},
{"sea;pdx;home:sea;42424242", 20, {{"pdx"}, {}}},
};
const std::string prefix = "set flood.filter ";
for (const auto& boundary : boundaries) {
const size_t maximum = mesh::FleetCommand::MaxCommandLength - boundary.records;
std::string command = prefix + std::string(maximum - prefix.size(), '1');
const std::string cli = "fleet send 1 " + boundary.token + " ";
const size_t before = node.manager.outbound.size();
rejected(node, (cli + command + '1').c_str());
assert(node.handleCommand((cli + command + '1').c_str(), 0, reply));
assert(strstr(reply, "exceed two packets") && node.queued == before);
node.queue_ok = false;
rejected(node, (cli + command).c_str());
node.queue_ok = true;
assert(node.handleCommand((cli + command).c_str(), 0, reply));
assert(node.sent_length == 308 && node.queued == before + 2 && node.sent_policy == 2);
auto membership = boundary.membership;
checkedFragments(node, before, regionMatches, &membership);
mesh::FleetCommand::Decoded decoded;
assert(decodedRegion(node, membership, decoded));
assert(!strcmp(decoded.command, command.c_str()));
++node.clock.now;
// The exact single-packet boundary changes admission to two packets
// when one additional command byte is supplied.
const size_t single = mesh::FleetCommand::SinglePacketMaxCommandLength - boundary.records;
command = prefix + std::string(single - prefix.size(), '1');
const size_t single_before = node.manager.outbound.size();
assert(node.handleCommand((cli + command).c_str(), 0, reply));
assert(node.sent_length == 165 && node.queued == single_before + 1);
assert(decodedRegion(node, membership, decoded));
++node.clock.now;
command += '1';
assert(node.handleCommand((cli + command).c_str(), 0, reply));
assert(node.sent_length == 166 && node.queued == single_before + 3);
checkedFragments(node, single_before + 1, regionMatches, &membership);
++node.clock.now;
}
} else if (scenario == "region_signature_binding") {
const std::string prefix = "set flood.filter ";
for (size_t length : {size_t(20), size_t(83)}) {
const std::string command = prefix + std::string(length - prefix.size(), '1');
const size_t before = node.manager.outbound.size();
assert(node.handleCommand(("fleet send 1 sea " + command).c_str(), 0, reply));
RegionMembership membership{{"sea", "tea"}, {"sea", "tea"}};
mesh::FleetCommand::Decoded decoded;
assert(decodedRegion(node, membership, decoded));
if (length == 83) checkedFragments(node, before, regionMatches, &membership);
// Both the readable name and its configured-vs-home selector are signed.
node.sent[15] = 't';
assert(!decodedRegion(node, membership, decoded));
node.sent[15] = 's';
node.sent[13] = mesh::FleetCommand::HomeRecordType;
assert(!decodedRegion(node, membership, decoded));
node.sent[13] = mesh::FleetCommand::RegionRecordType;
assert(decodedRegion(node, membership, decoded));
++node.clock.now;
}
} else if (scenario == "region_invalid_targets") {
for (const std::string& targets : {std::string("all;sea"), std::string("sea;all"),
std::string("sea;;pdx"), std::string("sea;"), std::string(",sea"),
std::string("region:"), std::string("home:"), std::string("region:#"),
std::string("home:*"), std::string("region:sea:pdx"), std::string("unknown:sea"),
std::string("region:") + std::string(31, 'z'), std::string("home:") + std::string(31, 'z'),
std::string("sea;GG112233"), std::string("sea;0011223"), std::string("sea;001122334"),
std::string("home:sea;0011223344556"), std::string("sea;00112233;44556677889Z"),
std::string("sea;region:\x7f"), std::string("sea;region:\xc3\xa9"),
std::string("sea;") + fullKey(0), std::string("sea;") + std::string(65, '1')}) {
rejected(node, ("fleet send 1 " + targets + " set radio2 off").c_str());
assert(node.queued == 0);
}
assert(node.handleCommand("fleet send 1 sea clock", 0, reply));
assert(node.queued == 1); // No malformed target consumed the successful second.
RegionMembership membership{{"sea"}, {}};
mesh::FleetCommand::Decoded decoded;
assert(decodedRegion(node, membership, decoded) && !strcmp(decoded.command, "clock"));
} else if (scenario == "rescue_region_overflow") {
const std::string oversized = "fleet send 1 sea;pdx;home:sea;" + fullKey(0x42)
+ " set radio2 off";
assert(oversized.size() > sizeof(node.cli_command));
rescue_input.input = oversized + "\r\nfleet send 1 sea clock\r\n";
while (rescue_input.available()) node.checkCLIRescueCmd();
assert(node.rescue_commands == std::vector<std::string>{"fleet send 1 sea clock"});
assert(node.queued == 1 && !node._cli_line_overflow);
assert(rescue_output.output.find("ERR: too long") != std::string::npos);
RegionMembership membership{{"sea"}, {}};
mesh::FleetCommand::Decoded decoded;
assert(decodedRegion(node, membership, decoded));
assert(!strcmp(decoded.command, "clock") && node.sent[12] == 1);
} else if (scenario == "gps_target") {
struct Circle { const char* text; int32_t lat, lon; uint32_t radius; };
const Circle circles[] = {
{"gps:47.6062,-122.3321:25", 47606200, -122332100, 25000},
{"gps:+47.606200,-122.332100:25.125", 47606200, -122332100, 25125},
{"gps:47.6062,-122.3321:0.001", 47606200, -122332100, 1},
};
GeoPosition position;
for (const auto& circle : circles) {
const std::string cli = std::string("fleet send 1 ") + circle.text + " get radio2";
rejected(node, cli.c_str(), 1);
assert(node.handleCommand(cli.c_str(), 0, reply));
assert(node.sent_policy == 2 && node.sent[12] == 1 && node.sent[13] == 0x22);
assert(int32_t(encoded32(node.sent + 14)) == circle.lat);
assert(int32_t(encoded32(node.sent + 18)) == circle.lon);
assert(encoded32(node.sent + 22) == circle.radius);
assert(node.sent_length == 78 + 13 + strlen("get radio2"));
mesh::FleetCommand::Decoded decoded;
assert(decodedGeo(node, position, decoded));
assert(decoded.broadcast && !strcmp(decoded.command, "get radio2"));
position.available = false;
assert(!decodedGeo(node, position, decoded));
position.available = true;
uint8_t recipient[32]; memset(recipient, 0x77, sizeof(recipient));
assert(!mesh::FleetCommand::decode(node.self_id, node.channels[1].channel.secret,
node.sent, node.sent_length, node.clock.now, recipient, decoded));
rejected(node, cli.c_str());
++node.clock.now;
}
} else if (scenario == "gps_mixed_targets") {
const std::string cli = "CD|fleet\tsend\t1\tsea;gps:47.6062,-122.3321:25;home:pdx;42424242\tclock sync";
rejected(node, cli.c_str(), 99);
assert(node.handleCommand(cli.c_str(), 0, reply));
assert(!strncmp(reply, "CD|OK - fleet command queued; seq=", 32));
assert(node.sent_policy == 2 && node.queued == 1 && node.sent[12] == 4);
assert(node.sent_length == 78 + 28 + strlen("clock sync"));
GeoPosition position; RegionMembership regions;
mesh::FleetCommand::Decoded decoded;
assert(decodedGeo(node, position, decoded, &regions)); // Seattle circle.
position.latitude_e6 = 47610100; position.longitude_e6 = -122201500;
assert(decodedGeo(node, position, decoded, &regions)); // Bellevue is inside25km.
position.latitude_e6 = 45523000; position.longitude_e6 = -122676000;
assert(!decodedGeo(node, position, decoded, &regions)); // Portland is outside.
regions.configured = {"sea"};
assert(decodedGeo(node, position, decoded, &regions)); // Configured-region OR.
regions.configured.clear(); regions.home_ancestors = {"suburb", "pdx"};
assert(decodedGeo(node, position, decoded, &regions)); // Home-ancestor OR.
regions.home_ancestors.clear(); position.available = false;
assert(decodedGeo(node, position, decoded, &regions, 0x42)); // Node-prefix OR.
assert(!decodedGeo(node, position, decoded, &regions));
rejected(node, cli.c_str());
} else if (scenario == "gps_packet_budgets") {
struct Boundary { const char* targets; size_t records; };
const Boundary boundaries[] = {
{"gps:47.6062,-122.3321:25", 13},
{"sea;gps:47.6062,-122.3321:25;home:pdx;42424242", 28},
};
const std::string prefix = "set flood.filter ";
GeoPosition position;
for (const auto& boundary : boundaries) {
const std::string cli = std::string("fleet send 1 ") + boundary.targets + " ";
const size_t maximum = mesh::FleetCommand::MaxCommandLength - boundary.records;
std::string command = prefix + std::string(maximum - prefix.size(), '1');
const size_t before = node.manager.outbound.size();
rejected(node, (cli + command + '1').c_str());
assert(node.handleCommand((cli + command + '1').c_str(), 0, reply));
assert(strstr(reply, "exceed two packets") && node.queued == before);
node.fail_queue = node.queue_calls + 2;
rejected(node, (cli + command).c_str());
assert(node.queued == before); // Neither half remains after second admission fails.
node.fail_queue = 0;
assert(node.handleCommand((cli + command).c_str(), 0, reply));
assert(node.sent_length == 308 && node.queued == before + 2 && node.sent_policy == 2);
checkedFragments(node, before, nullptr, nullptr, geoMatches, &position);
mesh::FleetCommand::Decoded decoded;
assert(decodedGeo(node, position, decoded) && !strcmp(decoded.command, command.c_str()));
++node.clock.now;
const size_t single = mesh::FleetCommand::SinglePacketMaxCommandLength - boundary.records;
command = prefix + std::string(single - prefix.size(), '1');
const size_t single_before = node.manager.outbound.size();
assert(node.handleCommand((cli + command).c_str(), 0, reply));
assert(node.sent_length == 165 && node.queued == single_before + 1);
assert(decodedGeo(node, position, decoded));
++node.clock.now; command += '1';
assert(node.handleCommand((cli + command).c_str(), 0, reply));
assert(node.sent_length == 166 && node.queued == single_before + 3);
checkedFragments(node, single_before + 1, nullptr, nullptr, geoMatches, &position);
++node.clock.now;
}
} else if (scenario == "gps_signature_binding") {
const std::string prefix = "set flood.filter "; GeoPosition position;
for (size_t length : {size_t(20), size_t(75)}) {
const std::string command = prefix + std::string(length - prefix.size(), '1');
const size_t before = node.manager.outbound.size();
assert(node.handleCommand(("fleet send 1 gps:47.6062,-122.3321:25 " + command).c_str(), 0, reply));
mesh::FleetCommand::Decoded decoded;
assert(decodedGeo(node, position, decoded));
if (length == 75) checkedFragments(node, before, nullptr, nullptr, geoMatches, &position);
for (size_t field : {size_t(14), size_t(18), size_t(22)}) {
node.sent[field] ^= 1; // Still valid and near the selected receiver.
assert(!decodedGeo(node, position, decoded));
node.sent[field] ^= 1;
assert(decodedGeo(node, position, decoded));
}
++node.clock.now;
}
} else if (scenario == "gps_invalid_targets") {
for (const char* target : {"gps:", "gps:47,-122", "gps:47,-122:", "gps:::-1",
"gps:47:-122:25", "gps:47,,122:25", "gps:47,-122:25;", ";gps:47,-122:25",
"sea;;gps:47,-122:25", "gps:47,-122:25;all", "all;gps:47,-122:25",
"gps:47,-122:25;clock", "gps:47,-122:25;sea;", "sea;gps:47;-122:25",
"gps:47,-122:25:1", "gps:91,-122:25", "gps:-91,-122:25", "gps:47,181:25",
"gps:47,-181:25", "gps:47,-122:0", "gps:47,-122:-1", "gps:47,-122:+1",
"gps:47,-122:0.0001", "gps:47,-122:20050.001", "gps:NaN,-122:25",
"gps:47,inf:25", "gps:47,-122:1e3", "gps:.5,-122:25", "gps:47.,-122:25",
"gps:47.6062001,-122:25", "gps:47,-122.3321001:25", "gps:047,-122:25",
"gps:47,-122:025", "gps:47,-122:429496729600000000000", "gps:47,-122:25,,sea",
"all,gps:47,-122:25", "gps:47,-122:25,all", "gps:47,-122:25,GG112233"}) {
rejected(node, (std::string("fleet send 1 ") + target + " clock").c_str());
assert(node.queued == 0);
}
std::string circles;
for (unsigned i = 0; i < 6; ++i) {
if (i) circles += ';';
circles += "gps:47.6062,-122.3321:25";
}
mesh::FleetCommand::Targets parsed;
assert(mesh::FleetCommand::parseTargets(circles.c_str(), circles.size(), parsed));
assert(parsed.count == 6 && parsed.length == 78);
rejected(node, ("fleet send 1 " + circles + ";gps:47,-122:25 clock").c_str());
assert(node.queued == 0); // Target-record overflow never signs a partial list.
assert(node.handleCommand(("fleet send 1 " + circles + " clock").c_str(), 0, reply));
assert(node.sent_length == 161 && node.queued == 1);
GeoPosition position; mesh::FleetCommand::Decoded decoded;
assert(decodedGeo(node, position, decoded) && !strcmp(decoded.command, "clock"));
} else if (scenario == "comma_target_lists_rejected") {
const std::string comma_lists[] = {
"42424242,515151515151", fullKey(0x42) + "," + fullKey(0x63),
"sea,pdx", "home:sea,home:pdx", "sea;home:pdx,region:sea",
"sea,gps:47.6062,-122.3321:25", "gps:47.6062,-122.3321:25,sea",
"gps:47.6062,-122.3321:25;sea,pdx",
"gps:47.6062,-122.3321:25,gps:45.5,-122.6:25",
};
for (const auto& targets : comma_lists) {
rejected(node, ("fleet send 1 " + targets + " clock").c_str());
assert(node.queued == 0);
}
assert(node.handleCommand("fleet send 1 sea;gps:47.6062,-122.3321:25;home:pdx;42424242 clock", 0, reply));
assert(node.queued == 1 && node.sent[12] == 4);
GeoPosition position; mesh::FleetCommand::Decoded decoded;
assert(decodedGeo(node, position, decoded) && !strcmp(decoded.command, "clock"));
} else if (scenario == "explicit_target_forms") {
for (const std::string& targets : {std::string("42424242"), std::string("424242424242"),
fullKey(0x42), std::string("42424242,515151515151,") + fullKey(0x63)}) {
fullKey(0x42), std::string("42424242;515151515151;") + fullKey(0x63)}) {
assert(node.handleCommand(("fleet send 1 " + targets + " set radio2 off").c_str(), 0, reply));
uint8_t recipient[32]; memset(recipient, 0x42, sizeof(recipient));
mesh::FleetCommand::Decoded decoded;
@@ -661,7 +1042,7 @@ int main(int argc, char** argv) {
assert(!mesh::FleetCommand::decode(node.self_id, node.channels[1].channel.secret,
node.sent, node.sent_length, node.clock.now, recipient, decoded));
} else if (scenario == "mixed_targets") {
const std::string command = "fleet send 1 42424242,515151515151," + fullKey(0x63)
const std::string command = "fleet send 1 42424242;515151515151;" + fullKey(0x63)
+ " set radio2 off";
assert(node.handleCommand(command.c_str(), 0, reply));
assert(node.queued == 1 && node.sent_policy == 2);
@@ -692,7 +1073,7 @@ int main(int argc, char** argv) {
memset(recipient, 0x77, sizeof(recipient));
assert(!matched());
} else if (scenario == "multiple_full_targets") {
const std::string command = "fleet send 1 " + fullKey(0x42) + "," + fullKey(0x63)
const std::string command = "fleet send 1 " + fullKey(0x42) + ";" + fullKey(0x63)
+ " set radio2 off";
assert(node.handleCommand(command.c_str(), 0, reply));
assert(node.queued == 1);
@@ -708,9 +1089,9 @@ int main(int argc, char** argv) {
node.sent, node.sent_length, node.clock.now, recipient, decoded));
}
} else if (scenario == "invalid_target_lists") {
for (const char* targets : {"all,00112233", "00112233,all", "00112233,,44556677",
",00112233", "00112233,", "0011223", "001122334", "0011223344556",
"GG112233", "00112233,44556677889Z"}) {
for (const char* targets : {"all;00112233", "00112233;all", "00112233;;44556677",
";00112233", "00112233;", "0011223", "001122334", "0011223344556",
"GG112233", "00112233;44556677889Z"}) {
rejected(node, (std::string("fleet send 1 ") + targets + " set radio2 off").c_str());
}
assert(node.handleCommand("fleet send 1 00112233 set radio2 off", 0, reply));
@@ -719,7 +1100,7 @@ int main(int argc, char** argv) {
std::string targets;
for (unsigned i = 1; i <= 17; ++i) {
char prefix[9]; snprintf(prefix, sizeof(prefix), "%08x", i);
if (!targets.empty()) targets += ',';
if (!targets.empty()) targets += ';';
targets += prefix;
}
mesh::FleetCommand::Targets parsed;
@@ -731,7 +1112,7 @@ int main(int argc, char** argv) {
assert(node.handleCommand(("fleet send 1 " + targets + " set radio2 off").c_str(), 0, reply));
assert(node.queued == 2); // The valid list now fits one atomic two-packet envelope.
++node.clock.now;
targets += ",44556677"; // More than the entire target-record capacity.
targets += ";44556677"; // More than the entire target-record capacity.
rejected(node, ("fleet send 1 " + targets + " set radio2 off").c_str());
assert(node.queued == 2);
} else if (scenario == "scheduled_sets") {
@@ -804,8 +1185,8 @@ int main(int argc, char** argv) {
assert(node.queued == 1);
} else if (scenario == "schedule_packet_budget") {
const std::string scheduled = "set tempradioat2 910.5,500,5,5,rxtx,+1,+2,auto";
std::string targets = fullKey(0x42) + "," + fullKey(0x51) + ","
+ fullKey(0x63) + "," + fullKey(0x74);
std::string targets = fullKey(0x42) + ";" + fullKey(0x51) + ";"
+ fullKey(0x63) + ";" + fullKey(0x74);
mesh::FleetCommand::Targets parsed;
assert(mesh::FleetCommand::parseTargets(targets.c_str(), targets.size(), parsed));
assert(mesh::FleetCommand::commandAllowed(scheduled.c_str()));
@@ -979,6 +1360,42 @@ class CompanionFleetControlTests(unittest.TestCase):
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_bare_and_explicit_region_targets_preserve_distinct_configured_and_home_modes(self):
self.scenario("region_target_modes")
def test_mixed_regions_home_and_node_prefix_are_signed_without_broadcast_fallback(self):
self.scenario("region_mixed_targets")
def test_region_records_share_exact_one_and_two_packet_budgets_and_allow_safe_retry(self):
self.scenario("region_packet_budgets")
def test_region_name_and_configured_or_home_selector_are_signature_bound(self):
self.scenario("region_signature_binding")
def test_invalid_region_and_keylike_lists_reject_atomically_without_consuming_sequence(self):
self.scenario("region_invalid_targets")
def test_rescue_overflow_never_executes_truncated_region_target_list(self):
self.scenario("rescue_region_overflow")
def test_signed_gps_circle_uses_exact_fixed_point_coordinates_and_requires_location(self):
self.scenario("gps_target")
def test_gps_region_home_and_node_targets_form_one_authorized_or_list(self):
self.scenario("gps_mixed_targets")
def test_gps_exact_packet_budgets_reject_and_roll_back_without_consuming_sequence(self):
self.scenario("gps_packet_budgets")
def test_gps_latitude_longitude_and_radius_are_bound_by_real_signature(self):
self.scenario("gps_signature_binding")
def test_malformed_gps_and_overcapacity_lists_never_sign_or_queue_partial_requests(self):
self.scenario("gps_invalid_targets")
def test_only_semicolons_separate_targets_and_gps_coordinate_comma_is_not_a_list(self):
self.scenario("comma_target_lists_rejected")
def test_explicit_8_12_64_hex_and_list_targets_remain_restricted(self):
self.scenario("explicit_target_forms")
+377 -3
View File
@@ -220,6 +220,39 @@ inline Dispatcher::ReceiveProfileScope::~ReceiveProfileScope(){mesh_.scoped_prof
}
'''
REGION_MAP = r'''
#pragma once
#include <cstdint>
#include <cstring>
#include <cassert>
#define MAX_REGION_ENTRIES 32
#define REGION_DENY_FLOOD 0x01
struct RegionEntry {
uint16_t id=0,parent=0;uint8_t flags=0;char name[31]={};
bool isWildcard()const{return id==0;}
};
class RegionMap {public:
RegionEntry entries[MAX_REGION_ENTRIES]{},wildcard{};
unsigned count=0;uint16_t home_id=0,default_id=0;
RegionEntry* put(const char* name,uint16_t parent=0){
assert(count<MAX_REGION_ENTRIES&&strlen(name)<sizeof(entries[0].name));
RegionEntry& row=entries[count++];row.id=uint16_t(count);row.parent=parent;
strcpy(row.name,name);return &row;
}
int getCount()const{return int(count);}
const RegionEntry* getByIdx(int index)const{return &entries[index];}
RegionEntry* findById(uint16_t id){
if(!id)return &wildcard;for(unsigned i=0;i<count;++i)if(entries[i].id==id)return &entries[i];
return nullptr;
}
RegionEntry* getHomeRegion(){return home_id?findById(home_id):nullptr;}
RegionEntry* getDefaultRegion(){return default_id?findById(default_id):nullptr;}
RegionEntry& getWildcard(){return wildcard;}
void setHomeRegion(const RegionEntry* row){home_id=row?row->id:0;}
void setDefaultRegion(const RegionEntry* row){default_id=row?row->id:0;}
};
'''
PROFILE = r'''
#pragma once
namespace mesh {
@@ -571,8 +604,282 @@ static int fragmentScenario(const std::string& scenario){
}else assert(false);
printf("fleet runtime %s passed\n",scenario.c_str());return 0;
}
static Packet whole(const std::vector<uint8_t>& bytes){
assert(bytes.size()<=FleetCommand::MaxPayloadLength);uint8_t data[168]={};
data[0]=uint8_t(FleetCommand::DataType);data[1]=uint8_t(FleetCommand::DataType>>8);
data[2]=uint8_t(bytes.size());memcpy(data+3,bytes.data(),bytes.size());return raw(data,3+bytes.size());
}
static void resign(const LocalIdentity& publisher,std::vector<uint8_t>& bytes){
constexpr const char* domain="MeshCoreFleet1";const size_t domain_size=strlen(domain);
assert(bytes.size()>=FleetCommand::SignatureSize);
std::vector<uint8_t> message(domain_size+16+bytes.size()-FleetCommand::SignatureSize);
memcpy(message.data(),domain,domain_size);memcpy(message.data()+domain_size,channel_key,16);
memcpy(message.data()+domain_size+16,bytes.data(),bytes.size()-FleetCommand::SignatureSize);
publisher.sign(bytes.data()+bytes.size()-FleetCommand::SignatureSize,message.data(),int(message.size()));
}
struct LocationHooks :FleetReplyHooks {
bool valid=false;int32_t latitude=0,longitude=0;mutable unsigned reads=0;
bool getFleetLocation(int32_t& lat_e6,int32_t& lon_e6)const override {
++reads;if(!valid)return false;lat_e6=latitude;lon_e6=longitude;return true;
}
};
struct RegionFixture {
fs::FS disk;Mesh mesh;RadioProfileCLI profiles;RegionMap regions;LocationHooks location;
FleetChannel fleet{&disk,&location,&regions};LocalIdentity publisher;
std::string expected="get radio2";unsigned calls=0,issued=0;
RegionEntry* usa=nullptr;RegionEntry* sea=nullptr;RegionEntry* pdx=nullptr;RegionEntry* north=nullptr;
RegionFixture(){
usa=regions.put("#usa");sea=regions.put("#sea",usa->id);pdx=regions.put("#pdx",usa->id);
north=regions.put("#north",sea->id);regions.put("seaside");regions.put("$private",usa->id);
regions.setHomeRegion(north);regions.setDefaultRegion(pdx);
enroll(fleet,publisher);bind(fleet,mesh,profiles);
}
uint32_t reserved()const{return storage::readLE32(disk.files.at(Path).data()+56);}
unsigned feed(FleetChannel& receiver,Packet& packet,const TransportKey* scope=nullptr){
fake_ms+=1000;const unsigned before=calls,verifies=verify_calls,writes=disk.writes,reads=location.reads;
const auto saved=disk.files.at(Path);receiver.receive(&packet,mesh,scope);
assert(location.reads==reads);
assert(verify_calls==verifies&&disk.writes==writes&&disk.files.at(Path)==saved);
receiver.service(mesh,profiles,"region:node",[&](uint32_t sequence,const char* text,char* reply){
++calls;assert(reserved()==sequence&&!strcmp(text,expected.c_str()));
assert(mesh.scoped_profile==packet.radio_profile&&mesh.scoped_generation==packet.radio_generation);
assert(profiles.command);strcpy(reply,"OK");
});
assert(!profiles.command&&mesh.scoped_profile==0&&mesh.scoped_generation==0);return calls-before;
}
unsigned feed(Packet& packet,const TransportKey* scope=nullptr){return feed(fleet,packet,scope);}
unsigned send(const std::string& target){
Packet packet=targetsCommand(publisher,mesh.clock.now+issued++,target,expected.c_str());return feed(packet);
}
};
static int regionScenario(const std::string& scenario){
RegionFixture f;
if(scenario=="regions_target_delimiter"){
FleetCommand::Targets parsed;
const auto saved=f.disk.files.at(Path);
for(const char* invalid:{"sea,pdx","sea,,pdx","sea;",";sea","sea;;pdx",
"gps:47.6062:-122.3321:25","gps:47.6062,-122.3321:25,sea"}){
assert(!FleetCommand::parseTargets(invalid,strlen(invalid),parsed));
assert(parsed.count==0&&parsed.length==0);
}
assert(f.disk.files.at(Path)==saved&&f.calls==0&&f.mesh.queued==0);
assert(f.send("sea;pdx;home:sea")==1&&f.calls==1);
}else if(scenario=="regions_list_home"){
f.sea->flags=REGION_DENY_FLOOD;f.pdx->flags=REGION_DENY_FLOOD;
assert(f.send("sea")==1&&f.send("region:pdx")==1); // Configured membership ignores routing flags.
assert(f.send("home:sea")==1&&f.send("home:north")==1&&f.send("home:usa")==1);
assert(f.send("home:pdx")==0&&f.send("home:seaside")==0);
assert(f.send("SEA")==0&&f.send("region:se")==0&&f.send("home:NORTH")==0);
assert(f.send("#sea")==1&&f.send("region:#pdx")==1&&f.send("home:#sea")==1);
assert(last_jitter_max==60500); // Region requests may have multiple matching radios.
}else if(scenario=="regions_private_names"){
assert(f.send("region:$private")==1&&f.send("private")==0);
RegionEntry* private_row=f.regions.findById(6);f.regions.setHomeRegion(private_row);
assert(f.send("home:$private")==1&&f.send("home:private")==0&&f.send("home:usa")==1);
}else if(scenario=="regions_home_not_default_or_scope"){
f.regions.setHomeRegion(f.pdx);f.regions.setDefaultRegion(f.sea);
Packet packet=targetsCommand(f.publisher,f.mesh.clock.now,"home:sea");
TransportKey incoming;memset(incoming.key,0x12,sizeof(incoming.key));
packet.scoped=true;packet.transport_codes[0]=incoming.calcTransportCode(&packet);
const auto before=f.disk.files.at(Path);assert(f.feed(packet,&incoming)==0&&f.disk.files.at(Path)==before);
assert(f.send("home:pdx")==1); // Only home and ancestors qualify, not the default.
f.regions.setHomeRegion(nullptr);f.regions.setDefaultRegion(f.sea);
assert(f.send("home:sea")==0&&f.send("sea")==1);
f.regions.setHomeRegion(&f.regions.getWildcard());strcpy(f.regions.wildcard.name,"#sea");
assert(f.send("home:sea")==0);
f.regions.home_id=99;assert(f.send("home:sea")==0); // Missing home IDs fail closed.
}else if(scenario=="regions_wildcard_and_cycles"){
RegionEntry* wildcard=f.regions.put("wildcard");wildcard->id=0;
assert(f.send("region:wildcard")==0&&f.send("home:wildcard")==0);
f.north->parent=f.sea->id;f.sea->parent=f.north->id;
assert(f.send("home:pdx")==0); // Bounded parent walk cannot hang on a damaged cycle.
}else if(scenario=="regions_damaged_map_bounds"){
const unsigned count=f.regions.count;const auto saved=f.disk.files.at(Path);
f.regions.count=MAX_REGION_ENTRIES+1;
assert(f.send("sea")==0&&f.send("home:sea")==0&&f.disk.files.at(Path)==saved);
f.regions.count=UINT32_MAX;
assert(f.send("sea")==0&&f.send("home:sea")==0&&f.disk.files.at(Path)==saved);
f.regions.count=count;memset(f.sea->name,'G',sizeof(f.sea->name));
assert(f.send(std::string(30,'G'))==0); // An unterminated row cannot alias a truncated name.
f.regions.setHomeRegion(f.sea);assert(f.send("home:"+std::string(30,'G'))==0);
f.sea->parent=99;assert(f.send("home:usa")==0&&f.disk.files.at(Path)==saved);
}else if(scenario=="regions_missing_map"){
FleetChannel no_map(&f.disk);const auto before=f.disk.files.at(Path);
Packet list=targetsCommand(f.publisher,f.mesh.clock.now,"sea");
assert(f.feed(no_map,list)==0&&f.disk.files.at(Path)==before);
Packet home=targetsCommand(f.publisher,f.mesh.clock.now,"home:sea");
assert(f.feed(no_map,home)==0&&f.disk.files.at(Path)==before);
Packet individual=targetsCommand(f.publisher,f.mesh.clock.now,"sea;"+publicHex(f.mesh.self_id));
assert(f.feed(no_map,individual)==1&&f.reserved()==f.mesh.clock.now);
}else if(scenario=="regions_overlap_once"){
const std::string key=publicHex(f.mesh.self_id);
assert(f.send("sea;home:sea;"+key.substr(0,8)+";"+key)==1);
assert(f.calls==1&&f.mesh.queued==1&&last_jitter_max==60500);
assert(config(f.fleet,"get fleet.stats").find("accepted=1")!=std::string::npos);
LocalIdentity another;const auto before=f.disk.files.at(Path);
assert(f.send("home:pdx;"+publicHex(another))==0&&f.disk.files.at(Path)==before);
}else if(scenario=="regions_signature_tamper"){
auto good=envelope(f.publisher,f.mesh.clock.now,"get radio2","sea");
assert(!memcmp(good.data(),"FMC2",4)&&good[12]==1&&good[13]==0x20&&good[14]==3);
const auto saved=f.disk.files.at(Path);auto changed=good;changed[13]=0x21; // Also matches, but signature differs.
Packet bad_type=whole(changed);assert(f.feed(bad_type)==0&&f.disk.files.at(Path)==saved);
changed=good;changed[17]='x';Packet bad_name=whole(changed);
assert(f.feed(bad_name)==0&&f.disk.files.at(Path)==saved&&f.mesh.queued==0);
Packet valid=whole(good);assert(f.feed(valid)==1&&f.calls==1);
}else if(scenario=="regions_malformed_after_match"){
auto good=envelope(f.publisher,f.mesh.clock.now,"get radio2","sea;pdx");
assert(good[12]==2&&good[13]==0x20&&good[18]==0x20&&good[19]==3);
const auto saved=f.disk.files.at(Path);
for(unsigned damage=0;damage<4;++damage){
auto broken=good;
if(damage==0)broken[18]=0x23; // Unknown type after an already-matching record.
else if(damage==1)broken[19]=0;
else if(damage==2)broken[19]=31;
else broken[20]=',';
resign(f.publisher,broken);Packet packet=whole(broken);
assert(f.feed(packet)==0&&f.disk.files.at(Path)==saved&&f.mesh.queued==0);
}
Packet valid=whole(good);assert(f.feed(valid)==1&&f.calls==1);
}else if(scenario=="regions_durable_replay"){
Packet packet=targetsCommand(f.publisher,f.mesh.clock.now,"home:sea");
assert(f.feed(packet)==1);const auto persisted=f.disk.files.at(Path);
assert(persisted.size()==64&&!memcmp(persisted.data(),"FCS1",4));
assert(f.feed(packet)==0&&f.disk.files.at(Path)==persisted);
FleetChannel rebooted(&f.disk,nullptr,&f.regions);assert(f.feed(rebooted,packet)==0);
assert(f.disk.files.at(Path)==persisted&&f.calls==1&&f.mesh.queued==1);
}else if(scenario=="regions_fragments_membership_changes"){
f.expected="set flood.rule.1 any "+std::string(150,' ')+"drop";
auto bytes=envelope(f.publisher,f.mesh.clock.now,f.expected,"home:sea");
assert(bytes.size()>FleetCommand::MaxPayloadLength);Packet first=part(bytes,0),second=part(bytes,1);
const auto saved=f.disk.files.at(Path);unsigned verifies=verify_calls;
assert(f.feed(first)==0&&f.disk.files.at(Path)==saved&&verify_calls==verifies&&f.mesh.queued==0);
f.regions.setHomeRegion(f.pdx);
assert(f.feed(second)==0&&f.disk.files.at(Path)==saved&&f.calls==0&&f.mesh.queued==0);
f.regions.setHomeRegion(f.north); // Rejected nonmember command did not consume its sequence.
assert(f.feed(second)==0&&f.feed(first)==1&&f.reserved()==f.mesh.clock.now);
assert(f.feed(first)==0&&f.feed(second)==0&&f.calls==1);
}else if(scenario=="regions_fragments_tamper"){
f.expected="set flood.rule.1 any "+std::string(150,' ')+"drop";
auto bytes=envelope(f.publisher,f.mesh.clock.now,f.expected,"sea");
Packet first=part(bytes,0),second=part(bytes,1);
const auto saved=f.disk.files.at(Path);assert(f.feed(first)==0);
Packet tampered=changePart(second,[](uint8_t* frame){frame[11+1]^=1;});
assert(f.feed(tampered)==0&&f.calls==0&&f.disk.files.at(Path)==saved&&f.mesh.queued==0);
assert(f.feed(second)==0&&f.feed(first)==1&&f.calls==1);
FleetChannel rebooted(&f.disk,nullptr,&f.regions);
assert(f.feed(rebooted,first)==0&&f.feed(rebooted,second)==0&&f.calls==1);
}else assert(false);
printf("fleet runtime %s passed\n",scenario.c_str());return 0;
}
static constexpr const char* Seattle="gps:47.6062,-122.3321:25";
static int gpsScenario(const std::string& scenario){
RegionFixture f;f.location.valid=true;f.location.latitude=47606200;f.location.longitude=-122332100;
if(scenario=="gps_inside_outside"){
assert(f.send(Seattle)==1&&f.location.reads==1&&last_jitter_max==60500);
f.location.latitude=47700000;f.location.longitude=-122300000;assert(f.send(Seattle)==1);
f.location.latitude=45515200;f.location.longitude=-122678400;
const auto saved=f.disk.files.at(Path);assert(f.send(Seattle)==0&&f.disk.files.at(Path)==saved);
assert(f.send("gps:45.5152,-122.6784:1")==1);
}else if(scenario=="gps_unknown_and_bad_fixes"){
f.location.valid=false;f.location.latitude=0;f.location.longitude=0;
const auto saved=f.disk.files.at(Path);assert(f.send("gps:0,0:10")==0&&f.disk.files.at(Path)==saved);
f.location.valid=true;assert(f.send("gps:0,0:10")==0&&f.disk.files.at(Path)==saved);
f.location.valid=false;
f.location.latitude=47606200;f.location.longitude=-122332100;
assert(f.send(Seattle)==0); // A stale/invalid provider value is never substituted for a known fix.
f.location.valid=true;
for(const auto& coords:{std::pair<int32_t,int32_t>{90000001,0},{-90000001,0},
{0,180000001},{0,-180000001},{INT32_MIN,INT32_MAX}}){
f.location.latitude=coords.first;f.location.longitude=coords.second;
assert(f.send(Seattle)==0&&f.disk.files.at(Path)==saved);
}
FleetChannel no_provider(&f.disk,nullptr,&f.regions);Packet packet=targetsCommand(f.publisher,f.mesh.clock.now,Seattle);
assert(f.feed(no_provider,packet)==0&&f.disk.files.at(Path)==saved&&f.mesh.queued==0);
}else if(scenario=="gps_boundaries_and_globe"){
assert(FleetCommand::withinRadius(0,0,1,0,8));
assert(!FleetCommand::withinRadius(0,0,1,0,10));
assert(FleetCommand::withinRadius(0,0,1000,0,8990));
assert(!FleetCommand::withinRadius(0,0,1000,0,9000));
assert(FleetCommand::withinRadius(90000000,0,1,90000000,180000000));
f.location.latitude=0;f.location.longitude=-179900000;
assert(f.send("gps:0,179.9:25")==1&&f.send("gps:0,179.9:22")==0);
f.location.latitude=89900000;f.location.longitude=180000000;
assert(f.send("gps:89.9,0:25")==1&&f.send("gps:89.9,0:22")==0);
f.location.latitude=90000000;f.location.longitude=180000000;
assert(f.send("gps:90,0:0.001")==1);
}else if(scenario=="gps_mixed_targets_once"){
const std::string key=publicHex(f.mesh.self_id);
assert(f.send(std::string(Seattle)+";sea;home:sea;"+key)==1);
assert(f.calls==1&&f.mesh.queued==1&&last_jitter_max==60500);
assert(f.send(std::string("gps:0,0:1;")+Seattle+";home:pdx")==1);
assert(f.send(key+";"+Seattle)==1); // Internal GPS comma remains distinct from target-list separators.
f.location.valid=false;assert(f.send(std::string(Seattle)+";sea")==1); // OR semantics retain valid region matches.
const auto saved=f.disk.files.at(Path);LocalIdentity stranger;
assert(f.send(std::string(Seattle)+";home:pdx;"+publicHex(stranger))==0&&f.disk.files.at(Path)==saved);
}else if(scenario=="gps_signed_metadata_tamper"){
auto good=envelope(f.publisher,f.mesh.clock.now,f.expected,Seattle);
assert(good[12]==1&&good[13]==FleetCommand::GeoRecordType);
const auto saved=f.disk.files.at(Path);
for(unsigned field:{14U,18U,22U,13U}){
auto altered=good;altered[field]^=1;Packet changed=whole(altered);
assert(f.feed(changed)==0&&f.disk.files.at(Path)==saved&&f.mesh.queued==0);
}
Packet valid=whole(good);assert(f.feed(valid)==1&&f.calls==1);
}else if(scenario=="gps_malformed_after_matching_region"){
auto good=envelope(f.publisher,f.mesh.clock.now,f.expected,std::string("sea;")+Seattle);
assert(good[12]==2&&good[13]==0x20&&good[18]==FleetCommand::GeoRecordType);
const auto saved=f.disk.files.at(Path);
for(unsigned damage=0;damage<5;++damage){
auto broken=good;
if(damage==0)storage::writeLE32(broken.data()+19,90000001);
else if(damage==1)storage::writeLE32(broken.data()+23,180000001);
else if(damage==2)storage::writeLE32(broken.data()+27,0);
else if(damage==3)storage::writeLE32(broken.data()+27,FleetCommand::MaxRadiusMeters+1);
else broken[18]=0x23;
resign(f.publisher,broken);Packet packet=whole(broken);
assert(f.feed(packet)==0&&f.disk.files.at(Path)==saved&&f.mesh.queued==0);
}
Packet valid=whole(good);assert(f.feed(valid)==1&&f.calls==1);
}else if(scenario=="gps_durable_replay"){
Packet packet=targetsCommand(f.publisher,f.mesh.clock.now,Seattle);
assert(f.feed(packet)==1);const auto persisted=f.disk.files.at(Path);
assert(persisted.size()==64&&!memcmp(persisted.data(),"FCS1",4)&&f.reserved()==f.mesh.clock.now);
assert(f.feed(packet)==0&&f.disk.files.at(Path)==persisted);
FleetChannel rebooted(&f.disk,&f.location,&f.regions);
assert(f.feed(rebooted,packet)==0&&f.disk.files.at(Path)==persisted&&f.calls==1&&f.mesh.queued==1);
}else if(scenario=="gps_fragments_fix_changes"){
f.expected="set flood.rule.1 any "+std::string(150,' ')+"drop";
auto bytes=envelope(f.publisher,f.mesh.clock.now,f.expected,Seattle);
assert(bytes.size()>FleetCommand::MaxPayloadLength);Packet first=part(bytes,0),second=part(bytes,1);
const auto saved=f.disk.files.at(Path);const unsigned reads=f.location.reads;
assert(f.feed(first)==0&&f.disk.files.at(Path)==saved&&f.location.reads==reads&&f.mesh.queued==0);
f.location.latitude=45515200;f.location.longitude=-122678400;
assert(f.feed(second)==0&&f.disk.files.at(Path)==saved&&f.calls==0&&f.mesh.queued==0);
f.location.latitude=47606200;f.location.longitude=-122332100;
assert(f.feed(second)==0&&f.feed(first)==1&&f.reserved()==f.mesh.clock.now);
assert(f.feed(first)==0&&f.feed(second)==0&&f.calls==1);
}else if(scenario=="gps_fragments_invalid_final_fix"){
f.expected="set flood.rule.1 any "+std::string(150,' ')+"drop";
auto bytes=envelope(f.publisher,f.mesh.clock.now,f.expected,Seattle);Packet first=part(bytes,0),second=part(bytes,1);
const auto saved=f.disk.files.at(Path);assert(f.feed(second)==0);f.location.valid=false;
assert(f.feed(first)==0&&f.disk.files.at(Path)==saved&&f.calls==0&&f.mesh.queued==0);
f.location.valid=true;
Packet tampered=changePart(second,[](uint8_t* frame){frame[11+1]^=1;});
assert(f.feed(first)==0&&f.feed(tampered)==0&&f.calls==0&&f.disk.files.at(Path)==saved);
assert(f.feed(second)==0&&f.feed(first)==1&&f.calls==1);
FleetChannel rebooted(&f.disk,&f.location,&f.regions);
assert(f.feed(rebooted,first)==0&&f.feed(rebooted,second)==0&&f.calls==1);
}else assert(false);
printf("fleet runtime %s passed\n",scenario.c_str());return 0;
}
int main(int argc,char** argv){
assert(argc==2);const std::string scenario=argv[1];
if(scenario.rfind("gps_",0)==0)return gpsScenario(scenario);
if(scenario.rfind("regions_",0)==0)return regionScenario(scenario);
if(scenario.rfind("fragments_",0)==0)return fragmentScenario(scenario);
fs::FS fs;Mesh mesh;RadioProfileCLI profiles;
FleetChannel fleet(&fs);LocalIdentity publisher;bind(fleet,mesh,profiles);
@@ -856,16 +1163,16 @@ int main(int argc,char** argv){
FleetChannel rebooted(&fs);assert(apply(rebooted,fs,mesh,profiles,packet)==0);
}else if(scenario=="fmc2_overlap_matches_once"){
enroll(fleet,publisher);const std::string mine=publicHex(mesh.self_id);
Packet packet=targetsCommand(publisher,mesh.clock.now,mine.substr(0,8)+","+mine);
Packet packet=targetsCommand(publisher,mesh.clock.now,mine.substr(0,8)+";"+mine);
assert(apply(fleet,fs,mesh,profiles,packet)==1);assert(mesh.queued==1&&last_jitter_max==60500);
assert(config(fleet,"get fleet.stats").find("accepted=1")!=std::string::npos);
}else if(scenario=="fmc2_nonmatching_no_reserve"){
enroll(fleet,publisher);const auto previous=fs.files.at(Path);LocalIdentity stranger;
char different_prefix[9];uint8_t changed[4];memcpy(changed,mesh.self_id.pub_key,4);changed[0]^=1;
Utils::toHex(different_prefix,changed,4);
Packet packet=targetsCommand(publisher,mesh.clock.now,std::string(different_prefix)+","+publicHex(stranger));
Packet packet=targetsCommand(publisher,mesh.clock.now,std::string(different_prefix)+";"+publicHex(stranger));
assert(apply(fleet,fs,mesh,profiles,packet)==0);assert(mesh.queued==0&&fs.files.at(Path)==previous);
packet=targetsCommand(publisher,mesh.clock.now,publicHex(stranger)+","+publicHex(mesh.self_id));
packet=targetsCommand(publisher,mesh.clock.now,publicHex(stranger)+";"+publicHex(mesh.self_id));
assert(apply(fleet,fs,mesh,profiles,packet)==1);assert(last_jitter_max==60500);
}else if(scenario=="fmc2_target_tamper"){
enroll(fleet,publisher);const auto previous=fs.files.at(Path);
@@ -896,6 +1203,7 @@ class FleetChannelRuntimeTests(unittest.TestCase):
(work / "helpers").mkdir()
for name, content in {"Utils.h": UTILS, "FS.h": FILESYSTEM, "Mesh.h": MESH,
"helpers/RadioProfileCLI.h": PROFILE,
"helpers/RegionMap.h": REGION_MAP,
"Packet.h": '#pragma once\n#include <Mesh.h>\n',
"Adafruit_LittleFS.h": '''
#pragma once
@@ -960,6 +1268,72 @@ class SHA256 : public SHA256Base {{ public:
result = subprocess.run([str(binary), name], capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_gps_center_radius_targets_inside_seattle_and_excludes_portland(self):
self.scenario("gps_inside_outside")
def test_gps_unknown_invalid_out_of_range_and_absent_location_providers_fail_closed(self):
self.scenario("gps_unknown_and_bad_fixes")
def test_gps_small_distance_boundary_antimeridian_and_poles_use_spherical_distance(self):
self.scenario("gps_boundaries_and_globe")
def test_gps_region_home_and_node_target_matches_use_or_semantics_and_execute_once(self):
self.scenario("gps_mixed_targets_once")
def test_gps_latitude_longitude_radius_and_record_type_are_signed(self):
self.scenario("gps_signed_metadata_tamper")
def test_signed_malformed_gps_record_after_matching_region_cannot_authorize_execution(self):
self.scenario("gps_malformed_after_matching_region")
def test_gps_command_replay_survives_reboot_with_the_existing_64_byte_storage_format(self):
self.scenario("gps_durable_replay")
def test_two_packet_gps_target_uses_location_at_completion_before_reserving_sequence(self):
self.scenario("gps_fragments_fix_changes")
def test_two_packet_gps_rejects_invalid_final_fix_tampering_and_reboot_replays(self):
self.scenario("gps_fragments_invalid_final_fix")
def test_semicolon_is_the_only_target_separator_with_comma_reserved_for_gps_coordinates(self):
self.scenario("regions_target_delimiter")
def test_existing_configured_regions_and_home_ancestors_are_distinct_exact_targets(self):
self.scenario("regions_list_home")
def test_private_region_dollar_identity_is_preserved_in_list_and_home_targets(self):
self.scenario("regions_private_names")
def test_default_received_scope_and_missing_or_wildcard_home_never_grant_membership(self):
self.scenario("regions_home_not_default_or_scope")
def test_wildcard_rows_and_damaged_parent_cycles_fail_closed_without_hanging(self):
self.scenario("regions_wildcard_and_cycles")
def test_invalid_map_counts_unterminated_names_and_missing_parent_ids_fail_closed(self):
self.scenario("regions_damaged_map_bounds")
def test_receiver_without_region_map_refuses_regions_but_can_still_match_node_keys(self):
self.scenario("regions_missing_map")
def test_mixed_list_home_and_node_targets_execute_once_with_nonmembers_unreserved(self):
self.scenario("regions_overlap_once")
def test_region_name_and_mode_are_bound_to_the_real_publisher_signature(self):
self.scenario("regions_signature_tamper")
def test_signed_malformed_trailing_region_record_rejects_despite_prior_matching_region(self):
self.scenario("regions_malformed_after_match")
def test_region_target_replay_survives_reboot_without_changing_existing_storage_format(self):
self.scenario("regions_durable_replay")
def test_region_membership_is_rechecked_when_second_fragment_arrives_before_reservation(self):
self.scenario("regions_fragments_membership_changes")
def test_region_fragment_tampering_and_reboot_replay_never_repeat_execution(self):
self.scenario("regions_fragments_tamper")
def test_private_channel_and_public_only_publisher_enrollment(self):
self.scenario("enrollment")
+2 -1
View File
@@ -15,7 +15,7 @@ import tempfile
import unittest
from cpp_source import body
from test_fleet_channel_runtime import FILESYSTEM, HARNESS as RUNTIME, MESH, PROFILE, UTILS
from test_fleet_channel_runtime import FILESYSTEM, HARNESS as RUNTIME, MESH, PROFILE, REGION_MAP, UTILS
ROOT = Path(__file__).resolve().parents[1]
@@ -243,6 +243,7 @@ class FleetClockControlTests(unittest.TestCase):
for name, content in {
"Utils.h": UTILS, "FS.h": FILESYSTEM, "Mesh.h": mesh,
"helpers/RadioProfileCLI.h": PROFILE,
"helpers/RegionMap.h": REGION_MAP,
"Packet.h": '#pragma once\n#include <Mesh.h>\n',
"Arduino.h": '#pragma once\n#include <Mesh.h>\n',
"Adafruit_LittleFS.h": r'''
+442 -17
View File
@@ -23,6 +23,38 @@ public:
}
};
struct RegionContext {
std::vector<std::string> configured;
std::vector<std::string> home;
std::vector<std::pair<Fleet::RegionTarget, std::string>> seen;
static bool match(void* context, Fleet::RegionTarget kind, const char* name, size_t length) {
auto& regions = *static_cast<RegionContext*>(context);
const std::string label(name, length);
regions.seen.emplace_back(kind, label);
const auto& names = kind == Fleet::RegionTarget::Configured ? regions.configured : regions.home;
return std::find(names.begin(), names.end(), label) != names.end();
}
};
struct GeoContext {
bool known = true;
int32_t latitude_e6 = 47606200, longitude_e6 = -122332100;
unsigned calls = 0;
int32_t received_latitude = 0, received_longitude = 0;
uint32_t received_radius = 0;
static bool match(void* context, int32_t latitude, int32_t longitude, uint32_t radius) {
auto& location = *static_cast<GeoContext*>(context);
++location.calls;
location.received_latitude = latitude;
location.received_longitude = longitude;
location.received_radius = radius;
return location.known && Fleet::withinRadius(latitude, longitude, radius,
location.latitude_e6, location.longitude_e6);
}
};
class FleetCommandTest : public ::testing::Test {
protected:
FixedRng rng;
@@ -50,6 +82,18 @@ protected:
now, publisher.pub_key, decoded);
}
bool decodeRegions(const std::vector<uint8_t>& bytes, RegionContext& regions) {
return Fleet::decode(publisher, ChannelKey, bytes.data(), bytes.size(), Now,
publisher.pub_key, decoded, RegionContext::match, &regions);
}
bool decodeGeo(const std::vector<uint8_t>& bytes, GeoContext& location,
RegionContext* regions = nullptr) {
return Fleet::decode(publisher, ChannelKey, bytes.data(), bytes.size(), Now,
publisher.pub_key, decoded, regions ? RegionContext::match : nullptr,
regions, GeoContext::match, &location);
}
std::vector<uint8_t> encodeTargets(const Fleet::Targets& targets,
const char* command = "get radio2") {
std::vector<uint8_t> bytes(Fleet::MaxEnvelopeLength);
@@ -395,7 +439,7 @@ TEST_F(FleetCommandTest, UnixRangeBoundaryDoesNotWrap) {
TEST_F(FleetCommandTest, BoundedTargetTokenParsesMixedPrefixWidthsAndCompleteKeys) {
Fleet::Targets targets{};
const std::string full = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
const std::string token = "A1B2C3D4,010203040506," + full;
const std::string token = "A1B2C3D4;010203040506;" + full;
const std::string joined = token + " get radio2";
ASSERT_TRUE(Fleet::parseTargets(joined.data(), token.size(), targets));
EXPECT_EQ(3, targets.count);
@@ -419,14 +463,14 @@ TEST_F(FleetCommandTest, BoundedTargetTokenParsesMixedPrefixWidthsAndCompleteKey
TEST_F(FleetCommandTest, RejectsMalformedWholeTargetListsWithoutPartialResults) {
for (const std::string& token : {
std::string(""), std::string("all,01020304"), std::string("01020304,all"),
std::string("all,all"), std::string("ALL"), std::string(",01020304"),
std::string("01020304,"), std::string("01020304,,05060708"),
std::string("01020304,0506070g"), std::string("01020304 05060708"),
std::string("01020304, 05060708"), std::string("01020304\n"),
std::string(""), std::string("all;01020304"), std::string("01020304;all"),
std::string("all;all"), std::string(";01020304"),
std::string("01020304;"), std::string("01020304;;05060708"),
std::string("01020304;0506070g"), std::string("01020304 05060708"),
std::string("01020304; 05060708"), std::string("01020304\n"),
std::string("01"), std::string(10, '1'), std::string(16, '1'),
std::string(32, '1'), std::string(63, '1'), std::string(65, '1'),
std::string(64, '0'), std::string("01020304,") + std::string(64, '0')}) {
std::string(64, '0'), std::string("01020304;") + std::string(64, '0')}) {
Fleet::Targets targets;
memset(&targets, 0xA5, sizeof(targets));
EXPECT_FALSE(Fleet::parseTargets(token.data(), token.size(), targets)) << token;
@@ -442,7 +486,7 @@ TEST_F(FleetCommandTest, RejectsMalformedWholeTargetListsWithoutPartialResults)
TEST_F(FleetCommandTest, TargetRecordAndPayloadBudgetsAreSeparateAndBounded) {
std::string list;
for (unsigned count = 1; count <= 17; ++count) {
if (!list.empty()) list += ',';
if (!list.empty()) list += ';';
list += "01020304";
Fleet::Targets targets{};
ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets));
@@ -451,18 +495,18 @@ TEST_F(FleetCommandTest, TargetRecordAndPayloadBudgetsAreSeparateAndBounded) {
EXPECT_FALSE(encodeTargets(targets).empty()) << count;
}
Fleet::Targets targets{};
list += ",01020304";
list += ";01020304";
EXPECT_FALSE(Fleet::parseTargets(list.data(), list.size(), targets));
list.clear();
const std::string full = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
for (unsigned count = 1; count <= 5; ++count) {
if (!list.empty()) list += ',';
if (!list.empty()) list += ';';
list += full;
ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets));
EXPECT_EQ(count * 17, targets.length);
}
EXPECT_FALSE(encodeTargets(targets).empty());
list += ',' + full;
list += ';' + full;
EXPECT_FALSE(Fleet::parseTargets(list.data(), list.size(), targets));
}
@@ -551,7 +595,7 @@ TEST_F(FleetCommandTest, PrefixesAndListsUseFmc2WithSignedFraming) {
EXPECT_TRUE(decode(bytes));
EXPECT_TRUE(decoded.broadcast);
}
const std::string list = std::string(full) + ',' + full;
const std::string list = std::string(full) + ';' + full;
Fleet::Targets targets{};
ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets));
const auto bytes = encodeTargets(targets);
@@ -565,7 +609,7 @@ TEST_F(FleetCommandTest, AnyMatchingEntryIsAcceptedButMalformedLaterEntryIsRejec
mesh::Utils::toHex(full, publisher.pub_key, PUB_KEY_SIZE);
const std::string prefix(full, 8);
Fleet::Targets targets{};
const std::string list = prefix + ",01020304," + full;
const std::string list = prefix + ";01020304;" + full;
ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets));
auto bytes = encodeTargets(targets);
ASSERT_TRUE(decode(bytes));
@@ -613,6 +657,387 @@ TEST_F(FleetCommandTest, RawPrefixesIncludeLegitimateZeroPrefixesAndCollisions)
Now, first, decoded));
}
TEST_F(FleetCommandTest, NamedRegionAndHomeRecordsPreserveCaseAndCanonicalHashAlias) {
const std::string list = "sea;pdx;#Sea;region:all;region:dead;region:zzzzzzzz;home:$Private";
Fleet::Targets targets{};
ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets));
ASSERT_EQ(7, targets.count);
size_t cursor = 0;
for (const auto& item : std::vector<std::pair<uint8_t, std::string>>{
{Fleet::RegionRecordType, "sea"}, {Fleet::RegionRecordType, "pdx"},
{Fleet::RegionRecordType, "Sea"}, {Fleet::RegionRecordType, "all"},
{Fleet::RegionRecordType, "dead"}, {Fleet::RegionRecordType, "zzzzzzzz"},
{Fleet::HomeRecordType, "$Private"}}) {
EXPECT_EQ(item.first, targets.data[cursor++]);
EXPECT_EQ(item.second.size(), targets.data[cursor++]);
EXPECT_EQ(item.second, std::string(reinterpret_cast<char*>(targets.data + cursor), item.second.size()));
cursor += item.second.size();
}
EXPECT_EQ(cursor, targets.length);
Fleet::Targets canonical{}, alias{};
ASSERT_TRUE(Fleet::parseTargets("home:#sea", 9, alias));
ASSERT_TRUE(Fleet::parseTargets("home:sea", 8, canonical));
EXPECT_EQ(encodeTargets(canonical), encodeTargets(alias));
ASSERT_TRUE(Fleet::parseTargets("ALL", 3, targets));
EXPECT_EQ(Fleet::RegionRecordType, targets.data[0]);
EXPECT_EQ("ALL", std::string(reinterpret_cast<char*>(targets.data + 2), 3));
}
TEST_F(FleetCommandTest, MalformedKeysNeverFallBackToNamedRegionTargets) {
for (const std::string& token : {
std::string("zzzzzzzz"), std::string("not-a-key-id"), std::string(12, 'z'),
std::string(64, 'g'), std::string("a"), std::string("dead"), std::string(16, 'a'),
std::string("region:"), std::string("home:"), std::string("#"), std::string("region:#"),
std::string("region:*"), std::string("home:*"), std::string("region:with.space"),
std::string("region:with space"), std::string("region:with:colon"),
std::string("region:bad\x7f"), std::string("region:bad\x80"),
std::string("region:bad\n"), std::string("sea;all"), std::string("all;sea"),
std::string("get"), std::string("set"), std::string("del"), std::string("time"), std::string("clock"),
std::string("region:") + std::string(31, 'z'), std::string("home:") + std::string(31, 'z')}) {
Fleet::Targets targets;
memset(&targets, 0xA5, sizeof(targets));
EXPECT_FALSE(Fleet::parseTargets(token.data(), token.size(), targets)) << token;
EXPECT_EQ(0, targets.length);
EXPECT_EQ(0, targets.count);
}
for (const char* token : {"region:a", "region:dead", "region:zzzzzzzz", "region:all",
"home:a", "home:zzzzzzzz", "home:all", "region:get", "home:get", "#get",
"region:set", "home:del", "region:time", "home:clock"}) {
Fleet::Targets targets{};
EXPECT_TRUE(Fleet::parseTargets(token, strlen(token), targets)) << token;
}
}
TEST_F(FleetCommandTest, NamedTargetsRequireExactReadOnlyMatchingAndRemainBroadcast) {
Fleet::Targets targets{};
ASSERT_TRUE(Fleet::parseTargets("sea", 3, targets));
const auto bytes = encodeTargets(targets);
ASSERT_FALSE(bytes.empty());
EXPECT_FALSE(decode(bytes)); // A role without RegionMap matching grants no named capability.
for (const char* label : {"sea", "Sea", "se", "seattle", "$sea", "pdx"}) {
RegionContext regions{{label}, {"sea"}, {}};
EXPECT_EQ(!strcmp(label, "sea"), decodeRegions(bytes, regions)) << label;
if (!strcmp(label, "sea")) EXPECT_TRUE(decoded.broadcast);
else EXPECT_FALSE(decoded.broadcast);
ASSERT_EQ(1U, regions.seen.size());
EXPECT_EQ(Fleet::RegionTarget::Configured, regions.seen[0].first);
EXPECT_EQ("sea", regions.seen[0].second);
}
ASSERT_TRUE(Fleet::parseTargets("home:#sea", 9, targets));
const auto home = encodeTargets(targets);
RegionContext configured_only{{"sea"}, {}, {}};
EXPECT_FALSE(decodeRegions(home, configured_only));
RegionContext home_only{{}, {"sea"}, {}};
EXPECT_TRUE(decodeRegions(home, home_only));
EXPECT_TRUE(decoded.broadcast);
EXPECT_EQ(Fleet::RegionTarget::Home, home_only.seen[0].first);
EXPECT_FALSE(Fleet::parseTargets("$Private", 8, targets)); // Raw key-length tokens are never labels.
ASSERT_TRUE(Fleet::parseTargets("region:$Private", 15, targets));
RegionContext private_name{{"$Private"}, {}, {}};
EXPECT_TRUE(decodeRegions(encodeTargets(targets), private_name));
}
TEST_F(FleetCommandTest, MixedHomeRegionsAndPublicKeysMatchOnceButValidateAllRecords) {
char full[PUB_KEY_SIZE * 2 + 1];
mesh::Utils::toHex(full, publisher.pub_key, PUB_KEY_SIZE);
const std::string list = "sea;home:pdx;" + std::string(full, 8) + ";region:$fleet";
Fleet::Targets targets{};
ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets));
const auto bytes = encodeTargets(targets);
EXPECT_TRUE(decode(bytes)); // The exact key prefix remains sufficient without a region matcher.
EXPECT_TRUE(decoded.broadcast);
RegionContext regions{{"sea", "$fleet"}, {"pdx"}, {}};
ASSERT_TRUE(decodeRegions(bytes, regions));
EXPECT_EQ(3U, regions.seen.size());
EXPECT_STREQ("get radio2", decoded.command);
auto malformed = bytes;
const size_t final_name = 13 + 5 + 5 + 5 + 2;
malformed[final_name] = '*'; // Invalid final label after a matching region and key.
EXPECT_FALSE(decodeRegions(malformed, regions));
EXPECT_FALSE(decoded.broadcast);
g_mock_ed25519_verify_result = false;
EXPECT_FALSE(decodeRegions(bytes, regions));
EXPECT_FALSE(decoded.broadcast);
}
TEST_F(FleetCommandTest, NamedTargetCountsAndBytesConsumeTheSameBoundedBudget) {
std::string list;
Fleet::Targets targets{};
for (unsigned count = 1; count <= 17; ++count) {
if (!list.empty()) list += ';';
list += "sea";
ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets));
EXPECT_EQ(count * 5, targets.length);
}
list += ";sea";
EXPECT_FALSE(Fleet::parseTargets(list.data(), list.size(), targets));
list = "region:" + std::string(30, 'z') + ";home:" + std::string(30, 'Y')
+ ";region:" + std::string(20, 'x');
ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets));
EXPECT_EQ(Fleet::MaxTargetBytes, targets.length);
const std::string prefix = "set flood.rule.1 ";
const std::string command = prefix + std::string(Fleet::MaxCommandLength - targets.length - prefix.size(), 'a');
const auto bytes = encodeTargets(targets, command.c_str());
ASSERT_EQ(Fleet::MaxEnvelopeLength, bytes.size());
RegionContext regions{{std::string(30, 'z')}, {}, {}};
EXPECT_TRUE(decodeRegions(bytes, regions));
EXPECT_TRUE(decoded.broadcast);
EXPECT_TRUE(encodeTargets(targets, (command + 'a').c_str()).empty());
std::array<uint8_t, Fleet::MaxPayloadLength> first{}, second{};
ASSERT_EQ(first.size(), Fleet::fragment(bytes.data(), bytes.size(), 0, first.data(), first.size()));
ASSERT_EQ(second.size(), Fleet::fragment(bytes.data(), bytes.size(), 1, second.data(), second.size()));
Fleet::Fragment part{};
ASSERT_TRUE(Fleet::parseFragment(second.data(), second.size(), part));
EXPECT_EQ(154U, part.length);
list += 'x';
EXPECT_FALSE(Fleet::parseTargets(list.data(), list.size(), targets));
EXPECT_EQ(0, targets.length);
}
TEST_F(FleetCommandTest, InvalidExternalNamedRecordsNeverWriteOutput) {
std::array<uint8_t, Fleet::MaxEnvelopeLength> output;
output.fill(0xA5);
for (unsigned invalid = 0; invalid < 8; ++invalid) {
Fleet::Targets targets{};
targets.count = 1;
targets.length = 5;
targets.data[0] = Fleet::RegionRecordType;
targets.data[1] = 3;
memcpy(targets.data + 2, "sea", 3);
if (invalid == 0) targets.data[1] = 0;
if (invalid == 1) targets.data[1] = 31;
if (invalid == 2) targets.data[2] = '*';
if (invalid == 3) targets.data[2] = 0;
if (invalid == 4) targets.data[2] = 0x80;
if (invalid == 5) targets.data[2] = ';';
if (invalid == 6) targets.length = 1;
if (invalid == 7) targets.data[0] = 0x22;
EXPECT_EQ(0U, Fleet::encode(publisher, ChannelKey, Now, Now + 120,
targets, "get radio2", output.data(), output.size()));
for (uint8_t byte : output) EXPECT_EQ(0xA5, byte);
}
}
TEST_F(FleetCommandTest, GpsTargetsUseExactSignedMicrodegreesAndMetres) {
struct Case { const char* token; int32_t latitude, longitude; uint32_t radius; };
for (const auto& item : std::vector<Case>{
{"gps:47.6062,-122.3321:10", 47606200, -122332100, 10000},
{"gps:+90,-180:0.001", 90000000, -180000000, 1},
{"gps:-90,+180:20050.000", -90000000, 180000000, 20050000},
{"gps:0.000001,-0.000001:1.001", 1, -1, 1001},
{"gps:0,0:1", 0, 0, 1000}}) {
SCOPED_TRACE(item.token);
Fleet::Targets targets{};
ASSERT_TRUE(Fleet::parseTargets(item.token, strlen(item.token), targets));
ASSERT_EQ(1, targets.count);
ASSERT_EQ(Fleet::GeoRecordLength, targets.length);
EXPECT_EQ(Fleet::GeoRecordType, targets.data[0]);
const uint32_t values[] = {uint32_t(item.latitude), uint32_t(item.longitude), item.radius};
for (unsigned field = 0; field < 3; ++field)
for (unsigned byte = 0; byte < 4; ++byte)
EXPECT_EQ(uint8_t(values[field] >> (byte * 8)), targets.data[1 + field * 4 + byte]);
EXPECT_FALSE(decode(encodeTargets(targets))); // No location provider means no geographic match.
GeoContext location;
location.latitude_e6 = item.latitude; location.longitude_e6 = item.longitude;
ASSERT_TRUE(decodeGeo(encodeTargets(targets), location));
EXPECT_TRUE(decoded.broadcast);
EXPECT_EQ(item.latitude, location.received_latitude);
EXPECT_EQ(item.longitude, location.received_longitude);
EXPECT_EQ(item.radius, location.received_radius);
}
}
TEST_F(FleetCommandTest, SemicolonsSeparateTargetsAndGpsOwnsOnlyItsCoordinateComma) {
const std::string gps = "gps:47.6062,-122.3321:25";
for (const std::string& token : {gps + ";sea;home:pdx", "sea;" + gps + ";home:pdx",
"sea;home:pdx;" + gps}) {
SCOPED_TRACE(token);
Fleet::Targets targets{};
const std::string input = token + " get radio2";
ASSERT_TRUE(Fleet::parseTargets(input.data(), token.size(), targets));
EXPECT_EQ(3, targets.count);
EXPECT_EQ(23, targets.length);
GeoContext location;
RegionContext regions{{"sea"}, {"pdx"}, {}};
ASSERT_TRUE(decodeGeo(encodeTargets(targets), location, &regions));
EXPECT_EQ(1U, location.calls);
EXPECT_EQ(25000U, location.received_radius);
EXPECT_EQ(2U, regions.seen.size());
EXPECT_TRUE(decoded.broadcast);
}
// The explicit substring bound must hold even without a terminating NUL.
const std::vector<char> nonterminated(gps.begin(), gps.end());
for (size_t length = 4; length <= nonterminated.size(); ++length) {
Fleet::Targets targets;
memset(&targets, 0xA5, sizeof(targets));
const bool complete_radius = length == gps.size() || length == gps.size() - 1;
EXPECT_EQ(complete_radius,
Fleet::parseTargets(nonterminated.data(), length, targets)) << length;
if (!complete_radius) {
EXPECT_EQ(0, targets.count);
EXPECT_EQ(0, targets.length);
}
}
}
TEST_F(FleetCommandTest, OldCommaListsAndMalformedGpsSeparatorsRejectTheEntireList) {
for (const char* token : {
"sea,pdx", "01020304,05060708", "region:sea,home:pdx", "sea;home:pdx,sea",
"gps:47.6062:-122.3321:25", "gps:47.6062;-122.3321:25",
"gps:47.6062,-122.3321,25", "gps:47.6062,,-122.3321:25",
"gps:47.6062,-122.3321,:25", "gps:47.6062,-122.3321:25,sea",
";gps:47.6062,-122.3321:25", "gps:47.6062,-122.3321:25;",
"sea;;gps:47.6062,-122.3321:25", "sea;gps:47.6062,-122.3321:25;;home:pdx",
"sea;gps:47.6062;home:pdx", "sea;gps:47.6062,-122.3321;home:pdx",
"sea;gps:47.6062,-122.3321:;home:pdx", "sea;gps:47.6062,-122.3321:25;home:pdx,sea"}) {
SCOPED_TRACE(token);
Fleet::Targets targets;
memset(&targets, 0xA5, sizeof(targets));
EXPECT_FALSE(Fleet::parseTargets(token, strlen(token), targets));
EXPECT_EQ(0, targets.count);
EXPECT_EQ(0, targets.length);
for (uint8_t byte : targets.data) EXPECT_EQ(0, byte);
}
}
TEST_F(FleetCommandTest, SemicolonMixedGpsListsEnforceRecordAndByteBudgets) {
std::string list = "gps:47.6062,-122.3321:25";
Fleet::Targets targets{};
for (unsigned count = 2; count <= 17; ++count) {
list += ";z";
ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets));
EXPECT_EQ(count, targets.count);
EXPECT_EQ(13 + (count - 1) * 3, targets.length);
}
list += ";z";
EXPECT_FALSE(Fleet::parseTargets(list.data(), list.size(), targets));
EXPECT_EQ(0, targets.count);
list = "gps:47.6062,-122.3321:25";
for (unsigned count = 0; count < 14; ++count) list += ";sea";
ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets));
EXPECT_EQ(83, targets.length);
list += ";sea";
EXPECT_FALSE(Fleet::parseTargets(list.data(), list.size(), targets));
EXPECT_EQ(0, targets.length);
}
TEST_F(FleetCommandTest, GpsDecimalGrammarAndBoundsRejectWithoutPartialTargets) {
for (const char* token : {
"gps:", "gps:0", "gps:0:0", "gps:0,0:", "gps:,0:1", "gps:0,:1",
"gps:0,0:1:2", "gps:0,0:0", "gps:0,0:0.000", "gps:0,0:0.0001",
"gps:0,0:20050.001", "gps:90.000001,0:1", "gps:-90.000001,0:1",
"gps:0,180.000001:1", "gps:0,-180.000001:1", "gps:0.0000001,0:1",
"gps:0,0.0000001:1", "gps:0,0:1.0001", "gps:0,0:+1", "gps:0,0:-1",
"gps:NaN,0:1", "gps:0,Inf:1", "gps:0,0:1e3", "gps:1e1,0:1",
"gps:.1,0:1", "gps:1.,0:1", "gps:0,0:.1", "gps:0,0:1.",
"gps:01,0:1", "gps:0,00:1", "gps:0,0:01", "gps:--1,0:1",
"gps:++1,0:1", "gps:+,0:1", "gps:0,0:4294967296", "gps:999999999999999999,0:1",
"gps:0,0:1m", "gps:0,0:1\n", "gps: 0:0:1", "gps:0,0:1;all"}) {
Fleet::Targets targets;
memset(&targets, 0xA5, sizeof(targets));
EXPECT_FALSE(Fleet::parseTargets(token, strlen(token), targets)) << token;
EXPECT_EQ(0, targets.count);
EXPECT_EQ(0, targets.length);
}
}
TEST_F(FleetCommandTest, GeographicCircleIsDefensiveAtPolesDatelineAndAntipodes) {
EXPECT_TRUE(Fleet::withinRadius(0, 0, 1, 0, 0));
EXPECT_FALSE(Fleet::withinRadius(0, 0, 0, 0, 0));
EXPECT_FALSE(Fleet::withinRadius(0, 0, Fleet::MaxRadiusMeters + 1, 0, 0));
EXPECT_FALSE(Fleet::withinRadius(90000001, 0, 1, 0, 0));
EXPECT_FALSE(Fleet::withinRadius(0, 180000001, 1, 0, 0));
EXPECT_FALSE(Fleet::withinRadius(0, 0, 1, -90000001, 0));
EXPECT_FALSE(Fleet::withinRadius(0, 0, 1, 0, -180000001));
EXPECT_FALSE(Fleet::withinRadius(0, 0, 111195, 0, 1000000));
EXPECT_TRUE(Fleet::withinRadius(0, 0, 111196, 0, 1000000));
EXPECT_TRUE(Fleet::withinRadius(0, 179999999, 1, 0, -179999999));
EXPECT_TRUE(Fleet::withinRadius(90000000, 0, 1, 90000000, 180000000));
EXPECT_FALSE(Fleet::withinRadius(0, 0, 20015114, 0, 180000000));
EXPECT_TRUE(Fleet::withinRadius(0, 0, 20015115, 0, 180000000));
EXPECT_TRUE(Fleet::withinRadius(47606200, -122332100, 10000, 47610000, -122330000));
EXPECT_FALSE(Fleet::withinRadius(47606200, -122332100, 10000, 45515200, -122678400));
}
TEST_F(FleetCommandTest, MixedGeographicRegionHomeAndKeyTargetsAreBoundedOrMatches) {
char full[PUB_KEY_SIZE * 2 + 1];
mesh::Utils::toHex(full, publisher.pub_key, PUB_KEY_SIZE);
const std::string token = "gps:47.6062,-122.3321:1;sea;home:pdx;" + std::string(full, 8);
Fleet::Targets targets{};
ASSERT_TRUE(Fleet::parseTargets(token.data(), token.size(), targets));
ASSERT_EQ(4, targets.count);
EXPECT_EQ(13 + 5 + 5 + 5, targets.length);
const auto bytes = encodeTargets(targets);
EXPECT_TRUE(decode(bytes)); // Public-key prefix can match without either callback.
GeoContext location;
EXPECT_TRUE(decodeGeo(bytes, location));
EXPECT_TRUE(decoded.broadcast);
RegionContext regions{{"sea"}, {"pdx"}, {}};
EXPECT_TRUE(decodeGeo(bytes, location, &regions));
EXPECT_EQ(2U, regions.seen.size());
EXPECT_STREQ("get radio2", decoded.command);
const char* geo_only = "gps:47.6062,-122.3321:1";
ASSERT_TRUE(Fleet::parseTargets(geo_only, strlen(geo_only), targets));
const auto geo_bytes = encodeTargets(targets);
location.known = false;
EXPECT_FALSE(decodeGeo(geo_bytes, location));
location.known = true; location.latitude_e6 = 45515200; location.longitude_e6 = -122678400;
EXPECT_FALSE(decodeGeo(geo_bytes, location));
// Invalid geometry after a matching key still rejects the complete request.
const std::string reversed = std::string(full, 8) + ';' + geo_only;
ASSERT_TRUE(Fleet::parseTargets(reversed.data(), reversed.size(), targets));
auto malformed = encodeTargets(targets);
memset(malformed.data() + 13 + 5 + 1 + 8, 0, 4); // Zero-radius final record.
EXPECT_FALSE(decode(malformed));
EXPECT_FALSE(decoded.broadcast);
}
TEST_F(FleetCommandTest, GeographicRecordUsesThirteenBytesAndTwoFragmentCommandBudget) {
const char* token = "gps:47.6062,-122.3321:1";
Fleet::Targets targets{};
ASSERT_TRUE(Fleet::parseTargets(token, strlen(token), targets));
const std::string prefix = "set flood.rule.1 ";
const std::string maximum = prefix + std::string(217 - prefix.size(), 'a');
auto bytes = encodeTargets(targets, maximum.c_str());
ASSERT_EQ(Fleet::MaxEnvelopeLength, bytes.size());
GeoContext location;
EXPECT_TRUE(decodeGeo(bytes, location));
EXPECT_TRUE(encodeTargets(targets, (maximum + 'a').c_str()).empty());
std::string list;
for (unsigned count = 1; count <= 6; ++count) {
if (!list.empty()) list += ';';
list += token;
ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets));
EXPECT_EQ(count * 13, targets.length);
}
list += ';' + std::string(token);
EXPECT_FALSE(Fleet::parseTargets(list.data(), list.size(), targets));
EXPECT_EQ(0, targets.count);
std::array<uint8_t, Fleet::MaxPayloadLength> part{};
ASSERT_EQ(part.size(), Fleet::fragment(bytes.data(), bytes.size(), 0, part.data(), part.size()));
Fleet::Fragment parsed{};
ASSERT_TRUE(Fleet::parseFragment(part.data(), part.size(), parsed));
EXPECT_FALSE(decodeGeo(std::vector<uint8_t>(part.begin(), part.end()), location));
}
TEST_F(FleetCommandTest, ExternalGeographicRecordBoundsRejectBeforeAnyOutputWrite) {
std::array<uint8_t, Fleet::MaxEnvelopeLength> output;
output.fill(0xA5);
for (unsigned invalid = 0; invalid < 5; ++invalid) {
Fleet::Targets targets{};
const char* token = "gps:0,0:1";
ASSERT_TRUE(Fleet::parseTargets(token, strlen(token), targets));
if (invalid == 0) targets.length = 12;
if (invalid == 1) memset(targets.data + 9, 0, 4);
if (invalid == 2) memset(targets.data + 1, 0x7f, 4);
if (invalid == 3) memset(targets.data + 5, 0x7f, 4);
if (invalid == 4) memset(targets.data + 9, 0xff, 4);
EXPECT_EQ(0U, Fleet::encode(publisher, ChannelKey, Now, Now + 120,
targets, "get radio2", output.data(), output.size()));
for (uint8_t byte : output) EXPECT_EQ(0xA5, byte);
}
}
TEST_F(FleetCommandTest, InvalidExternalTargetStructuresNeverWriteOutput) {
std::array<uint8_t, Fleet::MaxPayloadLength> output;
output.fill(0xA5);
@@ -633,13 +1058,13 @@ TEST_F(FleetCommandTest, InvalidExternalTargetStructuresNeverWriteOutput) {
TEST_F(FleetCommandTest, LargestFmc2EnvelopeFitsAndOversizedCombinationWritesNothing) {
Fleet::Targets targets{};
const char* three = "01020304,05060708,090A0B0C";
const char* three = "01020304;05060708;090A0B0C";
ASSERT_TRUE(Fleet::parseTargets(three, strlen(three), targets));
const std::string prefix = "set flood.rule.1 ";
const std::string maximum = prefix + std::string(
Fleet::MaxCommandLength - targets.length - prefix.size(), 'a');
EXPECT_EQ(Fleet::MaxEnvelopeLength, encodeTargets(targets, maximum.c_str()).size());
const char* four = "01020304,05060708,090A0B0C,01020304";
const char* four = "01020304;05060708;090A0B0C;01020304";
ASSERT_TRUE(Fleet::parseTargets(four, strlen(four), targets));
std::array<uint8_t, Fleet::MaxEnvelopeLength> output;
output.fill(0xA5);
@@ -655,8 +1080,8 @@ TEST_F(FleetCommandTest, EveryTargetFormUsesItsExactCommandBudgetWithoutPartialW
const std::string prefix = "set flood.rule.1 ";
const std::vector<std::pair<std::string, size_t>> cases = {
{"all", 230}, {short_key, 225}, {long_key, 223}, {full, 215},
{short_key + ',' + short_key, 220}, {short_key + ',' + long_key, 218},
{std::string(full) + ',' + short_key, 208}, {std::string(full) + ',' + full, 196}
{short_key + ';' + short_key, 220}, {short_key + ';' + long_key, 218},
{std::string(full) + ';' + short_key, 208}, {std::string(full) + ';' + full, 196}
};
for (const auto& item : cases) {
SCOPED_TRACE(item.first);
+423 -19
View File
@@ -2,6 +2,7 @@
"""Exercise the production fleet envelope with real Ed25519, never native mocks."""
import hashlib
import math
from pathlib import Path
import struct
import subprocess
@@ -85,6 +86,31 @@ void hex(const uint8_t* bytes, size_t size) {
std::cout << '\n';
}
struct RegionNames {
std::vector<std::string> configured, home;
static bool match(void* context, mesh::FleetCommand::RegionTarget kind,
const char* name, size_t length) {
const auto& regions = *static_cast<RegionNames*>(context);
const auto& names = kind == mesh::FleetCommand::RegionTarget::Configured
? regions.configured : regions.home;
for (const auto& candidate : names) {
const char* canonical = candidate.c_str() + (!candidate.empty() && candidate[0] == '#');
if (strlen(canonical) == length && !memcmp(canonical, name, length)) return true;
}
return false;
}
};
struct GeoLocation {
bool known = false;
int32_t latitude = 0, longitude = 0;
static bool match(void* context, int32_t latitude, int32_t longitude, uint32_t radius) {
const auto& location = *static_cast<GeoLocation*>(context);
return location.known && mesh::FleetCommand::withinRadius(latitude, longitude, radius,
location.latitude, location.longitude);
}
};
int main(int argc, char** argv) {
using Fleet = mesh::FleetCommand;
const std::string operation = argc > 1 ? argv[1] : "";
@@ -115,7 +141,13 @@ int main(int argc, char** argv) {
FixedRng rng; mesh::LocalIdentity publisher(&rng);
const auto key = unhex(argv[2]); assert(key.size() == Fleet::KeySize);
Fleet::Targets targets;
if (!Fleet::parseTargets(argv[3], strlen(argv[3]), targets)) { std::cout << "reject\n"; return 0; }
memset(&targets, 0xA5, sizeof(targets));
if (!Fleet::parseTargets(argv[3], strlen(argv[3]), targets)) {
assert(!targets.count && !targets.length);
for (uint8_t byte : targets.data) assert(byte == 0);
assert(fleet_sign_calls == 0);
std::cout << "reject\n"; return 0;
}
std::array<uint8_t, Fleet::MaxEnvelopeLength + 64> output;
output.fill(0xA5);
const unsigned signed_before = fleet_sign_calls;
@@ -166,17 +198,47 @@ int main(int argc, char** argv) {
}
return 0;
}
if (argc == 7 && (std::string(argv[1]) == "decode" || std::string(argv[1]) == "decode2")) {
if (argc == 7 && operation == "within-radius") {
std::cout << int(Fleet::withinRadius(int32_t(std::stol(argv[2])), int32_t(std::stol(argv[3])),
uint32_t(std::stoul(argv[4])), int32_t(std::stol(argv[5])), int32_t(std::stol(argv[6])))) << '\n';
return 0;
}
if ((argc == 7 && (operation == "decode" || operation == "decode2"))
|| (argc == 8 && operation == "decode-regions")
|| (argc == 11 && operation == "decode-targets")) {
const auto public_key = unhex(argv[2]), key = unhex(argv[3]), self = unhex(argv[4]), bytes = unhex(argv[6]);
assert(public_key.size() == PUB_KEY_SIZE && self.size() == PUB_KEY_SIZE && key.size() == Fleet::KeySize);
mesh::Identity publisher(public_key.data()); Fleet::Decoded output;
RegionNames regions;
if (operation == "decode-regions" || operation == "decode-targets") {
const std::string selected(argv[7]);
size_t cursor = 0;
while (cursor < selected.size()) {
const size_t end = selected.find(',', cursor);
const std::string name = selected.substr(cursor, end == std::string::npos ? end : end - cursor);
if (!name.compare(0, 7, "region:")) regions.configured.push_back(name.substr(7));
else { assert(!name.compare(0, 5, "home:")); regions.home.push_back(name.substr(5)); }
if (end == std::string::npos) break;
cursor = end + 1;
}
}
GeoLocation location;
if (operation == "decode-targets") {
assert(!strcmp(argv[8], "0") || !strcmp(argv[8], "1"));
location.known = !strcmp(argv[8], "1");
location.latitude = int32_t(std::stol(argv[9]));
location.longitude = int32_t(std::stol(argv[10]));
}
if (!Fleet::decode(publisher, key.data(), bytes.data(), bytes.size(),
uint32_t(std::stoul(argv[5])), self.data(), output)) {
uint32_t(std::stoul(argv[5])), self.data(), output,
operation == "decode-regions" || operation == "decode-targets"
? RegionNames::match : nullptr, &regions,
operation == "decode-targets" ? GeoLocation::match : nullptr, &location)) {
assert(output.sequence == 0 && output.expires == 0 && !output.broadcast && output.command[0] == 0);
std::cout << "reject\n";
} else {
std::cout << output.sequence << '\n' << output.expires << '\n' << output.command << '\n';
if (std::string(argv[1]) == "decode2") std::cout << int(output.broadcast) << '\n';
if (operation != "decode") std::cout << int(output.broadcast) << '\n';
}
return 0;
}
@@ -195,7 +257,8 @@ def envelope(command="get radio2", sequence=NOW, expires=NOW + 120,
def envelope2(entries=(), command="get radio2", sequence=NOW, expires=NOW + 120,
channel=CHANNEL, publisher=PUBLISHER, count=None):
command = command.encode("ascii") if isinstance(command, str) else command
records = b"".join(bytes([kind]) + value for kind, value in entries)
records = b"".join(bytes([kind]) + (bytes([len(value)]) if kind in (0x20, 0x21) else b"")
+ value for kind, value in entries)
count = len(entries) if count is None else count
unsigned = (b"FMC2" + struct.pack("<II", sequence, expires) + bytes([count])
+ records + bytes([len(command)]) + command)
@@ -240,6 +303,18 @@ class FleetCommandCryptoTests(unittest.TestCase):
return self.run_tool("decode2" if metadata else "decode",
publisher.hex(), channel.hex(), self_key.hex(), now, payload.hex())
def decode_regions(self, payload, configured=(), home=(), channel=CHANNEL,
publisher=PUBLIC, self_key=PUBLIC, now=NOW):
names = ["region:" + name for name in configured] + ["home:" + name for name in home]
return self.run_tool("decode-regions", publisher.hex(), channel.hex(), self_key.hex(),
now, payload.hex(), ",".join(names))
def decode_geo(self, payload, latitude_e6=47606200, longitude_e6=-122332100, known=True,
configured=(), home=(), channel=CHANNEL, publisher=PUBLIC, self_key=PUBLIC, now=NOW):
names = ["region:" + name for name in configured] + ["home:" + name for name in home]
return self.run_tool("decode-targets", publisher.hex(), channel.hex(), self_key.hex(),
now, payload.hex(), ",".join(names), int(known), latitude_e6, longitude_e6)
def test_cpp_and_python_signatures_match_exactly(self):
expected = envelope()
actual = self.run_tool("encode", CHANNEL.hex(), "all", NOW, NOW + 120, "get radio2")
@@ -353,7 +428,7 @@ class FleetCommandCryptoTests(unittest.TestCase):
def test_multitarget_scheduled_request_is_signed_and_body_bounded(self):
command = "set tempradioat2 910.5,500,5,5,rxtx,+1,+2,auto"
entries = [(4, PUBLIC[:4]), (6, OTHER_PUBLIC[:6])]
token = f"{PUBLIC[:4].hex()},{OTHER_PUBLIC[:6].hex()}"
token = f"{PUBLIC[:4].hex()};{OTHER_PUBLIC[:6].hex()}"
expected = envelope2(entries, command)
actual = self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, command)
self.assertEqual(actual, expected.hex())
@@ -524,10 +599,10 @@ class FleetCommandCryptoTests(unittest.TestCase):
for token, entries, maximum_length in [
("all", [], 230), (short, [(4, PUBLIC[:4])], 225),
(long, [(6, PUBLIC[:6])], 223), (PUBLIC.hex(), None, 215),
(f"{short},{short}", [(4, PUBLIC[:4])] * 2, 220),
(f"{short},{long}", [(4, PUBLIC[:4]), (6, PUBLIC[:6])], 218),
(f"{PUBLIC.hex()},{short}", [(16, digest), (4, PUBLIC[:4])], 208),
(f"{PUBLIC.hex()},{PUBLIC.hex()}", [(16, digest)] * 2, 196)]:
(f"{short};{short}", [(4, PUBLIC[:4])] * 2, 220),
(f"{short};{long}", [(4, PUBLIC[:4]), (6, PUBLIC[:6])], 218),
(f"{PUBLIC.hex()};{short}", [(16, digest), (4, PUBLIC[:4])], 208),
(f"{PUBLIC.hex()};{PUBLIC.hex()}", [(16, digest)] * 2, 196)]:
with self.subTest(token=token, maximum=maximum_length):
prefix = "set flood.rule.1 "
maximum = prefix + "a" * (maximum_length - len(prefix))
@@ -684,7 +759,7 @@ class FleetCommandCryptoTests(unittest.TestCase):
def test_real_signature_round_trip_for_mixed_prefixes_and_full_keys(self):
entries = [(4, PUBLIC[:4]), (6, OTHER_PUBLIC[:6]), (16, hashlib.sha256(PUBLIC).digest()[:16])]
token = f"{PUBLIC[:4].hex()},{OTHER_PUBLIC[:6].hex()},{PUBLIC.hex()}"
token = f"{PUBLIC[:4].hex()};{OTHER_PUBLIC[:6].hex()};{PUBLIC.hex()}"
expected = envelope2(entries)
actual = self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, "get radio2")
self.assertEqual(actual, expected.hex())
@@ -693,6 +768,335 @@ class FleetCommandCryptoTests(unittest.TestCase):
unrelated = hashlib.sha256(b"unrelated fleet node").digest()
self.assertEqual(self.decode(expected, self_key=unrelated), "reject")
def test_region_and_home_targets_have_distinct_cross_language_signed_records(self):
entries = [(0x20, b"sea"), (0x20, b"pdx"), (0x21, b"Sea"),
(0x20, b"$Private"), (4, PUBLIC[:4])]
token = f"sea;pdx;home:#Sea;region:$Private;{PUBLIC[:4].hex()}"
expected = envelope2(entries)
actual = self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, "get radio2")
self.assertEqual(actual, expected.hex())
PUBLISHER.public_key().verify(expected[-64:], DOMAIN + CHANNEL + expected[:-64])
self.assertTrue(self.decode(expected, metadata=True).endswith("\n1"))
# Each target is OR: configured and home matches select the intended
# callback domain independently of whether the public-key prefix fits.
for configured, home in [(["sea"], []), (["#pdx"], []), ([], ["#Sea"]),
(["$Private"], [])]:
with self.subTest(configured=configured, home=home):
self.assertEqual(self.decode_regions(expected, configured, home, self_key=OTHER_PUBLIC),
f"{NOW}\n{NOW + 120}\nget radio2\n1")
self.assertEqual(self.decode(expected, self_key=OTHER_PUBLIC), "reject")
self.assertEqual(self.decode_regions(expected, ["Sea"], ["sea"], self_key=OTHER_PUBLIC), "reject")
def test_region_exact_matching_preserves_case_private_names_and_hash_aliases(self):
for token, kind, canonical in [
("sea", 0x20, "sea"), ("#Sea", 0x20, "Sea"),
("region:#sea", 0x20, "sea"), ("home:#Sea", 0x21, "Sea"),
("region:$Private", 0x20, "$Private"), ("home:$Private", 0x21, "$Private")]:
with self.subTest(token=token):
expected = envelope2([(kind, canonical.encode("ascii"))])
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
"get radio2"), expected.hex())
self.assertEqual(self.decode(expected), "reject")
configured, home = ([canonical], []) if kind == 0x20 else ([], [canonical])
self.assertTrue(self.decode_regions(expected, configured, home).endswith("\n1"))
configured, home = ([], [canonical]) if kind == 0x20 else ([canonical], [])
self.assertEqual(self.decode_regions(expected, configured, home), "reject")
for wrong in [canonical.swapcase(), canonical + "-other", canonical[:-1]]:
configured, home = ([wrong], []) if kind == 0x20 else ([], [wrong])
self.assertEqual(self.decode_regions(expected, configured, home), "reject")
canonical = self.run_tool("encode2", CHANNEL.hex(), "home:sea", NOW, NOW + 120, "get radio2")
alias = self.run_tool("encode2", CHANNEL.hex(), "home:#sea", NOW, NOW + 120, "get radio2")
self.assertEqual(canonical, alias)
def test_named_target_parser_never_reinterprets_malformed_keys_or_command_verbs(self):
for token in ["zzzzzzzz", "z" * 12, "g" * 64, "a", "dead", "a" * 16,
"all;sea", "sea;all", "region:", "home:", "#", "region:#", "home:#",
"region:*", "home:*", "region:bad.name", "home:bad:name", "sea;pdx;",
"region:bad space", "region:bad\n", "region:bad\x7f", "region:badé",
"region:" + "z" * 31, "home:" + "z" * 31,
"get", "set", "del", "time", "clock"]:
with self.subTest(token=token):
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
"get radio2"), "reject")
# Explicit selectors remove any ambiguity with public-key formats and
# CLI verbs. Uppercase names retain the RegionMap's case sensitivity.
for token, kind, name in [
("region:all", 0x20, "all"), ("region:dead", 0x20, "dead"),
("region:zzzzzzzz", 0x20, "zzzzzzzz"), ("home:a", 0x21, "a"),
("region:get", 0x20, "get"), ("home:get", 0x21, "get"),
("#get", 0x20, "get"), ("region:clock", 0x20, "clock"),
("home:time", 0x21, "time"), ("ALL", 0x20, "ALL")]:
with self.subTest(token=token):
expected = envelope2([(kind, name.encode("ascii"))])
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
"get radio2"), expected.hex())
self.assertEqual(self.decode(expected), "reject")
configured, home = ([name], []) if kind == 0x20 else ([], [name])
self.assertNotEqual(self.decode_regions(expected, configured, home), "reject")
def test_region_record_schema_is_validated_after_an_earlier_matching_key(self):
matching = (4, PUBLIC[:4])
for kind in [0x20, 0x21]:
for name in [b"", b"z" * 31, b"*", b"bad.name", b"bad:name", b"bad/name",
b"bad\0", b"bad\n", b"bad\x7f", b"bad\x80", b"bad;name", b"bad name"]:
with self.subTest(kind=kind, name=name):
signed = envelope2([matching, (kind, name)])
self.assertEqual(self.decode_regions(signed, ["sea"], ["pdx"]), "reject")
expected = envelope2([matching, (0x20, b"sea"), (0x21, b"pdx")])
self.assertNotEqual(self.decode_regions(expected, ["sea"], ["pdx"]), "reject")
# The declared length is signed as part of the record, and it must
# still frame every later entry and the command exactly.
for value in [0, 1, 4, 30, 31, 255]:
changed = bytearray(expected)
changed[19] = value
self.assertEqual(self.decode_regions(changed, ["sea"], ["pdx"]), "reject")
unknown = envelope2([matching, (0x22, b"\3sea")])
self.assertEqual(self.decode_regions(unknown, ["sea"], ["pdx"]), "reject")
def test_signature_binds_region_name_and_configured_versus_home_selector(self):
original = envelope2([(0x20, b"sea")])
for changed in [envelope2([(0x20, b"pdx")]), envelope2([(0x21, b"sea")]),
envelope2([(0x20, b"Sea")]), envelope2([(0x20, b"sea"), (0x21, b"sea")])]:
with self.subTest(changed=changed.hex()):
self.assertNotEqual(self.decode_regions(changed, ["sea", "pdx", "Sea"], ["sea"]), "reject")
self.assertEqual(self.decode_regions(changed[:-64] + original[-64:],
["sea", "pdx", "Sea"], ["sea"]), "reject")
self.assertEqual(self.decode_regions(original, ["sea"], publisher=OTHER_PUBLIC), "reject")
self.assertEqual(self.decode_regions(original, ["sea"], channel=bytes(range(17, 33))), "reject")
self.assertEqual(self.decode_regions(original, ["sea"], now=0), "reject")
self.assertEqual(self.decode_regions(original, ["sea"], now=NOW + 121), "reject")
def test_named_targets_share_byte_count_and_fragment_budgets_without_partial_output(self):
for count in [1, 16, 17]:
token = ";".join(["sea"] * count)
expected = envelope2([(0x20, b"sea")] * count)
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
"get radio2"), expected.hex())
self.assertTrue(self.decode_regions(expected, ["sea"]).endswith("\n1"))
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), ";".join(["sea"] * 18), NOW,
NOW + 120, "get radio2"), "reject")
records = [(0x20, b"z" * 30), (0x21, b"Y" * 30), (0x20, b"x" * 20)]
token = "region:" + "z" * 30 + ";home:" + "Y" * 30 + ";region:" + "x" * 20
prefix = "set flood.rule.1 "
maximum = prefix + "a" * (144 - len(prefix))
expected = envelope2(records, maximum)
self.assertEqual(len(expected), 308) # Exactly86 bytes of target records.
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
maximum), expected.hex())
self.assertTrue(self.decode_regions(expected, ["z" * 30]).endswith("\n1"))
for operation in ["encode2", "encode2-wide"]:
self.assertEqual(self.run_tool(operation, CHANNEL.hex(), token, NOW, NOW + 120,
maximum + "a"), "reject")
self.assertEqual(self.run_tool(operation, CHANNEL.hex(), token + "x", NOW, NOW + 120,
"get radio2"), "reject")
parts = [bytes.fromhex(self.run_tool("fragment", expected.hex(), index, 165)) for index in [0, 1]]
for index, part in enumerate(parts):
self.assertEqual(part, fragment_frame(expected, index))
self.assertEqual(len(part), 165)
self.assertEqual(self.decode_regions(part, ["z" * 30]), "reject")
assembled = parts[0][11:] + parts[1][11:]
PUBLISHER.public_key().verify(assembled[-64:], DOMAIN + CHANNEL + assembled[:-64])
self.assertTrue(self.decode_regions(assembled, ["z" * 30]).endswith("\n1"))
# Tamper with a later home name while the first configured name still
# matches. The full reconstructed signature must reject this change.
changed = bytearray(parts[0])
changed[11 + 13 + 32 + 2] = ord("Z")
self.assertEqual(self.decode_regions(changed[11:] + parts[1][11:], ["z" * 30]), "reject")
def test_gps_cross_language_signatures_use_microdegrees_and_metres_exactly(self):
for token, latitude, longitude, radius in [
("gps:47.6062,-122.3321:10", 47606200, -122332100, 10000),
("gps:+90,-180:0.001", 90000000, -180000000, 1),
("gps:-90,+180:20050.000", -90000000, 180000000, 20050000),
("gps:0.000001,-0.000001:1.001", 1, -1, 1001),
("gps:0,0:1", 0, 0, 1000)]:
with self.subTest(token=token):
record = struct.pack("<iiI", latitude, longitude, radius)
expected = envelope2([(0x22, record)])
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
"get radio2"), expected.hex())
self.assertEqual(expected[13:26], b"\x22" + record)
self.assertEqual(len(expected), 101)
PUBLISHER.public_key().verify(expected[-64:], DOMAIN + CHANNEL + expected[:-64])
self.assertEqual(self.decode(expected), "reject")
self.assertEqual(self.decode_geo(expected, latitude, longitude),
f"{NOW}\n{NOW + 120}\nget radio2\n1")
self.assertEqual(self.decode_geo(expected, latitude, longitude, known=False), "reject")
def test_semicolon_target_lists_preserve_gps_at_first_middle_and_last(self):
gps = (0x22, struct.pack("<iiI", 47606200, -122332100, 25000))
circle = "gps:47.6062,-122.3321:25"
for token, entries in [
(f"{circle};sea;home:pdx", [gps, (0x20, b"sea"), (0x21, b"pdx")]),
(f"sea;{circle};home:pdx", [(0x20, b"sea"), gps, (0x21, b"pdx")]),
(f"sea;home:pdx;{circle}", [(0x20, b"sea"), (0x21, b"pdx"), gps]),
(f"{circle};{circle}", [gps, gps])]:
with self.subTest(token=token):
expected = envelope2(entries)
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
"get radio2"), expected.hex())
PUBLISHER.public_key().verify(expected[-64:], DOMAIN + CHANNEL + expected[:-64])
self.assertTrue(self.decode_geo(expected, self_key=OTHER_PUBLIC).endswith("\n1"))
if len(entries) == 3:
self.assertTrue(self.decode_geo(expected, known=False, configured=["sea"],
home=["pdx"], self_key=OTHER_PUBLIC).endswith("\n1"))
else:
self.assertEqual(self.decode_geo(expected, known=False), "reject")
def test_truncated_gps_tokens_only_accept_a_complete_nonempty_radius(self):
token = "gps:47.6062,-122.3321:25"
for length in range(4, len(token) + 1):
with self.subTest(length=length):
actual = self.run_tool("encode2", CHANNEL.hex(), token[:length], NOW, NOW + 120,
"get radio2")
if length in (len(token) - 1, len(token)):
radius = int(token[:length].rsplit(":", 1)[1]) * 1000
expected = envelope2([(0x22, struct.pack("<iiI", 47606200, -122332100, radius))])
self.assertEqual(actual, expected.hex())
else:
self.assertEqual(actual, "reject")
def test_comma_lists_and_malformed_gps_delimiters_cannot_send_partial_commands(self):
for token in [
"sea,pdx", f"{PUBLIC[:4].hex()},{OTHER_PUBLIC[:4].hex()}",
"region:sea,home:pdx", "sea;home:pdx,sea", "gps:47.6062:-122.3321:25",
"gps:47.6062;-122.3321:25", "gps:47.6062,-122.3321,25",
"gps:47.6062,,-122.3321:25", "gps:47.6062,-122.3321,:25",
"gps:47.6062,-122.3321:25,sea", ";gps:47.6062,-122.3321:25",
"gps:47.6062,-122.3321:25;", "sea;;gps:47.6062,-122.3321:25",
"sea;gps:47.6062,-122.3321:25;;home:pdx", "sea;gps:47.6062;home:pdx",
"sea;gps:47.6062,-122.3321;home:pdx", "sea;gps:47.6062,-122.3321:;home:pdx",
"sea;gps:47.6062,-122.3321:25;home:pdx,sea"]:
with self.subTest(token=token):
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
"get radio2"), "reject")
def test_semicolon_mixed_geographic_lists_keep_record_and_byte_limits(self):
circle = "gps:47.6062,-122.3321:25"
gps = (0x22, struct.pack("<iiI", 47606200, -122332100, 25000))
for token, entries in [
(";".join([circle] + ["z"] * 16), [gps] + [(0x20, b"z")] * 16),
(";".join([circle] + ["sea"] * 14), [gps] + [(0x20, b"sea")] * 14)]:
with self.subTest(token=token):
expected = envelope2(entries)
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
"get radio2"), expected.hex())
self.assertTrue(self.decode_geo(expected).endswith("\n1"))
for names in [["z"] * 17, ["sea"] * 15]:
token = ";".join([circle] + names)
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
"get radio2"), "reject")
def test_malformed_gps_decimal_tokens_never_fall_back_to_region_names(self):
for token in [
"gps:", "gps:0", "gps:0:0", "gps:0,0:", "gps:,0:1", "gps:0,:1", "gps:0,0:1:2",
"gps:0,0:0", "gps:0,0:0.000", "gps:0,0:0.0001", "gps:0,0:20050.001",
"gps:90.000001,0:1", "gps:-90.000001,0:1", "gps:0,180.000001:1",
"gps:0,-180.000001:1", "gps:0.0000001,0:1", "gps:0,0.0000001:1",
"gps:0,0:1.0001", "gps:0,0:+1", "gps:0,0:-1", "gps:NaN,0:1", "gps:0,Inf:1",
"gps:0,0:1e3", "gps:1e1,0:1", "gps:.1,0:1", "gps:1.,0:1", "gps:0,0:.1",
"gps:0,0:1.", "gps:01,0:1", "gps:0,00:1", "gps:0,0:01", "gps:--1,0:1",
"gps:++1,0:1", "gps:+,0:1", "gps:0,0:4294967296", "gps:999999999999999999,0:1",
"gps:0,0:1m", "gps:0,0:1\n", "gps: 0:0:1", "gps:0,0:1;all"]:
with self.subTest(token=token):
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
"get radio2"), "reject")
def test_geographic_math_matches_independent_distance_at_boundaries(self):
cases = [
(0, 0, 0, 0), (0, 0, 0, 1000000),
(0, 179999999, 0, -179999999), (90000000, 0, 90000000, 180000000),
(0, 0, 0, 180000000), (47606200, -122332100, 47610000, -122330000),
(47606200, -122332100, 45515200, -122678400),
(-89999999, -180000000, -89999999, 180000000)]
for latitude, longitude, local_latitude, local_longitude in cases:
center = math.radians(latitude / 1000000)
local = math.radians(local_latitude / 1000000)
half_latitude = math.radians((local_latitude - latitude) / 1000000) / 2
half_longitude = math.radians((local_longitude - longitude) / 1000000) / 2
value = (math.sin(half_latitude) ** 2 + math.cos(center) * math.cos(local)
* math.sin(half_longitude) ** 2)
value = max(0, min(1, value))
distance = 2 * 6371008.8 * math.atan2(math.sqrt(value), math.sqrt(1 - value))
radii = {1, max(1, int(math.floor(distance))), max(1, int(math.floor(distance)) + 1), 20050000}
for radius in radii:
with self.subTest(center=(latitude, longitude), local=(local_latitude, local_longitude), radius=radius):
actual = self.run_tool("within-radius", latitude, longitude, radius,
local_latitude, local_longitude)
self.assertEqual(actual, str(int(distance <= radius)))
for latitude, longitude, radius, local_latitude, local_longitude in [
(90000001, 0, 1, 0, 0), (0, 180000001, 1, 0, 0),
(0, 0, 1, -90000001, 0), (0, 0, 1, 0, -180000001),
(0, 0, 0, 0, 0), (0, 0, 20050001, 0, 0),
(2147483647, -2147483648, 4294967295, 0, 0)]:
self.assertEqual(self.run_tool("within-radius", latitude, longitude, radius,
local_latitude, local_longitude), "0")
def test_geographic_region_home_and_key_records_are_independent_signed_or_matches(self):
record = struct.pack("<iiI", 47606200, -122332100, 1000)
entries = [(0x22, record), (0x20, b"sea"), (0x21, b"pdx"), (4, PUBLIC[:4])]
token = f"gps:47.6062,-122.3321:1;sea;home:pdx;{PUBLIC[:4].hex()}"
expected = envelope2(entries)
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
"get radio2"), expected.hex())
self.assertTrue(self.decode(expected, metadata=True).endswith("\n1"))
self.assertTrue(self.decode_geo(expected, self_key=OTHER_PUBLIC).endswith("\n1"))
self.assertTrue(self.decode_geo(expected, known=False, configured=["sea"], self_key=OTHER_PUBLIC).endswith("\n1"))
self.assertTrue(self.decode_geo(expected, known=False, home=["pdx"], self_key=OTHER_PUBLIC).endswith("\n1"))
self.assertEqual(self.decode_geo(expected, known=False, self_key=OTHER_PUBLIC), "reject")
self.assertEqual(self.decode_geo(expected, 45515200, -122678400, self_key=OTHER_PUBLIC), "reject")
self.assertEqual(self.decode_geo(expected, 90000001, 0, self_key=OTHER_PUBLIC), "reject")
# Invalid later geographic records reject even after a matching key.
for latitude, longitude, radius in [
(90000001, 0, 1000), (-90000001, 0, 1000), (0, 180000001, 1000),
(0, -180000001, 1000), (0, 0, 0), (0, 0, 20050001), (0, 0, 4294967295)]:
invalid = envelope2([(4, PUBLIC[:4]), (0x22, struct.pack("<iiI", latitude, longitude, radius))])
self.assertEqual(self.decode_geo(invalid), "reject")
for missing in range(12):
invalid = envelope2([(4, PUBLIC[:4]), (0x22, record[:missing])])
self.assertEqual(self.decode_geo(invalid), "reject")
def test_signature_binds_every_geographic_field_and_fragmented_circle_command(self):
record = struct.pack("<iiI", 47606200, -122332100, 10000)
original = envelope2([(0x22, record)])
for changed in [
envelope2([(0x22, struct.pack("<iiI", 47606201, -122332100, 10000))]),
envelope2([(0x22, struct.pack("<iiI", 47606200, -122332101, 10000))]),
envelope2([(0x22, struct.pack("<iiI", 47606200, -122332100, 10001))]),
envelope2([(0x22, record), (0x20, b"sea")])]:
with self.subTest(changed=changed.hex()):
self.assertNotEqual(self.decode_geo(changed), "reject")
self.assertEqual(self.decode_geo(changed[:-64] + original[-64:]), "reject")
self.assertEqual(self.decode_geo(original, publisher=OTHER_PUBLIC), "reject")
self.assertEqual(self.decode_geo(original, channel=bytes(range(17, 33))), "reject")
self.assertEqual(self.decode_geo(original, now=0), "reject")
self.assertEqual(self.decode_geo(original, now=NOW + 121), "reject")
token = "gps:47.6062,-122.3321:10"
prefix = "set flood.rule.1 "
maximum = prefix + "a" * (217 - len(prefix))
expected = envelope2([(0x22, record)], maximum)
self.assertEqual(len(expected), 308)
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
maximum), expected.hex())
self.assertTrue(self.decode_geo(expected).endswith("\n1"))
for operation in ["encode2", "encode2-wide"]:
self.assertEqual(self.run_tool(operation, CHANNEL.hex(), token, NOW, NOW + 120,
maximum + "a"), "reject")
parts = [bytes.fromhex(self.run_tool("fragment", expected.hex(), index, 165)) for index in [0, 1]]
assembled = parts[0][11:] + parts[1][11:]
self.assertEqual(assembled, expected)
PUBLISHER.public_key().verify(assembled[-64:], DOMAIN + CHANNEL + assembled[:-64])
self.assertNotEqual(self.decode_geo(assembled), "reject")
for part in parts:
self.assertEqual(self.decode_geo(part), "reject")
for count in [1, 6]:
token_list = ";".join([token] * count)
signed = envelope2([(0x22, record)] * count)
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token_list, NOW, NOW + 120,
"get radio2"), signed.hex())
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), ";".join([token] * 7), NOW,
NOW + 120, "get radio2"), "reject")
def test_single_raw_prefix_is_a_broadcast_capability_for_matching_collisions(self):
first = PUBLIC
second = PUBLIC[:4] + bytes([PUBLIC[4] ^ 1]) + PUBLIC[5:]
@@ -746,11 +1150,11 @@ class FleetCommandCryptoTests(unittest.TestCase):
self.assertEqual(self.decode(envelope2([matching], count=2)), "reject")
def test_new_target_parser_refuses_partial_or_mixed_all_lists(self):
for token in ["", "all," + PUBLIC[:4].hex(), PUBLIC[:4].hex() + ",all",
PUBLIC[:4].hex() + ",", "," + PUBLIC[:4].hex(),
PUBLIC[:4].hex() + ",," + OTHER_PUBLIC[:4].hex(),
PUBLIC[:4].hex() + ",zzzzzzzz", "1" * 10, "1" * 16,
",".join([PUBLIC.hex()] * 6), ",".join([PUBLIC[:4].hex()] * 18)]:
for token in ["", "all;" + PUBLIC[:4].hex(), PUBLIC[:4].hex() + ";all",
PUBLIC[:4].hex() + ";", ";" + PUBLIC[:4].hex(),
PUBLIC[:4].hex() + ";;" + OTHER_PUBLIC[:4].hex(),
PUBLIC[:4].hex() + ";zzzzzzzz", "1" * 10, "1" * 16,
";".join([PUBLIC.hex()] * 6), ";".join([PUBLIC[:4].hex()] * 18)]:
with self.subTest(token=token):
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, "get radio2"), "reject")
@@ -759,15 +1163,15 @@ class FleetCommandCryptoTests(unittest.TestCase):
entries = [(4, PUBLIC[:4])] * 3
expected = envelope2(entries, maximum)
self.assertEqual(len(expected), 308)
token = ",".join([PUBLIC[:4].hex()] * 3)
token = ";".join([PUBLIC[:4].hex()] * 3)
actual = self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, maximum)
self.assertEqual(actual, expected.hex())
self.assertNotEqual(self.decode(expected), "reject")
token += "," + PUBLIC[:4].hex()
token += ";" + PUBLIC[:4].hex()
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, maximum), "reject")
self.assertEqual(self.decode(envelope2(entries * 2, maximum)), "reject")
for count in [15, 16, 17]:
token = ",".join([PUBLIC[:4].hex()] * count)
token = ";".join([PUBLIC[:4].hex()] * count)
result = self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, "get radio2")
self.assertNotEqual(result, "reject")
+243
View File
@@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""Exercise fleet location selection and the real read-only GPS cache accessor."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
from test_gps_upstream_adaptations import ARDUINO, CAYENNE, MESH
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <cassert>
#include <cmath>
#include <cstdint>
#include <limits>
#include <string>
#include <helpers/SensorManager.h>
#include <helpers/FleetLocation.h>
void LocationProvider::sendSentence(const char*) {}
struct Prefs { double node_lat = 0, node_lon = 0; unsigned advert_loc_policy = 0; };
struct Cached {
bool valid = true;
double latitude = 47.6062, longitude = -122.3321;
mutable unsigned reads = 0;
bool getCachedGpsPosition(double& lat, double& lon) const {
++reads; lat = latitude; lon = longitude; return valid;
}
};
#if ENV_INCLUDE_GPS
struct Sensors : SensorManager {
bool active = true;
unsigned starts = 0, stops = 0, providers = 0, queries = 0;
bool telemetryGpsDetected() const override { return true; }
bool telemetryGpsActive() const override { return active; }
void telemetryGpsStart() override { ++starts; active = true; }
void telemetryGpsStop() override { ++stops; active = false; }
LocationProvider* getLocationProvider() override { ++providers; return nullptr; }
bool querySensors(uint8_t, CayenneLPP&) override { ++queries; return false; }
void seed(float lat, float lon) {
setGpsTelemetryUserEnabled(true);
processGpsTelemetryFix(lat, lon, 5, millis());
setGpsTelemetryUserEnabled(false);
}
void block(bool blocked) { setGpsTelemetryTransportAvailable(!blocked); }
bool receiving() const { return gpsTelemetryReceiverRequired(millis()); }
void service() { loopGpsTelemetry(millis()); }
};
#endif
int main(int argc, char** argv) {
assert(argc == 2);
const std::string scenario = argv[1];
Prefs prefs; Cached cache; int32_t lat = 1, lon = 1;
if (scenario == "configured") {
prefs.node_lat = 47.6062; prefs.node_lon = -122.3321;
cache.valid = false;
for (unsigned policy : {0, 1, 2}) {
prefs.advert_loc_policy = policy;
assert(mesh::readFleetLocation(prefs, cache, lat, lon));
assert(lat == 47606200 && lon == -122332100 && cache.reads == 0);
}
prefs.node_lat = 0; prefs.node_lon = 180;
assert(mesh::readFleetLocation(prefs, cache, lat, lon) && lat == 0 && lon == 180000000);
prefs.node_lat = -90; prefs.node_lon = 0;
assert(mesh::readFleetLocation(prefs, cache, lat, lon) && lat == -90000000 && lon == 0);
prefs.node_lat = -0.0000015; prefs.node_lon = 0.0000015;
assert(mesh::readFleetLocation(prefs, cache, lat, lon) && lat == -2 && lon == 2);
assert(cache.reads == 0);
} else if (scenario == "invalid_configured") {
const double nan = std::numeric_limits<double>::quiet_NaN();
const double inf = std::numeric_limits<double>::infinity();
const double pairs[][2] = {{nan,0}, {0,nan}, {inf,1}, {1,-inf}, {90.1,0},
{-90.1,0}, {0,180.1}, {0,-180.1}, {0.0000001,-0.0000001}};
for (const auto& pair : pairs) {
prefs.node_lat = pair[0]; prefs.node_lon = pair[1]; lat = lon = 123;
assert(!mesh::readFleetLocation(prefs, cache, lat, lon));
assert(lat == 0 && lon == 0 && cache.reads == 0);
}
} else if (scenario == "fallback") {
for (unsigned policy : {0, 1, 2}) {
prefs.advert_loc_policy = policy;
assert(mesh::readFleetLocation(prefs, cache, lat, lon));
assert(lat == 47606200 && lon == -122332100);
}
assert(cache.reads == 3);
cache.valid = false; lat = lon = 123;
assert(!mesh::readFleetLocation(prefs, cache, lat, lon) && lat == 0 && lon == 0);
cache.valid = true;
for (const auto& pair : {std::pair<double,double>{0,0}, {0.0000001,-0.0000001},
{std::numeric_limits<double>::quiet_NaN(),0}, {0,std::numeric_limits<double>::infinity()},
{91,0}, {0,-181}}) {
cache.latitude = pair.first; cache.longitude = pair.second; lat = lon = 123;
assert(!mesh::readFleetLocation(prefs, cache, lat, lon) && lat == 0 && lon == 0);
}
#if ENV_INCLUDE_GPS
} else if (scenario == "cache_readonly") {
now_ms = 1000; Sensors sensors; sensors.powersaving_enabled = true;
double latitude = 1, longitude = 1;
assert(!sensors.getCachedGpsPosition(latitude, longitude));
assert(latitude == 0 && longitude == 0 && sensors.starts == 0 && sensors.stops == 0);
sensors.seed(47.6062f, -122.3321f);
sensors.block(true); // Cached reading must not reclaim a bridge-owned UART.
const unsigned starts = sensors.starts, stops = sensors.stops;
const bool active = sensors.active, receiving = sensors.receiving();
for (unsigned i = 0; i < 100; ++i) {
assert(sensors.getCachedGpsPosition(latitude, longitude));
assert(std::abs(latitude - 47.6062) < 0.00001);
assert(std::abs(longitude + 122.3321) < 0.00001);
assert(mesh::readFleetLocation(prefs, sensors, lat, lon));
}
assert(sensors.starts == starts && sensors.stops == stops && sensors.active == active);
assert(sensors.receiving() == receiving && sensors.providers == 0 && sensors.queries == 0);
assert(sensors.powersaving_enabled && !sensors.active && !sensors.receiving());
} else if (scenario == "cache_freshness" || scenario == "cache_wrap") {
const uint32_t start = scenario == "cache_wrap" ? UINT32_MAX - 1000 : 0;
now_ms = start; Sensors sensors; sensors.seed(47.6f, -122.3f);
double latitude = 1, longitude = 1;
now_ms = start + 12U * 3600000U;
assert(sensors.getCachedGpsPosition(latitude, longitude));
++now_ms; latitude = longitude = 123;
assert(!sensors.getCachedGpsPosition(latitude, longitude));
assert(latitude == 0 && longitude == 0);
assert(!mesh::readFleetLocation(prefs, sensors, lat, lon) && lat == 0 && lon == 0);
// Configured fixed locations remain usable while GPS cache is stale.
prefs.node_lat = 47.6; prefs.node_lon = -122.3;
assert(mesh::readFleetLocation(prefs, sensors, lat, lon));
} else if (scenario == "cache_full_cycle") {
const uint32_t original = 1000;
now_ms = original; Sensors sensors; sensors.seed(47.6f, -122.3f);
sensors.block(true);
double latitude = 1, longitude = 1;
assert(sensors.getCachedGpsPosition(latitude, longitude));
now_ms = original + 12U * 3600000U + 1;
sensors.service(); // Expiry must persist even while another owner has the UART.
assert(!sensors.getCachedGpsPosition(latitude, longitude));
// These low32 bits recur after one complete millis cycle. A cache that
// was observed expired must never become fresh again without a new fix.
now_ms = original;
assert(!sensors.getCachedGpsPosition(latitude, longitude));
assert(!mesh::readFleetLocation(prefs, sensors, lat, lon));
assert(latitude == 0 && longitude == 0 && lat == 0 && lon == 0);
sensors.block(false);
++now_ms; sensors.seed(47.7f, -122.4f);
assert(sensors.getCachedGpsPosition(latitude, longitude));
assert(std::abs(latitude - 47.7) < 0.00001 && std::abs(longitude + 122.4) < 0.00001);
} else if (scenario == "invalid_cache") {
for (const auto& pair : {std::pair<float,float>{0,0}, {91,0}, {-91,0}, {0,181}, {0,-181},
{std::numeric_limits<float>::quiet_NaN(),0}, {0,std::numeric_limits<float>::infinity()}}) {
now_ms = 100; Sensors sensors; sensors.seed(pair.first, pair.second);
double latitude = 1, longitude = 1;
assert(!sensors.getCachedGpsPosition(latitude, longitude));
assert(latitude == 0 && longitude == 0);
}
#else
} else if (scenario == "no_gps") {
SensorManager sensors; double latitude = 1, longitude = 1;
assert(!sensors.getCachedGpsPosition(latitude, longitude));
assert(latitude == 0 && longitude == 0);
assert(!mesh::readFleetLocation(prefs, sensors, lat, lon) && lat == 0 && lon == 0);
prefs.node_lat = 47.6062; prefs.node_lon = -122.3321;
assert(mesh::readFleetLocation(prefs, sensors, lat, lon));
assert(lat == 47606200 && lon == -122332100);
#endif
} else assert(false && "unknown scenario");
}
'''
class FleetLocationTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
compiler = shutil.which("g++") or shutil.which("clang++")
if compiler is None:
raise unittest.SkipTest("host C++17 compiler required")
cls.work = tempfile.TemporaryDirectory(prefix="meshcore-fleet-location-")
path = Path(cls.work.name)
for name, content in {"Arduino.h": ARDUINO, "Mesh.h": MESH,
"CayenneLPP.h": CAYENNE,
"Wire.h": "#pragma once\nclass TwoWire {};\n"}.items():
(path / name).write_text(content)
cpp = path / "fixture.cpp"
cpp.write_text(HARNESS)
cls.binaries = {}
sanitizers = (["-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-no-pie"]
if os.environ.get("MESHCORE_FLEET_SANITIZERS") == "1" else [])
for gps in (0, 1):
binary = path / f"gps-{gps}"
result = subprocess.run([
compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-O2",
"-Wno-unused-parameter", *sanitizers, f"-DENV_INCLUDE_GPS={gps}",
"-I", str(path), "-I", str(ROOT / "src"), str(cpp),
str(ROOT / "src/helpers/SensorManager.cpp"), "-o", str(binary),
], capture_output=True, text=True, timeout=60)
if result.returncode:
cls.work.cleanup()
raise AssertionError(result.stdout + result.stderr)
cls.binaries[gps] = binary
@classmethod
def tearDownClass(cls):
cls.work.cleanup()
def scenario(self, name, gps=1):
result = subprocess.run([str(self.binaries[gps]), name], capture_output=True,
text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_configured_position_preferred_and_independent_of_public_advertisement_policy(self):
self.scenario("configured")
def test_invalid_configured_position_fails_closed_without_gps_fallback(self):
self.scenario("invalid_configured")
def test_only_unset_configured_pair_falls_back_to_valid_cached_position(self):
self.scenario("fallback")
def test_real_cached_accessor_never_acquires_or_changes_sleep_or_uart_ownership(self):
self.scenario("cache_readonly")
def test_real_cache_twelve_hour_boundary_and_configured_position_after_expiry(self):
self.scenario("cache_freshness")
def test_real_cache_freshness_survives_millis_wrap(self):
self.scenario("cache_wrap")
def test_expired_cache_never_becomes_fresh_after_full_millis_cycle_while_uart_blocked(self):
self.scenario("cache_full_cycle")
def test_real_cached_accessor_rejects_nonfinite_out_of_range_and_unset_positions(self):
self.scenario("invalid_cache")
def test_gps_disabled_profiles_can_use_configured_position_without_receiver(self):
self.scenario("no_gps", gps=0)
if __name__ == "__main__":
unittest.main()
+3 -1
View File
@@ -14,7 +14,7 @@ import tempfile
import unittest
from cpp_source import body
from test_fleet_channel_runtime import UTILS, FILESYSTEM, MESH, HARNESS as RUNTIME_HARNESS
from test_fleet_channel_runtime import UTILS, FILESYSTEM, MESH, REGION_MAP, HARNESS as RUNTIME_HARNESS
from test_repeater_radio_timing_integration import HARNESS as PRIMARY_HARNESS
ROOT = Path(__file__).resolve().parents[1]
@@ -313,10 +313,12 @@ class FleetRadioSchedulesTests(unittest.TestCase):
compiler=shutil.which("g++") or shutil.which("clang++")
if not compiler:raise unittest.SkipTest("host C++ compiler required")
cls.work=tempfile.TemporaryDirectory(prefix="fleet-radio-schedules-");work=Path(cls.work.name)
(work/"helpers").mkdir()
mesh=MESH.replace('#include <Identity.h>','#include <Hardware.h>\n#include <Identity.h>')
mesh=mesh.replace('struct Clock {','struct Clock :RTCClock {').replace('uint32_t getCurrentTime() const{return now;}','uint32_t getCurrentTime()override{return now;}')
utils=UTILS.replace('class Utils { public:', 'class Utils { public:\n static int parseTextParts(char*,const char*[],int,char);')
for name,content in {"Utils.h":utils,"FS.h":FILESYSTEM,"Mesh.h":mesh,"Hardware.h":HARDWARE,
"helpers/RegionMap.h":REGION_MAP,
"Dispatcher.h":'#pragma once\n#include <Mesh.h>\n',
"Packet.h":'#pragma once\n#include <Mesh.h>\n',
"Arduino.h":'#pragma once\n#include <Mesh.h>\n#include <cstdlib>\n'}.items():
+65 -7
View File
@@ -20,6 +20,7 @@ HARNESS = r'''
#include <vector>
#include <algorithm>
#include <SHA256.h>
#include <helpers/FleetLocation.h>
#define MESH_ENABLE_CLOCK_SYNC 0
#define MAX_TKS_ENTRIES 16
static std::vector<int> events;
@@ -34,6 +35,7 @@ struct Packet {
struct Mesh {
virtual ~Mesh() = default;
virtual bool supportsFleetControl() const { return false; }
virtual bool getFleetLocation(int32_t&, int32_t&) const { return false; }
virtual void onSendComplete(Packet*) { events.push_back(1); }
virtual void onSendFail(Packet*) { events.push_back(3); }
virtual void onRadioProfileCopyQueued(Packet*, const Packet*, uint8_t) { events.push_back(5); }
@@ -55,6 +57,14 @@ struct Regions {
}
};
@SCOPE_HELPERS@
struct CachedSensors {
bool valid = false;
double latitude = 45.5152, longitude = -122.6784;
mutable unsigned reads = 0;
bool getCachedGpsPosition(double& lat, double& lon) const {
++reads; lat = latitude; lon = longitude; return valid;
}
};
struct Profiles {};
struct Fleet {
unsigned received = 0, serviced = 0;
@@ -93,7 +103,11 @@ class Repeater : public mesh::Mesh {
Regions region_map;
Region region;
Region* recv_pkt_region = &region;
struct { const char* node_name = "fleet node"; } _prefs;
struct { const char* node_name = "fleet node"; double node_lat = 0, node_lon = 0; } _prefs;
CachedSensors sensors;
#if MESH_ENABLE_FLEET_CONTROL
bool getFleetLocation(int32_t&, int32_t&) const override;
#endif
unsigned commands = 0;
uint32_t timestamp = 0;
@REPEATER_CAPABILITY@
@@ -112,7 +126,11 @@ class Room : public mesh::Mesh {
Regions region_map;
Region region;
Region* recv_pkt_region = &region;
struct { const char* node_name = "fleet node"; } _prefs;
struct { const char* node_name = "fleet node"; double node_lat = 0, node_lon = 0; } _prefs;
CachedSensors sensors;
#if MESH_ENABLE_FLEET_CONTROL
bool getFleetLocation(int32_t&, int32_t&) const override;
#endif
unsigned commands = 0;
uint32_t timestamp = 0;
@ROOM_CAPABILITY@
@@ -133,6 +151,20 @@ class Room : public mesh::Mesh {
template<typename Role>
void checkRole() {
Role role;
int32_t latitude = 0, longitude = 0;
assert(!role.getFleetLocation(latitude, longitude));
#if MESH_ENABLE_FLEET_CONTROL
assert(latitude == 0 && longitude == 0 && role.sensors.reads == 1);
role.sensors.valid = true;
role._prefs.node_lat = 47.6062; role._prefs.node_lon = -122.3321;
assert(role.getFleetLocation(latitude, longitude));
assert(latitude == 47606200 && longitude == -122332100 && role.sensors.reads == 1);
role._prefs.node_lat = role._prefs.node_lon = 0;
assert(role.getFleetLocation(latitude, longitude));
assert(latitude == 45515200 && longitude == -122678400 && role.sensors.reads == 2);
#else
assert(role.sensors.reads == 0);
#endif
mesh::Packet packet;
TransportKey original, rotated;
memset(original.key, 0x11, sizeof(original.key));
@@ -220,9 +252,14 @@ AUTHORIZATION = r'''
#include <new>
#include <initializer_list>
static unsigned configurations = 0, allocations = 0;
struct RegionMap {};
static RegionMap* enrolled_regions = nullptr;
struct FS { bool present = false; bool exists(const char*) const { return present; } };
namespace mesh {
struct FleetChannel {
explicit FleetChannel(void*, void* = nullptr) { ++allocations; }
explicit FleetChannel(void*, void* = nullptr, RegionMap* regions = nullptr) {
assert(regions); enrolled_regions = regions; ++allocations;
}
bool handleConfig(const char*, char* reply, size_t capacity) {
assert(capacity == 160); ++configurations; strcpy(reply, "OK"); return true;
}
@@ -237,9 +274,12 @@ class CommonCLI {
Callbacks callbacks;
Callbacks* _callbacks = &callbacks;
void* _management_fs = nullptr;
RegionMap regions;
RegionMap* _region_map = &regions;
mesh::FleetChannel* _fleet_channel = nullptr;
~CommonCLI() { delete _fleet_channel; }
bool handleFleetCommand(const char*, char*);
void loadExisting(FS* fs) { @BOOT_FLEET@ }
};
struct ClientInfo {
enum Role { Guest, Admin, RegionManager, FilterManager } role;
@@ -272,7 +312,7 @@ int main() {
dispatch(cli, nullptr, command, reply);
assert(!strcmp(reply, "OK"));
}
assert(allocations == 1 && configurations == 10);
assert(allocations == 1 && configurations == 10 && enrolled_regions == cli._region_map);
assert(isFilterMgrAllowed("set flood.filter.1 group-text 4"));
assert(isRegionMgrAllowed("set flood.channel.scope public #local"));
assert(!isFilterMgrAllowed("set radio2 off"));
@@ -283,6 +323,18 @@ int main() {
assert(strstr(reply, "infrastructure roles"));
assert(allocations == 1 && configurations == 10);
assert(!cli.handleFleetCommand("set fleeter.channel off", reply));
FS disk;
CommonCLI no_existing;
no_existing.loadExisting(&disk);
assert(allocations == 1 && no_existing._fleet_channel == nullptr);
disk.present = true;
CommonCLI existing;
existing.loadExisting(&disk);
assert(allocations == 2 && existing._fleet_channel && enrolled_regions == existing._region_map);
CommonCLI disabled;
disabled.callbacks.supported = false;
disabled.loadExisting(&disk);
assert(allocations == 2 && disabled._fleet_channel == nullptr);
puts("Fleet enrollment remains restricted to local/admin infrastructure dispatch");
}
'''
@@ -301,10 +353,13 @@ class FleetRoleWiringTests(unittest.TestCase):
guard_start = handler.rindex("if (sender && !sender->isAdmin())", 0,
handler.index("mesh::cli::handleACLGet("))
guard = body(handler[guard_start:], "if (sender && !sender->isAdmin())")
common = body((ROOT / "src/helpers/CommonCLI_Management.cpp").read_text(),
"bool CommonCLI::handleFleetCommand(")
management = (ROOT / "src/helpers/CommonCLI_Management.cpp").read_text()
common = body(management, "bool CommonCLI::handleFleetCommand(")
boot = body(body(management, "void CommonCLI::beginManagement("),
"if (_callbacks->supportsFleetControl()")
harness = AUTHORIZATION.replace("@MANAGER_HELPERS@", helpers)
harness = harness.replace("@COMMON_FLEET@", common).replace("@MANAGER_GUARD@", guard)
harness = harness.replace("@BOOT_FLEET@", boot)
with tempfile.TemporaryDirectory(prefix="meshcore-fleet-authorization-") as directory:
work = Path(directory)
source = work / "fixture.cpp"
@@ -332,6 +387,9 @@ class FleetRoleWiringTests(unittest.TestCase):
header = (ROOT / "examples" / folder / "MyMesh.h").read_text()
capabilities[role] = body(header, "bool supportsFleetControl() const override")
methods.append(body(source, "void MyMesh::onGroupPacketRecv(").replace("MyMesh::", role + "::", 1))
methods.append("#if MESH_ENABLE_FLEET_CONTROL\n"
+ body(source, "bool MyMesh::getFleetLocation(").replace("MyMesh::", role + "::", 1)
+ "\n#endif\n")
loop = body(source, "void MyMesh::loop()")
fleet = body(loop, "if (_cli.fleetChannel())")
methods.append("void " + role + "::serviceFleet() {\n#if MESH_ENABLE_FLEET_CONTROL\n"
@@ -362,7 +420,7 @@ class FleetRoleWiringTests(unittest.TestCase):
executable = work / str(enabled)
built = subprocess.run([
compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-isystem", str(ROOT / "test/mocks"),
"-isystem", str(ROOT / "test/mocks"), "-I", str(ROOT / "src"),
"-DMESH_ENABLE_FLEET_CONTROL=" + str(enabled),
str(fixture), "-o", str(executable),
], capture_output=True, text=True, timeout=60)