From 24b1b1167f36389a61ea578efa1d1fd9dedae52b Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 15 Sep 2026 16:46:17 -0700 Subject: [PATCH] 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. --- .github/workflows/run-unit-tests.yml | 4 + examples/companion_radio/DataStore.cpp | 125 +++++++- examples/companion_radio/DataStore.h | 4 + examples/companion_radio/MyMesh.cpp | 18 +- src/helpers/ClientACL.cpp | 83 ++++-- src/helpers/ClientACL.h | 2 + src/helpers/CommonCLI.cpp | 92 +++--- src/helpers/CommonCLI.h | 2 +- src/helpers/ContactFileTransaction.h | 33 +- src/helpers/FilePresence.h | 37 +++ src/helpers/IdentityStore.cpp | 77 ++--- src/helpers/IdentityStore.h | 3 + src/helpers/MQTTPrefsRecovery.h | 19 +- src/helpers/ota/OtaContext.cpp | 41 ++- src/helpers/ota/OtaContext.h | 6 +- test/fixtures/cli_settings/main.cpp | 20 ++ .../client_acl_cli/test_client_acl_cli.cpp | 16 + .../client_acl_spiffs/mocks/Arduino.h | 9 + .../client_acl_spiffs/mocks/sys/stat.h | 13 + .../test_client_acl_spiffs.cpp | 43 ++- .../companion_uncached_storage/test.cpp | 14 +- test/test_cli_settings_contract.py | 3 +- test/test_client_acl_spiffs.py | 4 +- test/test_companion_ota_config.py | 5 +- .../test_companion_preferences_transaction.py | 48 ++- ...est_companion_primary_radio_persistence.py | 3 +- test/test_companion_radio_gain_restore.py | 256 ++++++++++++++++ test/test_companion_tx_routing.py | 29 +- test/test_identity_and_settings_recovery.py | 203 +++++++++++++ .../test_mqtt_prefs_atomic_store.cpp | 45 ++- test/test_mqtt_prefs_commit.py | 196 ++++++++++++ test/test_ota_identity_policy.py | 282 ++++++++++++++++++ 32 files changed, 1546 insertions(+), 189 deletions(-) create mode 100644 src/helpers/FilePresence.h create mode 100644 test/fixtures/client_acl_spiffs/mocks/sys/stat.h create mode 100644 test/test_companion_radio_gain_restore.py create mode 100644 test/test_identity_and_settings_recovery.py create mode 100644 test/test_mqtt_prefs_commit.py create mode 100644 test/test_ota_identity_policy.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index c79593e1..ec3f9de7 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -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 diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index e49c1dcd..4491f93b 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -1,5 +1,6 @@ #include #include +#include #include "DataStore.h" #include #include @@ -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(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(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"); diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index 9857d11a..2c33269d 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -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 diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 4d8dc0b5..2d7ecfdf 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -18,7 +18,7 @@ #include #include #include -#if defined(ENABLE_OTA) && defined(OTA_HEAP_CONTEXT) +#if defined(ENABLE_OTA) #include #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(); diff --git a/src/helpers/ClientACL.cpp b/src/helpers/ClientACL.cpp index 791a097d..17df9b20 100644 --- a/src/helpers/ClientACL.cpp +++ b/src/helpers/ClientACL.cpp @@ -4,6 +4,8 @@ #include "ClientLoginPersistence.h" #include "ClientPathPersistence.h" #include "FileRead.h" +#include "FilePresence.h" +#include #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 diff --git a/src/helpers/ClientACL.h b/src/helpers/ClientACL.h index ec0e27b9..f8579c6e 100644 --- a/src/helpers/ClientACL.h +++ b/src/helpers/ClientACL.h @@ -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; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 0e9d8c35..4e99d6bd 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -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"); diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 7db07973..87bfc5c7 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -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); diff --git a/src/helpers/ContactFileTransaction.h b/src/helpers/ContactFileTransaction.h index c39e14ac..4a8cfede 100644 --- a/src/helpers/ContactFileTransaction.h +++ b/src/helpers/ContactFileTransaction.h @@ -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; } diff --git a/src/helpers/FilePresence.h b/src/helpers/FilePresence.h new file mode 100644 index 00000000..65a87011 --- /dev/null +++ b/src/helpers/FilePresence.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#if defined(ESP32_PLATFORM) +#include +#include +#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 +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(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 diff --git a/src/helpers/IdentityStore.cpp b/src/helpers/IdentityStore.cpp index 44ce79ae..38531f85 100644 --- a/src/helpers/IdentityStore.cpp +++ b/src/helpers/IdentityStore.cpp @@ -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); +#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); +#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; } diff --git a/src/helpers/IdentityStore.h b/src/helpers/IdentityStore.h index d0d7ee45..c8f7aefa 100644 --- a/src/helpers/IdentityStore.h +++ b/src/helpers/IdentityStore.h @@ -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); diff --git a/src/helpers/MQTTPrefsRecovery.h b/src/helpers/MQTTPrefsRecovery.h index 7534678e..2d3707b6 100644 --- a/src/helpers/MQTTPrefsRecovery.h +++ b/src/helpers/MQTTPrefsRecovery.h @@ -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; } diff --git a/src/helpers/ota/OtaContext.cpp b/src/helpers/ota/OtaContext.cpp index aca43c48..a85e7108 100644 --- a/src/helpers/ota/OtaContext.cpp +++ b/src/helpers/ota/OtaContext.cpp @@ -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 diff --git a/src/helpers/ota/OtaContext.h b/src/helpers/ota/OtaContext.h index 3dc4e315..838a6717 100644 --- a/src/helpers/ota/OtaContext.h +++ b/src/helpers/ota/OtaContext.h @@ -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) diff --git a/test/fixtures/cli_settings/main.cpp b/test/fixtures/cli_settings/main.cpp index 3aac99ba..13695e05 100644 --- a/test/fixtures/cli_settings/main.cpp +++ b/test/fixtures/cli_settings/main.cpp @@ -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); + } + } } } diff --git a/test/fixtures/client_acl_cli/test_client_acl_cli.cpp b/test/fixtures/client_acl_cli/test_client_acl_cli.cpp index 1d3bdba6..0fe19b73 100644 --- a/test/fixtures/client_acl_cli/test_client_acl_cli.cpp +++ b/test/fixtures/client_acl_cli/test_client_acl_cli.cpp @@ -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 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); diff --git a/test/fixtures/client_acl_spiffs/mocks/Arduino.h b/test/fixtures/client_acl_spiffs/mocks/Arduino.h index a796f3e0..f0c5ef2c 100644 --- a/test/fixtures/client_acl_spiffs/mocks/Arduino.h +++ b/test/fixtures/client_acl_spiffs/mocks/Arduino.h @@ -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> files; std::set unreadable; std::set 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); diff --git a/test/fixtures/client_acl_spiffs/mocks/sys/stat.h b/test/fixtures/client_acl_spiffs/mocks/sys/stat.h new file mode 100644 index 00000000..37807a36 --- /dev/null +++ b/test/fixtures/client_acl_spiffs/mocks/sys/stat.h @@ -0,0 +1,13 @@ +#pragma once +#include +#include +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; +} diff --git a/test/fixtures/client_acl_spiffs/test_client_acl_spiffs.cpp b/test/fixtures/client_acl_spiffs/test_client_acl_spiffs.cpp index 81417501..c3b3562f 100644 --- a/test/fixtures/client_acl_spiffs/test_client_acl_spiffs.cpp +++ b/test/fixtures/client_acl_spiffs/test_client_acl_spiffs.cpp @@ -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"); } diff --git a/test/fixtures/companion_uncached_storage/test.cpp b/test/fixtures/companion_uncached_storage/test.cpp index 43b3519b..71cce579 100644 --- a/test/fixtures/companion_uncached_storage/test.cpp +++ b/test/fixtures/companion_uncached_storage/test.cpp @@ -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(size() - position); } void flush() {} void close() { fs = nullptr; } }; @@ -37,12 +38,13 @@ public: size_t max_write = std::numeric_limits::max(); size_t max_read = std::numeric_limits::max(); std::string fail_open; + unsigned fail_open_remaining = std::numeric_limits::max(); int stat_error = 0; unsigned fail_rename = 0, renames = 0, writes = 0; std::vector 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]; diff --git a/test/test_cli_settings_contract.py b/test/test_cli_settings_contract.py index 1e0270c5..15f36ed7 100644 --- a/test/test_cli_settings_contract.py +++ b/test/test_cli_settings_contract.py @@ -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)')] diff --git a/test/test_client_acl_spiffs.py b/test/test_client_acl_spiffs.py index 68f8d15e..979d6714 100644 --- a/test/test_client_acl_spiffs.py +++ b/test/test_client_acl_spiffs.py @@ -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__": diff --git a/test/test_companion_ota_config.py b/test/test_companion_ota_config.py index cee2d1ad..e4f2e74a 100644 --- a/test/test_companion_ota_config.py +++ b/test/test_companion_ota_config.py @@ -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); diff --git a/test/test_companion_preferences_transaction.py b/test/test_companion_preferences_transaction.py index 23d448fc..5be5af02 100644 --- a/test/test_companion_preferences_transaction.py +++ b/test/test_companion_preferences_transaction.py @@ -13,6 +13,7 @@ HARNESS = r''' #include #include #include +#include #include #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: diff --git a/test/test_companion_primary_radio_persistence.py b/test/test_companion_primary_radio_persistence.py index 715f5815..35698c1a 100644 --- a/test/test_companion_primary_radio_persistence.py +++ b/test/test_companion_primary_radio_persistence.py @@ -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()'), diff --git a/test/test_companion_radio_gain_restore.py b/test/test_companion_radio_gain_restore.py new file mode 100644 index 00000000..d197e27c --- /dev/null +++ b/test/test_companion_radio_gain_restore.py @@ -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 +#include +#include +#include +#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 T nextResult(std::deque& 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 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_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() diff --git a/test/test_companion_tx_routing.py b/test/test_companion_tx_routing.py index 1b6da24c..0315b42f 100644 --- a/test/test_companion_tx_routing.py +++ b/test/test_companion_tx_routing.py @@ -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 #include #include +#include #include #include #include @@ -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(') diff --git a/test/test_identity_and_settings_recovery.py b/test/test_identity_and_settings_recovery.py new file mode 100644 index 00000000..100c4a77 --- /dev/null +++ b/test/test_identity_and_settings_recovery.py @@ -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 \n' + program += 'namespace mesh { template \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::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(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 +#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 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{1} : image)); + if (fault != 7) filesystem.fail_open.clear(); + filesystem.fail_rename = 0; + filesystem.stat_error = 0; filesystem.max_read = std::numeric_limits::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() diff --git a/test/test_mqtt_prefs_atomic_store/test_mqtt_prefs_atomic_store.cpp b/test/test_mqtt_prefs_atomic_store/test_mqtt_prefs_atomic_store.cpp index f3786975..6965ddbd 100644 --- a/test/test_mqtt_prefs_atomic_store/test_mqtt_prefs_atomic_store.cpp +++ b/test/test_mqtt_prefs_atomic_store/test_mqtt_prefs_atomic_store.cpp @@ -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")); } diff --git a/test/test_mqtt_prefs_commit.py b/test/test_mqtt_prefs_commit.py new file mode 100644 index 00000000..f11000d9 --- /dev/null +++ b/test/test_mqtt_prefs_commit.py @@ -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 +#include +#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 previous = {1, 2, 3, 4}; +const std::vector 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 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() diff --git a/test/test_ota_identity_policy.py b/test/test_ota_identity_policy.py new file mode 100644 index 00000000..6a20dc88 --- /dev/null +++ b/test/test_ota_identity_policy.py @@ -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 +#include +#include +#include +#include +#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 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()