Verify fallback namespace moves before accepting publication or rollback. Reload readable contact images at a safe host boundary, discard only unreadable contact data after bounded retries, and permit volatile operation when storage cannot recover. Preserve unrelated stores and fail explicit durability requests truthfully. Keep existing flash, RAM, OTA and capability limits by streaming nRF52 preferences through the validated wire table and sharing identical contact deadline resets. Add fault-injection, sanitizer, legacy-image parity and CLI lifecycle regressions.
1743 lines
98 KiB
Python
1743 lines
98 KiB
Python
#!/usr/bin/env python3
|
|
"""Execute production fleet receiver/storage/crypto boundaries with host mocks.
|
|
|
|
FleetChannel.cpp and FleetCommand.cpp are compiled unchanged. Only hardware,
|
|
packet admission, clock and filesystem I/O are mocked. Ed25519 signatures and
|
|
SHA-256 use real implementations; AES encryption/MAC are supplied by OpenSSL.
|
|
The real RP2040 ContactFileTransaction and nRF52 AtomicFileWriter validate and
|
|
publish persisted state using their actual platform-specific implementations.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
|
|
from cpp_source import body
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
UTILS = r'''
|
|
#pragma once
|
|
#include <cstdint>
|
|
#include <cstddef>
|
|
#include <cstring>
|
|
#include <cstdio>
|
|
#include <cassert>
|
|
#include <SHA256.h>
|
|
#include <openssl/evp.h>
|
|
#include <openssl/hmac.h>
|
|
#define PUB_KEY_SIZE 32
|
|
#define PRV_KEY_SIZE 64
|
|
#define SIGNATURE_SIZE 64
|
|
#define PATH_HASH_SIZE 1
|
|
#define CIPHER_MAC_SIZE 2
|
|
#define CIPHER_BLOCK_SIZE 16
|
|
inline unsigned hash_calls = 0, decrypt_calls = 0, verify_calls = 0, last_jitter_max = 0;
|
|
inline int jitter_pick_boundary = 0;
|
|
namespace mesh {
|
|
class RNG { public: uint32_t nextInt(uint32_t low, uint32_t high) {
|
|
assert(low == 500 && (high == 10000 || high == 1500)); last_jitter_max=high;
|
|
return jitter_pick_boundary < 0 ? low : jitter_pick_boundary > 0 ? high : 1000;
|
|
}};
|
|
class Utils { public:
|
|
static void sha256(uint8_t* out, size_t count, const uint8_t* data, int length) {
|
|
++hash_calls; SHA256 hash; hash.update(data, length); hash.finalize(out, count);
|
|
}
|
|
static void toHex(char* out, const uint8_t* data, size_t length) {
|
|
for (size_t i = 0; i < length; ++i) sprintf(out + 2*i, "%02x", data[i]);
|
|
out[2*length] = 0;
|
|
}
|
|
static bool fromHex(uint8_t* out, int length, const char* hex) {
|
|
if (strlen(hex) != size_t(2*length)) return false;
|
|
for (int i=0;i<length;++i) {
|
|
auto digit=[](char c) { return c>='0'&&c<='9'?c-'0':c>='a'&&c<='f'?c-'a'+10:
|
|
c>='A'&&c<='F'?c-'A'+10:-1; };
|
|
int a=digit(hex[2*i]), b=digit(hex[2*i+1]); if(a<0||b<0) return false;
|
|
out[i]=uint8_t(a*16+b);
|
|
} return true;
|
|
}
|
|
static int encryptThenMAC(const uint8_t* key, uint8_t* out, const uint8_t* data, int size) {
|
|
uint8_t padded[256] = {}; const int rounded = (size+15)/16*16;
|
|
assert(rounded <= int(sizeof(padded))); memcpy(padded,data,size);
|
|
EVP_CIPHER_CTX* ctx=EVP_CIPHER_CTX_new(); assert(ctx);
|
|
assert(EVP_EncryptInit_ex(ctx,EVP_aes_128_ecb(),nullptr,key,nullptr));
|
|
EVP_CIPHER_CTX_set_padding(ctx,0); int produced=0, tail=0;
|
|
assert(EVP_EncryptUpdate(ctx,out+CIPHER_MAC_SIZE,&produced,padded,rounded));
|
|
assert(EVP_EncryptFinal_ex(ctx,out+CIPHER_MAC_SIZE+produced,&tail));
|
|
EVP_CIPHER_CTX_free(ctx); assert(produced+tail==rounded);
|
|
uint8_t mac[32]; unsigned mac_len=0;
|
|
assert(HMAC(EVP_sha256(),key,32,out+CIPHER_MAC_SIZE,rounded,mac,&mac_len));
|
|
memcpy(out,mac,CIPHER_MAC_SIZE); return rounded+CIPHER_MAC_SIZE;
|
|
}
|
|
static int MACThenDecrypt(const uint8_t* key,uint8_t* out,const uint8_t* data,int size) {
|
|
++decrypt_calls; int cipher_size=size-CIPHER_MAC_SIZE;
|
|
if(cipher_size<=0 || cipher_size%16) return 0;
|
|
uint8_t mac[32]; unsigned mac_len=0;
|
|
assert(HMAC(EVP_sha256(),key,32,data+CIPHER_MAC_SIZE,cipher_size,mac,&mac_len));
|
|
if(memcmp(mac,data,CIPHER_MAC_SIZE)) return 0;
|
|
EVP_CIPHER_CTX* ctx=EVP_CIPHER_CTX_new(); assert(ctx);
|
|
assert(EVP_DecryptInit_ex(ctx,EVP_aes_128_ecb(),nullptr,key,nullptr));
|
|
EVP_CIPHER_CTX_set_padding(ctx,0); int produced=0,tail=0;
|
|
assert(EVP_DecryptUpdate(ctx,out,&produced,data+CIPHER_MAC_SIZE,cipher_size));
|
|
assert(EVP_DecryptFinal_ex(ctx,out+produced,&tail)); EVP_CIPHER_CTX_free(ctx);
|
|
return produced+tail;
|
|
}
|
|
};
|
|
}
|
|
'''
|
|
|
|
FILESYSTEM = r'''
|
|
#pragma once
|
|
#include <map>
|
|
#include <vector>
|
|
#include <string>
|
|
#include <memory>
|
|
#include <cstring>
|
|
#include <algorithm>
|
|
namespace fs { class FS; }
|
|
class File {
|
|
fs::FS* owner_=nullptr; std::string path_; size_t cursor_=0; bool live_=false;
|
|
public:
|
|
File() = default;
|
|
File(fs::FS* owner,const char* path,bool live):owner_(owner),path_(path),live_(live){}
|
|
explicit File(fs::FS& owner):owner_(&owner){}
|
|
bool open(const char* path,int flags);
|
|
explicit operator bool() const { return live_; }
|
|
bool isDirectory() const { return false; }
|
|
size_t size() const;
|
|
size_t read(uint8_t* data,size_t length);
|
|
size_t write(const uint8_t* data,size_t length);
|
|
void flush() {}
|
|
void close() {live_=false;}
|
|
};
|
|
namespace fs {
|
|
class FS { public:
|
|
std::map<std::string,std::vector<uint8_t>> files;
|
|
bool fail_open=false, fail_read=false, fail_write=false, fail_rename=false;
|
|
bool fail_after_publish=false, late_publish_error=false, fail_rollback=false;
|
|
bool false_success_rename=false, false_success_publish=false;
|
|
std::vector<uint8_t> substitute_temp;
|
|
unsigned reads=0,writes=0,renames=0;
|
|
std::string fail_rename_from;
|
|
void _lockFS() {} void _unlockFS() {} FS* _getFS(){return this;}
|
|
bool exists(const char* path) {return files.count(path)!=0;}
|
|
bool remove(const char* path) {return files.erase(path)!=0;}
|
|
bool mkdir(const char*) {return true;}
|
|
bool rename(const char* old,const char* next) {
|
|
++renames; if(fail_rename || fail_rename_from==old || !exists(old)) return false;
|
|
if(fail_rollback&&!strcmp(old,"/fleet_channel.bak"))return false;
|
|
if(false_success_rename||(false_success_publish&&!strcmp(old,"/fleet_channel.tmp")))return true;
|
|
files[next]=std::move(files[old]);files.erase(old);
|
|
if(!strcmp(next,"/fleet_channel")&&fail_after_publish)fail_read=true;
|
|
return !(late_publish_error&&!strcmp(old,"/fleet_channel.tmp"));
|
|
}
|
|
File open(const char* path,const char* mode="r") {
|
|
if(!path||fail_open) return {};
|
|
if(mode[0]=='w') {files[path].clear();return File(this,path,true);}
|
|
if(!strcmp(path,"/fleet_channel.tmp")&&!substitute_temp.empty())files[path]=substitute_temp;
|
|
return File(this,path,exists(path));
|
|
}
|
|
};
|
|
}
|
|
inline bool File::open(const char* path,int flags){
|
|
if(!owner_)return false;*this=owner_->open(path,flags==2?"w":"r");return live_;
|
|
}
|
|
inline size_t File::size() const {return live_?owner_->files[path_].size():0;}
|
|
inline size_t File::read(uint8_t* data,size_t length) {
|
|
++owner_->reads; if(!live_||owner_->fail_read) return 0;
|
|
auto& bytes=owner_->files[path_];size_t n=std::min(length,bytes.size()-cursor_);
|
|
memcpy(data,bytes.data()+cursor_,n);cursor_+=n;return n;
|
|
}
|
|
inline size_t File::write(const uint8_t* data,size_t length) {
|
|
++owner_->writes; if(!live_||owner_->fail_write) return 0;
|
|
auto& bytes=owner_->files[path_];bytes.insert(bytes.end(),data,data+length);return length;
|
|
}
|
|
'''
|
|
|
|
MESH = r'''
|
|
#pragma once
|
|
#include <Identity.h>
|
|
#include <FS.h>
|
|
#include <vector>
|
|
#include <functional>
|
|
#define MAX_PACKET_PAYLOAD 184
|
|
#define PAYLOAD_TYPE_GRP_TXT 5
|
|
#define PAYLOAD_TYPE_GRP_DATA 6
|
|
#define FLOOD_RETRY_POLICY_DENY 1
|
|
inline uint32_t fake_ms = 1000;
|
|
inline uint32_t millis() {return fake_ms;}
|
|
namespace mesh {
|
|
struct Packet {
|
|
uint8_t type=PAYLOAD_TYPE_GRP_DATA,payload[MAX_PACKET_PAYLOAD]={},flood_retry_policy=0;
|
|
uint16_t payload_len=0,transport_codes[2]={};
|
|
uint32_t radio_generation=0;uint8_t radio_profile=0,path_hash_size=1;
|
|
bool scoped=false,radio_reply=false,radio_bound=false;
|
|
uint8_t getPayloadType() const{return type;}
|
|
uint8_t getPathHashSize() const{return path_hash_size;}
|
|
bool hasTransportCodes() const{return scoped;}
|
|
};
|
|
struct GroupChannel {uint8_t hash[PATH_HASH_SIZE]={},secret[32]={};};
|
|
struct Clock {uint32_t now=1735689600UL+1000;
|
|
uint32_t getCurrentTime() const{return now;}};
|
|
class Mesh;
|
|
class Dispatcher {public:
|
|
class ReceiveProfileScope {Mesh& mesh_;uint8_t old_;uint32_t generation_;
|
|
public:ReceiveProfileScope(Mesh&,uint8_t,uint32_t);~ReceiveProfileScope();};
|
|
virtual void onSendFail(Packet*)=0;
|
|
};
|
|
class PacketManager {public:
|
|
std::vector<Packet*> outbound;
|
|
int getOutboundTotal() const{return int(outbound.size());}
|
|
Packet* getOutboundByIdx(int i){return outbound.at(i);}
|
|
Packet* removeOutboundByIdx(int i){Packet* p=outbound.at(i);outbound.erase(outbound.begin()+i);return p;}
|
|
};
|
|
class Mesh:public Dispatcher {public:
|
|
LocalIdentity self_id;Clock clock;RNG rng;PacketManager manager;PacketManager* _mgr=&manager;
|
|
std::function<void(Packet*,const Packet*)> copying;
|
|
std::function<void(Packet*)> failure;
|
|
std::vector<Packet*> allocated;
|
|
Packet* in_flight=nullptr;bool queue_ok=true,allocation_ok=true,fanout=false;
|
|
unsigned queued=0,cancelled=0,released=0;
|
|
uint8_t scoped_profile=0,ack_path_size=0;uint32_t scoped_generation=0,ack_delay=0;
|
|
bool ack_scoped=false;uint16_t ack_codes[2]={};
|
|
~Mesh(){for(Packet* p:allocated)delete p;}
|
|
Clock* getRTCClock(){return &clock;}RNG* getRNG(){return &rng;}
|
|
Packet* createGroupDatagram(uint8_t type,const GroupChannel& channel,const uint8_t* data,size_t length){
|
|
assert(length<=168);if(!allocation_ok)return nullptr;Packet* p=new Packet;allocated.push_back(p);p->type=type;
|
|
p->payload[0]=channel.hash[0];p->payload_len=1+Utils::encryptThenMAC(channel.secret,p->payload+1,data,int(length));return p;
|
|
}
|
|
bool sendFlood(Packet* p,uint32_t delay,uint8_t hashes){
|
|
ack_delay=delay;ack_path_size=hashes;
|
|
if(!queue_ok)return false;manager.outbound.push_back(p);++queued;
|
|
if(fanout){Packet* second=new Packet(*p);allocated.push_back(second);manager.outbound.push_back(second);
|
|
if(copying)copying(second,p);}
|
|
return true;
|
|
}
|
|
bool sendFlood(Packet* p,const uint16_t* codes,uint32_t delay,uint8_t hashes){
|
|
ack_scoped=true;memcpy(ack_codes,codes,sizeof(ack_codes));return sendFlood(p,delay,hashes);
|
|
}
|
|
const Packet* getOutboundInFlight()const{return in_flight;}
|
|
void cancelOutboundRadioRetry(const Packet* p){assert(p==in_flight);++cancelled;}
|
|
void onSendFail(Packet* p)override{if(failure)failure(p);}
|
|
void releasePacket(Packet*){++released;}
|
|
};
|
|
inline Dispatcher::ReceiveProfileScope::ReceiveProfileScope(Mesh& m,uint8_t profile,uint32_t generation)
|
|
:mesh_(m),old_(m.scoped_profile),generation_(m.scoped_generation){m.scoped_profile=profile;m.scoped_generation=generation;}
|
|
inline Dispatcher::ReceiveProfileScope::~ReceiveProfileScope(){mesh_.scoped_profile=old_;mesh_.scoped_generation=generation_;}
|
|
}
|
|
'''
|
|
|
|
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 {
|
|
class RadioProfileCLI {public:
|
|
bool mutation=false,command=false;uint32_t generation=0;
|
|
unsigned finishes=0,commits=0,rollbacks=0;
|
|
void beginReplyCommand(){command=true;}void endReplyCommand(){command=false;}
|
|
bool hasReplyMutation()const{return mutation;}
|
|
uint32_t replyMutationGeneration()const{return generation;}
|
|
void stage(){assert(command);mutation=true;++generation;}
|
|
bool finishReplyMutation(bool delivered){assert(mutation);mutation=false;++finishes;
|
|
if(delivered)++commits;else ++rollbacks;return true;}
|
|
};
|
|
}
|
|
'''
|
|
|
|
HARNESS = r'''
|
|
#include <helpers/FleetChannel.h>
|
|
#include <helpers/PersistentStoreFormat.h>
|
|
#include <cassert>
|
|
#include <string>
|
|
#include <algorithm>
|
|
#include <cstdio>
|
|
@TRANSPORT_METHODS@
|
|
|
|
inline unsigned identity_counter=0;
|
|
namespace mesh {
|
|
Identity::Identity(){memset(pub_key,0,sizeof(pub_key));}
|
|
LocalIdentity::LocalIdentity():Identity(){
|
|
memset(prv_key,0xAA+identity_counter++,32);EVP_PKEY* key=EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519,nullptr,prv_key,32);
|
|
assert(key);size_t length=sizeof(pub_key);assert(EVP_PKEY_get_raw_public_key(key,pub_key,&length)==1);
|
|
memcpy(prv_key+32,pub_key,32);EVP_PKEY_free(key);
|
|
}
|
|
void LocalIdentity::sign(uint8_t* sig,const uint8_t* data,int length)const{
|
|
EVP_PKEY* key=EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519,nullptr,prv_key,32);
|
|
EVP_MD_CTX* ctx=EVP_MD_CTX_new();assert(key&&ctx);assert(EVP_DigestSignInit(ctx,nullptr,nullptr,nullptr,key)==1);
|
|
size_t size=64;assert(EVP_DigestSign(ctx,sig,&size,data,length)==1);assert(size==64);
|
|
EVP_MD_CTX_free(ctx);EVP_PKEY_free(key);
|
|
}
|
|
bool Identity::verify(const uint8_t* sig,const uint8_t* data,int length)const{
|
|
++verify_calls;EVP_PKEY* key=EVP_PKEY_new_raw_public_key(EVP_PKEY_ED25519,nullptr,pub_key,32);
|
|
EVP_MD_CTX* ctx=EVP_MD_CTX_new();assert(key&&ctx);assert(EVP_DigestVerifyInit(ctx,nullptr,nullptr,nullptr,key)==1);
|
|
bool valid=EVP_DigestVerify(ctx,sig,64,data,length)==1;EVP_MD_CTX_free(ctx);EVP_PKEY_free(key);return valid;
|
|
}
|
|
}
|
|
using namespace mesh;
|
|
static constexpr const char* Path="/fleet_channel";
|
|
static constexpr const char* Key="21212121212121212121212121212121";
|
|
static uint8_t channel_key[32]={0x21,0x21,0x21,0x21,0x21,0x21,0x21,0x21,
|
|
0x21,0x21,0x21,0x21,0x21,0x21,0x21,0x21};
|
|
static std::string config(FleetChannel& fleet,const std::string& command){
|
|
char reply[160]={};assert(fleet.handleConfig(command.c_str(),reply,sizeof(reply)));return reply;
|
|
}
|
|
static void enroll(FleetChannel& fleet,const LocalIdentity& publisher){
|
|
assert(config(fleet,std::string("set fleet.channel ")+Key).rfind("OK",0)==0);
|
|
char hex[65];Utils::toHex(hex,publisher.pub_key,32);
|
|
assert(config(fleet,std::string("set fleet.controller ")+hex).rfind("OK",0)==0);
|
|
}
|
|
static Packet raw(const uint8_t* bytes,size_t length){
|
|
Packet packet;
|
|
assert(1+CIPHER_MAC_SIZE+(length+15)/16*16<=sizeof(packet.payload));
|
|
Utils::sha256(packet.payload,1,channel_key,16);
|
|
packet.payload_len=1+Utils::encryptThenMAC(channel_key,packet.payload+1,bytes,int(length));
|
|
packet.radio_profile=1;packet.radio_generation=17;packet.path_hash_size=2;
|
|
return packet;
|
|
}
|
|
static Packet command(const LocalIdentity& publisher,uint32_t sequence,const char* text="set radio2 off",
|
|
const char* target="all"){
|
|
assert(strlen(text)<=FleetCommand::MaxPayloadLength-FleetCommand::HeaderSize-FleetCommand::SignatureSize);
|
|
uint8_t payload[184]={},destination[16];assert(FleetCommand::parseTarget(target,destination));
|
|
size_t length=FleetCommand::encode(publisher,channel_key,sequence,sequence+300,destination,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 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);
|
|
assert(text_size<=FleetCommand::MaxPayloadLength-FleetCommand::HeaderSize-FleetCommand::SignatureSize);
|
|
uint8_t* payload=data+3;memcpy(payload,"FMC1",4);
|
|
storage::writeLE32(payload+4,sequence);storage::writeLE32(payload+8,expires);
|
|
payload[28]=uint8_t(text_size);memcpy(payload+29,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,29+text_size);
|
|
publisher.sign(payload+29+text_size,message,int(domain_size+16+29+text_size));
|
|
data[0]=uint8_t(FleetCommand::DataType);data[1]=uint8_t(FleetCommand::DataType>>8);
|
|
data[2]=uint8_t(29+text_size+64);return raw(data,3+data[2]);
|
|
}
|
|
static Packet compactUnchecked(const LocalIdentity& publisher,uint32_t sequence,const char* text){
|
|
uint8_t data[184]={},message[160]={};const size_t text_size=strlen(text);
|
|
// One byte over the valid limit still fits the cipher's final padded block.
|
|
assert(text_size<=FleetCommand::SinglePacketMaxCommandLength+1);
|
|
uint8_t* payload=data+3;memcpy(payload,"FMC2",4);
|
|
storage::writeLE32(payload+4,sequence);storage::writeLE32(payload+8,sequence+300);
|
|
payload[12]=0;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 void bind(FleetChannel& fleet,Mesh& mesh,RadioProfileCLI& profiles){
|
|
mesh.copying=[&](Packet* second,const Packet* first){fleet.copy(second,first);};
|
|
mesh.failure=[&](Packet* p){fleet.fail(p,profiles);};
|
|
}
|
|
static unsigned apply(FleetChannel& fleet,fs::FS& fs,Mesh& mesh,RadioProfileCLI& profiles,Packet& packet,
|
|
bool mutation=false,bool fail_handler=false,const TransportKey* scope=nullptr){
|
|
unsigned calls=0;fleet.receive(&packet,mesh,scope);
|
|
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")||!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;
|
|
}
|
|
static void reseal(std::vector<uint8_t>& image){
|
|
storage::writeLE32(image.data()+60,storage::updateCRC32(0xffffffff,image.data(),60));
|
|
}
|
|
static std::string longRule(){
|
|
const std::string prefix="set flood.rule.1 type=grp_txt channel="+std::string(64,'1')
|
|
+" prefix=AABBCC,DDEEFF,112233 hops=6+ priority=255 suspend=tempradio mode=radio hashbytes=3 in=any";
|
|
const std::string action=" action=drop";
|
|
assert(prefix.size()+action.size()<=FleetCommand::MaxCommandLength);
|
|
return prefix+std::string(FleetCommand::MaxCommandLength-prefix.size()-action.size(),' ')+action;
|
|
}
|
|
static std::vector<uint8_t> envelope(const LocalIdentity& publisher,uint32_t sequence,
|
|
const std::string& text,const std::string& target="all"){
|
|
FleetCommand::Targets targets;assert(FleetCommand::parseTargets(target.c_str(),target.size(),targets));
|
|
std::vector<uint8_t> bytes(FleetCommand::MaxEnvelopeLength);
|
|
size_t length=FleetCommand::encode(publisher,channel_key,sequence,sequence+300,targets,
|
|
text.c_str(),bytes.data(),bytes.size());
|
|
assert(length);bytes.resize(length);return bytes;
|
|
}
|
|
static Packet part(const std::vector<uint8_t>& bytes,uint8_t index){
|
|
uint8_t data[168]={};size_t length=FleetCommand::fragment(bytes.data(),bytes.size(),index,
|
|
data+3,sizeof(data)-3);
|
|
assert(length&&length<=FleetCommand::MaxPayloadLength);data[0]=uint8_t(FleetCommand::DataType);
|
|
data[1]=uint8_t(FleetCommand::DataType>>8);data[2]=uint8_t(length);
|
|
Packet result=raw(data,3+length);assert(result.payload_len<=MAX_PACKET_PAYLOAD);return result;
|
|
}
|
|
template<typename Change> static Packet changePart(const Packet& packet,Change change){
|
|
uint8_t data[184]={};int length=Utils::MACThenDecrypt(channel_key,data,packet.payload+1,packet.payload_len-1);
|
|
assert(length>0);change(data+3);return raw(data,size_t(length));
|
|
}
|
|
struct FragmentFixture {
|
|
fs::FS disk;Mesh mesh;RadioProfileCLI profiles;FleetChannel fleet{&disk};LocalIdentity publisher;
|
|
std::string expected=longRule();unsigned calls=0;bool mutation=false;
|
|
FragmentFixture(){enroll(fleet,publisher);bind(fleet,mesh,profiles);}
|
|
uint32_t reserved()const{return storage::readLE32(disk.files.at(Path).data()+56);}
|
|
std::string stats(){return config(fleet,"get fleet.stats");}
|
|
unsigned feed(Packet& packet,const TransportKey* scope=nullptr,uint32_t advance_ms=1000){
|
|
fake_ms+=advance_ms;const unsigned before=calls;
|
|
const auto image=disk.files.at(Path);const unsigned writes=disk.writes,verifies=verify_calls;
|
|
fleet.receive(&packet,mesh,scope);
|
|
assert(disk.files.at(Path)==image&&disk.writes==writes&&verify_calls==verifies);
|
|
fleet.service(mesh,profiles,"long:rule",[&](uint32_t seq,const char* text,char* reply){
|
|
++calls;assert(reserved()==seq&&profiles.command);
|
|
assert(mesh.scoped_profile==packet.radio_profile&&mesh.scoped_generation==packet.radio_generation);
|
|
assert(strlen(text)==expected.size()&&!strcmp(text,expected.c_str()));
|
|
assert(text[expected.size()]==0);if(mutation)profiles.stage();strcpy(reply,"OK");
|
|
});
|
|
assert(!profiles.command&&mesh.scoped_profile==0&&mesh.scoped_generation==0);return calls-before;
|
|
}
|
|
void tick(uint32_t advance_ms){
|
|
fake_ms+=advance_ms;fleet.service(mesh,profiles,"node",[](uint32_t,const char*,char*){assert(false);});
|
|
}
|
|
void partial(){assert(calls==0&&reserved()==0&&mesh.queued==0&&!profiles.mutation);
|
|
assert(stats().find("accepted=0")!=std::string::npos&&stats().find("parts=1")!=std::string::npos);}
|
|
};
|
|
static int fragmentScenario(const std::string& scenario){
|
|
FragmentFixture f;const uint32_t seq=f.mesh.clock.now;
|
|
auto bytes=envelope(f.publisher,seq,f.expected);assert(bytes.size()==308&&f.expected.size()==230);
|
|
Packet a=part(bytes,0),b=part(bytes,1);assert(a.payload_len==179&&b.payload_len==179);
|
|
if(scenario=="fragments_orders"){
|
|
for(unsigned reverse=0;reverse<2;++reverse){
|
|
FragmentFixture x;auto encoded=envelope(x.publisher,seq,x.expected);
|
|
Packet first=part(encoded,reverse?1:0),second=part(encoded,reverse?0:1);
|
|
const auto old=x.disk.files.at(Path);const unsigned verifies=verify_calls,writes=x.disk.writes;
|
|
assert(x.feed(first)==0);x.partial();assert(x.disk.files.at(Path)==old&&x.disk.writes==writes);
|
|
assert(verify_calls==verifies&&x.stats().find("rejected=0")!=std::string::npos);
|
|
assert(x.feed(second)==1&&verify_calls==verifies+1&&x.reserved()==seq&&x.mesh.queued==1);
|
|
assert(x.stats().find("parts=0")!=std::string::npos&&last_jitter_max==10000);
|
|
assert(x.feed(first)==0&&x.feed(second)==0&&x.calls==1);
|
|
FleetChannel rebooted(&x.disk);unsigned calls=0;
|
|
for(Packet* packet:{&first,&second}){fake_ms+=1000;rebooted.receive(packet,x.mesh);
|
|
rebooted.service(x.mesh,x.profiles,"node",[&](uint32_t,const char*,char*){++calls;});}
|
|
assert(calls==0&&x.reserved()==seq);
|
|
}
|
|
}else if(scenario=="fragments_duplicates"){
|
|
const unsigned verifies=verify_calls,writes=f.disk.writes;
|
|
assert(f.feed(b)==0&&f.feed(b)==0&&f.feed(b)==0);f.partial();
|
|
assert(verify_calls==verifies&&f.disk.writes==writes&&f.stats().find("rejected=0")!=std::string::npos);
|
|
assert(f.feed(a)==1&&f.feed(a)==0&&f.feed(b)==0&&f.calls==1&&f.mesh.queued==1);
|
|
}else if(scenario=="fragments_minimum_size"){
|
|
f.expected="set flood.rule.1 05 c="+std::string(64,'1')+" d";
|
|
assert(f.expected.size()==88);auto small=envelope(f.publisher,seq,f.expected);
|
|
assert(small.size()==166);Packet first=part(small,0),second=part(small,1);
|
|
assert(first.payload_len==179&&second.payload_len==35);
|
|
assert(f.feed(second)==0);f.partial();assert(f.feed(first)==1&&f.reserved()==seq);
|
|
FleetCommand::Targets all;assert(FleetCommand::parseTargets("all",3,all));
|
|
uint8_t output[308];memset(output,0xA5,sizeof(output));
|
|
const std::string too_long=longRule()+" ";assert(too_long.size()==231);
|
|
assert(!FleetCommand::encode(f.publisher,channel_key,seq+1,seq+301,all,too_long.c_str(),output,sizeof(output)));
|
|
for(uint8_t byte:output)assert(byte==0xA5);
|
|
}else if(scenario=="fragments_expired_during_collection"){
|
|
assert(f.feed(a)==0);f.partial();f.mesh.clock.now+=301;
|
|
assert(f.feed(b)==0&&f.reserved()==0&&f.mesh.queued==0&&f.calls==0);
|
|
assert(f.stats().find("parts=0")!=std::string::npos);
|
|
auto retry=envelope(f.publisher,f.mesh.clock.now,f.expected);
|
|
Packet first=part(retry,0),second=part(retry,1);
|
|
assert(f.feed(first)==0&&f.feed(second)==1&&f.reserved()==f.mesh.clock.now);
|
|
}else if(scenario=="fragments_conflicts"){
|
|
assert(f.feed(a)==0);f.partial();
|
|
Packet changed=changePart(a,[](uint8_t* frame){frame[11+90]^=1;});
|
|
assert(f.feed(changed)==0);f.partial();
|
|
Packet length=changePart(a,[](uint8_t* frame){storage::writeLE16(frame+8,307);});
|
|
assert(f.feed(length)==0);f.partial();
|
|
Packet index=changePart(a,[](uint8_t* frame){frame[10]=2;});
|
|
assert(f.feed(index)==0);f.partial();
|
|
assert(f.feed(b)==1&&f.calls==1&&f.reserved()==seq);
|
|
}else if(scenario=="fragments_fresh_retry"){
|
|
assert(f.feed(a)==0);f.partial();auto newer=envelope(f.publisher,seq+1,f.expected);
|
|
Packet fresh_first=part(newer,1),fresh_second=part(newer,0);
|
|
assert(f.feed(fresh_first)==0&&f.stats().find("parts=2")!=std::string::npos);
|
|
// The unsigned newer metadata does not invalidate an authentic older
|
|
// envelope. Its signed sequence still passes the durable monotonic gate.
|
|
assert(f.feed(b)==1&&f.reserved()==seq&&f.stats().find("parts=0")!=std::string::npos);
|
|
assert(f.feed(fresh_second)==0&&f.feed(fresh_first)==1&&f.reserved()==seq+1);
|
|
assert(f.feed(a)==0&&f.feed(b)==0&&f.calls==2&&f.mesh.queued==2);
|
|
}else if(scenario=="fragments_future_poison"){
|
|
for(unsigned reverse=0;reverse<2;++reverse){
|
|
FragmentFixture x;auto encoded=envelope(x.publisher,seq,x.expected);
|
|
Packet first=part(encoded,reverse?1:0),second=part(encoded,reverse?0:1);
|
|
Packet poison=changePart(first,[&](uint8_t* frame){storage::writeLE32(frame+4,seq+60);});
|
|
const auto saved=x.disk.files.at(Path);const unsigned verifies=verify_calls;
|
|
assert(x.feed(poison,nullptr,0)==0&&x.feed(first,nullptr,0)==0);
|
|
assert(x.stats().find("parts=2")!=std::string::npos&&x.disk.files.at(Path)==saved);
|
|
assert(x.feed(poison,nullptr,0)==0&&verify_calls==verifies);
|
|
assert(x.feed(second,nullptr,0)==1&&x.reserved()==seq&&verify_calls==verifies+1);
|
|
assert(x.stats().find("parts=0")!=std::string::npos&&x.feed(poison)==0);
|
|
assert(x.feed(first)==0&&x.feed(second)==0&&x.calls==1);
|
|
FleetChannel rebooted(&x.disk);unsigned calls=0;
|
|
for(Packet* packet:{&first,&second}){rebooted.receive(packet,x.mesh);
|
|
rebooted.service(x.mesh,x.profiles,"node",[&](uint32_t,const char*,char*){++calls;});}
|
|
assert(calls==0&&x.reserved()==seq);
|
|
}
|
|
}else if(scenario=="fragments_oldest_eviction"){
|
|
Packet poison_a=changePart(a,[&](uint8_t* frame){storage::writeLE32(frame+4,seq+60);});
|
|
Packet poison_b=changePart(a,[&](uint8_t* frame){storage::writeLE32(frame+4,seq+59);});
|
|
assert(f.feed(poison_a)==0&&f.feed(poison_b)==0);
|
|
assert(f.stats().find("parts=2")!=std::string::npos);
|
|
// A third transfer replaces the oldest slot despite its higher sequence.
|
|
assert(f.feed(a)==0&&f.feed(b)==1&&f.reserved()==seq&&f.calls==1);
|
|
assert(f.stats().find("parts=0")!=std::string::npos);
|
|
}else if(scenario=="fragments_independent_failure"){
|
|
Packet poison_a=changePart(a,[&](uint8_t* frame){storage::writeLE32(frame+4,seq+60);});
|
|
Packet poison_b=changePart(b,[&](uint8_t* frame){storage::writeLE32(frame+4,seq+60);});
|
|
assert(f.feed(a)==0&&f.feed(poison_a)==0&&f.feed(poison_b)==0);
|
|
f.partial();assert(f.feed(b)==1&&f.reserved()==seq&&f.calls==1);
|
|
}else if(scenario=="fragments_budget_partial"){
|
|
const unsigned verifies=verify_calls;
|
|
for(unsigned i=0;i<20;++i)assert(f.feed(a,nullptr,0)==0);
|
|
assert(verify_calls==verifies&&f.feed(b,nullptr,0)==1);
|
|
assert(verify_calls==verifies+1&&f.reserved()==seq);
|
|
}else if(scenario=="fragments_tamper"){
|
|
Packet mac=a;mac.payload[1]^=1;assert(f.feed(mac)==0&&f.stats().find("parts=0")!=std::string::npos);
|
|
assert(f.feed(a)==0);f.partial();const auto saved=f.disk.files.at(Path);
|
|
Packet signature=changePart(b,[](uint8_t* frame){frame[11+153]^=1;});
|
|
assert(f.feed(signature)==0&&f.calls==0&&f.disk.files.at(Path)==saved&&f.mesh.queued==0);
|
|
assert(f.stats().find("parts=0")!=std::string::npos);
|
|
assert(f.feed(b)==0&&f.feed(a)==1&&f.calls==1);
|
|
}else if(scenario=="fragments_wrong_signer"){
|
|
LocalIdentity stranger;auto forged=envelope(stranger,seq,f.expected);
|
|
Packet first=part(forged,0),second=part(forged,1);
|
|
assert(f.feed(first)==0);f.partial();assert(f.feed(second)==0&&f.reserved()==0&&f.mesh.queued==0);
|
|
assert(f.stats().find("parts=0")!=std::string::npos&&f.feed(a)==0&&f.feed(b)==1);
|
|
}else if(scenario=="fragments_fixed_timeout"){
|
|
assert(f.feed(a)==0);f.partial();assert(f.feed(a,nullptr,299999)==0);f.partial();
|
|
f.tick(1);assert(f.stats().find("parts=0")!=std::string::npos&&f.reserved()==0&&f.mesh.queued==0);
|
|
assert(f.feed(b)==0);f.partial();assert(f.feed(a)==1);
|
|
}else if(scenario=="fragments_timeout_wrap"){
|
|
fake_ms=UINT32_MAX-200000;assert(f.feed(b,nullptr,0)==0);f.partial();f.tick(299999);f.partial();
|
|
f.tick(1);assert(f.stats().find("parts=0")!=std::string::npos&&f.reserved()==0);
|
|
assert(f.feed(a)==0&&f.feed(b)==1);
|
|
}else if(scenario=="fragments_sequence_bounds"){
|
|
for(uint32_t invalid:{FleetCommand::MinEpoch-1,seq+61,seq-601}){
|
|
Packet changed=changePart(a,[&](uint8_t* frame){storage::writeLE32(frame+4,invalid);});
|
|
assert(f.feed(changed)==0&&f.stats().find("parts=0")!=std::string::npos&&f.reserved()==0);
|
|
}
|
|
Packet mismatch_a=changePart(a,[&](uint8_t* frame){storage::writeLE32(frame+4,seq+1);});
|
|
Packet mismatch_b=changePart(b,[&](uint8_t* frame){storage::writeLE32(frame+4,seq+1);});
|
|
const unsigned verifies=verify_calls;assert(f.feed(mismatch_a)==0&&f.feed(mismatch_b)==0);
|
|
assert(f.reserved()==0&&verify_calls==verifies&&f.stats().find("parts=0")!=std::string::npos);
|
|
assert(f.feed(a)==0&&f.feed(b)==1);
|
|
}else if(scenario=="fragments_target"){
|
|
LocalIdentity stranger;f.expected="set flood.rule.1 any "+std::string(150,' ')+"drop";
|
|
auto other=envelope(f.publisher,seq,f.expected,publicHex(stranger));
|
|
Packet other_a=part(other,0),other_b=part(other,1);assert(f.feed(other_a)==0);f.partial();
|
|
assert(f.feed(other_b)==0&&f.reserved()==0&&f.mesh.queued==0);
|
|
auto mine=envelope(f.publisher,seq,f.expected,publicHex(f.mesh.self_id));
|
|
Packet mine_a=part(mine,0),mine_b=part(mine,1);
|
|
assert(f.feed(mine_b)==0&&f.feed(mine_a)==1&&last_jitter_max==1500);
|
|
}else if(scenario=="fragments_config_reboot"){
|
|
assert(f.feed(a)==0);f.partial();assert(config(f.fleet,std::string("set fleet.channel ")+Key).rfind("OK",0)==0);
|
|
assert(f.stats().find("parts=0")!=std::string::npos&&f.feed(b)==0);f.partial();
|
|
assert(config(f.fleet,"set fleet.controller "+publicHex(f.publisher)).rfind("OK",0)==0);
|
|
assert(f.stats().find("parts=0")!=std::string::npos&&f.feed(a)==0);f.partial();
|
|
FleetChannel rebooted(&f.disk);unsigned calls=0;fake_ms+=1000;rebooted.receive(&b,f.mesh);
|
|
rebooted.service(f.mesh,f.profiles,"node",[&](uint32_t,const char*,char*){++calls;});
|
|
assert(calls==0&&f.reserved()==0&&f.mesh.queued==0);
|
|
fake_ms+=1000;rebooted.receive(&a,f.mesh);
|
|
rebooted.service(f.mesh,f.profiles,"node",[&](uint32_t sequence,const char* text,char* reply){
|
|
++calls;assert(sequence==seq&&!strcmp(text,f.expected.c_str()));strcpy(reply,"OK");});
|
|
assert(calls==1&&f.reserved()==seq);
|
|
}else if(scenario=="fragments_reserve_failure"){
|
|
const auto saved=f.disk.files.at(Path);f.disk.fail_write=true;const unsigned writes=f.disk.writes;
|
|
assert(f.feed(b)==0);f.partial();assert(f.disk.writes==writes&&f.disk.files.at(Path)==saved);
|
|
assert(f.feed(a)==0&&f.disk.files.at(Path)==saved&&f.mesh.queued==0&&f.calls==0);
|
|
assert(config(f.fleet,"get fleet.channel").find("store=error")!=std::string::npos);
|
|
f.disk.fail_write=false;assert(f.feed(a)==0&&f.feed(b)==0);
|
|
FleetChannel rebooted(&f.disk);unsigned calls=0;
|
|
for(Packet* packet:{&a,&b}){fake_ms+=1000;rebooted.receive(packet,f.mesh);
|
|
rebooted.service(f.mesh,f.profiles,"node",[&](uint32_t,const char*,char* reply){++calls;strcpy(reply,"OK");});}
|
|
assert(calls==1&&f.reserved()==seq);
|
|
}else if(scenario=="fragments_scoped_radio_ack"){
|
|
f.mutation=true;f.mesh.fanout=true;TransportKey scope;memset(scope.key,0x12,sizeof(scope.key));
|
|
a.radio_profile=0;a.radio_generation=9;b.radio_profile=1;b.radio_generation=27;b.path_hash_size=3;
|
|
b.scoped=true;b.transport_codes[0]=scope.calcTransportCode(&b);
|
|
assert(f.feed(a)==0);f.partial();assert(f.feed(b,&scope)==1&&f.fleet.waiting()&&f.profiles.mutation);
|
|
assert(f.mesh.manager.outbound.size()==2&&f.mesh.ack_scoped&&f.mesh.ack_path_size==3);
|
|
assert(f.mesh.ack_codes[0]==scope.calcTransportCode(f.mesh.manager.outbound[0]));
|
|
assert(f.mesh.ack_codes[0]!=b.transport_codes[0]&&last_jitter_max==10000);
|
|
f.fleet.complete(f.mesh.manager.removeOutboundByIdx(0),f.profiles);
|
|
assert(f.fleet.waiting()&&f.profiles.mutation&&f.profiles.commits==0);
|
|
f.fleet.fail(f.mesh.manager.removeOutboundByIdx(0),f.profiles);
|
|
assert(!f.fleet.waiting()&&!f.profiles.mutation&&f.profiles.commits==1);
|
|
assert(f.feed(a)==0&&f.feed(b,&scope)==0&&f.calls==1);
|
|
}else if(scenario=="fragments_unknown_scope"){
|
|
f.mutation=true;a.scoped=true;a.transport_codes[0]=123;
|
|
assert(f.feed(b)==0&&f.feed(a)==1&&f.mesh.queued==0&&f.profiles.rollbacks==1);
|
|
assert(f.reserved()==seq&&!f.fleet.waiting()&&!f.profiles.mutation);
|
|
}else if(scenario=="fragments_single_interrupt"){
|
|
assert(f.feed(a)==0);f.partial();f.expected="get radio2";
|
|
Packet single=targetsCommand(f.publisher,seq+1,"all",f.expected.c_str());
|
|
assert(f.feed(single)==1&&f.reserved()==seq+1&&f.stats().find("parts=0")!=std::string::npos);
|
|
assert(f.feed(b)==0&&f.feed(a)==0&&f.calls==1&&f.mesh.queued==1);
|
|
}else if(scenario=="fragments_malformed_frame"){
|
|
for(unsigned damage=0;damage<4;++damage){
|
|
Packet malformed=changePart(a,[&](uint8_t* frame){
|
|
if(damage==0)storage::writeLE16(frame+8,309);
|
|
else if(damage==1)storage::writeLE16(frame+8,165);
|
|
else if(damage==2)frame[10]=2;
|
|
else frame[-1]=uint8_t(FleetCommand::MaxPayloadLength-1);
|
|
});assert(f.feed(malformed)==0&&f.stats().find("parts=0")!=std::string::npos);
|
|
}
|
|
assert(f.reserved()==0&&f.mesh.queued==0&&f.feed(a)==0&&f.feed(b)==1);
|
|
}else assert(false);
|
|
printf("fleet runtime %s passed\n",scenario.c_str());return 0;
|
|
}
|
|
|
|
static Packet whole(const std::vector<uint8_t>& bytes){
|
|
assert(bytes.size()<=FleetCommand::MaxPayloadLength);uint8_t data[168]={};
|
|
data[0]=uint8_t(FleetCommand::DataType);data[1]=uint8_t(FleetCommand::DataType>>8);
|
|
data[2]=uint8_t(bytes.size());memcpy(data+3,bytes.data(),bytes.size());return raw(data,3+bytes.size());
|
|
}
|
|
static void resign(const LocalIdentity& publisher,std::vector<uint8_t>& bytes){
|
|
constexpr const char* domain="MeshCoreFleet1";const size_t domain_size=strlen(domain);
|
|
assert(bytes.size()>=FleetCommand::SignatureSize);
|
|
std::vector<uint8_t> message(domain_size+16+bytes.size()-FleetCommand::SignatureSize);
|
|
memcpy(message.data(),domain,domain_size);memcpy(message.data()+domain_size,channel_key,16);
|
|
memcpy(message.data()+domain_size+16,bytes.data(),bytes.size()-FleetCommand::SignatureSize);
|
|
publisher.sign(bytes.data()+bytes.size()-FleetCommand::SignatureSize,message.data(),int(message.size()));
|
|
}
|
|
struct LocationHooks :FleetReplyHooks {
|
|
bool valid=false;int32_t latitude=0,longitude=0;mutable unsigned reads=0;
|
|
bool getFleetLocation(int32_t& lat_e6,int32_t& lon_e6)const override {
|
|
++reads;if(!valid)return false;lat_e6=latitude;lon_e6=longitude;return true;
|
|
}
|
|
};
|
|
struct RegionFixture {
|
|
fs::FS disk;Mesh mesh;RadioProfileCLI profiles;RegionMap regions;LocationHooks location;
|
|
FleetChannel fleet{&disk,&location,®ions};LocalIdentity publisher;
|
|
std::string expected="get radio2";unsigned calls=0,issued=0;
|
|
RegionEntry* usa=nullptr;RegionEntry* sea=nullptr;RegionEntry* pdx=nullptr;RegionEntry* north=nullptr;
|
|
RegionFixture(){
|
|
usa=regions.put("#usa");sea=regions.put("#sea",usa->id);pdx=regions.put("#pdx",usa->id);
|
|
north=regions.put("#north",sea->id);regions.put("seaside");regions.put("$private",usa->id);
|
|
regions.setHomeRegion(north);regions.setDefaultRegion(pdx);
|
|
enroll(fleet,publisher);bind(fleet,mesh,profiles);
|
|
}
|
|
uint32_t reserved()const{return storage::readLE32(disk.files.at(Path).data()+56);}
|
|
unsigned feed(FleetChannel& receiver,Packet& packet,const TransportKey* scope=nullptr){
|
|
fake_ms+=1000;const unsigned before=calls,verifies=verify_calls,writes=disk.writes,reads=location.reads;
|
|
const auto saved=disk.files.at(Path);receiver.receive(&packet,mesh,scope);
|
|
assert(location.reads==reads);
|
|
assert(verify_calls==verifies&&disk.writes==writes&&disk.files.at(Path)==saved);
|
|
receiver.service(mesh,profiles,"region:node",[&](uint32_t sequence,const char* text,char* reply){
|
|
++calls;assert(reserved()==sequence&&!strcmp(text,expected.c_str()));
|
|
assert(mesh.scoped_profile==packet.radio_profile&&mesh.scoped_generation==packet.radio_generation);
|
|
assert(profiles.command);strcpy(reply,"OK");
|
|
});
|
|
assert(!profiles.command&&mesh.scoped_profile==0&&mesh.scoped_generation==0);return calls-before;
|
|
}
|
|
unsigned feed(Packet& packet,const TransportKey* scope=nullptr){return feed(fleet,packet,scope);}
|
|
unsigned send(const std::string& target){
|
|
Packet packet=targetsCommand(publisher,mesh.clock.now+issued++,target,expected.c_str());return feed(packet);
|
|
}
|
|
};
|
|
static int regionScenario(const std::string& scenario){
|
|
RegionFixture f;
|
|
if(scenario=="regions_target_delimiter"){
|
|
FleetCommand::Targets parsed;
|
|
const auto saved=f.disk.files.at(Path);
|
|
for(const char* invalid:{"sea,pdx","sea,,pdx","sea;",";sea","sea;;pdx",
|
|
"gps:47.6062:-122.3321:25","gps:47.6062,-122.3321:25,sea"}){
|
|
assert(!FleetCommand::parseTargets(invalid,strlen(invalid),parsed));
|
|
assert(parsed.count==0&&parsed.length==0);
|
|
}
|
|
assert(f.disk.files.at(Path)==saved&&f.calls==0&&f.mesh.queued==0);
|
|
assert(f.send("sea;pdx;home:sea")==1&&f.calls==1);
|
|
}else if(scenario=="regions_list_home"){
|
|
f.sea->flags=REGION_DENY_FLOOD;f.pdx->flags=REGION_DENY_FLOOD;
|
|
assert(f.send("sea")==1&&f.send("region:pdx")==1); // Configured membership ignores routing flags.
|
|
assert(f.send("home:sea")==1&&f.send("home:north")==1&&f.send("home:usa")==1);
|
|
assert(f.send("home:pdx")==0&&f.send("home:seaside")==0);
|
|
assert(f.send("SEA")==0&&f.send("region:se")==0&&f.send("home:NORTH")==0);
|
|
assert(f.send("#sea")==1&&f.send("region:#pdx")==1&&f.send("home:#sea")==1);
|
|
assert(last_jitter_max==10000); // 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==10000);
|
|
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==10000);
|
|
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==10000);
|
|
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;
|
|
}
|
|
|
|
|
|
static int ackDelayScenario(){
|
|
for(int boundary:{-1,1}){
|
|
jitter_pick_boundary=boundary;
|
|
for(const std::string mode:{"all","radio","prefix","list","region","home","gps","individual"}){
|
|
RegionFixture f;f.location.valid=true;f.location.latitude=47606200;f.location.longitude=-122332100;
|
|
LocalIdentity other;std::string target="all";
|
|
if(mode=="prefix")target=publicHex(f.mesh.self_id).substr(0,8);
|
|
else if(mode=="list")target=publicHex(other)+";"+publicHex(f.mesh.self_id);
|
|
else if(mode=="region")target="sea";
|
|
else if(mode=="home")target="home:sea";
|
|
else if(mode=="gps")target=Seattle;
|
|
else if(mode=="individual")target=publicHex(f.mesh.self_id);
|
|
const bool mutation=mode=="radio";f.mesh.fanout=mutation;
|
|
const char* text=mutation?"set radio2 off":"get radio2";
|
|
Packet packet=targetsCommand(f.publisher,f.mesh.clock.now,target,text);unsigned calls=0;
|
|
fake_ms+=1000;f.fleet.receive(&packet,f.mesh);
|
|
f.fleet.service(f.mesh,f.profiles,"bounded-delay",[&](uint32_t sequence,const char* command,char* reply){
|
|
++calls;assert(sequence==f.reserved()&&!strcmp(command,text));
|
|
if(mutation)f.profiles.stage();strcpy(reply,"OK");
|
|
});
|
|
const uint32_t upper=mode=="individual"?1500:10000;
|
|
const uint32_t expected=boundary<0?500:upper;
|
|
assert(calls==1&&last_jitter_max==upper&&f.mesh.ack_delay==expected&&f.mesh.ack_delay<=10000);
|
|
assert(f.mesh.queued==1&&f.mesh.manager.outbound.size()==(mutation?2U:1U));
|
|
if(mutation){
|
|
assert(f.fleet.waiting()&&f.profiles.mutation&&f.profiles.commits==0);
|
|
f.fleet.complete(f.mesh.manager.removeOutboundByIdx(0),f.profiles);
|
|
assert(f.fleet.waiting()&&f.profiles.mutation&&f.profiles.commits==0);
|
|
f.fleet.complete(f.mesh.manager.removeOutboundByIdx(0),f.profiles);
|
|
assert(!f.fleet.waiting()&&!f.profiles.mutation&&f.profiles.commits==1);
|
|
}
|
|
}
|
|
}
|
|
jitter_pick_boundary=0;puts("fleet runtime ack_delay_ceiling passed");return 0;
|
|
}
|
|
|
|
int main(int argc,char** argv){
|
|
assert(argc==2);const std::string scenario=argv[1];
|
|
if(scenario=="ack_delay_ceiling")return ackDelayScenario();
|
|
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);
|
|
if(scenario=="local_config_indeterminate"){
|
|
for(bool controller:{false,true}){
|
|
fs::FS disk;Mesh transport;RadioProfileCLI radio;FleetChannel receiver(&disk);enroll(receiver,publisher);
|
|
disk.fail_after_publish=true;
|
|
assert(config(receiver,controller?"set fleet.controller off":"set fleet.channel off").rfind("Err",0)==0);
|
|
const auto published=disk.files.at(Path);
|
|
assert(controller?std::all_of(published.begin()+24,published.begin()+56,[](uint8_t b){return b==0;}):published[4]==0);
|
|
disk.fail_after_publish=false;disk.fail_read=false;
|
|
assert(config(receiver,"get fleet.channel").find("store=error")!=std::string::npos);
|
|
const unsigned writes=disk.writes,renames=disk.renames,verifies=verify_calls;
|
|
Packet packet=command(publisher,transport.clock.now,"get radio2");
|
|
assert(apply(receiver,disk,transport,radio,packet)==0);
|
|
const std::string partial=controller?std::string("set fleet.channel ")+Key:
|
|
std::string("set fleet.controller ")+publicHex(publisher);
|
|
assert(config(receiver,partial).find("unconfirmed")!=std::string::npos);
|
|
assert(disk.files.at(Path)==published&&disk.writes==writes&&disk.renames==renames&&verify_calls==verifies);
|
|
FleetChannel rebooted(&disk);
|
|
assert(config(rebooted,"get fleet.channel").find("store=ok")!=std::string::npos);
|
|
assert(apply(rebooted,disk,transport,radio,packet)==0&&disk.files.at(Path)==published);
|
|
assert(config(rebooted,controller?std::string("set fleet.controller ")+publicHex(publisher):
|
|
std::string("set fleet.channel ")+Key).rfind("OK",0)==0);
|
|
assert(apply(rebooted,disk,transport,radio,packet)==1);
|
|
}
|
|
}else if(scenario=="local_config_prepublication"){
|
|
for(unsigned fault=0;fault<3;++fault){
|
|
fs::FS disk;Mesh transport;RadioProfileCLI radio;FleetChannel receiver(&disk);enroll(receiver,publisher);
|
|
const auto previous=disk.files.at(Path);
|
|
disk.fail_open=fault==0;disk.fail_write=fault==1;disk.fail_read=fault==2;
|
|
assert(config(receiver,"set fleet.controller off").rfind("Err",0)==0);
|
|
assert(disk.files.at(Path)==previous);
|
|
disk.fail_open=disk.fail_write=disk.fail_read=false;
|
|
assert(config(receiver,"get fleet.channel").find("store=ok")!=std::string::npos);
|
|
Packet packet=command(publisher,transport.clock.now,"get radio2");
|
|
assert(apply(receiver,disk,transport,radio,packet)==1);
|
|
assert(config(receiver,"set fleet.controller off").rfind("OK",0)==0);
|
|
++transport.clock.now;packet=command(publisher,transport.clock.now,"get radio2");
|
|
assert(apply(receiver,disk,transport,radio,packet)==0);
|
|
}
|
|
}else if(scenario=="local_config_publish_reconciliation"){
|
|
for(bool false_success:{false,true}){
|
|
fs::FS disk;Mesh transport;RadioProfileCLI radio;FleetChannel receiver(&disk);enroll(receiver,publisher);
|
|
const auto previous=disk.files.at(Path);
|
|
disk.false_success_rename=false_success;disk.late_publish_error=!false_success;disk.fail_rollback=true;
|
|
const std::string reply=config(receiver,"set fleet.controller off");
|
|
assert(reply.rfind(false_success?"Err":"OK",0)==0);
|
|
disk.false_success_rename=disk.late_publish_error=disk.fail_rollback=false;
|
|
#if defined(RP2040_PLATFORM)
|
|
// Both public names prove that the rejected backup move left the old
|
|
// enrollment untouched. This failed local edit remains retryable.
|
|
const bool held=false;
|
|
if(false_success)assert(reply.find("retry later")!=std::string::npos);
|
|
#else
|
|
const bool held=false_success;
|
|
#endif
|
|
assert(config(receiver,"get fleet.channel").find(held?"store=error":"store=ok")!=std::string::npos);
|
|
Packet packet=command(publisher,transport.clock.now,"get radio2");
|
|
const auto published=disk.files.at(Path);const unsigned writes=disk.writes;
|
|
const bool allowed=false_success&&!held;
|
|
assert(apply(receiver,disk,transport,radio,packet)==unsigned(allowed));
|
|
if(!allowed)assert(disk.writes==writes&&disk.files.at(Path)==published);
|
|
assert((published==previous)==false_success);
|
|
FleetChannel rebooted(&disk);
|
|
assert(apply(rebooted,disk,transport,radio,packet)==unsigned(false_success&&!allowed));
|
|
}
|
|
}else if(scenario=="local_config_missing_public"){
|
|
for(bool rollback_fault:{false,true}){
|
|
fs::FS disk;Mesh transport;RadioProfileCLI radio;FleetChannel receiver(&disk);enroll(receiver,publisher);
|
|
const auto previous=disk.files.at(Path);
|
|
disk.false_success_publish=true;disk.fail_rollback=rollback_fault;
|
|
const auto reply=config(receiver,"set fleet.controller off");assert(reply.rfind("Err",0)==0);
|
|
disk.false_success_publish=disk.fail_rollback=false;
|
|
#if defined(RP2040_PLATFORM)
|
|
const bool held=rollback_fault;
|
|
if(held){
|
|
assert(!disk.exists(Path)&&disk.files.at("/fleet_channel.bak")==previous);
|
|
assert(disk.exists("/fleet_channel.tmp"));
|
|
}else{
|
|
// A no-op publication must not delete the only prior enrollment. A
|
|
// confirmed rollback keeps the old controller usable without a reboot.
|
|
assert(disk.files.at(Path)==previous&&!disk.exists("/fleet_channel.bak"));
|
|
assert(!disk.exists("/fleet_channel.tmp"));
|
|
assert(reply.find("retry later")!=std::string::npos);
|
|
}
|
|
#else
|
|
const bool held=true;
|
|
assert(disk.files.at(Path)==previous);
|
|
#endif
|
|
assert(config(receiver,"get fleet.channel").find(held?"store=error":"store=ok")!=std::string::npos);
|
|
const auto published=disk.files;const unsigned writes=disk.writes,renames=disk.renames;
|
|
Packet packet=command(publisher,transport.clock.now,"get radio2");
|
|
assert(apply(receiver,disk,transport,radio,packet)==unsigned(!held));
|
|
if(held){
|
|
assert(config(receiver,"set fleet.channel off").find("unconfirmed")!=std::string::npos);
|
|
assert(disk.files==published&&disk.writes==writes&&disk.renames==renames);
|
|
}
|
|
FleetChannel rebooted(&disk);
|
|
assert(config(rebooted,"get fleet.channel").find("store=ok")!=std::string::npos);
|
|
assert(apply(rebooted,disk,transport,radio,packet)==unsigned(held));
|
|
}
|
|
}else if(scenario=="local_config_staged_substitution"){
|
|
enroll(fleet,publisher);const auto previous=fs.files.at(Path);
|
|
fs.substitute_temp=previous;
|
|
assert(config(fleet,"set fleet.controller off").rfind("Err",0)==0);
|
|
assert(fs.files.at(Path)==previous);fs.substitute_temp.clear();
|
|
// CRC32C detects the substituted IEEE-CRC-tail image before publication.
|
|
assert(config(fleet,"get fleet.channel").find("store=ok")!=std::string::npos);
|
|
assert(config(fleet,"set fleet.controller off").rfind("OK",0)==0);
|
|
Packet packet=command(publisher,mesh.clock.now,"get radio2");
|
|
assert(apply(fleet,fs,mesh,profiles,packet)==0);
|
|
FleetChannel rebooted(&fs);assert(apply(rebooted,fs,mesh,profiles,packet)==0);
|
|
}else if(scenario=="enrollment"){
|
|
Packet packet=command(publisher,mesh.clock.now);assert(apply(fleet,fs,mesh,profiles,packet)==0);
|
|
assert(config(fleet,"get fleet.channel").find("off,controller=unset,store=ok")!=std::string::npos);
|
|
assert(config(fleet,"set fleet.channel 00000000000000000000000000000000").rfind("Err",0)==0);
|
|
assert(config(fleet,"set fleet.channel 8b3387e9c5cdea6ac9e5edbaa115cd72").rfind("Err",0)==0);
|
|
assert(config(fleet,"set fleet.controller 0000000000000000000000000000000000000000000000000000000000000000").rfind("Err",0)==0);
|
|
assert(config(fleet,std::string("set fleet.channel ")+Key).rfind("OK",0)==0);
|
|
assert(apply(fleet,fs,mesh,profiles,packet)==0);enroll(fleet,publisher);
|
|
const auto& image=fs.files.at(Path);assert(image.size()==64);
|
|
assert(!memcmp(image.data()+24,publisher.pub_key,32));
|
|
const std::vector<uint8_t> private_seed(32,0xAB);
|
|
assert(std::search(image.begin(),image.end(),private_seed.begin(),private_seed.end())==image.end());
|
|
assert(apply(fleet,fs,mesh,profiles,packet)==1);
|
|
assert(config(fleet,"set fleet.controller off").rfind("OK",0)==0);
|
|
++mesh.clock.now;packet=command(publisher,mesh.clock.now);assert(apply(fleet,fs,mesh,profiles,packet)==0);
|
|
}else if(scenario=="deferred_single_mailbox"){
|
|
enroll(fleet,publisher);Packet a=command(publisher,mesh.clock.now),b=command(publisher,mesh.clock.now+1);
|
|
unsigned decrypt_before=decrypt_calls,verify_before=verify_calls,writes_before=fs.writes;
|
|
fleet.receive(&a,mesh);fleet.receive(&b,mesh);
|
|
assert(decrypt_calls==decrypt_before&&verify_calls==verify_before&&fs.writes==writes_before);
|
|
assert(config(fleet,"get fleet.stats").find("busy=1 pending=1")!=std::string::npos);
|
|
assert(config(fleet,"set fleet.channel off").rfind("Err",0)==0);
|
|
unsigned calls=0;fleet.service(mesh,profiles,"node",[&](uint32_t seq,const char*,char* reply){
|
|
++calls;assert(seq==mesh.clock.now);strcpy(reply,"OK");});assert(calls==1);
|
|
assert(config(fleet,"get fleet.stats").find("accepted=1")!=std::string::npos);
|
|
}else if(scenario=="durable_replay"){
|
|
enroll(fleet,publisher);Packet packet=command(publisher,mesh.clock.now);
|
|
assert(apply(fleet,fs,mesh,profiles,packet,false,true)==1);
|
|
assert(apply(fleet,fs,mesh,profiles,packet)==0);FleetChannel rebooted(&fs);
|
|
assert(apply(rebooted,fs,mesh,profiles,packet)==0);
|
|
++mesh.clock.now;packet=command(publisher,mesh.clock.now);assert(apply(rebooted,fs,mesh,profiles,packet)==1);
|
|
}else if(scenario=="reserve_fault"){
|
|
enroll(fleet,publisher);const auto previous=fs.files.at(Path);fs.fail_write=true;
|
|
Packet packet=command(publisher,mesh.clock.now);assert(apply(fleet,fs,mesh,profiles,packet)==0);
|
|
assert(fs.files.at(Path)==previous);fs.fail_write=false;
|
|
assert(config(fleet,"get fleet.channel").find("store=error")!=std::string::npos);
|
|
assert(apply(fleet,fs,mesh,profiles,packet)==0);FleetChannel rebooted(&fs);
|
|
assert(apply(rebooted,fs,mesh,profiles,packet)==1);
|
|
}else if(scenario=="config_fault"){
|
|
enroll(fleet,publisher);const auto previous=fs.files.at(Path);fs.fail_write=true;
|
|
assert(config(fleet,"set fleet.channel off").rfind("Err",0)==0);
|
|
assert(fs.files.at(Path)==previous);fs.fail_write=false;
|
|
assert(config(fleet,"get fleet.channel").find("on,controller=set")!=std::string::npos);
|
|
FleetChannel rebooted(&fs);Packet packet=command(publisher,mesh.clock.now);
|
|
assert(apply(rebooted,fs,mesh,profiles,packet)==1);
|
|
}else if(scenario=="images"){
|
|
enroll(fleet,publisher);const auto original=fs.files.at(Path);
|
|
for(unsigned damage=0;damage<7;++damage){
|
|
fs.files[Path]=original;auto& image=fs.files[Path];
|
|
if(damage==0)image.resize(63);else if(damage==1)image.push_back(0);
|
|
else if(damage==2)image[0]='X';else if(damage==3)image[4]=2;
|
|
else if(damage==4)image[5]=1;else if(damage==5)image[60]^=1;
|
|
else memset(image.data()+8,0,16);
|
|
if(damage>=3&&damage!=5)reseal(image);
|
|
FleetChannel bad(&fs);assert(config(bad,"get fleet.channel").find("store=error")!=std::string::npos);
|
|
Packet packet=command(publisher,mesh.clock.now);assert(apply(bad,fs,mesh,profiles,packet)==0);
|
|
assert(config(bad,std::string("set fleet.channel ")+Key).rfind("Err",0)==0);
|
|
}
|
|
fs.files[Path]=original;fs.fail_read=true;FleetChannel unreadable(&fs);fs.fail_read=false;
|
|
assert(config(unreadable,"get fleet.channel").find("store=error")!=std::string::npos);
|
|
#if defined(RP2040_PLATFORM)
|
|
fs.files.erase(Path);fs.files[std::string(Path)+".bak"]=original;
|
|
FleetChannel recovered(&fs);assert(config(recovered,"get fleet.channel").find("store=ok")!=std::string::npos);
|
|
assert(fs.files[Path]==original);Packet packet=command(publisher,mesh.clock.now);
|
|
assert(apply(recovered,fs,mesh,profiles,packet)==1);
|
|
#endif
|
|
}else if(scenario=="ack_both_copies"){
|
|
enroll(fleet,publisher);mesh.fanout=true;Packet packet=command(publisher,mesh.clock.now);
|
|
TransportKey scope;memset(scope.key,0x12,sizeof(scope.key));
|
|
packet.scoped=true;packet.transport_codes[0]=scope.calcTransportCode(&packet);packet.transport_codes[1]=456;
|
|
assert(apply(fleet,fs,mesh,profiles,packet,true,false,&scope)==1);assert(fleet.waiting());
|
|
assert(mesh.manager.outbound.size()==2);assert(mesh.ack_scoped&&mesh.ack_codes[1]==0);
|
|
assert(mesh.ack_codes[0]==scope.calcTransportCode(mesh.manager.outbound[0]));
|
|
assert(mesh.ack_codes[0]!=packet.transport_codes[0]);
|
|
assert(mesh.ack_delay==1000&&mesh.ack_path_size==2&&last_jitter_max==10000);
|
|
assert(mesh.manager.outbound[0]->radio_reply&&!mesh.manager.outbound[0]->radio_bound);
|
|
assert(mesh.manager.outbound[0]->flood_retry_policy==FLOOD_RETRY_POLICY_DENY);
|
|
fleet.receive(&packet,mesh);assert(config(fleet,"get fleet.stats").find("busy=1")!=std::string::npos);
|
|
fleet.complete(mesh.manager.removeOutboundByIdx(0),profiles);assert(fleet.waiting());assert(profiles.commits==0);
|
|
fleet.fail(mesh.manager.removeOutboundByIdx(0),profiles);assert(!fleet.waiting());assert(profiles.commits==1&&profiles.rollbacks==0);
|
|
}else if(scenario=="ack_all_fail"){
|
|
enroll(fleet,publisher);mesh.fanout=true;Packet packet=command(publisher,mesh.clock.now);
|
|
assert(apply(fleet,fs,mesh,profiles,packet,true)==1);
|
|
fleet.fail(mesh.manager.removeOutboundByIdx(0),profiles);assert(fleet.waiting()&&profiles.rollbacks==0);
|
|
fleet.fail(mesh.manager.removeOutboundByIdx(0),profiles);assert(!fleet.waiting());assert(profiles.rollbacks==1);
|
|
}else if(scenario=="ack_queue_or_allocation"){
|
|
enroll(fleet,publisher);Packet packet=command(publisher,mesh.clock.now);mesh.queue_ok=false;
|
|
assert(apply(fleet,fs,mesh,profiles,packet,true)==1);assert(!fleet.waiting()&&profiles.rollbacks==1);
|
|
assert(apply(fleet,fs,mesh,profiles,packet,true)==0);mesh.queue_ok=true;mesh.allocation_ok=false;
|
|
++mesh.clock.now;packet=command(publisher,mesh.clock.now);
|
|
assert(apply(fleet,fs,mesh,profiles,packet,true)==1);assert(profiles.rollbacks==2);
|
|
}else if(scenario=="ack_timeout"){
|
|
enroll(fleet,publisher);mesh.fanout=true;Packet packet=command(publisher,mesh.clock.now);
|
|
assert(apply(fleet,fs,mesh,profiles,packet,true)==1);fake_ms+=300000;
|
|
fleet.service(mesh,profiles,"node",[](uint32_t,const char*,char*){assert(false);});
|
|
assert(mesh.manager.outbound.empty()&&mesh.released==2);assert(profiles.rollbacks==1&&!fleet.waiting());
|
|
++mesh.clock.now;packet=command(publisher,mesh.clock.now);assert(apply(fleet,fs,mesh,profiles,packet,true)==1);
|
|
mesh.in_flight=mesh.manager.removeOutboundByIdx(0);fake_ms+=300000;
|
|
fleet.service(mesh,profiles,"node",[](uint32_t,const char*,char*){assert(false);});
|
|
assert(mesh.cancelled==1&&fleet.waiting());fleet.fail(mesh.in_flight,profiles);mesh.in_flight=nullptr;
|
|
assert(!fleet.waiting()&&profiles.rollbacks==2);
|
|
}else if(scenario=="tamper_padding_target"){
|
|
enroll(fleet,publisher);Packet good=command(publisher,mesh.clock.now);
|
|
uint8_t data[184];int size=Utils::MACThenDecrypt(channel_key,data,good.payload+1,good.payload_len-1);
|
|
data[3+29]^=1;Packet forged=raw(data,size);assert(apply(fleet,fs,mesh,profiles,forged)==0);
|
|
size=Utils::MACThenDecrypt(channel_key,data,good.payload+1,good.payload_len-1);
|
|
data[size-1]=1;Packet padded=raw(data,size);assert(apply(fleet,fs,mesh,profiles,padded)==0);
|
|
Packet mac=good;mac.payload[1]^=1;assert(apply(fleet,fs,mesh,profiles,mac)==0);
|
|
char target[65];uint8_t other[32];memset(other,0x23,sizeof(other));Utils::toHex(target,other,sizeof(other));
|
|
Packet wrong=command(publisher,mesh.clock.now,"set radio2 off",target);assert(apply(fleet,fs,mesh,profiles,wrong)==0);
|
|
fake_ms+=1000;Utils::toHex(target,mesh.self_id.pub_key,32);
|
|
Packet mine=command(publisher,mesh.clock.now,"set radio2 off",target);assert(apply(fleet,fs,mesh,profiles,mine)==1);
|
|
assert(last_jitter_max==1500);
|
|
}else if(scenario=="verify_budget"){
|
|
enroll(fleet,publisher);LocalIdentity stranger;
|
|
Packet forged=command(stranger,mesh.clock.now);
|
|
const unsigned before=verify_calls;
|
|
for(unsigned i=0;i<20;++i)assert(apply(fleet,fs,mesh,profiles,forged)==0);
|
|
assert(verify_calls-before==4);fake_ms+=1000;
|
|
Packet valid=command(publisher,mesh.clock.now);
|
|
assert(apply(fleet,fs,mesh,profiles,valid)==1);
|
|
}else if(scenario=="invalid_mac_cannot_starve"){
|
|
enroll(fleet,publisher);Packet valid=command(publisher,mesh.clock.now);
|
|
Packet garbage=valid;garbage.payload[1]^=1;
|
|
const unsigned before=verify_calls;
|
|
for(unsigned i=0;i<20;++i)assert(apply(fleet,fs,mesh,profiles,garbage)==0);
|
|
assert(verify_calls==before);
|
|
assert(apply(fleet,fs,mesh,profiles,valid)==1);
|
|
assert(verify_calls==before+1&&mesh.queued==1);
|
|
}else if(scenario=="replay_cannot_starve"){
|
|
enroll(fleet,publisher);const uint32_t seq=mesh.clock.now;
|
|
Packet previous=command(publisher,seq);
|
|
assert(apply(fleet,fs,mesh,profiles,previous)==1);
|
|
fake_ms+=1000;const unsigned before=verify_calls;
|
|
for(unsigned i=0;i<20;++i)assert(apply(fleet,fs,mesh,profiles,previous)==0);
|
|
assert(verify_calls==before);
|
|
Packet next=command(publisher,seq+1);
|
|
assert(apply(fleet,fs,mesh,profiles,next)==1&&verify_calls==before+1);
|
|
}else if(scenario=="wrong_target_cannot_starve"){
|
|
enroll(fleet,publisher);LocalIdentity other;
|
|
Packet not_mine=command(publisher,mesh.clock.now,"get radio2",publicHex(other).c_str());
|
|
const unsigned before=verify_calls;
|
|
for(unsigned i=0;i<20;++i)assert(apply(fleet,fs,mesh,profiles,not_mine)==0);
|
|
assert(verify_calls==before);
|
|
Packet valid=command(publisher,mesh.clock.now);
|
|
assert(apply(fleet,fs,mesh,profiles,valid)==1&&verify_calls==before+1);
|
|
}else if(scenario=="primary_denied"){
|
|
assert(!FleetCommand::commandAllowed("set radio 915,500,5,5"));
|
|
assert(!FleetCommand::commandAllowed("set tempradio 915,500,5,5,10"));
|
|
assert(!FleetCommand::commandAllowed("set password pass"));
|
|
assert(!FleetCommand::commandAllowed("reboot"));
|
|
assert(!FleetCommand::commandAllowed("set fleet.controller off"));
|
|
|
|
}else if(scenario=="signed_forbidden"){
|
|
enroll(fleet,publisher);
|
|
for(const char* text:{"set radio 915,500,5,5","set password pass","reboot",
|
|
"set fleet.channel off","get prv.key","set flood.max 5;reboot"}){
|
|
fake_ms+=1000;Packet packet=signedUnchecked(publisher,mesh.clock.now,mesh.clock.now+300,text);
|
|
assert(apply(fleet,fs,mesh,profiles,packet)==0);
|
|
}
|
|
assert(storage::readLE32(fs.files[Path].data()+56)==0);assert(mesh.queued==0);
|
|
}else if(scenario=="clock_limits"){
|
|
enroll(fleet,publisher);const uint32_t now=mesh.clock.now;
|
|
const std::vector<std::pair<uint32_t,uint32_t>> invalid={{now-301,now-1},{now+61,now+100},
|
|
{FleetCommand::MinEpoch-1,now+100},{now,now+FleetCommand::MaxLifetime+1},{now,now-1}};
|
|
for(const auto& times:invalid){fake_ms+=1000;
|
|
Packet packet=signedUnchecked(publisher,times.first,times.second,"set radio2 off");
|
|
assert(apply(fleet,fs,mesh,profiles,packet)==0);
|
|
}
|
|
mesh.clock.now=0;fake_ms+=1000;Packet packet=command(publisher,now);
|
|
assert(apply(fleet,fs,mesh,profiles,packet)==0);mesh.clock.now=now;
|
|
fake_ms+=1000;packet=signedUnchecked(publisher,now+60,now+100,"set radio2 off");
|
|
assert(apply(fleet,fs,mesh,profiles,packet)==1);
|
|
}else if(scenario=="transaction_faults"){
|
|
for(unsigned fault=0;fault<4;++fault){
|
|
fs::FS disk;Mesh transport;RadioProfileCLI radio;FleetChannel receiver(&disk);enroll(receiver,publisher);
|
|
auto previous=disk.files.at(Path);Packet packet=command(publisher,transport.clock.now);
|
|
if(fault==0)disk.fail_open=true;
|
|
else if(fault==1)disk.fail_read=true;
|
|
else if(fault==2)disk.fail_rename=true;
|
|
else disk.fail_rename_from=std::string(Path)+".tmp";
|
|
assert(apply(receiver,disk,transport,radio,packet)==0);assert(disk.files.at(Path)==previous);
|
|
assert(config(receiver,"get fleet.channel").find("store=error")!=std::string::npos);
|
|
}
|
|
}else if(scenario=="backup_replay"){
|
|
enroll(fleet,publisher);Packet packet=command(publisher,mesh.clock.now);
|
|
assert(apply(fleet,fs,mesh,profiles,packet)==1);const auto persisted=fs.files.at(Path);
|
|
#if defined(RP2040_PLATFORM)
|
|
fs.files.erase(Path);fs.files[std::string(Path)+".bak"]=persisted;
|
|
#else
|
|
// Atomic LittleFS replacement never has a live-name gap. Abandoned .tmp
|
|
// bytes are ignored; the committed live sequence remains authoritative.
|
|
fs.files[std::string(Path)+".tmp"]=persisted;
|
|
#endif
|
|
FleetChannel rebooted(&fs);assert(apply(rebooted,fs,mesh,profiles,packet)==0);
|
|
assert(storage::readLE32(fs.files.at(Path).data()+56)==mesh.clock.now);
|
|
|
|
}else if(scenario=="wrong_publisher"){
|
|
enroll(fleet,publisher);LocalIdentity stranger;
|
|
assert(memcmp(stranger.pub_key,publisher.pub_key,32));
|
|
Packet packet=command(stranger,mesh.clock.now);assert(apply(fleet,fs,mesh,profiles,packet)==0);
|
|
assert(verify_calls==1&&mesh.queued==0&&storage::readLE32(fs.files[Path].data()+56)==0);
|
|
}else if(scenario=="bounded_ack"){
|
|
enroll(fleet,publisher);Packet packet=command(publisher,mesh.clock.now);
|
|
fleet.receive(&packet,mesh);fleet.service(mesh,profiles,"sixteen:character:very long name",
|
|
[&](uint32_t,const char*,char* reply){memset(reply,'x',159);reply[159]=0;});
|
|
assert(mesh.queued==1);uint8_t data[184]={};Packet* ack=mesh.manager.outbound[0];
|
|
assert(ack->payload_len<=MAX_PACKET_PAYLOAD);
|
|
assert(Utils::MACThenDecrypt(channel_key,data,ack->payload+1,ack->payload_len-1)==176);
|
|
assert(!strncmp((char*)data+5,"sixteen;characte",15));
|
|
}else if(scenario=="unknown_scope"){
|
|
enroll(fleet,publisher);Packet packet=command(publisher,mesh.clock.now);packet.scoped=true;
|
|
packet.transport_codes[0]=123;assert(apply(fleet,fs,mesh,profiles,packet,true)==1);
|
|
assert(mesh.queued==0&&mesh.released==1&&profiles.rollbacks==1&&!fleet.waiting());
|
|
}else if(scenario=="rotated_scope"){
|
|
struct Region {bool wildcard=false;bool isWildcard()const{return wildcard;}};
|
|
struct Regions {TransportKey keys[2];int getTransportKeysFor(const Region&,TransportKey* out,int max){
|
|
assert(max==MAX_TKS_ENTRIES);memcpy(out,keys,sizeof(keys));return 2;}} regions;
|
|
memset(regions.keys[0].key,0x11,16);memset(regions.keys[1].key,0x22,16);
|
|
Region region;TransportKey captured{};Packet packet=command(publisher,mesh.clock.now);
|
|
packet.scoped=true;packet.transport_codes[0]=regions.keys[1].calcTransportCode(&packet);
|
|
assert(packet.transport_codes[0]!=regions.keys[0].calcTransportCode(&packet));
|
|
assert(captureFleetReplyScope(regions,®ion,&packet,captured));
|
|
assert(!memcmp(captured.key,regions.keys[1].key,16));
|
|
enroll(fleet,publisher);assert(apply(fleet,fs,mesh,profiles,packet,true,false,&captured)==1);
|
|
assert(mesh.ack_codes[0]==regions.keys[1].calcTransportCode(mesh.manager.outbound[0]));
|
|
assert(mesh.ack_codes[0]!=packet.transport_codes[0]);
|
|
region.wildcard=true;assert(!captureFleetReplyScope(regions,®ion,&packet,captured));
|
|
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==10000);
|
|
assert(apply(fleet,fs,mesh,profiles,packet)==0);
|
|
}else if(scenario=="fmc2_encoded_all"){
|
|
enroll(fleet,publisher);Packet packet=targetsCommand(publisher,mesh.clock.now,"all");
|
|
uint8_t plaintext[184]={};
|
|
assert(Utils::MACThenDecrypt(channel_key,plaintext,packet.payload+1,packet.payload_len-1)>0);
|
|
assert(!memcmp(plaintext+3,"FMC2",4)&&plaintext[3+12]==0);
|
|
assert(plaintext[2]==FleetCommand::MinHeaderSize+strlen("get radio2")+FleetCommand::SignatureSize);
|
|
assert(plaintext[2]<FleetCommand::HeaderSize+FleetCommand::SignatureSize);
|
|
Packet legacy=command(publisher,mesh.clock.now,"get radio2");
|
|
assert(packet.payload_len+16==legacy.payload_len);
|
|
assert(apply(fleet,fs,mesh,profiles,packet)==1&&mesh.queued==1&&last_jitter_max==10000);
|
|
assert(apply(fleet,fs,mesh,profiles,packet)==0);
|
|
mesh.releasePacket(mesh.manager.removeOutboundByIdx(0));
|
|
++mesh.clock.now;fake_ms+=1000;mesh.fanout=true;
|
|
Packet mutation=targetsCommand(publisher,mesh.clock.now,"all","set radio2 off");
|
|
assert(apply(fleet,fs,mesh,profiles,mutation,true)==1);
|
|
assert(fleet.waiting()&&profiles.mutation&&last_jitter_max==10000);
|
|
fleet.complete(mesh.manager.removeOutboundByIdx(0),profiles);
|
|
assert(fleet.waiting()&&profiles.mutation&&profiles.commits==0);
|
|
fleet.complete(mesh.manager.removeOutboundByIdx(0),profiles);
|
|
assert(!fleet.waiting()&&!profiles.mutation&&profiles.commits==1);
|
|
assert(apply(fleet,fs,mesh,profiles,mutation)==0);
|
|
FleetChannel rebooted(&fs);
|
|
assert(apply(rebooted,fs,mesh,profiles,packet)==0&&apply(rebooted,fs,mesh,profiles,mutation)==0);
|
|
}else if(scenario=="fmc2_maximum_command"){
|
|
enroll(fleet,publisher);
|
|
const std::string secret(64,'1');
|
|
const std::string text="set flood.rule.1 5 c="+secret+" d";
|
|
const std::string oversized="set flood.rule.1 05 c="+secret+" d";
|
|
assert(text.size()==87&&text.size()==FleetCommand::SinglePacketMaxCommandLength);
|
|
assert(oversized.size()==88&&FleetCommand::commandAllowed(oversized.c_str()));
|
|
FleetCommand::Targets all;assert(FleetCommand::parseTargets("all",3,all));
|
|
uint8_t output[184];memset(output,0xA5,sizeof(output));
|
|
assert(FleetCommand::encode(publisher,channel_key,mesh.clock.now,mesh.clock.now+300,all,
|
|
oversized.c_str(),output,sizeof(output))==166);
|
|
assert(output[166]==0xA5);
|
|
Packet packet=targetsCommand(publisher,mesh.clock.now,"all",text.c_str());
|
|
uint8_t plaintext[184]={};
|
|
const int size=Utils::MACThenDecrypt(channel_key,plaintext,packet.payload+1,packet.payload_len-1);
|
|
assert(size==176&&packet.payload_len==179&&packet.payload_len<=MAX_PACKET_PAYLOAD);
|
|
assert(plaintext[2]==FleetCommand::MaxPayloadLength&&plaintext[3+13]==87);
|
|
assert(!memcmp(plaintext+3,"FMC2",4)&&plaintext[3+12]==0);
|
|
assert(!memcmp(plaintext+3+14,text.data(),text.size()));
|
|
for(unsigned i=3+plaintext[2];i<unsigned(size);++i)assert(plaintext[i]==0);
|
|
const auto previous=fs.files.at(Path);unsigned calls=0;
|
|
struct Capture {char text[88]{};char guard='!';} capture;
|
|
const auto dispatch=[&](FleetChannel& receiver,Packet& request){
|
|
const unsigned before=calls;receiver.receive(&request,mesh);
|
|
receiver.service(mesh,profiles,"long-rule",[&](uint32_t seq,const char* command,char* reply){
|
|
++calls;assert(storage::readLE32(fs.files.at(Path).data()+56)==seq);
|
|
assert(strlen(command)==text.size()&&!strcmp(command,text.c_str())&&command[86]=='d'&&command[87]==0);
|
|
memcpy(capture.text,command,text.size()+1);assert(capture.guard=='!');strcpy(reply,"OK");
|
|
});return calls-before;
|
|
};
|
|
plaintext[3+14+86]='s';Packet changed=raw(plaintext,size);
|
|
assert(dispatch(fleet,changed)==0&&fs.files.at(Path)==previous&&mesh.queued==0);
|
|
Packet too_long=compactUnchecked(publisher,mesh.clock.now,oversized.c_str());
|
|
assert(too_long.payload_len==179&&dispatch(fleet,too_long)==0&&fs.files.at(Path)==previous);
|
|
assert(dispatch(fleet,packet)==1&&last_jitter_max==10000&&mesh.queued==1);
|
|
assert(!strcmp(capture.text,text.c_str())&&capture.guard=='!');
|
|
assert(dispatch(fleet,packet)==0);FleetChannel rebooted(&fs);assert(dispatch(rebooted,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==10000);
|
|
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==10000);
|
|
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==10000);
|
|
}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);
|
|
assert(storage::readLE32(fs.files[Path].data()+56)==0);
|
|
profiles.mutation=false;assert(apply(fleet,fs,mesh,profiles,packet)==1);
|
|
}else assert(false);
|
|
printf("fleet runtime %s passed\n",argv[1]);
|
|
}
|
|
'''
|
|
|
|
|
|
class FleetChannelRuntimeTests(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
compiler = shutil.which("g++") or shutil.which("clang++")
|
|
if compiler is None:
|
|
raise unittest.SkipTest("host C++ compiler required")
|
|
cls.work = tempfile.TemporaryDirectory(prefix="meshcore-fleet-runtime-")
|
|
work = Path(cls.work.name)
|
|
(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
|
|
#include <FS.h>
|
|
using Adafruit_LittleFS = fs::FS;
|
|
namespace Adafruit_LittleFS_Namespace {}
|
|
#define FILE_O_READ 1
|
|
#define FILE_O_WRITE 2
|
|
#define LFS_ERR_NOENT -2
|
|
struct lfs_info {};
|
|
inline int lfs_stat(fs::FS* fs,const char* path,lfs_info*) {return fs->exists(path)?0:LFS_ERR_NOENT;}
|
|
''',
|
|
"InternalFileSystem.h": '#pragma once\n#include <FS.h>\ninline fs::FS InternalFS;\n',
|
|
"Arduino.h": '#pragma once\n#include <Mesh.h>\n'}.items():
|
|
(work / name).write_text(content)
|
|
fixture = work / "fixture.cpp"
|
|
transport_source = (ROOT / "src/helpers/TransportKeyStore.cpp").read_text()
|
|
methods = "\n".join(body(transport_source, signature) for signature in [
|
|
"uint16_t TransportKey::calcTransportCode(", "bool TransportKey::isNull("])
|
|
fixture.write_text(HARNESS.replace("@TRANSPORT_METHODS@", methods))
|
|
sha_header = ROOT / "test/mocks/SHA256.h"
|
|
(work / "SHA256.h").write_text(f'''
|
|
#pragma once
|
|
#define SHA256 SHA256Base
|
|
#include "{sha_header}"
|
|
#undef SHA256
|
|
class SHA256 : public SHA256Base {{ public:
|
|
void finalize(void* out,size_t length) {{ SHA256Base::finalize(static_cast<uint8_t*>(out),length); }}
|
|
void finalizeHMAC(const uint8_t* key,size_t size,void* out,size_t length) {{
|
|
SHA256Base::finalizeHMAC(key,size,static_cast<uint8_t*>(out),length); }}
|
|
}};
|
|
''')
|
|
cls.binaries = {}
|
|
sanitizer_flags = (["-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-no-pie"]
|
|
if os.environ.get("MESHCORE_FLEET_SANITIZERS") == "1" else [])
|
|
for backend in ["RP2040", "NRF52"]:
|
|
binary = work / f"fleet-runtime-{backend.lower()}"
|
|
sources = [str(ROOT / "src/helpers/FleetChannel.cpp"),
|
|
str(ROOT / "src/helpers/FleetCommand.cpp")]
|
|
if backend == "NRF52":
|
|
sources.append(str(ROOT / "src/helpers/AtomicFileWriter.cpp"))
|
|
result = subprocess.run([
|
|
compiler, *sanitizer_flags, "-std=c++17", f"-D{backend}_PLATFORM",
|
|
"-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter",
|
|
"-Wno-misleading-indentation", "-Wno-sign-compare", "-O2", "-g",
|
|
"-I", str(work), "-isystem", str(ROOT / "test/mocks"),
|
|
"-I", str(ROOT / "src"), str(fixture), *sources,
|
|
"-lcrypto", "-o", str(binary),
|
|
], capture_output=True, text=True, timeout=60)
|
|
if result.returncode:
|
|
cls.work.cleanup()
|
|
raise AssertionError(f"{backend}:\n" + result.stdout + result.stderr)
|
|
cls.binaries[backend] = binary
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
cls.work.cleanup()
|
|
|
|
def scenario(self, name):
|
|
for backend, binary in self.binaries.items():
|
|
with self.subTest(backend=backend):
|
|
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_every_group_ack_uses_at_most_ten_seconds_and_single_identity_retains_1500ms(self):
|
|
self.scenario("ack_delay_ceiling")
|
|
|
|
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")
|
|
|
|
def test_receive_defers_crypto_writes_and_accepts_one_pending_command(self):
|
|
self.scenario("deferred_single_mailbox")
|
|
|
|
def test_sequence_reserved_before_handler_including_error_survives_reboot(self):
|
|
self.scenario("durable_replay")
|
|
|
|
def test_failed_sequence_save_grants_no_control_and_disables_receiver(self):
|
|
self.scenario("reserve_fault")
|
|
|
|
def test_failed_configuration_write_preserves_durable_enrollment(self):
|
|
self.scenario("config_fault")
|
|
|
|
def test_uncertain_local_revocation_blocks_old_authority_and_partial_writes_until_restart(self):
|
|
self.scenario("local_config_indeterminate")
|
|
|
|
def test_proven_prepublication_configuration_failures_remain_retryable(self):
|
|
self.scenario("local_config_prepublication")
|
|
|
|
def test_local_configuration_reconciles_late_errors_and_rejects_false_success(self):
|
|
self.scenario("local_config_publish_reconciliation")
|
|
|
|
def test_valid_same_crc_tail_staged_substitution_cannot_acknowledge_revocation(self):
|
|
self.scenario("local_config_staged_substitution")
|
|
|
|
def test_noop_final_rename_restores_old_enrollment_or_retains_backup_on_rollback_failure(self):
|
|
self.scenario("local_config_missing_public")
|
|
|
|
def test_corrupt_short_long_unreadable_images_fail_closed_and_backup_recovers(self):
|
|
self.scenario("images")
|
|
|
|
def test_ack_waits_for_both_profile_copies_with_one_success_and_retains_rx_scope(self):
|
|
self.scenario("ack_both_copies")
|
|
|
|
def test_both_ack_transmissions_failing_roll_back_radio_mutation(self):
|
|
self.scenario("ack_all_fail")
|
|
|
|
def test_ack_allocation_queue_failure_rolls_back_but_keeps_reserved_sequence(self):
|
|
self.scenario("ack_queue_or_allocation")
|
|
|
|
def test_ack_timeout_cancels_queued_and_inflight_packets_before_rollback(self):
|
|
self.scenario("ack_timeout")
|
|
|
|
def test_real_signature_mac_padding_and_full_identity_target_checks(self):
|
|
self.scenario("tamper_padding_target")
|
|
|
|
def test_forged_mac_authenticated_packets_have_bounded_signature_verification_cost(self):
|
|
self.scenario("verify_budget")
|
|
|
|
def test_invalid_mac_packets_cannot_exhaust_the_signature_verification_budget(self):
|
|
self.scenario("invalid_mac_cannot_starve")
|
|
|
|
def test_recorded_used_command_replays_cannot_spend_signature_budget_before_next_command(self):
|
|
self.scenario("replay_cannot_starve")
|
|
|
|
def test_recorded_commands_for_another_target_cannot_spend_signature_budget(self):
|
|
self.scenario("wrong_target_cannot_starve")
|
|
|
|
def test_fleet_permission_never_allows_primary_radio_or_administrator_changes(self):
|
|
self.scenario("primary_denied")
|
|
|
|
def test_publisher_signed_commands_still_cannot_escape_fleet_capability(self):
|
|
self.scenario("signed_forbidden")
|
|
|
|
def test_expiry_future_clock_bounds_and_invalid_rtc_prevent_dispatch(self):
|
|
self.scenario("clock_limits")
|
|
|
|
def test_open_read_rename_and_publish_faults_preserve_state_and_fail_closed(self):
|
|
self.scenario("transaction_faults")
|
|
|
|
def test_recovered_backup_preserves_durable_anti_replay_sequence(self):
|
|
self.scenario("backup_replay")
|
|
|
|
def test_another_valid_ed25519_identity_cannot_authorize_fleet_commands(self):
|
|
self.scenario("wrong_publisher")
|
|
|
|
def test_long_cli_reply_and_sender_name_fit_one_encrypted_group_packet(self):
|
|
self.scenario("bounded_ack")
|
|
|
|
def test_unknown_scoped_route_refuses_ack_and_rolls_back_radio_mutation(self):
|
|
self.scenario("unknown_scope")
|
|
|
|
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_actual_zero_target_sender_reaches_receiver_and_keeps_ack_and_reboot_replay_safety(self):
|
|
self.scenario("fmc2_encoded_all")
|
|
|
|
def test_single_packet_87_byte_body_is_intact_signed_and_88_bytes_require_fragmentation(self):
|
|
self.scenario("fmc2_maximum_command")
|
|
|
|
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")
|
|
|
|
def test_two_packets_deliver_exact_230_byte_command_in_either_order_once_across_reboot(self):
|
|
self.scenario("fragments_orders")
|
|
|
|
def test_identical_fragments_never_repeat_execution_or_reserve_partial_requests(self):
|
|
self.scenario("fragments_duplicates")
|
|
|
|
def test_smallest_fragmented_command_uses_a_short_second_packet_and_231_bytes_are_rejected(self):
|
|
self.scenario("fragments_minimum_size")
|
|
|
|
def test_command_expiring_between_parts_never_dispatches_and_fresh_retry_can_succeed(self):
|
|
self.scenario("fragments_expired_during_collection")
|
|
|
|
def test_conflicting_parts_cannot_destroy_a_valid_incomplete_transfer(self):
|
|
self.scenario("fragments_conflicts")
|
|
|
|
def test_unsigned_newer_fragment_cannot_invalidate_an_older_authenticated_envelope(self):
|
|
self.scenario("fragments_fresh_retry")
|
|
|
|
def test_forged_future_sequence_interleaved_with_both_legitimate_fragment_orders_cannot_lock_out(self):
|
|
self.scenario("fragments_future_poison")
|
|
|
|
def test_third_transfer_evicts_oldest_slot_instead_of_highest_unsigned_sequence(self):
|
|
self.scenario("fragments_oldest_eviction")
|
|
|
|
def test_failed_complete_poisoned_assembly_preserves_the_other_legitimate_slot(self):
|
|
self.scenario("fragments_independent_failure")
|
|
|
|
def test_duplicate_partial_fragments_cannot_spend_signature_verification_budget(self):
|
|
self.scenario("fragments_budget_partial")
|
|
|
|
def test_mac_and_last_signature_byte_tampering_never_authorize_partial_commands(self):
|
|
self.scenario("fragments_tamper")
|
|
|
|
def test_fragmented_request_from_another_real_signing_identity_is_rejected(self):
|
|
self.scenario("fragments_wrong_signer")
|
|
|
|
def test_fragment_deadline_is_fixed_despite_duplicates_and_missing_part(self):
|
|
self.scenario("fragments_fixed_timeout")
|
|
|
|
def test_fragment_deadline_handles_millisecond_timer_wrap(self):
|
|
self.scenario("fragments_timeout_wrap")
|
|
|
|
def test_fragment_sequence_plausibility_and_signed_sequence_match_precede_reservation(self):
|
|
self.scenario("fragments_sequence_bounds")
|
|
|
|
def test_fragmented_nonmatching_target_preserves_sequence_for_matching_retry(self):
|
|
self.scenario("fragments_target")
|
|
|
|
def test_channel_controller_changes_and_reboot_clear_partial_transfers(self):
|
|
self.scenario("fragments_config_reboot")
|
|
|
|
def test_fragment_completion_storage_failure_has_no_dispatch_or_reply(self):
|
|
self.scenario("fragments_reserve_failure")
|
|
|
|
def test_final_fragment_scope_and_radio_profile_feed_both_ack_copy_commit_barrier(self):
|
|
self.scenario("fragments_scoped_radio_ack")
|
|
|
|
def test_fragmented_radio_mutation_with_unknown_final_scope_rolls_back(self):
|
|
self.scenario("fragments_unknown_scope")
|
|
|
|
def test_new_valid_single_packet_can_interrupt_an_incomplete_two_packet_transfer(self):
|
|
self.scenario("fragments_single_interrupt")
|
|
|
|
def test_malformed_fragment_size_index_and_padding_never_allocate_partial_intent(self):
|
|
self.scenario("fragments_malformed_frame")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|