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()