Fix storage recovery and deferred runtime state

Recover usable ESP32 preferences and channels before permitting replacement; keep identity and ACL persistence transactional and complete.

Preserve MQTT commit boundaries, restore OTA identity and policy after deferred allocation, retain radio gain retries, and reject invalid flood limits. Add production-path fault regressions and CI coverage.
This commit is contained in:
mikecarper committed 2026-09-15 16:46:17 -07:00
1 parent b4315dabdb
commit 24b1b1167f
32 files changed
+1546 -189

No files matched your search

+4
View File
@@ -88,8 +88,12 @@ jobs:
python3 -B test/test_companion_response_bounds.py -v
python3 -B test/test_companion_uncached_storage.py -v
python3 -B test/test_companion_ota_config.py -v
python3 -B test/test_ota_identity_policy.py -v
python3 -B test/test_companion_radio_gain_restore.py -v
python3 -B test/test_common_radio_persistence.py -v
python3 -B test/test_common_prefs_commit.py -v
python3 -B test/test_mqtt_prefs_commit.py -v
python3 -B test/test_identity_and_settings_recovery.py -v
python3 -B test/test_contact_cache.py -v
- name: Verify receive recovery and pinned radio status transport
+122 -3
View File
@@ -1,5 +1,6 @@
#include <Arduino.h>
#include <stdlib.h>
#include <initializer_list>
#include "DataStore.h"
#include <helpers/FileRead.h>
#include <helpers/AdvertDataHelpers.h>
@@ -90,6 +91,24 @@ static bool companionPathPresence(FILESYSTEM* fs, const char* path,
#endif
return true;
}
#if defined(ESP32_PLATFORM)
// Only called after the alternate image has been read and validated in full.
// Irrecoverable settings/channels may be replaced, but the verified recovery
// source stays intact until its rename has actually succeeded.
static bool promoteCompanionRecoveryFile(FILESYSTEM* fs, const char* target,
const char*& source) {
if (source == nullptr) return true;
bool target_exists = false, source_exists = false;
if (!companionPathPresence(fs, target, target_exists)
|| !companionPathPresence(fs, source, source_exists) || !source_exists) {
return false;
}
if (target_exists && !fs->remove(target)) return false;
if (!fs->rename(source, target)) return false;
source = nullptr;
return true;
}
#endif
#endif
static File openWrite(FILESYSTEM* fs, const char* filename) {
@@ -586,6 +605,8 @@ bool DataStore::formatFileSystem() {
_identity_creation_blocked = false;
_prefs_load_incomplete = false;
_channel_load_incomplete = false;
_prefs_recovery_source = nullptr;
_channel_recovery_source = nullptr;
#if MESH_CONTACT_CACHE
_cache_load_incomplete = false;
#else
@@ -628,6 +649,12 @@ bool DataStore::repairInternalExtraFS() {
}
bool DataStore::loadMainIdentity(mesh::LocalIdentity &identity) {
#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)
if (!identity_store.recover("_main")) {
_identity_creation_blocked = true;
return false;
}
#endif
#if defined(NRF52_PLATFORM)
if (_primary_storage_unavailable) return false;
@@ -680,7 +707,30 @@ bool DataStore::saveMainIdentity(const mesh::LocalIdentity &identity) {
bool DataStore::loadPrefs(CompanionNodePrefs& prefs, double& node_lat,
double& node_lon) {
#if defined(NRF52_PLATFORM)
#if defined(ESP32_PLATFORM)
// A failed open is not absence on ESP32. Try each complete, committed
// source twice; a temporary I/O failure must not erase saved radio/PIN data.
// Unpublished .tmp candidates never supersede a previously saved image.
_prefs_load_incomplete = false;
_prefs_recovery_source = nullptr;
for (const char* path : {"/new_prefs", "/new_prefs.bak", "/node_prefs"}) {
for (unsigned attempt = 0; attempt < 2; ++attempt) {
bool present = false;
if (!companionPathPresence(_fs, path, present)) continue;
if (!present) break;
if (!loadPrefsInt(path, prefs, node_lat, node_lon)) continue;
if (strcmp(path, "/new_prefs") != 0) {
_prefs_recovery_source = path;
promoteCompanionRecoveryFile(_fs, "/new_prefs", _prefs_recovery_source);
}
return true;
}
}
// No usable saved settings remain. Continue with the caller's defaults and
// permit a later verified save, instead of permanently disabling settings.
MESH_DEBUG_PRINTLN("DataStore: no recoverable preferences; defaults may replace the old image");
return true;
#elif defined(NRF52_PLATFORM)
if (_primary_storage_unavailable
|| (_prefs_load_incomplete && !_secondary_authority_unknown)) {
_prefs_load_incomplete = true;
@@ -905,12 +955,17 @@ bool DataStore::loadPrefsInt(const char *filename,
bool DataStore::savePrefs(const CompanionNodePrefs& _prefs, double node_lat, double node_lon) {
if (_prefs_load_incomplete) return false;
#if defined(ESP32_PLATFORM)
if (!promoteCompanionRecoveryFile(_fs, "/new_prefs", _prefs_recovery_source)) return false;
#endif
#if defined(NRF52_PLATFORM)
if (_primary_storage_unavailable) return false;
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
mesh::AtomicFileWriter file(_fs, "/new_prefs");
#elif defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)
#elif defined(ESP32_PLATFORM)
mesh::ContactFileTransaction file(_fs, "/new_prefs", companionPathPresence);
#elif defined(RP2040_PLATFORM)
mesh::ContactFileTransaction file(_fs, "/new_prefs");
#else
File file = openWrite(_fs, "/new_prefs");
@@ -2057,6 +2112,63 @@ bool DataStore::hasIncompleteContactLoad() const {
}
void DataStore::loadChannels(DataStoreHost* host) {
#if defined(ESP32_PLATFORM)
_channel_load_incomplete = false;
_channel_recovery_source = nullptr;
FILESYSTEM* fs = _getContactsChannelsFS();
for (const char* path : {"/channels2", "/channels2.bak"}) {
for (unsigned attempt = 0; attempt < 2; ++attempt) {
bool present = false;
if (!companionPathPresence(fs, path, present)) continue;
if (!present) break;
File file = openRead(fs, path);
if (!file) continue;
static const uint32_t RECORD_SIZE = 4 + 32 + 32;
const size_t size = file.size();
if (size % RECORD_SIZE != 0 || size / RECORD_SIZE > MAX_GROUP_CHANNELS) {
file.close();
break;
}
const uint8_t count = size / RECORD_SIZE;
ChannelDetails* loaded = count == 0 ? nullptr
: static_cast<ChannelDetails*>(malloc(sizeof(ChannelDetails) * count));
if (count != 0 && loaded == nullptr) {
file.close();
// Heap pressure is not evidence that the durable data is damaged.
_channel_load_incomplete = true;
return;
}
bool valid = true;
for (uint8_t i = 0; valid && i < count; ++i) {
uint8_t unused[4];
valid = file.read(unused, sizeof(unused)) == sizeof(unused)
&& file.read(reinterpret_cast<uint8_t*>(loaded[i].name), 32) == 32
&& file.read(loaded[i].channel.secret, 32) == 32;
if (valid) {
loaded[i].name[31] = 0;
loaded[i].channel.tx_radio = mesh::decodeRadioTxPolicy(unused[0]);
}
}
file.close();
if (!valid) { free(loaded); continue; }
for (uint8_t i = 0; i < count; ++i) {
if (!host->onChannelLoaded(i, loaded[i])) {
free(loaded);
_channel_load_incomplete = true;
return;
}
}
free(loaded);
if (strcmp(path, "/channels2") != 0) {
_channel_recovery_source = path;
promoteCompanionRecoveryFile(fs, "/channels2", _channel_recovery_source);
}
return;
}
}
MESH_DEBUG_PRINTLN("DataStore: no recoverable channels; defaults may replace the old image");
return;
#else
#if defined(NRF52_PLATFORM)
bool& incomplete = _contact_load_incomplete;
#else
@@ -2151,9 +2263,14 @@ void DataStore::loadChannels(DataStoreHost* host) {
}
}
free(loaded);
#endif
}
bool DataStore::saveChannels(DataStoreHost* host) {
#if defined(ESP32_PLATFORM)
if (!promoteCompanionRecoveryFile(_getContactsChannelsFS(), "/channels2",
_channel_recovery_source)) return false;
#endif
#if !defined(NRF52_PLATFORM)
if (_channel_load_incomplete) return false;
#endif
@@ -2162,7 +2279,9 @@ bool DataStore::saveChannels(DataStoreHost* host) {
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
mesh::AtomicFileWriter file(_getContactsChannelsFS(), "/channels2");
#elif defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)
#elif defined(ESP32_PLATFORM)
mesh::ContactFileTransaction file(_getContactsChannelsFS(), "/channels2", companionPathPresence);
#elif defined(RP2040_PLATFORM)
mesh::ContactFileTransaction file(_getContactsChannelsFS(), "/channels2");
#else
File file = openWrite(_getContactsChannelsFS(), "/channels2");
+4
View File
@@ -41,6 +41,10 @@ class DataStore
IdentityStore identity_store;
bool _identity_creation_blocked = false;
bool _prefs_load_incomplete = false;
#if defined(ESP32_PLATFORM)
const char* _prefs_recovery_source = nullptr;
const char* _channel_recovery_source = nullptr;
#endif
#if !defined(NRF52_PLATFORM)
bool _channel_load_incomplete = false;
#if !MESH_CONTACT_CACHE
+13 -5
View File
@@ -18,7 +18,7 @@
#include <helpers/StatsFormatHelper.h>
#include <helpers/UsbAsciiBinarySwitch.h>
#include <helpers/UsbLogging.h>
#if defined(ENABLE_OTA) && defined(OTA_HEAP_CONTEXT)
#if defined(ENABLE_OTA)
#include <helpers/ota/OtaContext.h>
#endif
#if defined(ENABLE_OTA)
@@ -1745,6 +1745,9 @@ void MyMesh::begin(bool has_display, bool radio_available) {
board.reboot();
return;
}
#if defined(ENABLE_OTA)
mesh::ota::ota_refresh_seeder_identity(self_id.pub_key);
#endif
// if name is provided as a build flag, use that as default node name instead
#ifdef ADVERT_NAME
@@ -2033,8 +2036,9 @@ void MyMesh::configureRadioFromPrefs() {
radio_driver.setCADScanTimeoutMillis(_prefs.cad_scan_timeout_ms);
_radio->setCADEnabled(_prefs.cad_enabled != 0);
if (!saved_radio_apply_pending) {
saved_radio_apply_pending = !radio_driver.setTxPower(_prefs.tx_power_dbm);
radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain);
saved_radio_apply_pending = !radio_driver.setTxPower(_prefs.tx_power_dbm)
|| (radio_driver.supportsRxBoostedGainMode()
&& !radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain));
}
const bool fem_gain_changed = board.canControlLoRaFemLna()
&& board.isLoRaFemLnaEnabled() != (_prefs.radio_fem_rxgain != 0);
@@ -3309,8 +3313,9 @@ void MyMesh::serviceTempRadio() {
mesh::RadioParamApplyResult result = tryApplyRadioParams(
_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
if (result == mesh::RadioParamApplyResult::APPLIED
&& radio_driver.setTxPower(_prefs.tx_power_dbm)) {
radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain);
&& radio_driver.setTxPower(_prefs.tx_power_dbm)
&& (!radio_driver.supportsRxBoostedGainMode()
|| radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain))) {
_temp_radio_set_at = 0;
_temp_radio_revert_at = 0;
_temp_radio_retry_at = 0;
@@ -5003,6 +5008,9 @@ void MyMesh::handleCmdFrame(size_t len) {
identity.readFrom(&cmd_frame[1], 64);
if (_store->saveMainIdentity(identity)) {
self_id = identity;
#if defined(ENABLE_OTA)
mesh::ota::ota_refresh_seeder_identity(self_id.pub_key);
#endif
writeOKFrame();
// re-load contacts, to invalidate ecdh shared_secrets
stopContactsIterator();
+63 -20
View File
@@ -4,6 +4,8 @@
#include "ClientLoginPersistence.h"
#include "ClientPathPersistence.h"
#include "FileRead.h"
#include "FilePresence.h"
#include <initializer_list>
#if defined(NRF52_PLATFORM)
#include "AtomicFileWriter.h"
#endif
@@ -570,7 +572,14 @@ void ClientACL::load(FILESYSTEM* fs, const mesh::LocalIdentity& self_id) {
_fs = fs;
num_clients = 0;
login_replay_store_available = false;
acl_load_complete = false;
if (_fs == NULL) return;
bool primary_exists = false, backup_exists = false;
if (!mesh::filePresence(_fs, mesh::CLIENT_ACL_PRIMARY_PATH, primary_exists)
|| !mesh::filePresence(_fs, mesh::CLIENT_ACL_BACKUP_PATH, backup_exists)) {
return;
}
const bool durable_acl_expected = primary_exists || backup_exists;
#if defined(NRF52_PLATFORM)
// AtomicFileWriter may leave only a harmless temp image when reset before
// rename. The live image remains authoritative.
@@ -595,11 +604,22 @@ void ClientACL::load(FILESYSTEM* fs, const mesh::LocalIdentity& self_id) {
return;
}
#endif
if (_fs->exists("/s_contacts")) {
if (!mesh::filePresence(_fs, mesh::CLIENT_ACL_PRIMARY_PATH, primary_exists)
|| (durable_acl_expected && !primary_exists)) return;
if (primary_exists) {
File file = openRead(_fs, "/s_contacts");
if (file) {
bool full = false;
while (!full) {
size_t remaining = file.size();
#if !defined(NRF52_PLATFORM)
// Versioned SPIFFS images finish with a verified CRC trailer, not a
// partially readable extra contact. The data pass must consume every
// byte before it, even if the earlier integrity pass succeeded.
if (remaining >= 8 && (remaining - 8) % CONTACT_RECORD_SIZE == 0) {
remaining -= 8;
}
#endif
bool complete = true;
while (remaining != 0) {
ClientInfo c;
uint8_t pub_key[32];
uint8_t unused[2];
@@ -608,6 +628,11 @@ void ClientACL::load(FILESYSTEM* fs, const mesh::LocalIdentity& self_id) {
c.alt_path_len = OUT_PATH_UNKNOWN;
c.observed_path_len = OUT_PATH_UNKNOWN;
static const size_t base_size = 32 + 1 + 4 + 2 + 1 + 64 + PUB_KEY_SIZE;
if (remaining < base_size || num_clients >= capacity) {
complete = false;
break;
}
bool success = (file.read(pub_key, 32) == 32);
success = success && (file.read((uint8_t *) &c.permissions, 1) == 1);
success = success && (file.read((uint8_t *) &c.extra.room.sync_since, 4) == 4);
@@ -615,12 +640,20 @@ void ClientACL::load(FILESYSTEM* fs, const mesh::LocalIdentity& self_id) {
success = success && (file.read((uint8_t *)&c.out_path_len, 1) == 1);
success = success && (file.read(c.out_path, 64) == 64);
success = success && (file.read(c.shared_secret, PUB_KEY_SIZE) == PUB_KEY_SIZE); // will be recalculated below
if (success && unused[0] >= CONTACT_RECORD_VERSION_ALT_PATH) {
size_t record_size = base_size;
if (success && unused[0] > CONTACT_RECORD_VERSION_ALT_PATH) success = false;
if (success && unused[0] == CONTACT_RECORD_VERSION_ALT_PATH) {
record_size += 1 + 64;
success = remaining >= record_size;
success = success && (file.read((uint8_t *)&c.alt_path_len, 1) == 1);
success = success && (file.read(c.alt_path, 64) == 64);
}
if (!success) break; // EOF
if (!success) {
complete = false;
break;
}
remaining -= record_size;
c.id = mesh::Identity(pub_key);
c.out_path_is_persistable = true;
@@ -640,15 +673,21 @@ void ClientACL::load(FILESYSTEM* fs, const mesh::LocalIdentity& self_id) {
c.last_timestamp = UINT32_MAX;
}
self_id.calcSharedSecret(c.shared_secret, pub_key); // recalculate shared secrets in case our private key changed
if (num_clients < capacity) {
clients[num_clients++] = c;
} else {
full = true;
}
clients[num_clients++] = c;
}
file.close();
if (!complete) {
// A prefix is not an authoritative ACL. Keep flash untouched and
// refuse admission/mutations until an explicit reload or reboot can
// read the complete image; never save the prefix over its source.
num_clients = 0;
return;
}
} else {
return;
}
}
acl_load_complete = true;
}
bool ClientACL::authorizeLoginTimestamp(
@@ -656,7 +695,8 @@ bool ClientACL::authorizeLoginTimestamp(
uint32_t sender_timestamp,
uint32_t runtime_last_timestamp,
uint8_t login_permissions) {
if (_fs == NULL || pubkey == NULL || !login_replay_store_available) {
if (_fs == NULL || pubkey == NULL || !login_replay_store_available
|| !acl_load_complete) {
return false;
}
@@ -692,7 +732,7 @@ bool ClientACL::authorizeLoginTimestamp(
bool ClientACL::save(FILESYSTEM* fs, bool (*filter)(ClientInfo*)) {
// A failed allocation is not an empty ACL. Preserve the stored managers.
if (capacity == 0 || fs == NULL) return false;
if (capacity == 0 || fs == NULL || !acl_load_complete) return false;
_fs = fs;
#if defined(NRF52_PLATFORM)
mesh::AtomicFileWriter file(_fs, "/s_contacts");
@@ -797,17 +837,18 @@ bool ClientACL::save(FILESYSTEM* fs, bool (*filter)(ClientInfo*)) {
bool ClientACL::clear() {
if (!_fs) return false; // no filesystem, nothing to clear
if (_fs->exists("/s_contacts")) {
_fs->remove("/s_contacts");
for (const char* path : {mesh::CLIENT_ACL_PRIMARY_PATH,
mesh::CLIENT_ACL_TEMP_PATH,
mesh::CLIENT_ACL_BACKUP_PATH}) {
bool present = false;
if (!mesh::filePresence(_fs, path, present)) return false;
if (present && !_fs->remove(path)) return false;
if (!mesh::filePresence(_fs, path, present) || present) return false;
}
if (_fs->exists("/s_contacts.tmp")) _fs->remove("/s_contacts.tmp");
if (_fs->exists("/s_contacts.bak")) _fs->remove("/s_contacts.bak");
const bool files_cleared = !_fs->exists("/s_contacts")
&& !_fs->exists("/s_contacts.tmp")
&& !_fs->exists("/s_contacts.bak");
if (clients) memset(clients, 0, sizeof(ClientInfo) * (size_t)capacity);
num_clients = 0;
return files_cleared;
acl_load_complete = true;
return true;
}
ClientInfo* ClientACL::getClient(const uint8_t* pubkey, int key_len) {
@@ -819,6 +860,7 @@ ClientInfo* ClientACL::getClient(const uint8_t* pubkey, int key_len) {
}
ClientInfo* ClientACL::putClient(const mesh::Identity& id, uint8_t init_perms) {
if (!acl_load_complete) return NULL;
uint32_t min_time = 0xFFFFFFFF;
ClientInfo* oldest = NULL;
for (int i = 0; i < num_clients; i++) {
@@ -847,6 +889,7 @@ ClientInfo* ClientACL::putClient(const mesh::Identity& id, uint8_t init_perms) {
}
bool ClientACL::applyPermissions(const mesh::LocalIdentity& self_id, const uint8_t* pubkey, int key_len, uint8_t perms) {
if (!acl_load_complete) return false;
if (pubkey == NULL || key_len <= 0 || key_len > PUB_KEY_SIZE) return false;
ClientInfo* c;
if ((perms & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST) { // guest role is not persisted in contacts
+2
View File
@@ -68,6 +68,7 @@ class ClientACL {
int capacity; // 0 when the table could not be allocated
int num_clients;
bool login_replay_store_available;
bool acl_load_complete;
public:
// MAX_CLIENTS entries run to several kilobytes. Classic ESP32's link-time
@@ -83,6 +84,7 @@ public:
if (clients) memset(clients, 0, sizeof(ClientInfo) * (size_t)capacity);
num_clients = 0;
login_replay_store_available = false;
acl_load_complete = false;
}
~ClientACL() { delete[] clients; }
ClientACL(const ClientACL&) = delete;
+47 -45
View File
@@ -48,6 +48,7 @@
#ifdef WITH_MQTT_BRIDGE
#include "bridges/MQTTBridge.h"
#include "CommonPrefsRecovery.h"
#include "FilePresence.h"
#include "MQTTDefaults.h"
#include "MQTTPrefsAtomicStore.h"
#include "MQTTPrefsCodec.h"
@@ -875,18 +876,24 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) {
}
#if defined(ENABLE_OTA)
// Push the persisted OTA policy + signer allowlist into the running OtaContext (called after load).
// Register the existing preferences as the source of OTA policy. Registration
// does not need a workspace; a later allocation retry must still restore keys.
void CommonCLI::syncOtaConfigFromPrefs() {
if (!mesh::ota::ota_acquire_context(nullptr, 0)) return;
mesh::ota::OtaContext& c = mesh::ota::ota_ctx();
c.manager.set_autofetch(_prefs->ota_autofetch);
c.manager.set_checkpoint_blocks(_prefs->ota_checkpoint_blocks);
c.manager.set_advert_mins(_prefs->ota_advert_interval);
c.manager.set_max_hops(_prefs->ota_max_hops);
c.autoinstall = _prefs->ota_autoinstall;
c.allow.clear();
for (uint8_t i = 0; i < _prefs->ota_signer_count && i < MAX_OTA_SIGNERS; i++)
c.allow.add(_prefs->ota_signers[i]);
// Mesh owns one process-wide OTA context. Its role's NodePrefs outlives that
// context and already holds the durable policy, so retain only its pointer.
static const NodePrefs* ota_prefs = nullptr;
ota_prefs = _prefs;
mesh::ota::ota_set_context_config_loader([](mesh::ota::OtaConfigState& state) {
state.autofetch = ota_prefs->ota_autofetch;
state.checkpoint = ota_prefs->ota_checkpoint_blocks;
state.advert = ota_prefs->ota_advert_interval;
state.hops = ota_prefs->ota_max_hops;
state.autoinstall = ota_prefs->ota_autoinstall;
state.allow.clear();
for (uint8_t i = 0; i < ota_prefs->ota_signer_count && i < MAX_OTA_SIGNERS; i++)
state.allow.add(ota_prefs->ota_signers[i]);
return true;
});
}
#endif
@@ -1810,7 +1817,9 @@ static const char* mqttJsonImportResultName(MQTTPrefsJsonImport::Result result)
}
static MQTTPrefsRecovery::FileState mqttPrefsFileState(FILESYSTEM* fs, const char* path) {
if (!fs->exists(path)) return MQTTPrefsRecovery::FileState::Missing;
bool present = false;
if (!mesh::filePresence(fs, path, present)) return MQTTPrefsRecovery::FileState::Preserve;
if (!present) return MQTTPrefsRecovery::FileState::Missing;
File file = openMqttPrefsRead(fs, path);
if (!file) return MQTTPrefsRecovery::FileState::Preserve;
const size_t file_size = file.size();
@@ -1843,21 +1852,7 @@ static bool recoverMqttPrefsFiles(FILESYSTEM* fs) {
if (temp != MQTTPrefsRecovery::FileState::Missing) fs->remove("/mqtt_prefs.tmp");
if (backup != MQTTPrefsRecovery::FileState::Missing) fs->remove("/mqtt_prefs.bak");
}
return false;
}
if (action == MQTTPrefsRecovery::Action::PromoteTemp) {
if (fs->rename("/mqtt_prefs.tmp", "/mqtt_prefs")) {
// A usable temp is now the committed primary. Its backup is necessarily
// a stale transaction artifact, even if this firmware cannot decode it.
if (temp == MQTTPrefsRecovery::FileState::Usable &&
backup != MQTTPrefsRecovery::FileState::Missing) {
fs->remove("/mqtt_prefs.bak");
}
MESH_DEBUG_PRINTLN("MQTT: recovered /mqtt_prefs from transaction temp");
return false;
}
MESH_DEBUG_PRINTLN("MQTT: could not recover /mqtt_prefs temp; files preserved");
return true;
return primary == MQTTPrefsRecovery::FileState::Preserve;
}
if (action == MQTTPrefsRecovery::Action::PromoteBackup) {
if (fs->rename("/mqtt_prefs.bak", "/mqtt_prefs")) {
@@ -1868,11 +1863,16 @@ static bool recoverMqttPrefsFiles(FILESYSTEM* fs) {
fs->remove("/mqtt_prefs.tmp");
}
MESH_DEBUG_PRINTLN("MQTT: recovered /mqtt_prefs from transaction backup");
return false;
return backup == MQTTPrefsRecovery::FileState::Preserve;
}
MESH_DEBUG_PRINTLN("MQTT: could not recover /mqtt_prefs backup; files preserved");
return true;
}
if (action == MQTTPrefsRecovery::Action::DiscardTemp) {
// No published image exists. A first-save candidate is still uncommitted,
// even when fully written, and must not become active after a rejected save.
return !fs->remove("/mqtt_prefs.tmp");
}
return false;
}
@@ -1894,10 +1894,9 @@ public:
_open = false;
_owns_temp = false;
_bytes_written = 0;
// Recovery owns stale artifacts. Do not delete them here: a failed commit
// may have moved the old primary to .bak and left a verified temp that the
// next boot must choose between. Refusing the save is safer than erasing an
// image this firmware cannot decode.
// Retry interrupted cleanup/rollback without requiring a reboot. Recovery
// never publishes a rejected candidate or replaces an opaque primary.
if (recoverMqttPrefsFiles(_fs)) return false;
if (_fs->exists("/mqtt_prefs.tmp") || _fs->exists("/mqtt_prefs.bak")) return false;
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
_file = _fs->open("/mqtt_prefs.tmp", FILE_O_WRITE);
@@ -1939,13 +1938,17 @@ public:
if (!_finished) return false;
// SPIFFS refuses rename(tmp, existing_dest). Move the existing image to a
// recoverable backup first, then publish temp into the now-empty primary.
// Never remove either image after a failed boundary; boot recovery selects
// the completed temp or restores the backup.
// A rejected publication must keep the previous image authoritative.
if (_fs->exists("/mqtt_prefs.bak")) return false;
if (_fs->exists("/mqtt_prefs") && !_fs->rename("/mqtt_prefs", "/mqtt_prefs.bak")) {
return false;
}
if (!_fs->rename("/mqtt_prefs.tmp", "/mqtt_prefs")) return false;
if (!_fs->rename("/mqtt_prefs.tmp", "/mqtt_prefs")) {
if (_fs->exists("/mqtt_prefs.bak")) {
_fs->rename("/mqtt_prefs.bak", "/mqtt_prefs");
}
return false;
}
// Cleanup failure is non-fatal: the new primary is published and recovery
// will remove a known-good stale backup on a later boot.
if (_fs->exists("/mqtt_prefs.bak")) _fs->remove("/mqtt_prefs.bak");
@@ -1955,10 +1958,9 @@ public:
void abort() {
if (_open) _file.close();
_open = false;
// Once finish() has verified the temp, commit may already have moved the
// primary to .bak. Keep the temp on a commit failure so recovery can
// publish it (or fall back to .bak) after reset.
if (_owns_temp && !_finished && _fs->exists("/mqtt_prefs.tmp")) {
// Explicit rejection is not a successful commit. If cleanup or rollback
// also fails, boot recovery still chooses the old backup, never this temp.
if (_owns_temp && _fs->exists("/mqtt_prefs.tmp")) {
_fs->remove("/mqtt_prefs.tmp");
}
_finished = false;
@@ -3807,8 +3809,8 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
strcpy(reply, "Error, must be 0-2");
}
} else if (memcmp(config, "flood.max.unscoped ", 19) == 0) {
uint8_t m = atoi(&config[19]);
if (m <= 64) {
uint32_t m;
if (mesh::cli::parseUnsignedIntegerStrict(&config[19], m) && m <= 64) {
_prefs->flood_max_unscoped = m;
savePrefs();
strcpy(reply, "OK");
@@ -3816,8 +3818,8 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
strcpy(reply, "Error, max 64");
}
} else if (memcmp(config, "flood.max.advert ", 17) == 0) {
uint8_t m = atoi(&config[17]);
if (m <= 64) {
uint32_t m;
if (mesh::cli::parseUnsignedIntegerStrict(&config[17], m) && m <= 64) {
_prefs->flood_max_advert = m;
savePrefs();
strcpy(reply, "OK");
@@ -3825,8 +3827,8 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
strcpy(reply, "Error, max 64");
}
} else if (memcmp(config, "flood.max ", 10) == 0) {
uint8_t m = atoi(&config[10]);
if (m <= 64) {
uint32_t m;
if (mesh::cli::parseUnsignedIntegerStrict(&config[10], m) && m <= 64) {
_prefs->flood_max = m;
savePrefs();
strcpy(reply, "OK");
+1 -1
View File
@@ -775,7 +775,7 @@ class CommonCLI {
bool saveMQTTPrefs(FILESYSTEM* fs);
#endif
#if defined(ENABLE_OTA)
void syncOtaConfigFromPrefs(); // persisted OTA policy + signer allowlist -> running OtaContext
void syncOtaConfigFromPrefs(); // persisted OTA policy + signer allowlist -> idle/active OtaContext
#endif
void handleRegionCmd(char* command, char* reply);
+26 -7
View File
@@ -10,6 +10,9 @@
namespace mesh {
class ContactFileTransaction {
public:
using PresenceProbe = bool (*)(FILESYSTEM*, const char*, bool&);
private:
FILESYSTEM* _fs;
const char* _target;
char _temp[48];
@@ -19,17 +22,30 @@ class ContactFileTransaction {
uint32_t _crc = 0xffffffff;
bool _ok = false;
bool _finished = false;
PresenceProbe _presence;
static bool probe(FILESYSTEM* fs, const char* path, bool& present,
PresenceProbe presence) {
if (presence) return presence(fs, path, present);
present = fs->exists(path);
return true;
}
public:
static bool recover(FILESYSTEM* fs, const char* target) {
static bool recover(FILESYSTEM* fs, const char* target,
PresenceProbe presence = nullptr) {
char backup[48];
snprintf(backup, sizeof(backup), "%s.bak", target);
if (fs->exists(target)) return true;
return !fs->exists(backup) || fs->rename(backup, target);
bool target_exists = false, backup_exists = false;
if (!probe(fs, target, target_exists, presence)
|| !probe(fs, backup, backup_exists, presence)) return false;
if (target_exists) return true;
return !backup_exists || fs->rename(backup, target);
}
ContactFileTransaction(FILESYSTEM* fs, const char* target) : _fs(fs), _target(target) {
ContactFileTransaction(FILESYSTEM* fs, const char* target,
PresenceProbe presence = nullptr)
: _fs(fs), _target(target), _presence(presence) {
snprintf(_temp, sizeof(_temp), "%s.tmp", target);
snprintf(_backup, sizeof(_backup), "%s.bak", target);
if (!recover(fs, target)) return;
if (!recover(fs, target, presence)) return;
if (fs->exists(_temp) && !fs->remove(_temp)) return;
#if defined(RP2040_PLATFORM)
_file = fs->open(_temp, "w");
@@ -67,9 +83,12 @@ public:
}
if (verify) verify.close();
ok = ok && crc == _crc;
if (ok && _fs->exists(_backup)) ok = _fs->remove(_backup);
bool backup_exists = false, target_exists = false;
if (ok) ok = probe(_fs, _backup, backup_exists, _presence)
&& probe(_fs, _target, target_exists, _presence);
if (ok && backup_exists) ok = _fs->remove(_backup);
bool backed_up = false;
if (ok && _fs->exists(_target)) {
if (ok && target_exists) {
ok = _fs->rename(_target, _backup);
backed_up = ok;
}
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include <stdio.h>
#if defined(ESP32_PLATFORM)
#include <errno.h>
#include <sys/stat.h>
#endif
namespace mesh {
// Unlike ESP32 FS::exists(), this probe does not open the file. A failed read
// must not turn a previously committed image into an apparently fresh store.
template <typename Filesystem>
bool filePresence(Filesystem* fs, const char* path, bool& present) {
if (fs == nullptr || path == nullptr) return false;
#if defined(ESP32_PLATFORM)
char absolute[128];
const int length = snprintf(absolute, sizeof(absolute), "/spiffs%s", path);
if (length < 0 || static_cast<size_t>(length) >= sizeof(absolute)) return false;
struct stat info;
const int result = ::stat(absolute, &info);
if (result != 0 && errno != ENOENT) return false;
present = result == 0;
#elif defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
struct lfs_info info;
fs->_lockFS();
const int result = lfs_stat(fs->_getFS(), path, &info);
fs->_unlockFS();
if (result != 0 && result != LFS_ERR_NOENT) return false;
present = result == 0;
#else
// Arduino-Pico exposes only boolean metadata status. Its exists() does not
// open the file, but cannot distinguish absent files from metadata errors.
present = fs->exists(path);
#endif
return true;
}
} // namespace mesh
+32 -45
View File
@@ -1,10 +1,30 @@
#include "IdentityStore.h"
#if defined(NRF52_PLATFORM)
#include "FilePresence.h"
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
#include "AtomicFileWriter.h"
#else
#include "ContactFileTransaction.h"
#endif
bool IdentityStore::recover(const char* name) {
#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)
char filename[40], backup[48];
if (snprintf(filename, sizeof(filename), "%s/%s.id", _dir, name)
>= (int)sizeof(filename)) return false;
snprintf(backup, sizeof(backup), "%s.bak", filename);
bool primary_exists = false, backup_exists = false;
if (!mesh::filePresence(_fs, filename, primary_exists)
|| !mesh::filePresence(_fs, backup, backup_exists)) return false;
if (!primary_exists && backup_exists) return _fs->rename(backup, filename);
#else
(void)name;
#endif
return true;
}
bool IdentityStore::load(const char *name, mesh::LocalIdentity& id) {
if (!recover(name)) return false;
bool loaded = false;
char filename[40];
sprintf(filename, "%s/%s.id", _dir, name);
@@ -23,6 +43,7 @@ bool IdentityStore::load(const char *name, mesh::LocalIdentity& id) {
}
bool IdentityStore::load(const char *name, mesh::LocalIdentity& id, char display_name[], int max_name_sz) {
if (!recover(name)) return false;
bool loaded = false;
char filename[40];
sprintf(filename, "%s/%s.id", _dir, name);
@@ -50,44 +71,30 @@ bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id) {
char filename[40];
sprintf(filename, "%s/%s.id", _dir, name);
#if defined(NRF52_PLATFORM)
if (!recover(name)) return false;
uint8_t key_data[PRV_KEY_SIZE + PUB_KEY_SIZE];
if (id.writeTo(key_data, sizeof(key_data)) != sizeof(key_data)) return false;
// LocalIdentity's byte-buffer export is private-key then public-key, while
// the historical file format is public-key then private-key.
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
mesh::AtomicFileWriter writer(_fs, filename);
#else
mesh::ContactFileTransaction writer(_fs, filename, mesh::filePresence<FILESYSTEM>);
#endif
const bool wrote = writer
&& writer.write(&key_data[PRV_KEY_SIZE], PUB_KEY_SIZE) == PUB_KEY_SIZE
&& writer.write(key_data, PRV_KEY_SIZE) == PRV_KEY_SIZE;
const bool success = writer.commit(wrote);
MESH_DEBUG_PRINTLN("IdentityStore::save() atomic write - %s", success ? "OK" : "Err");
return success;
#elif defined(STM32_PLATFORM)
_fs->remove(filename);
File file = _fs->open(filename, FILE_O_WRITE);
#elif defined(RP2040_PLATFORM)
File file = _fs->open(filename, "w");
#else
File file = _fs->open(filename, "w", true);
#endif
#if !defined(NRF52_PLATFORM)
if (file) {
bool success = id.writeTo(file);
file.close();
MESH_DEBUG_PRINTLN("IdentityStore::save() write - %s", success ? "OK" : "Err");
return success;
}
#endif
MESH_DEBUG_PRINTLN("IdentityStore::save() failed");
return false;
}
bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id, const char display_name[]) {
char filename[40];
sprintf(filename, "%s/%s.id", _dir, name);
#if defined(NRF52_PLATFORM)
if (!recover(name)) return false;
uint8_t key_data[PRV_KEY_SIZE + PUB_KEY_SIZE];
if (id.writeTo(key_data, sizeof(key_data)) != sizeof(key_data)) return false;
uint8_t display_data[32];
@@ -96,34 +103,14 @@ bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id, const
if (display_len > sizeof(display_data) - 1) display_len = sizeof(display_data) - 1;
memcpy(display_data, display_name, display_len);
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
mesh::AtomicFileWriter writer(_fs, filename);
#else
mesh::ContactFileTransaction writer(_fs, filename, mesh::filePresence<FILESYSTEM>);
#endif
const bool wrote = writer
&& writer.write(&key_data[PRV_KEY_SIZE], PUB_KEY_SIZE) == PUB_KEY_SIZE
&& writer.write(key_data, PRV_KEY_SIZE) == PRV_KEY_SIZE
&& writer.write(display_data, sizeof(display_data)) == sizeof(display_data);
return writer.commit(wrote);
#elif defined(STM32_PLATFORM)
_fs->remove(filename);
File file = _fs->open(filename, FILE_O_WRITE);
#elif defined(RP2040_PLATFORM)
File file = _fs->open(filename, "w");
#else
File file = _fs->open(filename, "w", true);
#endif
#if !defined(NRF52_PLATFORM)
if (file) {
bool success = id.writeTo(file);
uint8_t tmp[32];
memset(tmp, 0, sizeof(tmp));
int n = strlen(display_name);
if (n > sizeof(tmp)-1) n = sizeof(tmp)-1;
memcpy(tmp, display_name, n);
success = success && file.write(tmp, sizeof(tmp)) == sizeof(tmp);
file.close();
return success;
}
#endif
return false;
}
+3
View File
@@ -19,6 +19,9 @@ public:
void begin() {
if (_dir && _dir[0] == '/') { _fs->mkdir(_dir); } }
// Recover an interrupted non-replacing rename before deciding this is a
// fresh identity. False means the previous image must remain protected.
bool recover(const char* name);
bool load(const char *name, mesh::LocalIdentity& id);
bool load(const char *name, mesh::LocalIdentity& id, char display_name[], int max_name_sz);
bool save(const char *name, const mesh::LocalIdentity& id);
+5 -14
View File
@@ -19,8 +19,8 @@ enum class FileState : uint8_t {
enum class Action : uint8_t {
None,
KeepPrimary,
PromoteTemp,
PromoteBackup,
DiscardTemp,
};
inline Action select(FileState primary, FileState temp, FileState backup) {
@@ -29,20 +29,11 @@ inline Action select(FileState primary, FileState temp, FileState backup) {
// by this firmware.
if (primary != FileState::Missing) return Action::KeepPrimary;
// A completed temp is the new image and wins over the old backup.
if (temp == FileState::Usable) return Action::PromoteTemp;
// If temp is opaque but a known-good backup exists, boot from the backup.
// The caller may discard the opaque temp once that usable backup has become
// primary. Otherwise, rename the opaque temp into the empty primary name so
// the normal loader can hold it.
if (temp == FileState::Preserve) {
return backup == FileState::Usable ? Action::PromoteBackup : Action::PromoteTemp;
}
// No temp survived. The backup is the only recoverable image, even when it
// is a newer layout that this firmware must preserve rather than decode.
// Until temp has its final name, it is not committed. A failed publication
// may have been reported to the caller, so never activate it during recovery.
// The backup remains authoritative, including an opaque newer layout.
if (backup != FileState::Missing) return Action::PromoteBackup;
if (temp != FileState::Missing) return Action::DiscardTemp;
return Action::None;
}
+32 -9
View File
@@ -11,14 +11,6 @@ namespace {
bool (*context_config_loader)(OtaConfigState&) = nullptr;
}
void ota_set_context_config_loader(bool (*load)(OtaConfigState&)) {
context_config_loader = load;
if (auto* context = ota_context_if_active()) {
OtaConfigState restored;
if (load && load(restored)) restored.apply(*context);
}
}
#if OTA_DYNAMIC_CONTEXT
namespace {
OtaContext* active_context = nullptr;
@@ -79,7 +71,13 @@ void ota_begin_context(uint32_t target, OtaSend send, void* ctx,
saved_send_ctx = ctx;
strncpy(saved_hw, hw ? hw : "", sizeof(saved_hw) - 1);
saved_hw[sizeof(saved_hw) - 1] = 0;
if (seeder_id) memcpy(saved_seeder_id, seeder_id, sizeof(saved_seeder_id));
ota_refresh_seeder_identity(seeder_id);
}
void ota_refresh_seeder_identity(const uint8_t* seeder_id) {
if (!seeder_id) return;
memcpy(saved_seeder_id, seeder_id, sizeof(saved_seeder_id));
if (active_context) active_context->manager.set_seeder_id(seeder_id);
}
bool ota_acquire_context(char* reply, size_t cap) {
@@ -159,10 +157,35 @@ bool ota_acquire_context(char*, size_t) { return true; }
void ota_begin_context(uint32_t target, OtaSend send, void* ctx,
const char* hw, const uint8_t* seeder_id) {
ota_ctx().begin(target, send, ctx, hw);
ota_refresh_seeder_identity(seeder_id);
}
void ota_refresh_seeder_identity(const uint8_t* seeder_id) {
ota_ctx().manager.set_seeder_id(seeder_id);
}
uint8_t ota_hop_limit() { return ota_ctx().manager.max_hops(); }
#endif
void ota_set_context_config_loader(bool (*load)(OtaConfigState&)) {
context_config_loader = load;
OtaConfigState restored;
if (!load || !load(restored)) return;
#if OTA_DYNAMIC_CONTEXT
// The mesh also consults the hop limit while no OTA workspace is allocated.
// Seed the existing idle policy cache, without claiming heap/queue storage.
#if defined(OTA_SEEDER_ONLY)
saved_autofetch = OtaManager::AUTOFETCH_OFF;
saved_autoinstall = OtaContext::AUTOINSTALL_OFF;
#else
saved_autofetch = restored.autofetch;
saved_autoinstall = restored.autoinstall;
#endif
saved_checkpoint = restored.checkpoint;
saved_advert = restored.advert;
saved_hops = restored.hops;
saved_allow = restored.allow;
#endif
if (auto* context = ota_context_if_active()) restored.apply(*context);
}
} // namespace ota
} // namespace mesh
+5 -1
View File
@@ -706,11 +706,15 @@ OtaContext& ota_ctx(); // process-wide context
// Dynamic builds return null outside an acquired workspace. Static builds
// always return their process-wide context.
OtaContext* ota_context_if_active();
// Optional role-specific policy reload; registration never allocates a context.
// Optional role-specific policy reload; registration updates idle/active policy
// without allocating a context. A failed load leaves the prior policy intact.
void ota_set_context_config_loader(bool (*load)(OtaConfigState&));
bool ota_acquire_context(char* reply, size_t cap);
void ota_begin_context(uint32_t target, OtaSend send, void* ctx,
const char* hw, const uint8_t* seeder_id);
// Identity can become available after Mesh::begin or change through key import.
// Refresh both active and future OTA sessions without resetting transfer state.
void ota_refresh_seeder_identity(const uint8_t* seeder_id);
uint8_t ota_hop_limit();
#if defined(OTA_SHARED_COMPANION_QUEUE)
+20
View File
@@ -49,6 +49,7 @@ struct Prefs {
uint32_t rx_ps_rx_us=1000, rx_ps_sleep_us=1000;
bool cad_enabled=false, rx_boosted_gain=false;
uint8_t extra_sf[4]={};
uint8_t flood_max=16, flood_max_advert=16, flood_max_unscoped=16;
};
struct Callbacks {
unsigned saves=0, tx_calls=0, gain_calls=0;
@@ -175,5 +176,24 @@ int main() {
assert(cli.callbacks.saves==saves);
cli.call(sender,"set unknown.setting 1",reply);
assert(!strcmp(reply,"unknown config") && cli.callbacks.saves==saves);
for (const char* key : {"flood.max", "flood.max.advert", "flood.max.unscoped"}) {
for (const char* invalid : {"", "invalid", "65", "256", "-1", "1tail",
"1.5", "4294967296", "999999999999999999999"}) {
saves=cli.callbacks.saves;
snprintf(text,sizeof(text),"set %s %s",key,invalid); cli.call(sender,text,reply);
assert(!strncmp(reply,"Error",5) && cli.callbacks.saves==saves);
assert(cli.prefs.flood_max==16 && cli.prefs.flood_max_advert==16
&& cli.prefs.flood_max_unscoped==16);
}
}
for (const char* key : {"flood.max", "flood.max.advert", "flood.max.unscoped"}) {
for (unsigned value : {0u, 1u, 64u}) {
saves=cli.callbacks.saves;
snprintf(text,sizeof(text),"set %s %u",key,value); cli.call(sender,text,reply);
assert(!strcmp(reply,"OK") && cli.callbacks.saves==saves+1);
snprintf(text,sizeof(text),"get %s",key); cli.call(sender,text,reply);
unsigned actual=1000; assert(sscanf(reply,"> %u",&actual)==1 && actual==value);
}
}
}
}
+16
View File
@@ -68,7 +68,9 @@ static std::string query(const ClientACL& acl, const char* command, bool local =
}
static void empty_and_single_entry() {
FakeFilesystem fs; mesh::LocalIdentity self;
ClientACL acl;
acl.load(&fs, self);
CHECK(query(acl, "get acl") == "ACL: empty");
CHECK(query(acl, "get acl 1") == "ACL: empty");
CHECK(query(acl, "get acl 2") == "Err - ACL page range: 1-1");
@@ -79,7 +81,9 @@ static void empty_and_single_entry() {
}
static void skips_inactive_and_preserves_full_keys() {
FakeFilesystem fs; mesh::LocalIdentity self;
ClientACL acl;
acl.load(&fs, self);
std::vector<std::string> active;
for (unsigned i = 0; i < 9; ++i) {
auto* client = add(acl, i, i % 2 == 0 ? uint8_t(i + 1) : 0);
@@ -92,7 +96,9 @@ static void skips_inactive_and_preserves_full_keys() {
}
static void full_table_pages() {
FakeFilesystem fs; mesh::LocalIdentity self;
ClientACL acl;
acl.load(&fs, self);
for (unsigned i = 0; i < MAX_CLIENTS; ++i) add(acl, i, PERM_ACL_ADMIN);
const unsigned pages = (MAX_CLIENTS + 1) / 2;
for (unsigned page = 1; page <= pages; ++page) {
@@ -108,7 +114,9 @@ static void full_table_pages() {
}
static void rejects_bad_pages() {
FakeFilesystem fs; mesh::LocalIdentity self;
ClientACL acl;
acl.load(&fs, self);
add(acl, 0, 3);
for (const char* command : {
"get acl 0", "get acl -1", "get acl +1", "get acl 1x", "get acl 1 2",
@@ -124,7 +132,9 @@ static void rejects_bad_pages() {
}
static void matching_and_local_fallback() {
FakeFilesystem fs; mesh::LocalIdentity self;
ClientACL acl;
acl.load(&fs, self);
char reply[157] = "unchanged";
for (const char* command : {"", "get", "get ac", "get aclx", "get acl.other", "set acl"}) {
CHECK(!mesh::cli::handleACLGet(acl, command, reply, sizeof(reply), false));
@@ -136,7 +146,9 @@ static void matching_and_local_fallback() {
}
static void bounds_never_truncate_keys() {
FakeFilesystem fs; mesh::LocalIdentity self;
ClientACL acl;
acl.load(&fs, self);
add(acl, 0, 3);
add(acl, 1, 3);
const auto expected = query(acl, "get acl");
@@ -174,7 +186,9 @@ static void listing_does_not_mutate_acl() {
}
static void actual_role_dispatch_handles_radio_and_local() {
FakeFilesystem fs; mesh::LocalIdentity self;
ClientACL acl;
acl.load(&fs, self);
auto* admin = add(acl, 0, PERM_ACL_ADMIN);
add(acl, 1, PERM_ACL_READ_WRITE);
const auto expected = query(acl, "get acl");
@@ -208,7 +222,9 @@ static void actual_role_dispatch_handles_radio_and_local() {
}
static void repeater_delegation_stays_denied() {
FakeFilesystem fs; mesh::LocalIdentity self;
ClientACL acl;
acl.load(&fs, self);
auto* sender = add(acl, 0, 3);
for (unsigned permissions = 0; permissions <= 255; ++permissions) {
sender->permissions = uint8_t(permissions);
+9
View File
@@ -13,6 +13,7 @@
#define MESH_DEBUG_PRINTLN(...) ((void)0)
class FakeFilesystem;
inline FakeFilesystem* metadata_filesystem = nullptr;
struct FakeFileHandle {
FakeFilesystem* fs;
std::string path;
@@ -38,6 +39,7 @@ public:
class FakeFilesystem {
public:
FakeFilesystem() { metadata_filesystem = this; }
std::map<std::string, std::vector<uint8_t>> files;
std::set<std::string> unreadable;
std::set<std::string> directories_on_read;
@@ -54,6 +56,10 @@ public:
size_t bytes_written = 0;
size_t directory_closes = 0;
size_t missing_read_opens = 0;
bool metadata_error = false;
std::string fail_read_path;
size_t fail_read_open = 0;
size_t fail_read_after = 0;
bool exists(const char* path) const { return files.count(path) != 0; }
File open(const char* path, const char* mode = "r", bool = false) {
@@ -94,6 +100,9 @@ inline size_t File::size() const {
}
inline int File::read(uint8_t* output, size_t length) {
if (!*this || handle->directory) return 0;
if (handle->fs->fail_read_path == handle->path
&& handle->fs->read_open_count[handle->path] == handle->fs->fail_read_open
&& handle->position >= handle->fs->fail_read_after) return 0;
auto found = handle->fs->files.find(handle->path);
if (found == handle->fs->files.end() || handle->position >= found->second.size()) return 0;
length = std::min(length, found->second.size() - handle->position);
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include <cerrno>
#include <cstring>
struct stat {};
inline int stat(const char* path, struct stat*) {
if (!metadata_filesystem || metadata_filesystem->metadata_error) {
errno = EIO;
return -1;
}
if (strncmp(path, "/spiffs", 7) != 0) { errno = EINVAL; return -1; }
if (!metadata_filesystem->files.count(path + 7)) { errno = ENOENT; return -1; }
return 0;
}
+42 -1
View File
@@ -475,9 +475,50 @@ static void allocation_failure_preserves_saved_clients() {
CHECK(recovered.getNumClients() == 1 && recovered.getClient(KEY, PUB_KEY_SIZE));
}
static void incomplete_acl_load_is_never_authoritative() {
for (unsigned fault = 0; fault < 5; ++fault) {
FakeFilesystem fs;
ClientACL original;
original.load(&fs, SELF);
CHECK(original.putClient(mesh::Identity(KEY), PERM_ACL_ADMIN));
CHECK(original.putClient(mesh::Identity(SECOND_KEY), PERM_ACL_REGION_MGR));
CHECK(original.save(&fs));
const auto image = fs.files["/s_contacts"];
fs.read_open_count.clear();
if (fault < 2) {
fs.fail_read_path = "/s_contacts";
fs.fail_read_open = 2; // integrity pass succeeds, actual load fails
fs.fail_read_after = fault == 0 ? 0 : CONTACT_RECORD_SIZE;
} else if (fault == 2) {
fs.unreadable.insert("/s_contacts");
} else if (fault == 3) {
fs.metadata_error = true;
} else {
fs.files["/s_contacts"].resize(CONTACT_RECORD_SIZE + 5);
}
const auto retained = fs.files["/s_contacts"];
ClientACL partial;
partial.load(&fs, SELF);
CHECK(partial.getNumClients() == 0);
CHECK(!partial.putClient(mesh::Identity(ARCHIVED_KEY), PERM_ACL_ADMIN));
CHECK(!partial.applyPermissions(SELF, KEY, PUB_KEY_SIZE, PERM_ACL_ADMIN));
CHECK(!partial.save(&fs));
CHECK(!partial.authorizeLoginTimestamp(KEY, 900, 0, PERM_ACL_ADMIN));
CHECK(fs.files["/s_contacts"] == retained);
fs.fail_read_path.clear(); fs.unreadable.clear(); fs.metadata_error = false;
fs.files["/s_contacts"] = image;
partial.load(&fs, SELF); // explicit retry can read the original authority
CHECK(partial.getNumClients() == 2);
CHECK(partial.getClient(KEY, PUB_KEY_SIZE)->isAdmin());
CHECK(partial.getClient(SECOND_KEY, PUB_KEY_SIZE)->isRegionMgr());
CHECK(partial.save(&fs));
}
}
int main() {
const struct { const char* name; void (*run)(); } tests[] = {
{"allocation failure preserves clients", allocation_failure_preserves_saved_clients},
{"incomplete ACL is not authoritative", incomplete_acl_load_is_never_authoritative},
{"missing read differs from empty file", missing_read_is_not_empty_file},
{"first admin and monotonic retries", first_admin_and_retries},
{"reboot preserves ceiling", reboot_preserves_ceiling},
@@ -507,5 +548,5 @@ int main() {
test.run();
std::printf("PASS: %s\n", test.name);
}
std::puts("25 ClientACL SPIFFS checks passed");
std::puts("26 ClientACL SPIFFS checks passed");
}
+10 -4
View File
@@ -26,6 +26,7 @@ public:
size_t read(uint8_t* bytes, size_t length);
size_t write(const uint8_t* bytes, size_t length);
size_t size() const;
int available() const { return static_cast<int>(size() - position); }
void flush() {}
void close() { fs = nullptr; }
};
@@ -37,12 +38,13 @@ public:
size_t max_write = std::numeric_limits<size_t>::max();
size_t max_read = std::numeric_limits<size_t>::max();
std::string fail_open;
unsigned fail_open_remaining = std::numeric_limits<unsigned>::max();
int stat_error = 0;
unsigned fail_rename = 0, renames = 0, writes = 0;
std::vector<Files> snapshots;
bool exists(const char* path) const {
#if defined(ESP32_PLATFORM)
if (fail_open == path) return false; // ESP32 VFS exists opens the file.
if (fail_open == path && fail_open_remaining != 0) return false; // ESP32 VFS exists opens the file.
#endif
return files.count(path) != 0;
}
@@ -52,7 +54,10 @@ public:
void _unlockFS() {}
FakeFilesystem* _getFS() { return this; }
File open(const char* path, const char* mode = "r", bool = false) {
if (fail_open == path) return File();
if (fail_open == path && fail_open_remaining != 0) {
--fail_open_remaining;
return File();
}
if (*mode != 'r') {
files[path].clear();
return File(this, path, true);
@@ -61,9 +66,9 @@ public:
}
bool rename(const char* from, const char* to) {
++renames;
if (renames == fail_rename || !exists(from)) return false;
if (renames == fail_rename || files.count(from) == 0) return false;
#if defined(ESP32_PLATFORM)
if (exists(to)) return false;
if (files.count(to) != 0) return false;
#endif
files[to] = files.at(from);
files.erase(from);
@@ -153,6 +158,7 @@ class DataStore {
bool _channel_load_incomplete = false;
bool _uncached_contact_load_incomplete = false;
struct IdentityAdapter {
bool recover(const char*) { return true; }
bool load(const char*, mesh::LocalIdentity&) {
File file = filesystem.open(identity_path);
uint8_t data[96];
+2 -1
View File
@@ -210,7 +210,8 @@ class CLISettingsContractTest(unittest.TestCase):
source = COMMON.read_text(encoding='utf-8')
setter, getter = methods(source, 'void CommonCLI::handle')
keys = ['radio', 'freq', 'af', 'dutycycle', 'int.thresh', 'cad', 'radio.rxgain',
'tx', 'rxdelay', 'agc.reset.interval', 'multi.acks', 'txdelay', 'direct.txdelay']
'tx', 'rxdelay', 'agc.reset.interval', 'multi.acks', 'txdelay', 'direct.txdelay',
'flood.max', 'flood.max.advert', 'flood.max.unscoped']
# Preserve the actual conditions and bodies. The only excluded branches
# are unrelated settings needing board-specific dependencies.
set_blocks = [extract_braced(setter, 'if (strncmp(config, "path.hash.mode", 14)')]
+2 -2
View File
@@ -25,8 +25,8 @@ class ClientAclSpiffsTest(unittest.TestCase):
self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr)
checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
self.assertIn("25 ClientACL SPIFFS checks passed", checked.stdout)
self.assertEqual(checked.stdout.count("PASS:"), 25)
self.assertIn("26 ClientACL SPIFFS checks passed", checked.stdout)
self.assertEqual(checked.stdout.count("PASS:"), 26)
if __name__ == "__main__":
+3 -2
View File
@@ -330,11 +330,12 @@ int main() {
disk.autofetch=2; disk.autoinstall=1;
uint8_t key[32]={71}; disk.allow.add(key);
ota_set_context_config_loader(load);
assert(calls==0 && !ota_context_if_active()); // no eager heap or queue claim
assert(calls==1 && !ota_context_if_active()); // policy read, no eager heap/queue claim
assert(ota_hop_limit()==7); // idle relay decisions use the saved limit too
ota_begin_context(123, send, nullptr, "test", nullptr);
for (int i=0; i<3; ++i) {
assert(ota_acquire_context(nullptr, 0));
assert(calls==i+1 && ota_ctx().manager.max_hops()==7);
assert(calls==i+2 && ota_ctx().manager.max_hops()==7);
assert(ota_ctx().manager.advert_mins()==17 && ota_ctx().manager.checkpoint_blocks()==32);
assert(ota_ctx().allow.contains(key));
assert(ota_ctx().manager.autofetch()==0 && ota_ctx().autoinstall==0);
+47 -1
View File
@@ -13,6 +13,7 @@ HARNESS = r'''
#include <cstdlib>
#include <cstring>
#include <new>
#include <cerrno>
#include <helpers/IdentityStore.h>
#include "ContactFileTransaction.h"
#if defined(STM32_PLATFORM) || defined(NRF52_PLATFORM)
@@ -26,6 +27,7 @@ struct DataStore {
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;
@@ -84,12 +86,22 @@ int main(int argc,char** argv){
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;
@@ -103,11 +115,20 @@ int main(int argc,char** argv){
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);
@@ -125,11 +146,15 @@ int main(int argc,char** argv){
} 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);
}
@@ -137,10 +162,31 @@ int main(int argc,char** argv){
'''
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 = '\n'.join(method(source, signature) for signature in (
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:
@@ -8,6 +8,7 @@ import tempfile
import unittest
from test_companion_preferences_transaction import HARNESS as PREFERENCES_HARNESS
from test_companion_preferences_transaction import esp_recovery_helpers
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
@@ -189,7 +190,7 @@ class CompanionPrimaryRadioPersistenceTests(unittest.TestCase):
return nonfinite.group() + '\n' + clamp.group()
support = PREFERENCES_HARNESS[:PREFERENCES_HARNESS.index('int main(')]
support = support.replace('@METHODS@', '\n'.join(extract_braced(datastore, sig) for sig in (
support = support.replace('@METHODS@', esp_recovery_helpers(datastore) + '\n'.join(extract_braced(datastore, sig) for sig in (
'bool DataStore::loadPrefs(', 'bool DataStore::loadPrefsInt(', 'bool DataStore::savePrefs(')))
replacements = {
'@SAVE_PREFS@': extract_braced(header, 'bool savePrefs()'),
+256
View File
@@ -0,0 +1,256 @@
"""Fault-inject gain restoration in actual Companion startup/TempRadio methods."""
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / 'examples/companion_radio/MyMesh.cpp'
HARNESS = r'''
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <deque>
#define MESH_DEBUG_PRINTLN(...) ((void)0)
namespace mesh { enum class RadioParamApplyResult { APPLIED, BUSY, FAILED }; }
using Apply = mesh::RadioParamApplyResult;
static uint32_t now_ms=100;
template<typename T> T nextResult(std::deque<T>& results,T fallback) {
if(results.empty())return fallback;
T result=results.front();results.pop_front();return result;
}
struct Prefs {
float freq=915,bw=250;
uint8_t sf=7,cr=5,rx_boosted_gain=0;
bool radio_fem_rxgain=false,radio_fem_txgain=false,cad_enabled=false;
bool rx_powersaving_enabled=false;
uint32_t rx_ps_rx_us=1000,rx_ps_sleep_us=2000;
int tx_power_dbm=22,cad_scan_timeout_ms=100;
void* getCustom(){return nullptr;}
};
struct Board {
void attachDynamicPrefs(void*){}
bool setLoRaFemLnaEnabled(bool){return true;}
bool setLoRaFemPaGainEnabled(bool){return true;}
bool canControlLoRaFemLna(){return false;}
bool isLoRaFemLnaEnabled(){return false;}
} board;
struct Driver {
bool gain=false,supported=true;
int power=0;
unsigned gain_calls=0,power_calls=0;
std::deque<bool> gain_results,power_results;
bool supportsRxBoostedGainMode(){return supported;}
bool setRxBoostedGainMode(bool value){
++gain_calls;
bool ok=nextResult(gain_results,true);
if(ok)gain=value;
return ok;
}
bool getRxBoostedGainMode(){return gain;}
bool setTxPower(int value){
++power_calls;
bool ok=nextResult(power_results,true);
if(ok)power=value;
return ok;
}
bool supportsRxPowerSaving(){return false;}
bool setRxPowerSaving(bool,uint32_t,uint32_t){return true;}
void setCADScanTimeoutMillis(int){}
} radio_driver;
struct Radio {
void setCADEnabled(bool){}
void recalibrateNoiseFloor(){}
} radio;
struct Clock { uint32_t getMillis(){return now_ms;} } clock_source;
@RETRY_DELAY@
struct MyMesh {
Prefs _prefs;
Radio* _radio=&radio;
Clock* _ms=&clock_source;
bool _radio_available=true,saved_radio_apply_pending=false;
bool _temp_radio_applied=false,command_radio_apply_pending=false,outbound=false;
uint32_t _temp_radio_set_at=0,_temp_radio_revert_at=0,_temp_radio_retry_at=0;
uint32_t radio_apply_retry_at=0;
uint8_t _temp_radio_failures=0,radio_apply_failures=0;
float _temp_radio_freq=916,_temp_radio_bw=125,live_freq=0;
uint8_t _temp_radio_sf=8,_temp_radio_cr=6,_temp_radio_preamble=16;
unsigned applies=0,saves=0;
std::deque<Apply> apply_results;
MyMesh(){now_ms=100;radio_driver=Driver();}
bool hasOutbound(){return outbound;}
bool millisHasNowPassed(uint32_t at){return int32_t(now_ms-at)>0;}
uint32_t futureMillis(uint32_t delay){return now_ms+delay;}
Apply tryApplyRadioParams(float freq,float,uint8_t,uint8_t,bool=false,uint16_t=0){
++applies;
Apply result=nextResult(apply_results,Apply::APPLIED);
if(result==Apply::APPLIED)live_freq=freq;
return result;
}
bool savePrefs(){++saves;return false;}
void configureRadioFromPrefs();
bool applySavedRadioParams();
bool applyAndSaveRxBoostedGain(bool);
#if COMPANION_FEATURE_TEMP_RADIO
void serviceTempRadio();
#endif
void recover(){@RECOVERY@}
};
@METHODS@
static void checkStartupAndRetry(){
{
MyMesh m;m._prefs.rx_boosted_gain=1;
m.configureRadioFromPrefs();
assert(radio_driver.gain && !m.saved_radio_apply_pending);
assert(radio_driver.power==m._prefs.tx_power_dbm && m.saves==0);
}
for(bool desired : {false,true}){
MyMesh m;m._prefs.rx_boosted_gain=desired;radio_driver.gain=!desired;
radio_driver.gain_results={false,false,true};
m.configureRadioFromPrefs();
assert(radio_driver.gain!=desired && m.saved_radio_apply_pending);
m.recover();
assert(m.saved_radio_apply_pending && m.radio_apply_retry_at>now_ms);
unsigned calls=radio_driver.gain_calls;
m.recover();
assert(radio_driver.gain_calls==calls); // Respect backoff, do not spin.
now_ms=m.radio_apply_retry_at+1;m.recover();
assert(radio_driver.gain==desired && !m.saved_radio_apply_pending);
assert(!m.radio_apply_retry_at && !m.radio_apply_failures && m.saves==0);
}
{
MyMesh m;m.apply_results={Apply::BUSY};
m.configureRadioFromPrefs();
assert(m.saved_radio_apply_pending && radio_driver.gain_calls==0);
m.recover();assert(!m.saved_radio_apply_pending);
}
{
MyMesh m;radio_driver.power_results={false};
m.configureRadioFromPrefs();
assert(m.saved_radio_apply_pending);
m.recover();assert(!m.saved_radio_apply_pending);
}
{
MyMesh m;m._radio_available=false;
m.configureRadioFromPrefs();
assert(!m.saved_radio_apply_pending && !m.applies && !radio_driver.gain_calls);
}
{
MyMesh m;radio_driver.supported=false;radio_driver.gain_results={false};
m.configureRadioFromPrefs();
assert(!m.saved_radio_apply_pending && !radio_driver.gain_calls);
m.saved_radio_apply_pending=true;m.recover();
assert(!m.saved_radio_apply_pending && !radio_driver.gain_calls);
}
}
#if COMPANION_FEATURE_TEMP_RADIO
static void checkTemporaryOverrideCoexistence(){
for(int phase=0;phase<3;++phase){
MyMesh m;m._prefs.rx_boosted_gain=1;radio_driver.gain_results={false};
m.configureRadioFromPrefs();assert(m.saved_radio_apply_pending);
if(phase==0)m._temp_radio_set_at=now_ms+1500;
if(phase==1)m._temp_radio_applied=true;
if(phase==2)m._temp_radio_revert_at=now_ms+60000;
unsigned applies=m.applies,gain_calls=radio_driver.gain_calls;
m.recover();
assert(m.saved_radio_apply_pending && m.applies==applies);
assert(radio_driver.gain_calls==gain_calls); // Never override a lease.
m._temp_radio_set_at=0;m._temp_radio_applied=false;m._temp_radio_revert_at=0;
m.recover();assert(!m.saved_radio_apply_pending && radio_driver.gain);
}
}
static void checkTempRadioRestoration(){
for(bool rollback_pending : {false,true}){
MyMesh m;m._temp_radio_applied=true;m._temp_radio_revert_at=now_ms;
if(rollback_pending){
radio_driver.gain_results={true,false};
assert(!m.applyAndSaveRxBoostedGain(true));
assert(radio_driver.gain && m.saved_radio_apply_pending);
assert(!m._prefs.rx_boosted_gain);
}else{
m._prefs.rx_boosted_gain=1;
}
radio_driver.gain_results={false,true};
m.serviceTempRadio();
assert(m._temp_radio_applied && m._temp_radio_revert_at);
assert(m._temp_radio_retry_at>now_ms);
assert(m.saved_radio_apply_pending==rollback_pending);
assert(radio_driver.gain!=bool(m._prefs.rx_boosted_gain));
unsigned applies=m.applies,gain_calls=radio_driver.gain_calls;
m.recover();m.serviceTempRadio();
assert(m.applies==applies && radio_driver.gain_calls==gain_calls);
now_ms=m._temp_radio_retry_at;m.serviceTempRadio();
assert(radio_driver.gain==bool(m._prefs.rx_boosted_gain));
assert(!m._temp_radio_applied && !m._temp_radio_revert_at);
assert(!m._temp_radio_retry_at && !m._temp_radio_failures);
assert(!m.saved_radio_apply_pending && m.live_freq==m._prefs.freq);
assert(m.saves==unsigned(rollback_pending)); // Restoring never saves settings.
}
{
MyMesh m;m._temp_radio_applied=true;m._temp_radio_revert_at=now_ms;
m.saved_radio_apply_pending=true;radio_driver.supported=false;
radio_driver.gain_results={false};m.serviceTempRadio();
assert(!m._temp_radio_revert_at && !m.saved_radio_apply_pending);
assert(!radio_driver.gain_calls);
}
{
MyMesh m;m._temp_radio_applied=true;m._temp_radio_revert_at=now_ms;
m.outbound=true;m.serviceTempRadio();
assert(!m.applies && !radio_driver.gain_calls && m._temp_radio_revert_at);
m.outbound=false;m.serviceTempRadio();assert(!m._temp_radio_revert_at);
}
}
#endif
int main(){
checkStartupAndRetry();
#if COMPANION_FEATURE_TEMP_RADIO
checkTemporaryOverrideCoexistence();
checkTempRadioRestoration();
#endif
}
'''
class CompanionRadioGainRestoreTests(unittest.TestCase):
def test_production_gain_restore_keeps_failed_work_pending(self):
text = SOURCE.read_text(encoding='utf-8')
methods = '\n'.join(extract_braced(text, signature) for signature in (
'void MyMesh::configureRadioFromPrefs(',
'bool MyMesh::applySavedRadioParams(',
'bool MyMesh::applyAndSaveRxBoostedGain(',
))
methods += '\n#if COMPANION_FEATURE_TEMP_RADIO\n'
methods += extract_braced(text, 'void MyMesh::serviceTempRadio(')
methods += '\n#endif\n'
replacements = {
'@METHODS@': methods,
'@RETRY_DELAY@': extract_braced(text, 'static uint32_t nextRadioApplyRetryDelay('),
'@RECOVERY@': extract_braced(text, 'if (!command_radio_apply_pending && saved_radio_apply_pending && !hasOutbound()'),
}
harness = HARNESS
for marker, value in replacements.items():
harness = harness.replace(marker, value)
with tempfile.TemporaryDirectory(prefix='mesh-companion-gain-restore-') as tmp:
cpp = Path(tmp) / 'test.cpp'
binary = Path(tmp) / ('test.exe' if os.name == 'nt' else 'test')
cpp.write_text(harness, encoding='utf-8')
for temporary_radio in (0, 1):
with self.subTest(temporary_radio=temporary_radio):
subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17',
'-Wall', '-Wextra', '-Werror',
f'-DCOMPANION_FEATURE_TEMP_RADIO={temporary_radio}',
str(cpp), '-o', str(binary)], check=True)
subprocess.run([str(binary)], check=True)
if __name__ == '__main__':
unittest.main()
+26 -3
View File
@@ -4,6 +4,7 @@ import subprocess
import tempfile
import unittest
from test_radio_receive_contract import method
from test_companion_preferences_transaction import esp_recovery_helpers
ROOT = Path(__file__).resolve().parents[1]
@@ -11,6 +12,7 @@ HARNESS = r'''
#include <cassert>
#include <cstdio>
#include <cstring>
#include <cerrno>
#include <vector>
#include <RadioProfiles.h>
#include <helpers/CLICommandUtils.h>
@@ -45,6 +47,7 @@ struct DataStore {
FILESYSTEM fs;
bool _channel_load_incomplete=false;
bool _uncached_contact_load_incomplete=false;
const char* _channel_recovery_source=nullptr;
bool hasIncompleteContactLoad() const;
FILESYSTEM* _getContactsChannelsFS() { return &fs; }
File openRead(FILESYSTEM* fs, const char* name) { return fs->open(name,"r"); }
@@ -123,8 +126,8 @@ int main() {
assert(disk.size()==MAX_GROUP_CHANNELS*68);
assert(disk[0]==mesh::encodeRadioTxPolicy(mesh::RADIO_TX_BOTH));
assert(disk[68]==mesh::encodeRadioTxPolicy(mesh::RADIO_TX_SECONDARY));
// Loading is all-or-nothing. An unreadable/partial file must not become a
// default channel table that a later phone or CLI update can persist over it.
// Loading is all-or-nothing. ESP32 retries recovery and then permits an
// explicitly requested defaults save; the other backends remain fail-closed.
for(int fault : {0,1,2,3,4}) {
MyMesh boot;
boot.store.fs.files["/channels2"]=disk;
@@ -136,12 +139,22 @@ int main() {
if(fault==4) boot.store.fs.files["/channels2"].resize((MAX_GROUP_CHANNELS+1)*68);
auto durable=boot.store.fs.files["/channels2"];
boot.store.loadChannels(&boot);
#if defined(ESP32_PLATFORM)
assert(!boot.store.hasIncompleteContactLoad());
#else
assert(boot.store.hasIncompleteContactLoad());
#endif
assert(!strcmp(boot.channels[0].name,"keep existing"));
assert(!boot.channels[1].name[0]);
boot.store.fs.fail_read_open=false;boot.store.fs.fail_read_after=-1;
#if defined(ESP32_PLATFORM)
assert(boot.store.fs.files["/channels2"]==durable);
assert(boot.canMutateContacts()&&boot.store.saveChannels(&boot));
assert(boot.store.fs.files["/channels2"]!=disk);
#else
assert(!boot.canMutateContacts()&&!boot.store.saveChannels(&boot));
assert(boot.store.fs.files["/channels2"]==durable);
#endif
}
MyMesh fresh;fresh.store.loadChannels(&fresh);
assert(!fresh.store.hasIncompleteContactLoad()); // absent file is a fresh boot
@@ -179,12 +192,22 @@ int main() {
assert(node.store.fs.files["/channels2.bak"]==disk);
MyMesh failed_boot;failed_boot.store.fs=node.store.fs;
failed_boot.store.loadChannels(&failed_boot);
#if defined(ESP32_PLATFORM)
assert(!failed_boot.store.hasIncompleteContactLoad());
assert(failed_boot.channels[0].channel.tx_radio==mesh::RADIO_TX_BOTH);
assert(!failed_boot.store.saveChannels(&failed_boot));
assert(failed_boot.store.fs.files["/channels2.bak"]==disk);
failed_boot.store.fs.fail_rename_from.clear();
assert(failed_boot.store.saveChannels(&failed_boot));
assert(failed_boot.store.fs.files["/channels2"]==disk);
#else
assert(failed_boot.store.hasIncompleteContactLoad());
failed_boot.store.fs.fail_rename_from.clear();
failed_boot.store.loadChannels(&failed_boot);
assert(failed_boot.store.hasIncompleteContactLoad());
assert(!failed_boot.store.saveChannels(&failed_boot));
assert(failed_boot.store.fs.files["/channels2.bak"]==disk);
#endif
node.store.fs.fail_rename_from.clear();
node.store.loadChannels(&node);
assert(!node.store.hasIncompleteContactLoad());
@@ -217,7 +240,7 @@ class CompanionTxRoutingTest(unittest.TestCase):
def test_cli_resolution_persistence_and_failures(self):
companion = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text()
store = (ROOT / 'examples/companion_radio/DataStore.cpp').read_text()
methods = method(companion, 'bool MyMesh::handleTxRoutingCommand(')
methods = esp_recovery_helpers(store) + method(companion, 'bool MyMesh::handleTxRoutingCommand(')
methods += '\n' + method(store, 'void DataStore::loadChannels(')
methods += '\n' + method(store, 'bool DataStore::saveChannels(')
methods += '\n' + method(store, 'bool DataStore::hasIncompleteContactLoad(')
+203
View File
@@ -0,0 +1,203 @@
"""Production identity transactions and bounded Companion settings recovery."""
from pathlib import Path
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
def fixture_prefix():
text = (ROOT / 'test/fixtures/companion_uncached_storage/test.cpp').read_text()
return text[:text.index('struct Host {')]
def compile_run(program, platform):
with tempfile.TemporaryDirectory(prefix='mesh-storage-recovery-') as directory:
directory = Path(directory)
(directory / 'FS.h').write_text('#pragma once\nnamespace fs { using FS = FakeFilesystem; }\n')
(directory / 'Adafruit_LittleFS.h').write_text(
'#pragma once\nusing Adafruit_LittleFS = FakeFilesystem;\n'
'namespace Adafruit_LittleFS_Namespace {}\n')
cpp, exe = directory / 'test.cpp', directory / 'test'
cpp.write_text(program)
compiled = subprocess.run([
'c++', '-std=c++17', '-O1', '-g', '-fsanitize=address,undefined',
'-fno-pie', '-no-pie', '-DMESH_CONTACT_CACHE=0', '-D' + platform + '=1',
'-I', str(directory), '-I', str(ROOT / 'test/fixtures/contact_cache/mocks'),
'-I', str(ROOT / 'src'), '-I', str(ROOT / 'lib/ed25519'),
'-I', str(ROOT / 'examples/companion_radio'), str(cpp), '-o', str(exe),
], capture_output=True, text=True, timeout=60)
if compiled.returncode:
raise AssertionError(compiled.stdout + compiled.stderr)
run = subprocess.run([str(exe)], capture_output=True, text=True, timeout=30)
if run.returncode:
raise AssertionError(run.stdout + run.stderr)
class IdentityAndSettingsRecovery(unittest.TestCase):
def test_identity_atomic_esp32(self):
self.check_identity('ESP32_PLATFORM')
def test_identity_atomic_rp2040(self):
self.check_identity('RP2040_PLATFORM')
def test_identity_atomic_stm32(self):
self.check_identity('STM32_PLATFORM')
def check_identity(self, platform):
source = (ROOT / 'src/helpers/IdentityStore.cpp').read_text()
presence = (ROOT / 'src/helpers/FilePresence.h').read_text()
program = fixture_prefix() + '\n#include <helpers/IdentityStore.h>\n'
program += 'namespace mesh { template <typename Filesystem>\n' + extract_braced(
presence, 'bool filePresence(') + '\n}\n'
for signature in ('bool IdentityStore::recover(',
'bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id)',
'bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id, const char display_name[])'):
program += extract_braced(source, signature) + '\n'
program += r'''
int main() {
for (bool display : {false, true}) {
for (unsigned fault = 0; fault < 4; ++fault) {
filesystem = FakeFilesystem();
IdentityStore store(filesystem, "");
mesh::LocalIdentity old_id, new_id;
memset(old_id.pub_key, 7, PUB_KEY_SIZE); memset(new_id.pub_key, 9, PUB_KEY_SIZE);
assert(display ? store.save("main", old_id, "old") : store.save("main", old_id));
const auto old = filesystem.files["/main.id"];
if (fault == 0) filesystem.max_write = 3;
if (fault == 1) filesystem.max_read = 3;
if (fault == 2) filesystem.fail_open = "/main.id.tmp";
if (fault == 3) filesystem.fail_rename = filesystem.renames +
#if defined(STM32_PLATFORM)
1;
#else
2;
#endif
assert(!(display ? store.save("main", new_id, "new") : store.save("main", new_id)));
assert(filesystem.files["/main.id"] == old);
filesystem.max_write = filesystem.max_read = std::numeric_limits<size_t>::max();
filesystem.fail_open.clear(); filesystem.fail_rename = 0;
assert(display ? store.save("main", new_id, "new") : store.save("main", new_id));
assert(filesystem.files["/main.id"] != old);
}
}
#if !defined(STM32_PLATFORM)
filesystem = FakeFilesystem();
filesystem.files["/main.id.bak"] = std::vector<uint8_t>(96, 7);
IdentityStore recovering(filesystem, "");
filesystem.fail_rename = 1;
assert(!recovering.recover("main") && filesystem.files.count("/main.id.bak"));
filesystem.fail_rename = 0;
assert(recovering.recover("main") && filesystem.files["/main.id"].size() == 96);
#endif
}
'''
compile_run(program, platform)
def test_esp32_preferences_and_channels_recover_or_reset(self):
store = (ROOT / 'examples/companion_radio/DataStore.cpp').read_text()
prefs = (ROOT / 'examples/companion_radio/NodePrefs.h').read_text()
fields = prefs[prefs.index('class CompanionNodePrefs {'):prefs.index('\nprivate:')]
program = fixture_prefix() + r'''
#include <helpers/BluetoothMac.h>
#include "BluetoothName.h"
''' + fields + '\n};\n' + r'''
struct ChannelDetails { struct { uint8_t secret[32] = {}; uint8_t tx_radio = 0; } channel; char name[32] = {}; };
#define MAX_GROUP_CHANNELS 40
struct DataStoreHost {
std::vector<ChannelDetails> channels;
bool onChannelLoaded(uint8_t index, const ChannelDetails& channel) {
if (index >= channels.size()) channels.resize(index + 1);
channels[index] = channel; return true;
}
bool getChannelForSave(uint8_t index, ChannelDetails& channel) {
if (index >= channels.size()) return false;
channel = channels[index]; return true;
}
};
struct DataStore {
FakeFilesystem* _fs = &filesystem;
bool _prefs_load_incomplete = false, _channel_load_incomplete = false;
const char* _prefs_recovery_source = nullptr;
const char* _channel_recovery_source = nullptr;
FakeFilesystem* _getContactsChannelsFS() { return _fs; }
File openRead(FakeFilesystem* fs, const char* path) { return fs->open(path); }
bool loadPrefs(CompanionNodePrefs&, double&, double&);
bool loadPrefsInt(const char*, CompanionNodePrefs&, double&, double&);
bool savePrefs(const CompanionNodePrefs&, double, double);
void loadChannels(DataStoreHost*);
bool saveChannels(DataStoreHost*);
};
'''
for signature in ('static bool companionPathPresence(',
'static bool promoteCompanionRecoveryFile(',
'bool DataStore::loadPrefs(', 'bool DataStore::loadPrefsInt(',
'bool DataStore::savePrefs(', 'void DataStore::loadChannels(',
'bool DataStore::saveChannels('):
program += extract_braced(store, signature) + '\n'
program += r'''
int main() {
CompanionNodePrefs saved; strcpy(saved.node_name, "saved"); saved.ble_pin = 654321;
DataStore writer; assert(writer.savePrefs(saved, 11, 22));
const auto prefs_image = filesystem.files["/new_prefs"];
DataStoreHost original; ChannelDetails channel; strcpy(channel.name, "private");
channel.channel.secret[0] = 7; original.channels.push_back(channel);
assert(writer.saveChannels(&original)); const auto channel_image = filesystem.files["/channels2"];
for (bool channels : {false, true}) {
const char* path = channels ? "/channels2" : "/new_prefs";
const std::string backup = std::string(path) + ".bak";
const auto& image = channels ? channel_image : prefs_image;
for (unsigned fault = 0; fault < 8; ++fault) {
filesystem = FakeFilesystem(); filesystem.files[path] = image;
if (fault == 0) { filesystem.fail_open = path; filesystem.fail_open_remaining = 1; }
if (fault == 1 || fault == 2 || fault == 3) {
filesystem.files[path] = {1}; filesystem.files[backup] = image;
if (fault == 2) filesystem.fail_rename = 1;
if (fault == 3 && !channels) { filesystem.files.erase(backup); filesystem.files["/node_prefs"] = image; }
}
if (fault == 4) filesystem.files[path] = {1};
if (fault == 5) filesystem.max_read = 1;
if (fault == 6) filesystem.stat_error = EIO;
if (fault == 7) filesystem.fail_open = path;
DataStore reader; CompanionNodePrefs restored; strcpy(restored.node_name, "default");
double lat = 1, lon = 2; DataStoreHost loaded;
if (channels) reader.loadChannels(&loaded);
else assert(reader.loadPrefs(restored, lat, lon));
const bool recovered = fault < 4;
if (channels) {
assert(loaded.channels.size() == (recovered ? 1 : 0));
if (recovered) assert(!strcmp(loaded.channels[0].name, "private") && loaded.channels[0].channel.secret[0] == 7);
} else {
assert(!strcmp(restored.node_name, recovered ? "saved" : "default"));
assert(restored.ble_pin == (recovered ? 654321u : 0u));
}
// Neither failed reads nor failed recovery publication erase the only
// verified backup. Once I/O recovers, a normal save is allowed.
if (fault == 2) assert(filesystem.files[backup] == image);
if (fault >= 4) assert(filesystem.files[path] == (fault == 4 ? std::vector<uint8_t>{1} : image));
if (fault != 7) filesystem.fail_open.clear();
filesystem.fail_rename = 0;
filesystem.stat_error = 0; filesystem.max_read = std::numeric_limits<size_t>::max();
if (channels) {
if (!recovered) loaded.channels.push_back(channel);
assert(reader.saveChannels(&loaded));
filesystem.fail_open.clear();
DataStore again; DataStoreHost check; again.loadChannels(&check); assert(check.channels.size() == 1);
} else {
assert(reader.savePrefs(restored, lat, lon));
filesystem.fail_open.clear();
DataStore again; CompanionNodePrefs check; assert(again.loadPrefs(check, lat, lon));
assert(!strcmp(check.node_name, restored.node_name));
}
}
}
}
'''
compile_run(program, 'ESP32_PLATFORM')
if __name__ == '__main__':
unittest.main()
@@ -74,10 +74,8 @@ public:
++abort_calls;
_open = false;
_staging.clear();
// Mirrors MQTTPrefsFileStore: after finish(), a failed commit may already
// have moved the old primary to .bak, so the verified temp is recovery
// data rather than disposable staging.
if (_owns_temp && !_finished) _files.erase("/mqtt_prefs.tmp");
// Explicit rejection discards this candidate, even after finish().
if (_owns_temp) _files.erase("/mqtt_prefs.tmp");
_finished = false;
_owns_temp = false;
}
@@ -277,11 +275,15 @@ public:
}
// Inject ordinary operation failures (as distinct from a power cut). A
// failed temp rename leaves both the verified temp and old backup intact.
// failed temp rename restores the old image and discards the candidate.
bool publish(bool fail_backup_rename, bool fail_temp_rename, bool fail_cleanup) {
writeVerifiedTemp();
if (fail_backup_rename || !rename("/mqtt_prefs", "/mqtt_prefs.bak")) return false;
if (fail_temp_rename || !rename("/mqtt_prefs.tmp", "/mqtt_prefs")) return false;
if (fail_temp_rename || !rename("/mqtt_prefs.tmp", "/mqtt_prefs")) {
rename("/mqtt_prefs.bak", "/mqtt_prefs");
_files.erase("/mqtt_prefs.tmp");
return false;
}
if (!fail_cleanup) _files.erase("/mqtt_prefs.bak");
return true; // backup cleanup is intentionally non-fatal after publish
}
@@ -296,13 +298,8 @@ public:
const Recovery::Action action = Recovery::select(
stateFor("/mqtt_prefs", primary), stateFor("/mqtt_prefs.tmp", temp),
stateFor("/mqtt_prefs.bak", backup));
if (action == Recovery::Action::PromoteTemp) {
rename("/mqtt_prefs.tmp", "/mqtt_prefs");
// Match production: once a usable temp becomes primary, every backup is
// stale and is cleared so a second save can start this boot.
if (temp == Recovery::FileState::Usable && backup != Recovery::FileState::Missing) {
_files.erase("/mqtt_prefs.bak");
}
if (action == Recovery::Action::DiscardTemp) {
_files.erase("/mqtt_prefs.tmp");
return;
}
if (action == Recovery::Action::PromoteBackup) {
@@ -376,7 +373,7 @@ TEST(MQTTPrefsAtomicStore, AnyFailureAbortsAndPreservesExistingSource) {
InMemoryStore store(test_case.point);
EXPECT_EQ(test_case.expected, run(&store));
EXPECT_EQ(source, store.source());
EXPECT_EQ(test_case.point == FailurePoint::Commit, store.tempExists());
EXPECT_FALSE(store.tempExists());
EXPECT_EQ(1, store.begin_calls);
EXPECT_EQ(test_case.writes, store.write_calls);
EXPECT_EQ(test_case.finishes, store.finish_calls);
@@ -539,8 +536,8 @@ TEST(MQTTPrefsAtomicStore, SpiffsPowerCutsAtEveryPublishBoundaryLeaveRecoverable
} cases[] = {
// Temp has not become the committed image yet, so the old primary wins.
{SpiffsMqttTransaction::Boundary::BeforeBackupRename, SpiffsMqttTransaction::oldImage()},
// Old primary is .bak and verified new temp wins the recovery race.
{SpiffsMqttTransaction::Boundary::AfterBackupRename, SpiffsMqttTransaction::newImage()},
// Not published yet: the previous committed backup still wins.
{SpiffsMqttTransaction::Boundary::AfterBackupRename, SpiffsMqttTransaction::oldImage()},
{SpiffsMqttTransaction::Boundary::AfterPrimaryRename, SpiffsMqttTransaction::newImage()},
{SpiffsMqttTransaction::Boundary::AfterBackupCleanup, SpiffsMqttTransaction::newImage()},
};
@@ -573,12 +570,12 @@ TEST(MQTTPrefsAtomicStore, RecoveredUsablePrimaryClearsOpaqueTransactionArtifact
{
SpiffsMqttTransaction store;
store.cutAt(SpiffsMqttTransaction::Boundary::AfterBackupRename);
// A current-format temp wins; the old backup need not be decodable to be
// stale once that usable temp owns the primary name.
// An opaque backup was the previous committed image. A known-format temp
// must not replace it, and saving remains held for operator recovery.
store.recover(Recovery::FileState::Usable, Recovery::FileState::Usable,
Recovery::FileState::Preserve);
EXPECT_EQ(SpiffsMqttTransaction::newImage(), store.primary());
EXPECT_TRUE(store.canStartSave());
EXPECT_EQ(SpiffsMqttTransaction::oldImage(), store.primary());
EXPECT_FALSE(store.canStartSave());
}
{
SpiffsMqttTransaction store;
@@ -604,11 +601,11 @@ TEST(MQTTPrefsAtomicStore, SpiffsRenameAndCleanupFailuresRemainRecoverable) {
{
SpiffsMqttTransaction store;
EXPECT_FALSE(store.publish(false, true, false));
EXPECT_FALSE(store.has("/mqtt_prefs"));
EXPECT_TRUE(store.has("/mqtt_prefs.tmp"));
EXPECT_TRUE(store.has("/mqtt_prefs.bak"));
EXPECT_TRUE(store.has("/mqtt_prefs"));
EXPECT_FALSE(store.has("/mqtt_prefs.tmp"));
EXPECT_FALSE(store.has("/mqtt_prefs.bak"));
store.recover();
EXPECT_EQ(SpiffsMqttTransaction::newImage(), store.primary());
EXPECT_EQ(SpiffsMqttTransaction::oldImage(), store.primary());
EXPECT_FALSE(store.has("/mqtt_prefs.tmp"));
EXPECT_FALSE(store.has("/mqtt_prefs.bak"));
}
+196
View File
@@ -0,0 +1,196 @@
"""Exercise production MQTT commit/recovery after rejected saves and power loss."""
from pathlib import Path
import subprocess
import tempfile
import unittest
from test_common_prefs_commit import HARNESS
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
class MqttPrefsCommitTest(unittest.TestCase):
def test_rejected_changes_never_publish_during_recovery(self):
source = (ROOT / 'src/helpers/CommonCLI.cpp').read_text()
filesystem = HARNESS[:HARNESS.index('@STORE@')]
# ESP32 exists() opens the file, but native rename/stat do not. Model
# those separately so a failed read cannot masquerade as absence.
filesystem = filesystem.replace(
'bool exists(const char* path) const { return files.count(path) != 0; }',
'bool exists(const char* path) const { return files.count(path) != 0 '
'&& !faults.count(std::string("open:r:") + path); }')
filesystem = filesystem.replace(
'|| !exists(from) || exists(to)',
'|| !files.count(from) || files.count(to)')
filesystem = filesystem.replace(
'if (!exists(path)) return {};', 'if (!files.count(path)) return {};')
filesystem = filesystem.replace(
'bool short_write = false;', 'MemoryFS();\n bool short_write = false;')
filesystem = filesystem.replace(
'size_t write(const uint8_t*, size_t);',
'size_t write(const uint8_t*, size_t);\n'
' size_t read(uint8_t* out, size_t count) {\n'
' if (!valid) return 0;\n'
' count = count < bytes->size() ? count : bytes->size();\n'
' for (size_t i=0;i<count;++i) out[i]=(*bytes)[i];\n'
' return count;\n'
' }')
program = filesystem + r'''
#include <helpers/MQTTPrefsRecovery.h>
#include <helpers/FilePresence.h>
#define MESH_DEBUG_PRINTLN(...) ((void)0)
static MemoryFS* stat_fs = nullptr;
MemoryFS::MemoryFS() { stat_fs=this; }
extern "C" int stat(const char* absolute, struct stat*) noexcept {
const std::string path=std::string(absolute).substr(7); // /spiffs
if (stat_fs->faults.count("stat:" + path)) { errno=EIO;return -1; }
if (!stat_fs->files.count(path)) { errno=ENOENT;return -1; }
return 0;
}
// Codec layout correctness has separate native tests. Exercise the actual
// production presence/read classifier with a small opaque-layout marker.
struct MQTTPrefsHeader { uint8_t bytes[4]; };
namespace MQTTPrefsCodec {
struct Plan { bool preserve_file; };
Plan classify(const uint8_t* prefix,size_t read,size_t size) {
return {size < sizeof(MQTTPrefsHeader) || read != sizeof(MQTTPrefsHeader)
|| prefix[0] == 0xff};
}
}
'''
program += extract_braced(source, 'static File openMqttPrefsRead(') + '\n'
program += extract_braced(source, 'static MQTTPrefsRecovery::FileState mqttPrefsFileState(') + '\n'
program += extract_braced(source, 'static bool recoverMqttPrefsFiles(') + '\n'
program += extract_braced(source, 'class MQTTPrefsFileStore') + ';\n'
program += r'''
const std::vector<uint8_t> previous = {1, 2, 3, 4};
const std::vector<uint8_t> candidate = {9, 8, 7, 6, 5};
bool save(MemoryFS& fs) {
MQTTPrefsFileStore store(&fs);
return MQTTPrefsAtomicStore::imageCommitted(MQTTPrefsAtomicStore::writeImage(
store, [&](MQTTPrefsFileStore& target) {
return target.write(candidate.data(), candidate.size()) == candidate.size();
}));
}
int main() {
// Individual failures retain the published previous configuration.
for (const char* fault : {"open:w:/mqtt_prefs.tmp", "open:r:/mqtt_prefs.tmp",
"rename:/mqtt_prefs:/mqtt_prefs.bak",
"rename:/mqtt_prefs.tmp:/mqtt_prefs"}) {
MemoryFS fs; fs.put("/mqtt_prefs", previous); fs.faults.insert(fault);
assert(!save(fs));
assert(fs.get("/mqtt_prefs") == previous);
fs.faults.clear();
assert(!recoverMqttPrefsFiles(&fs));
assert(fs.get("/mqtt_prefs") == previous);
assert(save(fs)); // no reboot needed after a transient failure
assert(fs.get("/mqtt_prefs") == candidate);
}
{
MemoryFS fs; fs.put("/mqtt_prefs", previous); fs.short_write=true;
assert(!save(fs)); assert(fs.get("/mqtt_prefs") == previous);
fs.short_write=false;
assert(save(fs));
}
// Compound failure: publish, rollback, and scratch removal all fail.
for (bool reboot : {false, true}) {
MemoryFS fs; fs.put("/mqtt_prefs", previous);
fs.faults = {"rename:/mqtt_prefs.tmp:/mqtt_prefs",
"rename:/mqtt_prefs.bak:/mqtt_prefs", "remove:/mqtt_prefs.tmp"};
assert(!save(fs));
assert(!fs.exists("/mqtt_prefs") && fs.get("/mqtt_prefs.bak") == previous);
assert(fs.get("/mqtt_prefs.tmp") == candidate);
fs.faults.clear();
if (reboot) {
assert(!recoverMqttPrefsFiles(&fs));
assert(fs.get("/mqtt_prefs") == previous);
} else {
// A subsequent attempted save can finish rollback itself. Refuse the new
// write, then verify that begin() recovered only the old published image.
fs.faults.insert("open:w:/mqtt_prefs.tmp");
assert(!save(fs)); assert(fs.get("/mqtt_prefs") == previous);
}
}
// A rejected first save has no backup; it must never turn into a saved image.
{
MemoryFS fs;
fs.faults = {"rename:/mqtt_prefs.tmp:/mqtt_prefs", "remove:/mqtt_prefs.tmp"};
assert(!save(fs)); assert(fs.exists("/mqtt_prefs.tmp"));
fs.faults.clear(); assert(!recoverMqttPrefsFiles(&fs));
assert(!fs.exists("/mqtt_prefs") && !fs.exists("/mqtt_prefs.tmp"));
assert(save(fs)); assert(fs.get("/mqtt_prefs") == candidate);
}
// Power loss at each publication boundary: only a named primary is committed.
for (unsigned boundary=0; boundary<4; ++boundary) {
MemoryFS fs; fs.put("/mqtt_prefs", previous); fs.put("/mqtt_prefs.tmp", candidate);
if (boundary >= 1) assert(fs.rename("/mqtt_prefs", "/mqtt_prefs.bak"));
if (boundary >= 2) assert(fs.rename("/mqtt_prefs.tmp", "/mqtt_prefs"));
if (boundary >= 3) assert(fs.remove("/mqtt_prefs.bak"));
assert(!recoverMqttPrefsFiles(&fs));
assert(fs.get("/mqtt_prefs") == (boundary < 2 ? previous : candidate));
}
// Failure to clean a backup after a successful publication is not rejection.
{
MemoryFS fs; fs.put("/mqtt_prefs", previous);
fs.faults.insert("remove:/mqtt_prefs.bak");
assert(save(fs)); assert(fs.get("/mqtt_prefs") == candidate);
fs.faults.clear(); assert(!recoverMqttPrefsFiles(&fs));
assert(fs.get("/mqtt_prefs") == candidate);
assert(!fs.exists("/mqtt_prefs.bak"));
}
// Preserve opaque committed primary/backup images, not an unpublished temp.
for (bool backup : {false, true}) {
MemoryFS fs; const char* path = backup ? "/mqtt_prefs.bak" : "/mqtt_prefs";
const std::vector<uint8_t> opaque = {0xff, 2, 3, 4};
fs.put(path, opaque); fs.put("/mqtt_prefs.tmp", candidate);
assert(recoverMqttPrefsFiles(&fs));
assert(fs.get("/mqtt_prefs") == opaque);
assert(!save(fs)); assert(fs.get("/mqtt_prefs") == opaque);
}
// A failed open is not a missing committed file, even when exists() says so.
for (bool backup : {false, true}) {
MemoryFS fs; const char* path = backup ? "/mqtt_prefs.bak" : "/mqtt_prefs";
fs.put(path, previous); fs.put("/mqtt_prefs.tmp", candidate);
fs.faults.insert(std::string("open:r:") + path);
assert(!fs.exists(path));
assert(mqttPrefsFileState(&fs,path) == MQTTPrefsRecovery::FileState::Preserve);
assert(recoverMqttPrefsFiles(&fs));
assert(fs.get("/mqtt_prefs") == previous);
fs.faults.insert("open:r:/mqtt_prefs");
assert(!save(fs)); assert(fs.get("/mqtt_prefs") == previous);
assert(fs.get("/mqtt_prefs.tmp") == candidate);
fs.faults.clear(); assert(!recoverMqttPrefsFiles(&fs));
assert(fs.get("/mqtt_prefs") == previous);
assert(save(fs)); assert(fs.get("/mqtt_prefs") == candidate);
}
// Metadata I/O failures also hold the existing image; retry can recover.
{
MemoryFS fs; fs.put("/mqtt_prefs",previous);
fs.faults.insert("stat:/mqtt_prefs");
assert(recoverMqttPrefsFiles(&fs)); assert(!save(fs));
assert(fs.get("/mqtt_prefs") == previous && !fs.exists("/mqtt_prefs.tmp"));
fs.faults.clear(); assert(save(fs));
}
// Failed recovery holds artifacts and refuses a new write.
{
MemoryFS fs; fs.put("/mqtt_prefs.bak", previous); fs.put("/mqtt_prefs.tmp", candidate);
fs.faults.insert("rename:/mqtt_prefs.bak:/mqtt_prefs");
assert(recoverMqttPrefsFiles(&fs)); assert(!save(fs));
assert(fs.get("/mqtt_prefs.bak") == previous && fs.get("/mqtt_prefs.tmp") == candidate);
}
puts("MQTT rejected-save and power-loss recovery scenarios passed");
}
'''
with tempfile.TemporaryDirectory(prefix='mesh-mqtt-commit-') as directory:
cpp, exe = Path(directory) / 'test.cpp', Path(directory) / 'test'
cpp.write_text(program)
subprocess.run(['g++', '-std=c++17', '-Wall', '-Wextra', '-Werror',
'-DESP32_PLATFORM',
'-fsanitize=address,undefined', '-fno-pie', '-no-pie',
'-I', str(ROOT / 'src'), str(cpp), '-o', str(exe)], check=True)
subprocess.run([str(exe)], check=True)
if __name__ == '__main__':
unittest.main()
+282
View File
@@ -0,0 +1,282 @@
#!/usr/bin/env python3
"""Exercise production OTA identity refresh and deferred persistent policy load."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <helpers/ota/OtaContext.h>
#include <helpers/ota/OtaProtocol.h>
#include <cassert>
#include <new>
#include <vector>
#include "vectors.h"
using namespace mesh::ota;
static bool allocation_fails = false;
static unsigned allocation_attempts = 0;
void* operator new(std::size_t size, const std::nothrow_t&) noexcept {
++allocation_attempts;
return allocation_fails ? nullptr : ::operator new(size);
}
namespace mesh { namespace ota {
bool ota_self_firmware(SelfFwInfo& info) { info = SelfFwInfo(); return false; }
} }
static std::vector<uint8_t> wire;
static bool send(void*, const uint8_t* data, uint16_t length, bool) {
wire.assign(data, data + length); return true;
}
static AdvMsg beacon(OtaManager& manager) {
manager.announce();
AdvMsg adv{};
assert(decode_adv(wire.data(), wire.size(), adv));
return adv;
}
#if defined(OTA_SHARED_COMPANION_QUEUE)
static OtaContext* borrowed_context = nullptr;
static OtaContext* acquire(void*) {
borrowed_context = new (std::nothrow) OtaContext;
return borrowed_context;
}
static void release(void*) { delete borrowed_context; borrowed_context = nullptr; }
#endif
struct NodePrefs {
uint8_t ota_autofetch=2, ota_max_hops=7, ota_autoinstall=1, ota_signer_count=1;
uint16_t ota_checkpoint_blocks=99, ota_advert_interval=180;
uint8_t ota_signers[MAX_OTA_SIGNERS][32]={{42}};
};
struct CommonCLI {
NodePrefs prefs;
NodePrefs* _prefs = &prefs;
void syncOtaConfigFromPrefs();
};
@SYNC@
struct TestIdentity { uint8_t pub_key[32] = {}; };
namespace mesh {
static bool generation_ok = true;
static unsigned generation_calls = 0;
static bool hasReservedIdentityPrefix(const TestIdentity& id) { return !id.pub_key[0]; }
static bool generateUsableLocalIdentity(TestIdentity& id, int) {
++generation_calls;
id.pub_key[0] = 0x91; return generation_ok;
}
static void discardESP32TrueRandom() {}
}
struct Store {
bool load_ok = true, can_create = true, save_ok = true;
unsigned saves = 0;
bool loadMainIdentity(TestIdentity& id) { id.pub_key[0] = load_ok ? 0x12 : 0; return load_ok; }
bool canCreateMainIdentity() const { return can_create; }
bool saveMainIdentity(const TestIdentity&) { ++saves; return save_ok; }
};
struct Companion {
Store store;
Store* _store = &store;
TestIdentity self_id;
struct Board { bool rebooted = false; void reboot() { rebooted = true; } } board;
int radio_new_identity = 0;
bool accepted = false;
void loadIdentity() {
@STARTUP@
accepted = true;
}
bool ok_reply = false;
void writeOKFrame() { ok_reply = true; }
void importIdentity(const TestIdentity& identity) {
@IMPORT@
}
};
static void checkPolicy(const OtaContext& c, const NodePrefs& prefs) {
assert(c.manager.max_hops() == prefs.ota_max_hops);
assert(c.manager.checkpoint_blocks() == prefs.ota_checkpoint_blocks);
assert(c.manager.advert_mins() == prefs.ota_advert_interval);
assert(c.allow.count() == prefs.ota_signer_count);
assert(c.allow.contains(prefs.ota_signers[0]));
#if defined(OTA_SEEDER_ONLY)
assert(c.manager.autofetch() == 0 && c.autoinstall == 0);
#else
assert(c.manager.autofetch() == prefs.ota_autofetch);
assert(c.autoinstall == prefs.ota_autoinstall);
#endif
}
int main() {
#if defined(OTA_SHARED_COMPANION_QUEUE)
ota_set_context_storage(nullptr, acquire, release);
#endif
uint8_t initial_id[32] = {};
ota_begin_context(SIM_TARGET_ID, send, nullptr, "test", initial_id);
// Loaded, generated, and failed startup identities run the real Companion block.
Companion loaded;
loaded.loadIdentity();
assert(loaded.accepted && !loaded.board.rebooted && loaded.store.saves == 0);
#if OTA_DYNAMIC_CONTEXT
assert(!ota_context_if_active()); // an identity refresh must not claim storage
#endif
assert(ota_acquire_context(nullptr, 0));
assert(beacon(ota_ctx().manager).seeder_id[0] == 0x12);
Companion generated;
generated.store.load_ok = false;
generated.loadIdentity();
assert(generated.accepted && generated.store.saves == 1);
assert(beacon(ota_ctx().manager).seeder_id[0] == 0x91);
Companion blocked;
blocked.store.load_ok = false; blocked.store.can_create = false;
blocked.loadIdentity();
assert(!blocked.accepted && blocked.board.rebooted && blocked.store.saves == 0);
assert(beacon(ota_ctx().manager).seeder_id[0] == 0x91);
Companion failed_save;
failed_save.store.load_ok = false; failed_save.store.save_ok = false;
failed_save.loadIdentity();
assert(!failed_save.accepted && failed_save.board.rebooted);
assert(beacon(ota_ctx().manager).seeder_id[0] == 0x91);
// Changing identity must preserve current fetch progress and the served set.
auto& manager = ota_ctx().manager;
assert(manager.serve(SIM_MOTA_1K, SIM_MOTA_1K_LEN));
MotaManifest manifest;
assert(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest));
assert(manager.pull_archive(manifest.merkle_root, SIM_TARGET_ID) == OtaManager::PULL_STARTED);
const auto prior_state = manager.fetchState();
assert(prior_state != OtaManager::IDLE);
TestIdentity imported; imported.pub_key[0] = 0x34;
loaded.importIdentity(imported);
assert(loaded.ok_reply && loaded.self_id.pub_key[0] == 0x34);
assert(manager.fetchState() == prior_state && manager.servedCount() == 1);
assert(beacon(manager).seeder_id[0] == 0x34);
loaded.store.save_ok = false; loaded.ok_reply = false;
imported.pub_key[0] = 0x56;
loaded.importIdentity(imported);
assert(!loaded.ok_reply && loaded.self_id.pub_key[0] == 0x34);
assert(beacon(manager).seeder_id[0] == 0x34);
ota_refresh_seeder_identity(nullptr);
assert(beacon(manager).seeder_id[0] == 0x34);
// Two actual identities retain separate discovery records.
OtaManager receiver;
receiver.begin(SIM_TARGET_ID, send, nullptr);
AdvMsg adv = beacon(manager);
adv.n_motas = 1;
uint8_t packet[256];
receiver.on_message(packet, encode_adv(packet, sizeof(packet), adv));
generated.loadIdentity();
adv = beacon(manager); adv.n_motas = 1;
receiver.on_message(packet, encode_adv(packet, sizeof(packet), adv));
assert(receiver.sourceCount() == 2);
manager.reset_session();
#if OTA_DYNAMIC_CONTEXT
ota_release_context_if_idle(false);
assert(!ota_context_if_active());
assert(ota_acquire_context(nullptr, 0));
assert(beacon(ota_ctx().manager).seeder_id[0] == 0x91);
ota_release_context_if_idle(false);
#endif
// Persisted policy registration works while allocation is impossible, and
// policy/keys remain available on every later successful workspace claim.
CommonCLI cli;
allocation_fails = true;
const auto attempts = allocation_attempts;
cli.syncOtaConfigFromPrefs();
assert(allocation_attempts == attempts);
assert(ota_hop_limit() == cli.prefs.ota_max_hops);
#if OTA_DYNAMIC_CONTEXT
assert(!ota_context_if_active());
assert(!ota_acquire_context(nullptr, 0));
assert(!ota_context_if_active());
#endif
allocation_fails = false;
for (unsigned cycle = 0; cycle < 3; ++cycle) {
assert(ota_acquire_context(nullptr, 0));
checkPolicy(ota_ctx(), cli.prefs);
#if OTA_DYNAMIC_CONTEXT
ota_release_context_if_idle(false);
assert(!ota_context_if_active());
++cli.prefs.ota_advert_interval; // a later saved value, no stale startup copy
#endif
}
// Re-registering against an active session refreshes policy without reset.
assert(ota_acquire_context(nullptr, 0));
assert(ota_ctx().manager.pull_archive(manifest.merkle_root, SIM_TARGET_ID)
== OtaManager::PULL_STARTED);
auto state = ota_ctx().manager.fetchState();
cli.prefs.ota_max_hops = 2;
cli.syncOtaConfigFromPrefs();
assert(ota_ctx().manager.fetchState() == state);
checkPolicy(ota_ctx(), cli.prefs);
ota_ctx().manager.reset_session();
#if OTA_DYNAMIC_CONTEXT
ota_release_context_if_idle(false);
#endif
ota_set_context_config_loader(nullptr);
}
'''
class OtaIdentityPolicyTest(unittest.TestCase):
def test_static_heap_and_borrowed_contexts_preserve_identity_and_policy(self):
companion = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
begin = extract_braced(companion, "void MyMesh::begin(")
startup = begin[begin.index("const bool identity_loaded"):
begin.index("// if name is provided")]
self.assertLess(begin.index("BaseChatMesh::begin()"),
begin.index("ota_refresh_seeder_identity(self_id.pub_key)"))
imported = companion[companion.index("cmd_frame[0] == CMD_IMPORT_PRIVATE_KEY"):]
# Execute the real publication prefix. Contact-cache refresh after its
# acknowledgement is unrelated to the OTA identity being checked here.
imported = extract_braced(imported, "if (_store->saveMainIdentity(identity))")
imported = imported[:imported.index("// re-load contacts")] + "}\n"
sync = extract_braced((ROOT / "src/helpers/CommonCLI.cpp").read_text(),
"void CommonCLI::syncOtaConfigFromPrefs()")
source_text = HARNESS.replace("@SYNC@", sync).replace(
"@STARTUP@", startup).replace("@IMPORT@", imported)
with tempfile.TemporaryDirectory(prefix="ota-identity-policy-") as directory:
path = Path(directory)
source = path / "test.cpp"
source.write_text(source_text)
(path / "vectors.h").write_text('#include "' +
(ROOT / "test/test_ota/mota_vectors.h").as_posix() + '"\n')
sanitizers = [] if os.name == "nt" else ["-fsanitize=address,undefined"]
tinf = path / "tinf.o"
subprocess.run([shutil.which("cc") or "gcc", "-DENABLE_OTA=1",
*sanitizers, "-c", str(ROOT / "src/helpers/ota/OtaTinf.c"),
"-o", str(tinf)], check=True)
for mode in ("static", "heap", "shared"):
for seeder in (False, True):
if mode == "shared" and not seeder:
continue # borrowing the queue is source-only by design
with self.subTest(mode=mode, seeder=seeder):
# Production installers use a flash-backed fetch store.
# A small RAM stand-in avoids platform flash APIs while
# keeping the real heap-context budget assertion valid.
flags = ["-DENABLE_OTA=1", "-DESP32_PLATFORM=1",
"-DOTA_FETCH_BUF_SIZE=4096"]
if mode == "heap":
flags += ["-DOTA_HEAP_CONTEXT=1"]
if mode == "shared":
flags += ["-DOTA_SHARED_COMPANION_QUEUE=1",
"-DCOMPANION_RADIO_FULL=1"]
if seeder:
flags += ["-DOTA_SEEDER_ONLY=1"]
binary = path / "identity-policy.exe"
sources = ["OtaContext.cpp", "OtaManager.cpp", "OtaProtocol.cpp",
"MotaContainer.cpp", "MerkleTree.cpp", "OtaDeflate.cpp"]
result = subprocess.run([
"c++", "-std=c++17", *sanitizers, *flags,
"-I", str(ROOT / "src"), "-I", str(ROOT / "test/mocks"),
str(source),
*[str(ROOT / "src/helpers/ota" / name) for name in sources],
str(ROOT / "src/Utils.cpp"), str(tinf), "-o", str(binary),
], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
subprocess.run([str(binary)], check=True)
if __name__ == "__main__":
unittest.main()