Files
MeshCore/test/test_companion_preferences_transaction.py
T

432 lines
20 KiB
Python

"""Exercise real Companion preferences saves and startup recovery with I/O faults."""
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
from test_radio_receive_contract import method
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <cassert>
#include <cstdlib>
#include <cstring>
#include <new>
#include <cerrno>
#include <helpers/IdentityStore.h>
#include "ContactFileTransaction.h"
#if defined(STM32_PLATFORM) || defined(NRF52_PLATFORM)
#define ATOMIC_FILE_WRITER_IMPLEMENTATION
#include <helpers/AtomicFileWriter.h>
#endif
#include "examples/companion_radio/NodePrefs.h"
#include "examples/companion_radio/PrefsStorageLayout.h"
#if defined(RAK_4631)
#include <helpers/PeripheralModePrefs.h>
#endif
#define MESH_DEBUG_PRINTLN(...) ((void)0)
struct DataStore {
MemoryFS fs;
FILESYSTEM* _fs=&fs;
bool _prefs_load_incomplete=false, _primary_storage_unavailable=false;
bool _secondary_authority_unknown=false;
const char* _prefs_recovery_source=nullptr;
DataStore(){
#if defined(STM32_PLATFORM) || defined(NRF52_PLATFORM)
fs.rename_replaces=true;
#endif
}
bool loadPrefs(CompanionNodePrefs&, double&, double&);
bool loadPrefsInt(const char*, CompanionNodePrefs&, double&, double&);
bool savePrefs(const CompanionNodePrefs&, double, double);
};
File openRead(FILESYSTEM* fs,const char* path){return fs->open(path,"r");}
File openWrite(FILESYSTEM* fs,const char* path){return fs->open(path,"w");}
bool contactPathPresence(FILESYSTEM* fs,const char* path,bool& present){
present=fs->exists(path);return true;
}
@METHODS@
int main(int argc,char** argv){
const int scenario=argc>1?atoi(argv[1]):0;
DataStore store;CompanionNodePrefs original;
strcpy(original.node_name,"durable node");original.freq=910.525f;
original.ble_pin=876543;original.bluetooth_stealth_mode=2;
original.gps_interval=123;original.sf=7;original.cr=7;original.bw=62.5f;
original.tx_delay_factor=1.25f;original.direct_tx_delay_factor=0.75f;
original.interference_threshold=8;original.agc_reset_interval=15;original.tz_offset=-7;
original.flood_retry_attempts=4;original.flood_retry_max_path=3;
original.flood_retry_group_max_path=2;original.flood_retry_advert_enabled=0;
original.one_key_dm_enabled=1;
original.gps_sync_interval_hours=336;
original.usb_debug_enabled=1;
original.lost_reply=2;
original.tx_media=mesh::RADIO_TX_SECONDARY;
original.tx_ssh=mesh::RADIO_TX_OFF;
#if defined(RAK_4631)
original.peripherals_shared=1;
#endif
#ifdef TBEAM_1W
strcpy(original.fan_mode,"on");original.fan_lo=33;original.fan_hi=42;
#endif
#if defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
strcpy(original.wifi_ssid,"test-network");strcpy(original.wifi_pwd,"test-password");
#endif
assert(store.savePrefs(original,47.1,-122.2));
const auto disk=store.fs.files["/new_prefs"];
const size_t historical_size=disk.size()-mesh::companion::traffic::FooterSize
#if defined(RAK_4631)
-mesh::peripherals::FooterSize
#endif
;
CompanionNodePrefs changed=original;changed.freq=910.1f;
if(scenario==0){
store.fs.fail_write=true;
assert(!store.savePrefs(changed,42.3,-121.2));
assert(store.fs.files["/new_prefs"]==disk);
store.fs.fail_write=false;store.fs.fail_write_after=17;
assert(!store.savePrefs(changed,42.3,-121.2));
assert(store.fs.files["/new_prefs"]==disk);
store.fs.fail_write_after=-1;
#if defined(STM32_PLATFORM) || defined(NRF52_PLATFORM)
const int rename_steps[]={1};
#else
const int rename_steps[]={1,2};
#endif
for(int fail_step : rename_steps){
store.fs.fail_rename=fail_step;
assert(!store.savePrefs(changed,42.3,-121.2));
assert(store.fs.files["/new_prefs"]==disk);
}
assert(store.savePrefs(changed,42.3,-121.2));
CompanionNodePrefs loaded;double lat=0,lon=0;
assert(store.loadPrefs(loaded,lat,lon));
assert(loaded.freq==changed.freq&&loaded.ble_pin==original.ble_pin);
assert(loaded.tx_delay_factor==1.25f&&loaded.direct_tx_delay_factor==0.75f);
assert(loaded.interference_threshold==8&&loaded.agc_reset_interval==15&&loaded.tz_offset==-7);
assert(loaded.flood_retry_attempts==4&&loaded.flood_retry_max_path==3);
assert(loaded.flood_retry_group_max_path==2&&loaded.flood_retry_advert_enabled==0);
assert(loaded.one_key_dm_enabled==1);
assert(loaded.gps_sync_interval_hours==336);
assert(loaded.usb_debug_enabled==1);
assert(loaded.lost_reply==2);
assert(loaded.tx_media==mesh::RADIO_TX_SECONDARY&&loaded.tx_ssh==mesh::RADIO_TX_OFF);
#if defined(RAK_4631)
assert(loaded.peripherals_shared==1);
#endif
#ifdef TBEAM_1W
assert(!strcmp(loaded.fan_mode,"on")&&loaded.fan_lo==33&&loaded.fan_hi==42);
#endif
#if defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
assert(!strcmp(loaded.wifi_ssid,"test-network")&&!strcmp(loaded.wifi_pwd,"test-password"));
#endif
assert(lat==42.3&&lon==-121.2);
} else if(scenario==1){
for(int fault : {0,1,2,3,4}){
for(const char* path : {"/new_prefs","/node_prefs"}){
DataStore boot;boot.fs.files[path]=disk;
CompanionNodePrefs live;double lat=1,lon=2;
if(fault==0)boot.fs.fail_read_open=true;
if(fault==1)boot.fs.fail_read_after=0;
if(fault==2)boot.fs.fail_read_after=84;
if(fault==3)boot.fs.fail_read_after=214;
if(fault==4)boot.fs.files[path].resize(87);
const auto durable=boot.fs.files[path];
#if defined(ESP32_PLATFORM)
assert(boot.loadPrefs(live,lat,lon)); // unrecoverable settings permit defaults
#else
assert(!boot.loadPrefs(live,lat,lon));
#endif
assert(live.node_name[0]==0&&lat==1&&lon==2);
assert(boot.fs.files[path]==durable); // no destructive read-side reset
boot.fs.fail_read_after=-1;boot.fs.fail_read_open=false;
#if defined(ESP32_PLATFORM)
assert(boot.savePrefs(live,lat,lon));
assert(boot.fs.files["/new_prefs"]!=disk);
#else
assert(!boot.loadPrefs(live,lat,lon)); // quarantine lasts this boot
assert(!boot.savePrefs(live,lat,lon));
assert(boot.fs.files[path]==durable);
#endif
}
}
DataStore fresh;CompanionNodePrefs defaults;double lat=0,lon=0;
assert(fresh.loadPrefs(defaults,lat,lon));
assert(defaults.wifi_enabled==0); // Fresh installs never enable WiFi implicitly.
assert(fresh.savePrefs(defaults,lat,lon));
} else if(scenario==2){
#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)
store.fs.fail_rename_from={"/new_prefs.tmp","/new_prefs.bak"};
assert(!store.savePrefs(changed,42.3,-121.2));
assert(!store.fs.exists("/new_prefs"));
assert(store.fs.files["/new_prefs.bak"]==disk);
DataStore failed_boot;failed_boot.fs=store.fs;
CompanionNodePrefs loaded;double lat=0,lon=0;
#if defined(ESP32_PLATFORM)
assert(failed_boot.loadPrefs(loaded,lat,lon)); // verified backup is usable in RAM
assert(loaded.freq==original.freq&&loaded.ble_pin==original.ble_pin);
assert(!failed_boot.savePrefs(loaded,lat,lon)); // preserve source while rename fails
assert(failed_boot.fs.files["/new_prefs.bak"]==disk);
failed_boot.fs.fail_rename_from.clear();
assert(failed_boot.savePrefs(loaded,lat,lon));
#else
assert(!failed_boot.loadPrefs(loaded,lat,lon));
failed_boot.fs.fail_rename_from.clear();
assert(!failed_boot.loadPrefs(loaded,lat,lon));
assert(!failed_boot.savePrefs(changed,42.3,-121.2));
assert(failed_boot.fs.files["/new_prefs.bak"]==disk);
#endif
DataStore reboot;reboot.fs=failed_boot.fs;
assert(reboot.loadPrefs(loaded,lat,lon));
assert(loaded.freq==original.freq&&loaded.ble_pin==original.ble_pin);
assert(lat==47.1&&lon==-122.2);
assert(loaded.lost_reply==2);
#endif
} else if(scenario==3){
// Copy the real adapter-bearing preferences from dirty storage. This
// catches reads of uninitialized serializer bools under UBSan reliably.
alignas(CompanionNodePrefs) unsigned char memory[sizeof(CompanionNodePrefs)];
memset(memory,0xa5,sizeof(memory));
auto* prefs=new(memory)CompanionNodePrefs();
CompanionNodePrefs snapshot=*prefs;
assert(!snapshot.isDirty());
prefs->~CompanionNodePrefs();
} else if(scenario==5){
DataStore legacy;legacy.fs.files["/new_prefs"]=disk;
legacy.fs.files["/new_prefs"].resize(215);
CompanionNodePrefs loaded;double lat=0,lon=0;
assert(legacy.loadPrefs(loaded,lat,lon));
assert(loaded.tx_delay_factor==0.5f&&loaded.direct_tx_delay_factor==0.2f);
assert(loaded.interference_threshold==0&&loaded.agc_reset_interval==0&&loaded.tz_offset==0);
assert(loaded.flood_retry_attempts==15&&loaded.flood_retry_max_path==1);
assert(loaded.flood_retry_group_max_path==0xff&&loaded.flood_retry_advert_enabled==1);
assert(loaded.one_key_dm_enabled==0);
assert(loaded.freq==original.freq&&loaded.ble_pin==original.ble_pin);
} else if(scenario==6){
DataStore legacy;legacy.fs.files["/new_prefs"]=disk;
#if defined(TBEAM_1W)
legacy.fs.files["/new_prefs"].resize(233);
#elif defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
legacy.fs.files["/new_prefs"].resize(323);
#else
legacy.fs.files["/new_prefs"].resize(226);
#endif
CompanionNodePrefs loaded;double lat=0,lon=0;
assert(legacy.loadPrefs(loaded,lat,lon));
assert(loaded.flood_retry_attempts==15&&loaded.flood_retry_max_path==1);
assert(loaded.flood_retry_group_max_path==0xff&&loaded.flood_retry_advert_enabled==1);
assert(loaded.one_key_dm_enabled==0);
} else if(scenario==7){
// Pre-consent image: all earlier settings survive with default-off consent.
DataStore legacy;legacy.fs.files["/new_prefs"]=disk;
legacy.fs.files["/new_prefs"].resize(historical_size-6);
CompanionNodePrefs loaded;double lat=0,lon=0;
assert(legacy.loadPrefs(loaded,lat,lon));
assert(loaded.flood_retry_advert_enabled==0);
assert(loaded.one_key_dm_enabled==0);
} else if(scenario==9){
// The previous complete image loads with the board policy even when the
// receiving prefs object previously held an explicit configured interval.
DataStore legacy;legacy.fs.files["/new_prefs"]=disk;
legacy.fs.files["/new_prefs"].resize(historical_size-4);
CompanionNodePrefs loaded;loaded.gps_sync_interval_hours=24;loaded.usb_debug_enabled=1;
double lat=0,lon=0;
assert(legacy.loadPrefs(loaded,lat,lon));
assert(loaded.gps_sync_interval_hours==0 && loaded.one_key_dm_enabled==1);
assert(loaded.usb_debug_enabled==0);
// Half of the two-byte append is never accepted as a complete image.
DataStore torn;torn.fs.files["/new_prefs"]=disk;
torn.fs.files["/new_prefs"].resize(historical_size-3);
assert(!torn.loadPrefsInt("/new_prefs",loaded,lat,lon));
// The complete published GPS image is accepted without borrowing either
// GPS byte as the new debug preference.
DataStore gps_image;gps_image.fs.files["/new_prefs"]=disk;
gps_image.fs.files["/new_prefs"].resize(historical_size-2);
loaded.usb_debug_enabled=1;
assert(gps_image.loadPrefsInt("/new_prefs",loaded,lat,lon));
assert(loaded.gps_sync_interval_hours==336&&loaded.usb_debug_enabled==0);
// The compact known-size table must never narrow the actual file length.
// A large unknown image sharing a recognized low 16 bits remains invalid.
DataStore oversized;oversized.fs.files["/new_prefs"]=disk;
oversized.fs.files["/new_prefs"].resize(disk.size()+65536);
assert(!oversized.loadPrefsInt("/new_prefs",loaded,lat,lon));
assert(loaded.gps_sync_interval_hours==336&&loaded.usb_debug_enabled==0);
} else if(scenario==10){
// Even a checksum-consistent unknown policy tag must not become AUTO or
// update just one output. Version/size and both enum fields are checked.
for(unsigned value=0;value<256;++value){
uint8_t footer[mesh::companion::traffic::FooterSize];
mesh::companion::traffic::encodeFooter(footer,mesh::RADIO_TX_SECONDARY,mesh::RADIO_TX_OFF);
for(unsigned field : {4u,5u}){
mesh::companion::traffic::encodeFooter(footer,mesh::RADIO_TX_SECONDARY,mesh::RADIO_TX_OFF);
footer[field]=uint8_t(value);
uint16_t crc=mesh::companion::traffic::checksum(footer,6);
footer[6]=uint8_t(crc>>8);footer[7]=uint8_t(crc);
uint8_t media=mesh::RADIO_TX_BOTH,ssh=mesh::RADIO_TX_PRIMARY;
bool valid=value==0||(value>=0xa1&&value<=0xa4);
assert(mesh::companion::traffic::decodeFooter(footer,sizeof(footer),media,ssh)==valid);
if(!valid)assert(media==mesh::RADIO_TX_BOTH&&ssh==mesh::RADIO_TX_PRIMARY);
}
}
// Legacy preference files cannot borrow a configured live traffic route.
for(size_t size : {historical_size, disk.size()}){
DataStore boot;boot.fs.files["/new_prefs"]=disk;
boot.fs.files["/new_prefs"].resize(size);
CompanionNodePrefs loaded;loaded.tx_media=mesh::RADIO_TX_BOTH;
loaded.tx_ssh=mesh::RADIO_TX_PRIMARY;double lat=1,lon=2;
assert(boot.loadPrefsInt("/new_prefs",loaded,lat,lon));
assert(loaded.tx_media==(size==disk.size()?mesh::RADIO_TX_SECONDARY:mesh::RADIO_TX_AUTO));
assert(loaded.tx_ssh==(size==disk.size()?mesh::RADIO_TX_OFF:mesh::RADIO_TX_AUTO));
assert(loaded.freq==original.freq&&loaded.ble_pin==original.ble_pin);
}
#if defined(RAK_4631)
DataStore old_rak;old_rak.fs.files["/new_prefs"]=disk;
old_rak.fs.files["/new_prefs"].resize(disk.size()-mesh::companion::traffic::FooterSize);
CompanionNodePrefs rak;rak.tx_media=mesh::RADIO_TX_BOTH;double lat=1,lon=2;
assert(old_rak.loadPrefsInt("/new_prefs",rak,lat,lon));
assert(rak.peripherals_shared==1&&rak.tx_media==mesh::RADIO_TX_AUTO);
#endif
// Every torn and bit-flipped checked footer fails without any live mutation.
const size_t start=disk.size()-mesh::companion::traffic::FooterSize;
for(size_t offset=0;offset<mesh::companion::traffic::FooterSize;++offset){
for(bool truncate : {false,true}){
DataStore boot;auto candidate=disk;
if(truncate)candidate.resize(start+offset);
else candidate[start+offset]^=0x80;
boot.fs.files["/new_prefs"]=candidate;
CompanionNodePrefs loaded;loaded.freq=900;loaded.tx_media=mesh::RADIO_TX_BOTH;
loaded.tx_ssh=mesh::RADIO_TX_PRIMARY;double lat=1,lon=2;
const bool legacy=truncate&&offset==0;
assert(boot.loadPrefsInt("/new_prefs",loaded,lat,lon)==legacy);
if(!legacy)assert(loaded.freq==900&&loaded.tx_media==mesh::RADIO_TX_BOTH
&&loaded.tx_ssh==mesh::RADIO_TX_PRIMARY&&lat==1&&lon==2);
assert(boot.fs.files["/new_prefs"]==candidate);
}
}
// Check every valid independent policy pair through real persistence.
for(uint8_t media=0;media<=mesh::RADIO_TX_OFF;++media){
for(uint8_t ssh=0;ssh<=mesh::RADIO_TX_OFF;++ssh){
original.tx_media=media;original.tx_ssh=ssh;
assert(store.savePrefs(original,47.1,-122.2));
CompanionNodePrefs loaded;double lat=1,lon=2;
assert(store.loadPrefs(loaded,lat,lon));
assert(loaded.tx_media==media&&loaded.tx_ssh==ssh);
}
}
} else if(scenario==8){
for(uint8_t enabled : {0,1}){
CompanionNodePrefs saved=original;saved.wifi_enabled=enabled;
assert(store.savePrefs(saved,47.1,-122.2));
CompanionNodePrefs loaded;double lat=0,lon=0;
loaded.wifi_enabled=1-enabled;
assert(store.loadPrefs(loaded,lat,lon));
assert(loaded.wifi_enabled==enabled); // Saved choices override the default.
assert(store.fs.files["/new_prefs"][156]==enabled); // Established byte offset.
}
for(unsigned size : {84,144,155,156}){
DataStore legacy;legacy.fs.files["/new_prefs"]=store.fs.files["/new_prefs"];
legacy.fs.files["/new_prefs"].resize(size);
CompanionNodePrefs loaded;double lat=0,lon=0;
assert(legacy.loadPrefs(loaded,lat,lon));
assert(loaded.wifi_enabled==0); // Old files with no WiFi field use opt-in.
}
} else if(scenario==4){
DataStore legacy;legacy.fs.files["/node_prefs"]=disk;
legacy.fs.fail_write_after=17;
#if defined(ESP32_PLATFORM)
legacy.fs.fail_rename_from={"/node_prefs"};
#endif
CompanionNodePrefs loaded;double lat=0,lon=0;
assert(legacy.loadPrefs(loaded,lat,lon));
assert(loaded.freq==original.freq);
assert(legacy.fs.files["/node_prefs"]==disk&&!legacy.fs.exists("/new_prefs"));
legacy.fs.fail_write_after=-1;
legacy.fs.fail_rename_from.clear();
assert(legacy.loadPrefs(loaded,lat,lon));
assert(!legacy.fs.exists("/node_prefs")&&legacy.fs.files["/new_prefs"]==disk);
}
}
'''
def esp_recovery_helpers(source):
"""Compile production recovery, substituting only its OS metadata call.
SPIFFS namespace presence is independent of open/read failures. Binding
the fake syscall to the filesystem argument also supports several device
instances in the same host test without a process-global mount mock.
"""
presence = method(source, 'static bool companionPathPresence(')
presence = presence.replace('struct stat info;', 'struct FixtureStat info;')
presence = presence.replace('::stat(vfs_path, &info)', 'fixtureStat(fs, vfs_path, &info)')
return '''
#if defined(ESP32_PLATFORM)
struct FixtureStat {};
static int fixtureStat(FILESYSTEM* fs, const char* path, FixtureStat*) {
assert(!strncmp(path, "/spiffs", 7));
if (!fs->files.count(path + 7)) { errno=ENOENT; return -1; }
return 0;
}
''' + presence + '\n' + method(source, 'static bool promoteCompanionRecoveryFile(') + '\n#endif\n'
class CompanionPreferencesTransactionTests(unittest.TestCase):
def test_real_preferences_io_failures(self):
source = (ROOT / 'examples/companion_radio/DataStore.cpp').read_text()
methods = esp_recovery_helpers(source) + '\n'.join(method(source, signature) for signature in (
'bool DataStore::loadPrefs(', 'bool DataStore::loadPrefsInt(',
'bool DataStore::savePrefs('))
with tempfile.TemporaryDirectory() as directory:
work = Path(directory)
# Binary preference persistence never invokes JSON blob conversion.
# Satisfy those unrelated serializer links without crypto libraries.
(work / 'Utils.h').write_text('''#pragma once
#include <Arduino.h>
#include <cassert>
namespace mesh { struct Utils {
static void printHex(Stream&,const uint8_t*,size_t){assert(false);}
static void fromHex(uint8_t*,size_t,const char*){assert(false);}
}; }
''')
(work / 'platform_shim.h').write_text('''#include <cstdlib>
#include <cstdio>
inline char* utoa(unsigned int value,char* output,int base){
if(base!=10)abort();sprintf(output,"%u",value);return output;
}
''')
transaction = (ROOT / 'src/helpers/ContactFileTransaction.h').read_text()
(work / 'ContactFileTransaction.h').write_text(transaction.replace(
'#include "IdentityStore.h"', '#include <helpers/IdentityStore.h>'))
(work / 'test.cpp').write_text(HARNESS.replace('@METHODS@', methods))
for platform in ('ESP32_PLATFORM', 'RP2040_PLATFORM', 'STM32_PLATFORM', 'NRF52_PLATFORM',
'ESP32_PLATFORM,TBEAM_1W', 'RP2040_PLATFORM,ENABLE_WIFI_INTERFACE',
'NRF52_PLATFORM,RAK_4631'):
with self.subTest(platform=platform):
binary = work / 'test'
sanitizer_flags = [] if os.name == 'nt' else [
'-fsanitize=address,undefined', '-fno-sanitize-recover=all',
'-fno-pie', '-no-pie']
build = subprocess.run(['g++', '-std=c++17',
*['-D'+flag+'=1' for flag in platform.split(',')],
*sanitizer_flags, '-include', str(work / 'platform_shim.h'),
'-I', str(work),
'-I', str(ROOT / 'test/fixtures/radio_profiles/mocks'),
'-I', str(ROOT / 'test/mocks'), '-I', str(ROOT / 'src'),
'-I', str(ROOT / 'src/helpers'), '-I', str(ROOT),
str(work / 'test.cpp'),
str(ROOT / 'src/helpers/ConfigSerializer.cpp'),
str(ROOT / 'src/helpers/DynamicConfigSerializer.cpp'),
str(ROOT / 'src/helpers/CommonRadioPrefs.cpp'),
str(ROOT / 'src/helpers/TxtDataHelpers.cpp'),
'-o', str(binary)], capture_output=True, text=True)
self.assertEqual(build.returncode, 0, build.stdout + build.stderr)
for scenario in range(11):
with self.subTest(scenario=scenario):
run = subprocess.run([str(binary), str(scenario)], capture_output=True, text=True)
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)
if __name__ == '__main__':
unittest.main()