Support public-key prefixes and lists in signed fleet commands
This commit is contained in:
1 parent
39394a255a
commit
a91ec9407a
12 files changed
+756
-52
No files matched your search
+3
-2
@@ -25,12 +25,13 @@ struct FleetCommand {
|
||||
static constexpr uint16_t DataType = 0xFF01;
|
||||
static constexpr size_t KeySize = 16, TargetSize = 16, MaxPayloadLength = 165;
|
||||
static constexpr uint32_t MinEpoch = 1735689600UL, MaxLifetime = 600;
|
||||
static bool parseTarget(const char*, uint8_t*) { return false; }
|
||||
struct Targets { uint8_t length = 0, count = 0, data[86] = {}; };
|
||||
static bool parseTargets(const char*, size_t, Targets&) { return false; }
|
||||
static bool commandAllowed(const char*) { return false; }
|
||||
static bool privateKeyAllowed(const uint8_t*) { return false; }
|
||||
template<class Signer>
|
||||
static size_t encode(const Signer&, const uint8_t*, uint32_t, uint32_t,
|
||||
const uint8_t*, const char*, uint8_t*, size_t) {
|
||||
const Targets&, const char*, uint8_t*, size_t) {
|
||||
assert(false && "fleet encoding belongs in the dedicated real-codec suite");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -126,6 +126,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", 1);
|
||||
rejected(node, "fleet send 1 all reboot");
|
||||
rejected(node, "fleet send 1 all get prv.key");
|
||||
@@ -134,12 +135,13 @@ int main(int argc, char** argv) {
|
||||
assert(!strncmp(reply, "AB|OK - fleet command queued; seq=", 32));
|
||||
assert(node.queued == 1 && node.sent_type == mesh::FleetCommand::DataType);
|
||||
assert(node.sent_policy == 2); // The configured radio2 channel route survives.
|
||||
assert(!memcmp(node.sent, "FMC1", 4)); // Broadcasts remain compatible with earlier receivers.
|
||||
} else if (scenario == "syntax") {
|
||||
for (const char* command : {"fleet", "fleet send", "fleet sender 1 all set radio2 off",
|
||||
"fleet send -1 all set radio2 off", "fleet send 4 all set radio2 off",
|
||||
"fleet send 4294967296 all set radio2 off", "fleet send 1x all set radio2 off",
|
||||
"fleet send 1 all", "fleet send 1 a set radio2 off",
|
||||
"fleet send 1 111111111111 set radio2 off", "fleet send 1 all get password",
|
||||
"fleet send 1 11111111111111 set radio2 off", "fleet send 1 all get password",
|
||||
"fleet send 1 all set radio2 off\nreboot"}) rejected(node, command);
|
||||
rejected(node, ("fleet send 1 " + std::string(65, '1') + " set radio2 off").c_str());
|
||||
rejected(node, ("fleet send 1 all set flood.filter " + std::string(150, '1')).c_str());
|
||||
@@ -187,6 +189,7 @@ int main(int argc, char** argv) {
|
||||
+ " set tempradio2 910.5,500,5,5,rxtx,1";
|
||||
assert(node.handleCommand(command.c_str(), 0, reply));
|
||||
assert(node.queued == 1);
|
||||
assert(!memcmp(node.sent, "FMC1", 4)); // One full key keeps the original envelope.
|
||||
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,
|
||||
@@ -197,6 +200,78 @@ int main(int argc, char** argv) {
|
||||
recipient[31] ^= 1;
|
||||
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)
|
||||
+ " set radio2 off";
|
||||
assert(node.handleCommand(command.c_str(), 0, reply));
|
||||
assert(node.queued == 1 && node.sent_policy == 2);
|
||||
assert(!memcmp(node.sent, "FMC2", 4));
|
||||
mesh::FleetCommand::Decoded decoded;
|
||||
uint8_t recipient[32];
|
||||
auto matched = [&]() {
|
||||
return mesh::FleetCommand::decode(node.self_id, node.channels[1].channel.secret,
|
||||
node.sent, node.sent_length, node.clock.now, recipient, decoded);
|
||||
};
|
||||
memset(recipient, 0x42, sizeof(recipient));
|
||||
assert(matched());
|
||||
assert(!strcmp(decoded.command, "set radio2 off"));
|
||||
recipient[31] ^= 1;
|
||||
assert(matched()); // An eight-hex target selects the four-byte prefix.
|
||||
recipient[3] ^= 1;
|
||||
assert(!matched());
|
||||
memset(recipient, 0x51, sizeof(recipient));
|
||||
assert(matched());
|
||||
recipient[31] ^= 1;
|
||||
assert(matched()); // Twelve hex selects all six specified prefix bytes.
|
||||
recipient[5] ^= 1;
|
||||
assert(!matched());
|
||||
memset(recipient, 0x63, sizeof(recipient));
|
||||
assert(matched());
|
||||
recipient[31] ^= 1;
|
||||
assert(!matched()); // Full keys retain the complete-key fingerprint.
|
||||
memset(recipient, 0x77, sizeof(recipient));
|
||||
assert(!matched());
|
||||
} else if (scenario == "multiple_full_targets") {
|
||||
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);
|
||||
assert(!memcmp(node.sent, "FMC2", 4));
|
||||
mesh::FleetCommand::Decoded decoded;
|
||||
uint8_t recipient[32];
|
||||
for (uint8_t value : {0x42, 0x63}) {
|
||||
memset(recipient, value, sizeof(recipient));
|
||||
assert(mesh::FleetCommand::decode(node.self_id, node.channels[1].channel.secret,
|
||||
node.sent, node.sent_length, node.clock.now, recipient, decoded));
|
||||
recipient[31] ^= 1;
|
||||
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 == "invalid_target_lists") {
|
||||
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));
|
||||
assert(node.queued == 1); // Invalid lists consumed no sequence or partial send.
|
||||
} else if (scenario == "target_capacity") {
|
||||
std::string targets;
|
||||
for (unsigned i = 1; i <= 17; ++i) {
|
||||
char prefix[9]; snprintf(prefix, sizeof(prefix), "%08x", i);
|
||||
if (!targets.empty()) targets += ',';
|
||||
targets += prefix;
|
||||
}
|
||||
mesh::FleetCommand::Targets parsed;
|
||||
assert(mesh::FleetCommand::parseTargets(targets.c_str(), targets.size(), parsed));
|
||||
assert(node.handleCommand(("fleet send 1 " + targets + " set radio2 off").c_str(), 0, reply));
|
||||
assert(strstr(reply, "exceed one packet") && node.queued == 0);
|
||||
assert(node.handleCommand("fleet send 1 00112233 set radio2 off", 0, reply));
|
||||
assert(node.queued == 1);
|
||||
++node.clock.now;
|
||||
targets += ",44556677"; // More than the entire target-record capacity.
|
||||
rejected(node, ("fleet send 1 " + targets + " set radio2 off").c_str());
|
||||
assert(node.queued == 1);
|
||||
} else assert(false);
|
||||
printf("Companion fleet scenario %s passed\n", argv[1]);
|
||||
}
|
||||
@@ -263,6 +338,18 @@ class CompanionFleetControlTests(unittest.TestCase):
|
||||
def test_production_encode_targets_full_key_and_sets_bounded_expiry(self):
|
||||
self.scenario("signed_target")
|
||||
|
||||
def test_mixed_prefix_and_full_key_list_matches_only_selected_nodes(self):
|
||||
self.scenario("mixed_targets")
|
||||
|
||||
def test_multiple_full_keys_share_one_signed_packet(self):
|
||||
self.scenario("multiple_full_targets")
|
||||
|
||||
def test_invalid_lists_reject_atomically_without_consuming_sequence(self):
|
||||
self.scenario("invalid_target_lists")
|
||||
|
||||
def test_valid_list_exceeding_packet_capacity_never_partially_sends(self):
|
||||
self.scenario("target_capacity")
|
||||
|
||||
def test_disabled_profiles_require_no_sender_peripherals_or_crypto_link(self):
|
||||
harness = r'''
|
||||
#include <cassert>
|
||||
|
||||
@@ -292,6 +292,32 @@ static Packet command(const LocalIdentity& publisher,uint32_t sequence,const cha
|
||||
return raw(payload,3+length);
|
||||
}
|
||||
|
||||
static Packet targetsCommand(const LocalIdentity& publisher,uint32_t sequence,const std::string& list,
|
||||
const char* text="get radio2"){
|
||||
FleetCommand::Targets targets;assert(FleetCommand::parseTargets(list.c_str(),list.size(),targets));
|
||||
uint8_t payload[184]={};
|
||||
const size_t length=FleetCommand::encode(publisher,channel_key,sequence,sequence+300,targets,text,
|
||||
payload+3,sizeof(payload)-3);
|
||||
assert(length);payload[0]=uint8_t(FleetCommand::DataType);payload[1]=uint8_t(FleetCommand::DataType>>8);
|
||||
payload[2]=uint8_t(length);
|
||||
return raw(payload,3+length);
|
||||
}
|
||||
static Packet shortNewAll(const LocalIdentity& publisher,uint32_t sequence){
|
||||
constexpr const char* text="get radio2";
|
||||
uint8_t data[184]={},message[160]={};uint8_t* payload=data+3;
|
||||
memcpy(payload,"FMC2",4);storage::writeLE32(payload+4,sequence);
|
||||
storage::writeLE32(payload+8,sequence+300);payload[12]=0;
|
||||
const size_t text_size=strlen(text);payload[13]=uint8_t(text_size);memcpy(payload+14,text,text_size);
|
||||
constexpr const char* domain="MeshCoreFleet1";const size_t domain_size=strlen(domain);
|
||||
memcpy(message,domain,domain_size);memcpy(message+domain_size,channel_key,16);
|
||||
memcpy(message+domain_size+16,payload,14+text_size);
|
||||
publisher.sign(payload+14+text_size,message,int(domain_size+16+14+text_size));
|
||||
data[0]=uint8_t(FleetCommand::DataType);data[1]=uint8_t(FleetCommand::DataType>>8);
|
||||
data[2]=uint8_t(14+text_size+64);return raw(data,3+data[2]);
|
||||
}
|
||||
static std::string publicHex(const Identity& identity){
|
||||
char text[65];Utils::toHex(text,identity.pub_key,32);return text;
|
||||
}
|
||||
static Packet signedUnchecked(const LocalIdentity& publisher,uint32_t sequence,uint32_t expires,
|
||||
const char* text){
|
||||
uint8_t data[184]={},message[160]={};size_t text_size=strlen(text);
|
||||
@@ -317,7 +343,7 @@ static unsigned apply(FleetChannel& fleet,fs::FS& fs,Mesh& mesh,RadioProfileCLI&
|
||||
fleet.service(mesh,profiles,"test:node",[&](uint32_t seq,const char* text,char* reply){
|
||||
++calls;assert(storage::readLE32(fs.files.at(Path).data()+56)==seq);
|
||||
assert(mesh.scoped_profile==1&&mesh.scoped_generation==17);assert(profiles.command);
|
||||
assert(!strcmp(text,"set radio2 off")||!strcmp(text,"get radio2.status"));
|
||||
assert(!strcmp(text,"set radio2 off")||!strcmp(text,"get radio2.status")||!strcmp(text,"get radio2"));
|
||||
if(mutation)profiles.stage();strcpy(reply,fail_handler?"Err - rejected":"OK");
|
||||
});
|
||||
assert(mesh.scoped_profile==0&&mesh.scoped_generation==0);assert(!profiles.command);return calls;
|
||||
@@ -532,6 +558,40 @@ int main(int argc,char** argv){
|
||||
assert(!captureFleetReplyScope(regions,static_cast<Region*>(nullptr),&packet,captured));
|
||||
region.wildcard=false;packet.transport_codes[0]^=1;
|
||||
assert(!captureFleetReplyScope(regions,®ion,&packet,captured));
|
||||
|
||||
}else if(scenario=="fmc2_short_all"){
|
||||
enroll(fleet,publisher);Packet packet=shortNewAll(publisher,mesh.clock.now);
|
||||
uint8_t plaintext[184]={};assert(Utils::MACThenDecrypt(channel_key,plaintext,packet.payload+1,packet.payload_len-1)>0);
|
||||
assert(plaintext[2]<FleetCommand::HeaderSize+FleetCommand::SignatureSize);
|
||||
assert(apply(fleet,fs,mesh,profiles,packet)==1);assert(last_jitter_max==60500);
|
||||
assert(apply(fleet,fs,mesh,profiles,packet)==0);
|
||||
}else if(scenario=="fmc2_short_prefix"){
|
||||
enroll(fleet,publisher);Packet packet=targetsCommand(publisher,mesh.clock.now,publicHex(mesh.self_id).substr(0,8));
|
||||
uint8_t plaintext[184]={};assert(Utils::MACThenDecrypt(channel_key,plaintext,packet.payload+1,packet.payload_len-1)>0);
|
||||
assert(plaintext[2]==FleetCommand::HeaderSize+FleetCommand::SignatureSize);
|
||||
assert(!memcmp(plaintext+3,"FMC2",4));
|
||||
assert(apply(fleet,fs,mesh,profiles,packet)==1);assert(last_jitter_max==60500);
|
||||
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);
|
||||
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));
|
||||
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));
|
||||
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);
|
||||
Packet good=targetsCommand(publisher,mesh.clock.now,publicHex(mesh.self_id).substr(0,12));
|
||||
uint8_t plaintext[184]={};const int length=Utils::MACThenDecrypt(channel_key,plaintext,good.payload+1,good.payload_len-1);
|
||||
assert(length>0);plaintext[3+13]^=1;Packet changed=raw(plaintext,length);
|
||||
assert(apply(fleet,fs,mesh,profiles,changed)==0);assert(fs.files.at(Path)==previous);
|
||||
assert(apply(fleet,fs,mesh,profiles,good)==1);
|
||||
}else if(scenario=="existing_mutation"){
|
||||
enroll(fleet,publisher);Packet packet=command(publisher,mesh.clock.now);
|
||||
profiles.mutation=true;assert(apply(fleet,fs,mesh,profiles,packet)==0);
|
||||
@@ -681,6 +741,21 @@ class SHA256 : public SHA256Base {{ public:
|
||||
def test_scope_selects_matching_rotated_region_key_and_authenticates_new_ack_payload(self):
|
||||
self.scenario("rotated_scope")
|
||||
|
||||
def test_new_short_broadcast_envelope_dispatches_and_preserves_replay_protection(self):
|
||||
self.scenario("fmc2_short_all")
|
||||
|
||||
def test_new_public_key_prefix_envelope_dispatches_and_staggers_possible_multiple_acks(self):
|
||||
self.scenario("fmc2_short_prefix")
|
||||
|
||||
def test_overlapping_prefix_and_full_key_matches_execute_once(self):
|
||||
self.scenario("fmc2_overlap_matches_once")
|
||||
|
||||
def test_nonmatching_target_list_never_reserves_sequence_and_matching_list_can_reuse_it(self):
|
||||
self.scenario("fmc2_nonmatching_no_reserve")
|
||||
|
||||
def test_modified_new_target_header_never_dispatches_without_valid_signature(self):
|
||||
self.scenario("fmc2_target_tamper")
|
||||
|
||||
def test_existing_remote_radio_mutation_blocks_fleet_without_reserving_sequence(self):
|
||||
self.scenario("existing_mutation")
|
||||
|
||||
|
||||
@@ -47,6 +47,15 @@ protected:
|
||||
return Fleet::decode(publisher, ChannelKey, bytes.data(), bytes.size(),
|
||||
now, publisher.pub_key, decoded);
|
||||
}
|
||||
|
||||
std::vector<uint8_t> encodeTargets(const Fleet::Targets& targets,
|
||||
const char* command = "get radio2") {
|
||||
std::vector<uint8_t> bytes(Fleet::MaxPayloadLength);
|
||||
const size_t size = Fleet::encode(publisher, ChannelKey, Now, Now + 120,
|
||||
targets, command, bytes.data(), bytes.size());
|
||||
bytes.resize(size);
|
||||
return bytes;
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(FleetCommandTest, ExplicitRadioAndFilterFamiliesAreAllowed) {
|
||||
@@ -156,6 +165,7 @@ TEST_F(FleetCommandTest, ExactEnvelopeRoundTripsThroughVerifierBoundary) {
|
||||
EXPECT_EQ(calls + 1, g_mock_ed25519_verify_calls);
|
||||
EXPECT_EQ(Now, decoded.sequence);
|
||||
EXPECT_EQ(Now + 120, decoded.expires);
|
||||
EXPECT_TRUE(decoded.broadcast);
|
||||
EXPECT_STREQ("get radio2", decoded.command);
|
||||
}
|
||||
|
||||
@@ -226,6 +236,7 @@ TEST_F(FleetCommandTest, MatchesHashedCompleteSelfIdentityOrAll) {
|
||||
hash.update(publisher.pub_key, PUB_KEY_SIZE);
|
||||
hash.finalize(target.data(), target.size());
|
||||
ASSERT_TRUE(decode(encode("get radio2", Now, Now + 120, target.data())));
|
||||
EXPECT_FALSE(decoded.broadcast);
|
||||
target[15] ^= 1;
|
||||
EXPECT_FALSE(decode(encode("get radio2", Now, Now + 120, target.data())));
|
||||
EXPECT_TRUE(decode(encode()));
|
||||
@@ -265,6 +276,215 @@ TEST_F(FleetCommandTest, UnixRangeBoundaryDoesNotWrap) {
|
||||
EXPECT_TRUE(encode("get radio2", maximum - 50, 1).empty());
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, BoundedTargetTokenParsesMixedPrefixWidthsAndCompleteKeys) {
|
||||
Fleet::Targets targets{};
|
||||
const std::string full = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
|
||||
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);
|
||||
EXPECT_EQ(5 + 7 + 17, targets.length);
|
||||
EXPECT_EQ(4, targets.data[0]);
|
||||
EXPECT_EQ(0xA1, targets.data[1]);
|
||||
EXPECT_EQ(0xD4, targets.data[4]);
|
||||
EXPECT_EQ(6, targets.data[5]);
|
||||
EXPECT_EQ(6, targets.data[11]);
|
||||
EXPECT_EQ(16, targets.data[12]);
|
||||
EXPECT_EQ(0xAE, targets.data[13]);
|
||||
EXPECT_EQ(0xE4, targets.data[28]);
|
||||
const char nonterminated[8] = {'0','1','0','2','0','3','0','4'};
|
||||
EXPECT_TRUE(Fleet::parseTargets(nonterminated, sizeof(nonterminated), targets));
|
||||
EXPECT_EQ(1, targets.count);
|
||||
EXPECT_EQ(5, targets.length);
|
||||
EXPECT_TRUE(Fleet::parseTargets("all plus other text", 3, targets));
|
||||
EXPECT_EQ(0, targets.count);
|
||||
EXPECT_EQ(0, targets.length);
|
||||
}
|
||||
|
||||
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("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')}) {
|
||||
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);
|
||||
}
|
||||
Fleet::Targets targets{};
|
||||
EXPECT_FALSE(Fleet::parseTargets(nullptr, 8, targets));
|
||||
EXPECT_FALSE(Fleet::parseTargets("01020304", SIZE_MAX, targets));
|
||||
EXPECT_FALSE(Fleet::parseTargets("01020304", 0, targets));
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, TargetRecordAndPayloadBudgetsAreSeparateAndBounded) {
|
||||
std::string list;
|
||||
for (unsigned count = 1; count <= 17; ++count) {
|
||||
if (!list.empty()) list += ',';
|
||||
list += "01020304";
|
||||
Fleet::Targets targets{};
|
||||
ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets));
|
||||
EXPECT_EQ(count, targets.count);
|
||||
EXPECT_EQ(count * 5, targets.length);
|
||||
EXPECT_EQ(count <= 15, !encodeTargets(targets).empty()) << count;
|
||||
}
|
||||
Fleet::Targets targets{};
|
||||
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 += ',';
|
||||
list += full;
|
||||
ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets));
|
||||
EXPECT_EQ(count * 17, targets.length);
|
||||
}
|
||||
EXPECT_TRUE(encodeTargets(targets).empty());
|
||||
list += ',' + full;
|
||||
EXPECT_FALSE(Fleet::parseTargets(list.data(), list.size(), targets));
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, AllAndSingleCompleteKeyKeepLegacyFmc1Encoding) {
|
||||
Fleet::Targets targets{};
|
||||
ASSERT_TRUE(Fleet::parseTargets("all", 3, targets));
|
||||
EXPECT_EQ(encode(), encodeTargets(targets));
|
||||
char full[PUB_KEY_SIZE * 2 + 1];
|
||||
mesh::Utils::toHex(full, publisher.pub_key, PUB_KEY_SIZE);
|
||||
uint8_t hash[Fleet::TargetSize];
|
||||
ASSERT_TRUE(Fleet::parseTarget(full, hash));
|
||||
ASSERT_TRUE(Fleet::parseTargets(full, 64, targets));
|
||||
EXPECT_EQ(encode("get radio2", Now, Now + 120, hash), encodeTargets(targets));
|
||||
EXPECT_TRUE(decode(encodeTargets(targets)));
|
||||
EXPECT_FALSE(decoded.broadcast);
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, PrefixesAndListsUseFmc2WithSignedFraming) {
|
||||
char full[PUB_KEY_SIZE * 2 + 1];
|
||||
mesh::Utils::toHex(full, publisher.pub_key, PUB_KEY_SIZE);
|
||||
for (size_t width : {8U, 12U}) {
|
||||
Fleet::Targets targets{};
|
||||
ASSERT_TRUE(Fleet::parseTargets(full, width, targets));
|
||||
const auto bytes = encodeTargets(targets);
|
||||
ASSERT_FALSE(bytes.empty());
|
||||
EXPECT_EQ(std::string("FMC2"), std::string(bytes.begin(), bytes.begin() + 4));
|
||||
EXPECT_EQ(1, bytes[12]);
|
||||
EXPECT_EQ(width / 2, bytes[13]);
|
||||
EXPECT_TRUE(decode(bytes));
|
||||
EXPECT_TRUE(decoded.broadcast);
|
||||
}
|
||||
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);
|
||||
EXPECT_TRUE(decode(bytes));
|
||||
EXPECT_TRUE(decoded.broadcast);
|
||||
EXPECT_EQ(2, bytes[12]);
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, AnyMatchingEntryIsAcceptedButMalformedLaterEntryIsRejected) {
|
||||
char full[PUB_KEY_SIZE * 2 + 1];
|
||||
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;
|
||||
ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets));
|
||||
auto bytes = encodeTargets(targets);
|
||||
ASSERT_TRUE(decode(bytes));
|
||||
bytes[18] = 7; // Unknown type after the first matching four-byte prefix.
|
||||
EXPECT_FALSE(decode(bytes));
|
||||
EXPECT_FALSE(decoded.broadcast);
|
||||
bytes = encodeTargets(targets);
|
||||
bytes[12] = 18;
|
||||
EXPECT_FALSE(decode(bytes));
|
||||
bytes = encodeTargets(targets);
|
||||
bytes[12] = 4;
|
||||
EXPECT_FALSE(decode(bytes));
|
||||
bytes = encodeTargets(targets);
|
||||
bytes[13 + targets.length] = 0;
|
||||
EXPECT_FALSE(decode(bytes));
|
||||
bytes = encodeTargets(targets);
|
||||
bytes[13 + targets.length] = 73;
|
||||
EXPECT_FALSE(decode(bytes));
|
||||
for (size_t length = 0; length < bytes.size(); ++length) {
|
||||
const std::vector<uint8_t> truncated(bytes.begin(), bytes.begin() + length);
|
||||
EXPECT_FALSE(decode(truncated));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, RawPrefixesIncludeLegitimateZeroPrefixesAndCollisions) {
|
||||
Fleet::Targets targets{};
|
||||
ASSERT_TRUE(Fleet::parseTargets("00000000", 8, targets));
|
||||
const auto bytes = encodeTargets(targets);
|
||||
uint8_t first[PUB_KEY_SIZE] = {}, second[PUB_KEY_SIZE] = {};
|
||||
first[4] = 1;
|
||||
second[4] = 2;
|
||||
ASSERT_TRUE(Fleet::decode(publisher, ChannelKey, bytes.data(), bytes.size(),
|
||||
Now, first, decoded));
|
||||
EXPECT_TRUE(decoded.broadcast);
|
||||
ASSERT_TRUE(Fleet::decode(publisher, ChannelKey, bytes.data(), bytes.size(),
|
||||
Now, second, decoded));
|
||||
ASSERT_TRUE(Fleet::parseTargets("000000000100", 12, targets));
|
||||
const auto narrow = encodeTargets(targets);
|
||||
EXPECT_TRUE(Fleet::decode(publisher, ChannelKey, narrow.data(), narrow.size(),
|
||||
Now, first, decoded));
|
||||
EXPECT_FALSE(Fleet::decode(publisher, ChannelKey, narrow.data(), narrow.size(),
|
||||
Now, second, decoded));
|
||||
first[0] = 1;
|
||||
EXPECT_FALSE(Fleet::decode(publisher, ChannelKey, bytes.data(), bytes.size(),
|
||||
Now, first, decoded));
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, InvalidExternalTargetStructuresNeverWriteOutput) {
|
||||
std::array<uint8_t, Fleet::MaxPayloadLength> output;
|
||||
output.fill(0xA5);
|
||||
for (unsigned invalid = 0; invalid < 7; ++invalid) {
|
||||
Fleet::Targets targets{};
|
||||
if (invalid == 0) targets.length = 1;
|
||||
if (invalid == 1) targets.count = 1;
|
||||
if (invalid == 2) targets.count = 18;
|
||||
if (invalid == 3) targets.length = 87;
|
||||
if (invalid == 4) { targets.count = 1; targets.length = 5; targets.data[0] = 3; }
|
||||
if (invalid == 5) { targets.count = 2; targets.length = 5; targets.data[0] = 4; }
|
||||
if (invalid == 6) { targets.count = 1; targets.length = 4; targets.data[0] = 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, LargestFmc2EnvelopeFitsAndOversizedCombinationWritesNothing) {
|
||||
const std::string prefix = "set flood.rule.1 ";
|
||||
const std::string maximum = prefix + std::string(Fleet::MaxCommandLength - prefix.size(), 'a');
|
||||
Fleet::Targets targets{};
|
||||
const char* three = "01020304,05060708,090A0B0C";
|
||||
ASSERT_TRUE(Fleet::parseTargets(three, strlen(three), targets));
|
||||
EXPECT_EQ(Fleet::MaxPayloadLength, encodeTargets(targets, maximum.c_str()).size());
|
||||
const char* four = "01020304,05060708,090A0B0C,01020304";
|
||||
ASSERT_TRUE(Fleet::parseTargets(four, strlen(four), targets));
|
||||
std::array<uint8_t, Fleet::MaxPayloadLength> output;
|
||||
output.fill(0xA5);
|
||||
EXPECT_EQ(0U, Fleet::encode(publisher, ChannelKey, Now, Now + 120,
|
||||
targets, maximum.c_str(), output.data(), output.size()));
|
||||
for (uint8_t byte : output) EXPECT_EQ(0xA5, byte);
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, UnrepresentableZeroDigestNeverBecomesAllDuringLegacyEncoding) {
|
||||
Fleet::Targets targets{};
|
||||
targets.count = 1;
|
||||
targets.length = 17;
|
||||
targets.data[0] = 16;
|
||||
const auto bytes = encodeTargets(targets);
|
||||
ASSERT_FALSE(bytes.empty());
|
||||
EXPECT_EQ(std::string("FMC2"), std::string(bytes.begin(), bytes.begin() + 4));
|
||||
EXPECT_FALSE(decode(bytes));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
|
||||
@@ -95,15 +95,29 @@ int main(int argc, char** argv) {
|
||||
if (!size) std::cout << "reject\n"; else hex(output.data(), size);
|
||||
return 0;
|
||||
}
|
||||
if (argc == 7 && std::string(argv[1]) == "decode") {
|
||||
if (argc == 7 && std::string(argv[1]) == "encode2") {
|
||||
FixedRng rng; mesh::LocalIdentity publisher(&rng);
|
||||
const auto key = unhex(argv[2]); assert(key.size() == Fleet::KeySize);
|
||||
Fleet::Targets targets;
|
||||
if (!Fleet::parseTargets(argv[3], strlen(argv[3]), targets)) { std::cout << "reject\n"; return 0; }
|
||||
std::array<uint8_t, Fleet::MaxPayloadLength> output;
|
||||
const size_t size = Fleet::encode(publisher, key.data(), uint32_t(std::stoul(argv[4])),
|
||||
uint32_t(std::stoul(argv[5])), targets, argv[6], output.data(), output.size());
|
||||
if (!size) std::cout << "reject\n"; else hex(output.data(), size);
|
||||
return 0;
|
||||
}
|
||||
if (argc == 7 && (std::string(argv[1]) == "decode" || std::string(argv[1]) == "decode2")) {
|
||||
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;
|
||||
if (!Fleet::decode(publisher, key.data(), bytes.data(), bytes.size(),
|
||||
uint32_t(std::stoul(argv[5])), self.data(), output)) {
|
||||
assert(output.sequence == 0 && output.expires == 0 && output.command[0] == 0);
|
||||
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';
|
||||
} else {
|
||||
std::cout << output.sequence << '\n' << output.expires << '\n' << output.command << '\n';
|
||||
if (std::string(argv[1]) == "decode2") std::cout << int(output.broadcast) << '\n';
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return 2;
|
||||
@@ -118,6 +132,16 @@ def envelope(command="get radio2", sequence=NOW, expires=NOW + 120,
|
||||
return unsigned + publisher.sign(DOMAIN + channel + unsigned)
|
||||
|
||||
|
||||
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)
|
||||
count = len(entries) if count is None else count
|
||||
unsigned = (b"FMC2" + struct.pack("<II", sequence, expires) + bytes([count])
|
||||
+ records + bytes([len(command)]) + command)
|
||||
return unsigned + publisher.sign(DOMAIN + channel + unsigned)
|
||||
|
||||
|
||||
class FleetCommandCryptoTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
@@ -145,8 +169,9 @@ class FleetCommandCryptoTests(unittest.TestCase):
|
||||
self.assertNotIn("AddressSanitizer", result.stderr)
|
||||
return result.stdout.strip()
|
||||
|
||||
def decode(self, payload, channel=CHANNEL, publisher=PUBLIC, self_key=PUBLIC, now=NOW):
|
||||
return self.run_tool("decode", publisher.hex(), channel.hex(), self_key.hex(), now, payload.hex())
|
||||
def decode(self, payload, channel=CHANNEL, publisher=PUBLIC, self_key=PUBLIC, now=NOW, metadata=False):
|
||||
return self.run_tool("decode2" if metadata else "decode",
|
||||
publisher.hex(), channel.hex(), self_key.hex(), now, payload.hex())
|
||||
|
||||
def test_cpp_and_python_signatures_match_exactly(self):
|
||||
expected = envelope()
|
||||
@@ -244,6 +269,127 @@ class FleetCommandCryptoTests(unittest.TestCase):
|
||||
self.assertEqual(self.decode(envelope(channel=channel), channel=channel), "reject")
|
||||
self.assertEqual(self.run_tool("encode", channel.hex(), "all", NOW, NOW + 120, "get radio2"), "reject")
|
||||
|
||||
def test_new_sender_preserves_fmc1_for_all_and_single_complete_key(self):
|
||||
for token, target in [("all", b"\0" * 16), (PUBLIC.hex(), hashlib.sha256(PUBLIC).digest()[:16])]:
|
||||
with self.subTest(token=token):
|
||||
actual = self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, "get radio2")
|
||||
self.assertEqual(actual, envelope(target=target).hex())
|
||||
expected_broadcast = "1" if token == "all" else "0"
|
||||
self.assertTrue(self.decode(bytes.fromhex(actual), metadata=True).endswith("\n" + expected_broadcast))
|
||||
|
||||
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()}"
|
||||
expected = envelope2(entries)
|
||||
actual = self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, "get radio2")
|
||||
self.assertEqual(actual, expected.hex())
|
||||
self.assertTrue(self.decode(expected, metadata=True).endswith("\n1"))
|
||||
self.assertTrue(self.decode(expected, self_key=OTHER_PUBLIC, metadata=True).endswith("\n1"))
|
||||
unrelated = hashlib.sha256(b"unrelated fleet node").digest()
|
||||
self.assertEqual(self.decode(expected, self_key=unrelated), "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:]
|
||||
broad = envelope2([(4, PUBLIC[:4])])
|
||||
self.assertTrue(self.decode(broad, self_key=first, metadata=True).endswith("\n1"))
|
||||
self.assertTrue(self.decode(broad, self_key=second, metadata=True).endswith("\n1"))
|
||||
narrow = envelope2([(6, PUBLIC[:6])])
|
||||
self.assertNotEqual(self.decode(narrow, self_key=first), "reject")
|
||||
self.assertEqual(self.decode(narrow, self_key=second), "reject")
|
||||
full = envelope2([(16, hashlib.sha256(first).digest()[:16])])
|
||||
self.assertTrue(self.decode(full, self_key=first, metadata=True).endswith("\n0"))
|
||||
self.assertEqual(self.decode(full, self_key=second), "reject")
|
||||
|
||||
def test_fmc2_all_is_accepted_and_duplicate_matches_decode_once(self):
|
||||
self.assertTrue(self.decode(envelope2(), metadata=True).endswith("\n1"))
|
||||
duplicates = envelope2([(16, hashlib.sha256(PUBLIC).digest()[:16])] * 2)
|
||||
result = self.decode(duplicates, metadata=True)
|
||||
self.assertEqual(result, f"{NOW}\n{NOW + 120}\nget radio2\n1")
|
||||
|
||||
def test_legitimate_zero_prefixes_are_accepted_but_zero_complete_identity_is_not(self):
|
||||
node = b"\0" * 6 + b"\1" * 26
|
||||
for width in [4, 6]:
|
||||
with self.subTest(width=width):
|
||||
self.assertNotEqual(self.decode(envelope2([(width, b"\0" * width)]), self_key=node), "reject")
|
||||
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), "0" * 64, NOW, NOW + 120, "get radio2"), "reject")
|
||||
|
||||
def test_multitarget_signature_binds_every_target_entry_and_count(self):
|
||||
original = envelope2([(4, PUBLIC[:4]), (6, OTHER_PUBLIC[:6]),
|
||||
(16, hashlib.sha256(PUBLIC).digest()[:16])])
|
||||
# The first prefix still matches. Every later record byte nevertheless
|
||||
# remains signed, so modifying a nonmatching or duplicate target fails.
|
||||
for offset in range(18, 13 + 5 + 7 + 17):
|
||||
with self.subTest(target_byte=offset):
|
||||
changed = bytearray(original)
|
||||
changed[offset] ^= 1
|
||||
self.assertEqual(self.decode(changed), "reject")
|
||||
changed = bytearray(original)
|
||||
changed[12] = 2
|
||||
self.assertEqual(self.decode(changed), "reject")
|
||||
|
||||
def test_malformed_target_after_matching_record_is_rejected_even_when_signed(self):
|
||||
matching = (4, PUBLIC[:4])
|
||||
malformed = [
|
||||
[(3, b"abc")], [(0, b"")], [(5, b"abcde")], [(32, PUBLIC)],
|
||||
[(16, b"x" * 15)], [(6, b"x" * 5)], [(255, b"")]]
|
||||
for suffix in malformed:
|
||||
with self.subTest(suffix=suffix):
|
||||
self.assertEqual(self.decode(envelope2([matching] + suffix)), "reject")
|
||||
self.assertEqual(self.decode(envelope2([matching], count=18)), "reject")
|
||||
self.assertEqual(self.decode(envelope2([matching], count=0)), "reject")
|
||||
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)]:
|
||||
with self.subTest(token=token):
|
||||
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, "get radio2"), "reject")
|
||||
|
||||
def test_fmc2_packet_capacity_is_checked_before_signing_or_sending(self):
|
||||
maximum = "set flood.rule.1 " + "a" * (72 - len("set flood.rule.1 "))
|
||||
entries = [(4, PUBLIC[:4])] * 3
|
||||
expected = envelope2(entries, maximum)
|
||||
self.assertEqual(len(expected), 165)
|
||||
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()
|
||||
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)
|
||||
result = self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, "get radio2")
|
||||
self.assertEqual(result == "reject", count > 15)
|
||||
|
||||
def test_fmc2_rejects_short_trailing_and_signed_forbidden_commands(self):
|
||||
original = envelope2([(4, PUBLIC[:4]), (6, OTHER_PUBLIC[:6])])
|
||||
for length in [0, 12, 13, 14, 18, 19, len(original) - 1]:
|
||||
self.assertEqual(self.decode(original[:length]), "reject")
|
||||
self.assertEqual(self.decode(original + b"\0"), "reject")
|
||||
for command in ["get prv.key", "set fleet.controller off", "get radio2\nreboot", b"get radio2\0"]:
|
||||
self.assertEqual(self.decode(envelope2([(4, PUBLIC[:4])], command)), "reject")
|
||||
|
||||
def test_fmc2_uses_same_signed_sequence_and_clock_rules(self):
|
||||
entries = [(4, PUBLIC[:4])]
|
||||
original = envelope2(entries)
|
||||
self.assertEqual(struct.unpack("<I", original[4:8])[0], NOW)
|
||||
forged = envelope2(entries, sequence=NOW + 1)[:-64] + original[-64:]
|
||||
self.assertEqual(self.decode(forged), "reject")
|
||||
for sequence, expires, now in [(NOW + 61, NOW + 120, NOW),
|
||||
(NOW, NOW + 601, NOW), (NOW, NOW + 120, NOW + 121)]:
|
||||
self.assertEqual(self.decode(envelope2(entries, sequence=sequence, expires=expires), now=now), "reject")
|
||||
|
||||
def test_fmc2_signature_cannot_move_between_channels_or_publishers(self):
|
||||
original = envelope2([(4, PUBLIC[:4]), (6, OTHER_PUBLIC[:6])])
|
||||
self.assertEqual(self.decode(original, publisher=OTHER_PUBLIC), "reject")
|
||||
self.assertEqual(self.decode(original, channel=bytes(range(17, 33))), "reject")
|
||||
self.assertEqual(self.decode(envelope2([(4, PUBLIC[:4])], publisher=OTHER)), "reject")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -251,7 +251,8 @@ public:
|
||||
checked([compiler, "-std=c++17", "-Wall", "-Wextra", "-Wno-unused-function",
|
||||
"-Wno-unused-parameter", "-Wno-sign-compare", "-Wno-reorder",
|
||||
"-DESP32_PLATFORM=1", *SANITIZERS,
|
||||
f"-I{work}", f"-I{ROOT / 'test/fixtures/radio_profiles/mocks'}",
|
||||
f"-I{work}", f"-I{ROOT / 'test/fixtures'}",
|
||||
f"-I{ROOT / 'test/fixtures/radio_profiles/mocks'}",
|
||||
f"-I{ROOT / 'test/mocks'}", f"-I{ROOT / 'src'}", f"-I{ROOT / 'src/helpers'}", f"-I{ROOT}",
|
||||
str(work / "radio.cpp"), *[str(ROOT / "src/helpers" / name) for name in (
|
||||
"ConfigSerializer.cpp", "DynamicConfigSerializer.cpp", "CommonRadioPrefs.cpp",
|
||||
|
||||
Reference in new issue
Block a user