From 0bc1176643876dfd7d3e108d569bf72d65fdae92 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:02:52 -0700 Subject: [PATCH 01/11] feat: isolate event radio profiles --- src/mesh/NodeDB.cpp | 69 ++++++++++++++++++- src/mesh/NodeDB.h | 42 ++++++++++- test/test_event_profile_storage/test_main.cpp | 65 +++++++++++++++++ 3 files changed, 171 insertions(+), 5 deletions(-) create mode 100644 test/test_event_profile_storage/test_main.cpp diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index ef8b3c9f628..f8528365b81 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -2166,6 +2166,19 @@ void NodeDB::loadFromDisk() migrationSavePending = false; configDecodeFailed = false; +#if USERPREFS_EVENT_MODE + // The event config is intentionally isolated from the ordinary config. + // We only seed it when no event file exists; a corrupt event file must not + // silently fall back to and overwrite the user's normal radio profile. + bool eventConfigMissing = false; +#ifdef FSCom + spiLock->lock(); + eventConfigMissing = !FSCom.exists(configFileName); + spiLock->unlock(); +#endif + bool initializedEventConfig = false; +#endif + meshtastic_Config_SecurityConfig backupSecurity = meshtastic_Config_SecurityConfig_init_zero; #ifdef ARCH_ESP32 @@ -2354,6 +2367,31 @@ void NodeDB::loadFromDisk() state = loadProto(configFileName, meshtastic_LocalConfig_size, sizeof(meshtastic_LocalConfig), &meshtastic_LocalConfig_msg, &config); +#if USERPREFS_EVENT_MODE + if (eventConfigMissing && state != LoadFileResult::LOAD_SUCCESS) { + const LoadFileResult eventConfigState = state; + const LoadFileResult standardConfigState = + loadProto(standardConfigFileName, meshtastic_LocalConfig_size, sizeof(meshtastic_LocalConfig), + &meshtastic_LocalConfig_msg, &config); + if (standardConfigState == LoadFileResult::LOAD_SUCCESS) { + // Preserve the user's identity and non-radio preferences, then + // replace only LoRa with this event build's compiled defaults. + const meshtastic_LocalConfig standardConfig = config; + installDefaultConfig(true); + const meshtastic_Config_LoRaConfig eventLora = config.lora; + config = eventConfigFromStandard(standardConfig, eventLora); + state = LoadFileResult::LOAD_SUCCESS; + initializedEventConfig = true; + LOG_INFO("Initialized event config without modifying %s", standardConfigFileName); + } else { + // loadProto() clears config before decoding. Restore the event + // outcome so the normal default/degraded path handles that safely. + // A corrupt normal config is especially important: preserving its + // decode failure prevents a replacement identity from being made. + state = standardConfigState == LoadFileResult::DECODE_FAILED ? LoadFileResult::DECODE_FAILED : eventConfigState; + } + } +#endif if (state == LoadFileResult::DECODE_FAILED) { // Config file present but undecodable this boot (corruption / torn write / transient decrypt fail). // loadProto() already zeroed `config`, so the keypair is gone from RAM; minting a new one would change @@ -2415,6 +2453,15 @@ void NodeDB::loadFromDisk() config.lora.override_frequency = USERPREFS_LORACONFIG_OVERRIDE_FREQUENCY; #endif +#if USERPREFS_EVENT_MODE + if (initializedEventConfig) { + // This is the first durable event-profile write. A failed write is + // safe: normal files remain untouched and the next event boot retries. + if (!saveToDisk(SEGMENT_CONFIG)) + LOG_ERROR("Unable to persist initial event config"); + } +#endif + if (backupSecurity.private_key.size > 0) { LOG_DEBUG("Restoring backup of security config"); config.security = backupSecurity; @@ -2546,6 +2593,23 @@ void NodeDB::loadFromDisk() EncryptedStorage::migrateFile(fn); } } + + // Both radio profiles can contain channel PSKs. The inactive profile + // is not covered by saveToDisk(), so migrate it explicitly when it + // exists before treating the encrypted-storage pass as complete. +#ifdef FSCom + const char *radioProfileFiles[] = {standardConfigFileName, standardChannelFileName, standardBackupFileName, + eventConfigFileName, eventChannelFileName, eventBackupFileName}; + for (const char *fn : radioProfileFiles) { + spiLock->lock(); + const bool exists = FSCom.exists(fn); + spiLock->unlock(); + if (exists && !EncryptedStorage::isEncrypted(fn)) { + LOG_INFO("Migrating inactive radio profile %s to encrypted storage", fn); + EncryptedStorage::migrateFile(fn); + } + } +#endif } #endif @@ -2680,8 +2744,9 @@ bool NodeDB::disableLockdownToPlaintext() // plaintext->encrypted migrate loop above. Order does not matter here; // EncryptedStorage::removeLockdownArtifacts() (which deletes the DEK, // the commit point) only runs after every file is confirmed plaintext. - const char *filesToCheck[] = {configFileName, moduleConfigFileName, channelFileName, deviceStateFileName, - nodeDatabaseFileName}; + const char *filesToCheck[] = {standardConfigFileName, standardChannelFileName, standardBackupFileName, + eventConfigFileName, eventChannelFileName, eventBackupFileName, + moduleConfigFileName, deviceStateFileName, nodeDatabaseFileName}; for (const char *fn : filesToCheck) { if (!EncryptedStorage::migrateFileToPlaintext(fn)) { LOG_ERROR("NodeDB: failed to revert %s to plaintext; aborting disable (device stays in lockdown)", fn); diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index fafe95e5609..894b9a84798 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -112,11 +112,47 @@ extern meshtastic_Position localPosition; static constexpr const char *deviceStateFileName = "/prefs/device.proto"; static constexpr const char *legacyPrefFileName = "/prefs/db.proto"; static constexpr const char *nodeDatabaseFileName = "/prefs/nodes.proto"; -static constexpr const char *configFileName = "/prefs/config.proto"; +// Event builds keep their radio profile in separate files. A normal firmware +// image therefore resumes the user's ordinary config and channel table without +// a client-mediated restore after an event OTA. +static constexpr const char *standardConfigFileName = "/prefs/config.proto"; +static constexpr const char *standardChannelFileName = "/prefs/channels.proto"; +static constexpr const char *eventConfigFileName = "/prefs/event-config.proto"; +static constexpr const char *eventChannelFileName = "/prefs/event-channels.proto"; +static constexpr const char *standardBackupFileName = "/backups/backup.proto"; +static constexpr const char *eventBackupFileName = "/backups/event-backup.proto"; + +struct RadioProfileStoragePaths { + const char *config; + const char *channels; + const char *backup; +}; + +constexpr RadioProfileStoragePaths radioProfileStoragePaths(bool eventMode) +{ + return eventMode ? RadioProfileStoragePaths{eventConfigFileName, eventChannelFileName, eventBackupFileName} + : RadioProfileStoragePaths{standardConfigFileName, standardChannelFileName, standardBackupFileName}; +} + +inline meshtastic_LocalConfig eventConfigFromStandard(const meshtastic_LocalConfig &standard, + const meshtastic_Config_LoRaConfig &eventLora) +{ + meshtastic_LocalConfig eventConfig = standard; + eventConfig.has_lora = true; + eventConfig.lora = eventLora; + return eventConfig; +} + +#if USERPREFS_EVENT_MODE +static constexpr auto radioProfileStorage = radioProfileStoragePaths(true); +#else +static constexpr auto radioProfileStorage = radioProfileStoragePaths(false); +#endif +static constexpr const char *configFileName = radioProfileStorage.config; +static constexpr const char *channelFileName = radioProfileStorage.channels; +static constexpr const char *backupFileName = radioProfileStorage.backup; static constexpr const char *uiconfigFileName = "/prefs/uiconfig.proto"; static constexpr const char *moduleConfigFileName = "/prefs/module.proto"; -static constexpr const char *channelFileName = "/prefs/channels.proto"; -static constexpr const char *backupFileName = "/backups/backup.proto"; /// Given a node, return how many seconds in the past (vs now) that we last heard from it uint32_t sinceLastSeen(const meshtastic_NodeInfoLite *n); diff --git a/test/test_event_profile_storage/test_main.cpp b/test/test_event_profile_storage/test_main.cpp new file mode 100644 index 00000000000..bcc257cfbb1 --- /dev/null +++ b/test/test_event_profile_storage/test_main.cpp @@ -0,0 +1,65 @@ +#include "MeshTypes.h" // Include BEFORE TestUtil.h +#include "TestUtil.h" +#include "mesh/NodeDB.h" +#include + +void test_standard_profile_uses_standard_files() +{ + const auto paths = radioProfileStoragePaths(false); + + TEST_ASSERT_EQUAL_STRING("/prefs/config.proto", paths.config); + TEST_ASSERT_EQUAL_STRING("/prefs/channels.proto", paths.channels); + TEST_ASSERT_EQUAL_STRING("/backups/backup.proto", paths.backup); +} + +void test_event_profile_uses_isolated_files() +{ + const auto paths = radioProfileStoragePaths(true); + + TEST_ASSERT_EQUAL_STRING("/prefs/event-config.proto", paths.config); + TEST_ASSERT_EQUAL_STRING("/prefs/event-channels.proto", paths.channels); + TEST_ASSERT_EQUAL_STRING("/backups/event-backup.proto", paths.backup); +} + +void test_event_config_preserves_identity_but_replaces_lora() +{ + auto standard = meshtastic_LocalConfig_init_zero; + standard.has_security = true; + standard.security.private_key.size = 1; + standard.security.private_key.bytes[0] = 0xA5; + standard.has_device = true; + standard.device.node_info_broadcast_secs = 600; + standard.has_lora = true; + standard.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + standard.lora.channel_num = 20; + + auto eventLora = standard.lora; + eventLora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + eventLora.channel_num = 3; + + const auto eventConfig = eventConfigFromStandard(standard, eventLora); + + TEST_ASSERT_EQUAL_UINT8(1, eventConfig.security.private_key.size); + TEST_ASSERT_EQUAL_HEX8(0xA5, eventConfig.security.private_key.bytes[0]); + TEST_ASSERT_EQUAL_UINT32(600, eventConfig.device.node_info_broadcast_secs); + TEST_ASSERT_EQUAL_UINT32(static_cast(eventLora.region), static_cast(eventConfig.lora.region)); + TEST_ASSERT_EQUAL_UINT32(3, eventConfig.lora.channel_num); +} + +void setUp(void) {} + +void tearDown(void) {} + +extern "C" { +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_standard_profile_uses_standard_files); + RUN_TEST(test_event_profile_uses_isolated_files); + RUN_TEST(test_event_config_preserves_identity_but_replaces_lora); + exit(UNITY_END()); +} + +void loop() {} +} From 72cf426601099e17158007be7a8dd4a6380524bd Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:51:04 -0700 Subject: [PATCH 02/11] fix: preserve identity on degraded config boot --- src/mesh/NodeDB.cpp | 8 ++++++++ src/mesh/NodeDB.h | 10 ++++++++++ test/test_event_profile_storage/test_main.cpp | 11 ++++++++++- 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index f8528365b81..ae069a6022d 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -635,6 +635,7 @@ NodeDB::NodeDB() #endif sortMeshDB(); saveToDisk(saveWhat); + bootInitializationInProgress = false; } /** @@ -2165,6 +2166,7 @@ void NodeDB::loadFromDisk() migrationSavePending = false; configDecodeFailed = false; + configLoadComplete = false; #if USERPREFS_EVENT_MODE // The event config is intentionally isolated from the ordinary config. @@ -2413,6 +2415,7 @@ void NodeDB::loadFromDisk() } else { LOG_INFO("Loaded saved config version %d", config.version); } + configLoadComplete = true; // Coerce LoRa config fields derived from presets while bootstrapping. // Some clients/UI components display bandwidth/spread_factor directly from config even in preset mode. @@ -2766,6 +2769,11 @@ bool NodeDB::saveProto(const char *filename, size_t protoSize, const pb_msgdesc_ bool fullAtomic) { + if (shouldDeferBootPersistence(bootInitializationInProgress, configLoadComplete, configDecodeFailed)) { + LOG_WARN("NodeDB: deferred boot write to %s until config recovery completes", filename); + return true; + } + // do not try to save anything if power level is not safe. In many cases flash will be lock-protected // and all writes will fail anyway. Device should be sleeping at this point anyway. if (!powerHAL_isPowerLevelSafe()) { diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index 894b9a84798..60cfa66dcfa 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -143,6 +143,11 @@ inline meshtastic_LocalConfig eventConfigFromStandard(const meshtastic_LocalConf return eventConfig; } +constexpr bool shouldDeferBootPersistence(bool bootInitializationInProgress, bool configLoadComplete, bool configDecodeFailed) +{ + return bootInitializationInProgress && (!configLoadComplete || configDecodeFailed); +} + #if USERPREFS_EVENT_MODE static constexpr auto radioProfileStorage = radioProfileStoragePaths(true); #else @@ -565,6 +570,11 @@ class NodeDB /// skip boot keygen and skip persisting defaults, so a transient read failure can't change our NodeNum /// or overwrite the on-disk config. Cleared at the top of every loadFromDisk() run. bool configDecodeFailed = false; + // Automatic writes stay deferred until the initial config load is known to + // be healthy. This prevents a damaged config from changing device state or + // node data during the same boot. + bool bootInitializationInProgress = true; + bool configLoadComplete = false; uint32_t lastNodeDbSave = 0; // when we last saved our db to flash uint32_t lastBackupAttempt = 0; // when we last tried a backup automatically or manually uint32_t lastSort = 0; // When last sorted the nodeDB diff --git a/test/test_event_profile_storage/test_main.cpp b/test/test_event_profile_storage/test_main.cpp index bcc257cfbb1..8ad0d056b35 100644 --- a/test/test_event_profile_storage/test_main.cpp +++ b/test/test_event_profile_storage/test_main.cpp @@ -23,7 +23,7 @@ void test_event_profile_uses_isolated_files() void test_event_config_preserves_identity_but_replaces_lora() { - auto standard = meshtastic_LocalConfig_init_zero; + meshtastic_LocalConfig standard = meshtastic_LocalConfig_init_zero; standard.has_security = true; standard.security.private_key.size = 1; standard.security.private_key.bytes[0] = 0xA5; @@ -46,6 +46,14 @@ void test_event_config_preserves_identity_but_replaces_lora() TEST_ASSERT_EQUAL_UINT32(3, eventConfig.lora.channel_num); } +void test_boot_defers_persistence_until_config_is_verified() +{ + TEST_ASSERT_TRUE(shouldDeferBootPersistence(true, false, false)); + TEST_ASSERT_TRUE(shouldDeferBootPersistence(true, true, true)); + TEST_ASSERT_FALSE(shouldDeferBootPersistence(true, true, false)); + TEST_ASSERT_FALSE(shouldDeferBootPersistence(false, true, true)); +} + void setUp(void) {} void tearDown(void) {} @@ -58,6 +66,7 @@ void setup() RUN_TEST(test_standard_profile_uses_standard_files); RUN_TEST(test_event_profile_uses_isolated_files); RUN_TEST(test_event_config_preserves_identity_but_replaces_lora); + RUN_TEST(test_boot_defers_persistence_until_config_is_verified); exit(UNITY_END()); } From dc6033ef9eaa9548357001de5192038c6a520b9a Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:22:23 -0700 Subject: [PATCH 03/11] fix: guard event profile storage --- src/mesh/NodeDB.cpp | 23 +++++++++++++++++++ src/mesh/NodeDB.h | 15 ++++++++++++ test/test_event_profile_storage/test_main.cpp | 9 ++++++++ 3 files changed, 47 insertions(+) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index ae069a6022d..346fa315937 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -2176,6 +2176,16 @@ void NodeDB::loadFromDisk() #ifdef FSCom spiLock->lock(); eventConfigMissing = !FSCom.exists(configFileName); + if (eventConfigMissing) { + const size_t totalBytes = FSCom.totalBytes(); + const size_t usedBytes = FSCom.usedBytes(); + eventProfileStorageUnavailable = !hasEventProfileStorageSpace(totalBytes, usedBytes); + if (eventProfileStorageUnavailable) { + LOG_ERROR("Event profile requires %u bytes free; only %u bytes available. Profile changes will not persist.", + static_cast(eventProfileStorageReservationBytes), + static_cast(totalBytes >= usedBytes ? totalBytes - usedBytes : 0)); + } + } spiLock->unlock(); #endif bool initializedEventConfig = false; @@ -3026,6 +3036,19 @@ bool NodeDB::saveToDiskNoRetry(int saveWhat) spiLock->unlock(); #endif +#if USERPREFS_EVENT_MODE + if (eventProfileStorageUnavailable) { + if (saveWhat & SEGMENT_CONFIG) { + LOG_WARN("Skipping event config write: insufficient profile storage at boot"); + saveWhat &= ~SEGMENT_CONFIG; + } + if (saveWhat & SEGMENT_CHANNELS) { + LOG_WARN("Skipping event channel write: insufficient profile storage at boot"); + saveWhat &= ~SEGMENT_CHANNELS; + } + } +#endif + if (saveWhat & SEGMENT_CONFIG) { config.has_device = true; config.has_display = true; diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index 60cfa66dcfa..0946232dbe5 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -134,6 +134,16 @@ constexpr RadioProfileStoragePaths radioProfileStoragePaths(bool eventMode) : RadioProfileStoragePaths{standardConfigFileName, standardChannelFileName, standardBackupFileName}; } +// Config and channel files use full-atomic writes. Reserve both complete event +// files and their temporary copies before seeding an event profile so a full +// filesystem cannot enter saveToDisk()'s format-and-retry path. +static constexpr size_t eventProfileStorageReservationBytes = 2 * (meshtastic_LocalConfig_size + meshtastic_ChannelFile_size); + +constexpr bool hasEventProfileStorageSpace(size_t totalBytes, size_t usedBytes) +{ + return usedBytes <= totalBytes && totalBytes - usedBytes >= eventProfileStorageReservationBytes; +} + inline meshtastic_LocalConfig eventConfigFromStandard(const meshtastic_LocalConfig &standard, const meshtastic_Config_LoRaConfig &eventLora) { @@ -575,6 +585,11 @@ class NodeDB // node data during the same boot. bool bootInitializationInProgress = true; bool configLoadComplete = false; +#if USERPREFS_EVENT_MODE + // The active event profile is intentionally non-durable when there was not + // enough room to create it safely at boot. A later boot retries the check. + bool eventProfileStorageUnavailable = false; +#endif uint32_t lastNodeDbSave = 0; // when we last saved our db to flash uint32_t lastBackupAttempt = 0; // when we last tried a backup automatically or manually uint32_t lastSort = 0; // When last sorted the nodeDB diff --git a/test/test_event_profile_storage/test_main.cpp b/test/test_event_profile_storage/test_main.cpp index 8ad0d056b35..1388b9c6710 100644 --- a/test/test_event_profile_storage/test_main.cpp +++ b/test/test_event_profile_storage/test_main.cpp @@ -54,6 +54,14 @@ void test_boot_defers_persistence_until_config_is_verified() TEST_ASSERT_FALSE(shouldDeferBootPersistence(false, true, true)); } +void test_event_profile_requires_room_for_atomic_profile_writes() +{ + TEST_ASSERT_TRUE(hasEventProfileStorageSpace(eventProfileStorageReservationBytes, 0)); + TEST_ASSERT_FALSE(hasEventProfileStorageSpace(eventProfileStorageReservationBytes - 1, 0)); + TEST_ASSERT_FALSE(hasEventProfileStorageSpace(eventProfileStorageReservationBytes, 1)); + TEST_ASSERT_FALSE(hasEventProfileStorageSpace(1, 2)); +} + void setUp(void) {} void tearDown(void) {} @@ -67,6 +75,7 @@ void setup() RUN_TEST(test_event_profile_uses_isolated_files); RUN_TEST(test_event_config_preserves_identity_but_replaces_lora); RUN_TEST(test_boot_defers_persistence_until_config_is_verified); + RUN_TEST(test_event_profile_requires_room_for_atomic_profile_writes); exit(UNITY_END()); } From 810d2651b4d4a03053622a9438ccc71e47d32bd5 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:40:23 -0700 Subject: [PATCH 04/11] style: align event profile storage names --- src/mesh/NodeDB.cpp | 30 ++++++------- src/mesh/NodeDB.h | 42 ++++++++----------- test/test_event_profile_storage/test_main.cpp | 6 +-- 3 files changed, 33 insertions(+), 45 deletions(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 346fa315937..f0c4b9691d2 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -2169,9 +2169,7 @@ void NodeDB::loadFromDisk() configLoadComplete = false; #if USERPREFS_EVENT_MODE - // The event config is intentionally isolated from the ordinary config. - // We only seed it when no event file exists; a corrupt event file must not - // silently fall back to and overwrite the user's normal radio profile. + // Seed only a missing event config; never overwrite normal files after a corrupt event config. bool eventConfigMissing = false; #ifdef FSCom spiLock->lock(); @@ -2182,7 +2180,7 @@ void NodeDB::loadFromDisk() eventProfileStorageUnavailable = !hasEventProfileStorageSpace(totalBytes, usedBytes); if (eventProfileStorageUnavailable) { LOG_ERROR("Event profile requires %u bytes free; only %u bytes available. Profile changes will not persist.", - static_cast(eventProfileStorageReservationBytes), + static_cast(EVENT_PROFILE_STORAGE_RESERVATION_BYTES), static_cast(totalBytes >= usedBytes ? totalBytes - usedBytes : 0)); } } @@ -2383,7 +2381,7 @@ void NodeDB::loadFromDisk() if (eventConfigMissing && state != LoadFileResult::LOAD_SUCCESS) { const LoadFileResult eventConfigState = state; const LoadFileResult standardConfigState = - loadProto(standardConfigFileName, meshtastic_LocalConfig_size, sizeof(meshtastic_LocalConfig), + loadProto(STANDARD_CONFIG_FILE_NAME, meshtastic_LocalConfig_size, sizeof(meshtastic_LocalConfig), &meshtastic_LocalConfig_msg, &config); if (standardConfigState == LoadFileResult::LOAD_SUCCESS) { // Preserve the user's identity and non-radio preferences, then @@ -2394,12 +2392,10 @@ void NodeDB::loadFromDisk() config = eventConfigFromStandard(standardConfig, eventLora); state = LoadFileResult::LOAD_SUCCESS; initializedEventConfig = true; - LOG_INFO("Initialized event config without modifying %s", standardConfigFileName); + LOG_INFO("Initialized event config without modifying %s", STANDARD_CONFIG_FILE_NAME); } else { - // loadProto() clears config before decoding. Restore the event - // outcome so the normal default/degraded path handles that safely. - // A corrupt normal config is especially important: preserving its - // decode failure prevents a replacement identity from being made. + // Keep the event load outcome because loadProto() clears config before decoding. + // A normal decode failure must not create a replacement identity. state = standardConfigState == LoadFileResult::DECODE_FAILED ? LoadFileResult::DECODE_FAILED : eventConfigState; } } @@ -2607,12 +2603,10 @@ void NodeDB::loadFromDisk() } } - // Both radio profiles can contain channel PSKs. The inactive profile - // is not covered by saveToDisk(), so migrate it explicitly when it - // exists before treating the encrypted-storage pass as complete. + // Migrate inactive radio files explicitly: they may contain PSKs and are outside saveToDisk(). #ifdef FSCom - const char *radioProfileFiles[] = {standardConfigFileName, standardChannelFileName, standardBackupFileName, - eventConfigFileName, eventChannelFileName, eventBackupFileName}; + const char *radioProfileFiles[] = {STANDARD_CONFIG_FILE_NAME, STANDARD_CHANNEL_FILE_NAME, STANDARD_BACKUP_FILE_NAME, + EVENT_CONFIG_FILE_NAME, EVENT_CHANNEL_FILE_NAME, EVENT_BACKUP_FILE_NAME}; for (const char *fn : radioProfileFiles) { spiLock->lock(); const bool exists = FSCom.exists(fn); @@ -2757,9 +2751,9 @@ bool NodeDB::disableLockdownToPlaintext() // plaintext->encrypted migrate loop above. Order does not matter here; // EncryptedStorage::removeLockdownArtifacts() (which deletes the DEK, // the commit point) only runs after every file is confirmed plaintext. - const char *filesToCheck[] = {standardConfigFileName, standardChannelFileName, standardBackupFileName, - eventConfigFileName, eventChannelFileName, eventBackupFileName, - moduleConfigFileName, deviceStateFileName, nodeDatabaseFileName}; + const char *filesToCheck[] = {STANDARD_CONFIG_FILE_NAME, STANDARD_CHANNEL_FILE_NAME, STANDARD_BACKUP_FILE_NAME, + EVENT_CONFIG_FILE_NAME, EVENT_CHANNEL_FILE_NAME, EVENT_BACKUP_FILE_NAME, + moduleConfigFileName, deviceStateFileName, nodeDatabaseFileName}; for (const char *fn : filesToCheck) { if (!EncryptedStorage::migrateFileToPlaintext(fn)) { LOG_ERROR("NodeDB: failed to revert %s to plaintext; aborting disable (device stays in lockdown)", fn); diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index 0946232dbe5..762c2556863 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -112,15 +112,13 @@ extern meshtastic_Position localPosition; static constexpr const char *deviceStateFileName = "/prefs/device.proto"; static constexpr const char *legacyPrefFileName = "/prefs/db.proto"; static constexpr const char *nodeDatabaseFileName = "/prefs/nodes.proto"; -// Event builds keep their radio profile in separate files. A normal firmware -// image therefore resumes the user's ordinary config and channel table without -// a client-mediated restore after an event OTA. -static constexpr const char *standardConfigFileName = "/prefs/config.proto"; -static constexpr const char *standardChannelFileName = "/prefs/channels.proto"; -static constexpr const char *eventConfigFileName = "/prefs/event-config.proto"; -static constexpr const char *eventChannelFileName = "/prefs/event-channels.proto"; -static constexpr const char *standardBackupFileName = "/backups/backup.proto"; -static constexpr const char *eventBackupFileName = "/backups/event-backup.proto"; +// Event builds isolate radio profiles so normal firmware resumes its config and channels after OTA. +static constexpr const char *STANDARD_CONFIG_FILE_NAME = "/prefs/config.proto"; +static constexpr const char *STANDARD_CHANNEL_FILE_NAME = "/prefs/channels.proto"; +static constexpr const char *EVENT_CONFIG_FILE_NAME = "/prefs/event-config.proto"; +static constexpr const char *EVENT_CHANNEL_FILE_NAME = "/prefs/event-channels.proto"; +static constexpr const char *STANDARD_BACKUP_FILE_NAME = "/backups/backup.proto"; +static constexpr const char *EVENT_BACKUP_FILE_NAME = "/backups/event-backup.proto"; struct RadioProfileStoragePaths { const char *config; @@ -130,18 +128,16 @@ struct RadioProfileStoragePaths { constexpr RadioProfileStoragePaths radioProfileStoragePaths(bool eventMode) { - return eventMode ? RadioProfileStoragePaths{eventConfigFileName, eventChannelFileName, eventBackupFileName} - : RadioProfileStoragePaths{standardConfigFileName, standardChannelFileName, standardBackupFileName}; + return eventMode ? RadioProfileStoragePaths{EVENT_CONFIG_FILE_NAME, EVENT_CHANNEL_FILE_NAME, EVENT_BACKUP_FILE_NAME} + : RadioProfileStoragePaths{STANDARD_CONFIG_FILE_NAME, STANDARD_CHANNEL_FILE_NAME, STANDARD_BACKUP_FILE_NAME}; } -// Config and channel files use full-atomic writes. Reserve both complete event -// files and their temporary copies before seeding an event profile so a full -// filesystem cannot enter saveToDisk()'s format-and-retry path. -static constexpr size_t eventProfileStorageReservationBytes = 2 * (meshtastic_LocalConfig_size + meshtastic_ChannelFile_size); +// Reserve event files and their atomic temporaries before seeding, preventing retry formatting on full storage. +static constexpr size_t EVENT_PROFILE_STORAGE_RESERVATION_BYTES = 2 * (meshtastic_LocalConfig_size + meshtastic_ChannelFile_size); constexpr bool hasEventProfileStorageSpace(size_t totalBytes, size_t usedBytes) { - return usedBytes <= totalBytes && totalBytes - usedBytes >= eventProfileStorageReservationBytes; + return usedBytes <= totalBytes && totalBytes - usedBytes >= EVENT_PROFILE_STORAGE_RESERVATION_BYTES; } inline meshtastic_LocalConfig eventConfigFromStandard(const meshtastic_LocalConfig &standard, @@ -159,13 +155,13 @@ constexpr bool shouldDeferBootPersistence(bool bootInitializationInProgress, boo } #if USERPREFS_EVENT_MODE -static constexpr auto radioProfileStorage = radioProfileStoragePaths(true); +static constexpr auto RADIO_PROFILE_STORAGE = radioProfileStoragePaths(true); #else -static constexpr auto radioProfileStorage = radioProfileStoragePaths(false); +static constexpr auto RADIO_PROFILE_STORAGE = radioProfileStoragePaths(false); #endif -static constexpr const char *configFileName = radioProfileStorage.config; -static constexpr const char *channelFileName = radioProfileStorage.channels; -static constexpr const char *backupFileName = radioProfileStorage.backup; +static constexpr const char *configFileName = RADIO_PROFILE_STORAGE.config; +static constexpr const char *channelFileName = RADIO_PROFILE_STORAGE.channels; +static constexpr const char *backupFileName = RADIO_PROFILE_STORAGE.backup; static constexpr const char *uiconfigFileName = "/prefs/uiconfig.proto"; static constexpr const char *moduleConfigFileName = "/prefs/module.proto"; @@ -580,9 +576,7 @@ class NodeDB /// skip boot keygen and skip persisting defaults, so a transient read failure can't change our NodeNum /// or overwrite the on-disk config. Cleared at the top of every loadFromDisk() run. bool configDecodeFailed = false; - // Automatic writes stay deferred until the initial config load is known to - // be healthy. This prevents a damaged config from changing device state or - // node data during the same boot. + // Defer automatic writes until config load is healthy to protect device and node data from damaged configs. bool bootInitializationInProgress = true; bool configLoadComplete = false; #if USERPREFS_EVENT_MODE diff --git a/test/test_event_profile_storage/test_main.cpp b/test/test_event_profile_storage/test_main.cpp index 1388b9c6710..9013404b7ce 100644 --- a/test/test_event_profile_storage/test_main.cpp +++ b/test/test_event_profile_storage/test_main.cpp @@ -56,9 +56,9 @@ void test_boot_defers_persistence_until_config_is_verified() void test_event_profile_requires_room_for_atomic_profile_writes() { - TEST_ASSERT_TRUE(hasEventProfileStorageSpace(eventProfileStorageReservationBytes, 0)); - TEST_ASSERT_FALSE(hasEventProfileStorageSpace(eventProfileStorageReservationBytes - 1, 0)); - TEST_ASSERT_FALSE(hasEventProfileStorageSpace(eventProfileStorageReservationBytes, 1)); + TEST_ASSERT_TRUE(hasEventProfileStorageSpace(EVENT_PROFILE_STORAGE_RESERVATION_BYTES, 0)); + TEST_ASSERT_FALSE(hasEventProfileStorageSpace(EVENT_PROFILE_STORAGE_RESERVATION_BYTES - 1, 0)); + TEST_ASSERT_FALSE(hasEventProfileStorageSpace(EVENT_PROFILE_STORAGE_RESERVATION_BYTES, 1)); TEST_ASSERT_FALSE(hasEventProfileStorageSpace(1, 2)); } From 72eaecac6c5e1feae11c9ae39f12066b263e3f7d Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:54:18 -0700 Subject: [PATCH 05/11] fix: discard event profile on normal boot --- src/mesh/NodeDB.cpp | 12 ++++++++++++ src/mesh/NodeDB.h | 7 +++++++ test/test_event_profile_storage/test_main.cpp | 7 +++++++ 3 files changed, 26 insertions(+) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index f0c4b9691d2..63dc047210a 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -2168,6 +2168,18 @@ void NodeDB::loadFromDisk() configDecodeFailed = false; configLoadComplete = false; + if (shouldDiscardEventProfile(RUNNING_EVENT_FIRMWARE)) { +#ifdef FSCom + const char *eventProfileFiles[] = {EVENT_CONFIG_FILE_NAME, EVENT_CHANNEL_FILE_NAME, EVENT_BACKUP_FILE_NAME}; + spiLock->lock(); + for (const char *filename : eventProfileFiles) { + if (FSCom.exists(filename) && !FSCom.remove(filename)) + LOG_WARN("Unable to remove stale event profile file %s", filename); + } + spiLock->unlock(); +#endif + } + #if USERPREFS_EVENT_MODE // Seed only a missing event config; never overwrite normal files after a corrupt event config. bool eventConfigMissing = false; diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index 762c2556863..70b4c0ce2ac 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -140,6 +140,11 @@ constexpr bool hasEventProfileStorageSpace(size_t totalBytes, size_t usedBytes) return usedBytes <= totalBytes && totalBytes - usedBytes >= EVENT_PROFILE_STORAGE_RESERVATION_BYTES; } +constexpr bool shouldDiscardEventProfile(bool eventMode) +{ + return !eventMode; +} + inline meshtastic_LocalConfig eventConfigFromStandard(const meshtastic_LocalConfig &standard, const meshtastic_Config_LoRaConfig &eventLora) { @@ -155,8 +160,10 @@ constexpr bool shouldDeferBootPersistence(bool bootInitializationInProgress, boo } #if USERPREFS_EVENT_MODE +static constexpr bool RUNNING_EVENT_FIRMWARE = true; static constexpr auto RADIO_PROFILE_STORAGE = radioProfileStoragePaths(true); #else +static constexpr bool RUNNING_EVENT_FIRMWARE = false; static constexpr auto RADIO_PROFILE_STORAGE = radioProfileStoragePaths(false); #endif static constexpr const char *configFileName = RADIO_PROFILE_STORAGE.config; diff --git a/test/test_event_profile_storage/test_main.cpp b/test/test_event_profile_storage/test_main.cpp index 9013404b7ce..d48b43b9f38 100644 --- a/test/test_event_profile_storage/test_main.cpp +++ b/test/test_event_profile_storage/test_main.cpp @@ -62,6 +62,12 @@ void test_event_profile_requires_room_for_atomic_profile_writes() TEST_ASSERT_FALSE(hasEventProfileStorageSpace(1, 2)); } +void test_normal_boot_discards_event_profile() +{ + TEST_ASSERT_TRUE(shouldDiscardEventProfile(false)); + TEST_ASSERT_FALSE(shouldDiscardEventProfile(true)); +} + void setUp(void) {} void tearDown(void) {} @@ -76,6 +82,7 @@ void setup() RUN_TEST(test_event_config_preserves_identity_but_replaces_lora); RUN_TEST(test_boot_defers_persistence_until_config_is_verified); RUN_TEST(test_event_profile_requires_room_for_atomic_profile_writes); + RUN_TEST(test_normal_boot_discards_event_profile); exit(UNITY_END()); } From c29f2287194d8325d03c43ab225599c19d7ff7a7 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:27:31 -0700 Subject: [PATCH 06/11] refactor: simplify event profile cleanup --- src/mesh/NodeDB.cpp | 18 +++++++++--------- src/mesh/NodeDB.h | 7 ------- test/test_event_profile_storage/test_main.cpp | 7 ------- 3 files changed, 9 insertions(+), 23 deletions(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 63dc047210a..92ea4e19b9d 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -2168,17 +2168,17 @@ void NodeDB::loadFromDisk() configDecodeFailed = false; configLoadComplete = false; - if (shouldDiscardEventProfile(RUNNING_EVENT_FIRMWARE)) { +#if !USERPREFS_EVENT_MODE #ifdef FSCom - const char *eventProfileFiles[] = {EVENT_CONFIG_FILE_NAME, EVENT_CHANNEL_FILE_NAME, EVENT_BACKUP_FILE_NAME}; - spiLock->lock(); - for (const char *filename : eventProfileFiles) { - if (FSCom.exists(filename) && !FSCom.remove(filename)) - LOG_WARN("Unable to remove stale event profile file %s", filename); - } - spiLock->unlock(); -#endif + const char *eventProfileFiles[] = {EVENT_CONFIG_FILE_NAME, EVENT_CHANNEL_FILE_NAME, EVENT_BACKUP_FILE_NAME}; + spiLock->lock(); + for (const char *filename : eventProfileFiles) { + if (FSCom.exists(filename) && !FSCom.remove(filename)) + LOG_WARN("Unable to remove stale event profile file %s", filename); } + spiLock->unlock(); +#endif +#endif #if USERPREFS_EVENT_MODE // Seed only a missing event config; never overwrite normal files after a corrupt event config. diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index 70b4c0ce2ac..762c2556863 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -140,11 +140,6 @@ constexpr bool hasEventProfileStorageSpace(size_t totalBytes, size_t usedBytes) return usedBytes <= totalBytes && totalBytes - usedBytes >= EVENT_PROFILE_STORAGE_RESERVATION_BYTES; } -constexpr bool shouldDiscardEventProfile(bool eventMode) -{ - return !eventMode; -} - inline meshtastic_LocalConfig eventConfigFromStandard(const meshtastic_LocalConfig &standard, const meshtastic_Config_LoRaConfig &eventLora) { @@ -160,10 +155,8 @@ constexpr bool shouldDeferBootPersistence(bool bootInitializationInProgress, boo } #if USERPREFS_EVENT_MODE -static constexpr bool RUNNING_EVENT_FIRMWARE = true; static constexpr auto RADIO_PROFILE_STORAGE = radioProfileStoragePaths(true); #else -static constexpr bool RUNNING_EVENT_FIRMWARE = false; static constexpr auto RADIO_PROFILE_STORAGE = radioProfileStoragePaths(false); #endif static constexpr const char *configFileName = RADIO_PROFILE_STORAGE.config; diff --git a/test/test_event_profile_storage/test_main.cpp b/test/test_event_profile_storage/test_main.cpp index d48b43b9f38..9013404b7ce 100644 --- a/test/test_event_profile_storage/test_main.cpp +++ b/test/test_event_profile_storage/test_main.cpp @@ -62,12 +62,6 @@ void test_event_profile_requires_room_for_atomic_profile_writes() TEST_ASSERT_FALSE(hasEventProfileStorageSpace(1, 2)); } -void test_normal_boot_discards_event_profile() -{ - TEST_ASSERT_TRUE(shouldDiscardEventProfile(false)); - TEST_ASSERT_FALSE(shouldDiscardEventProfile(true)); -} - void setUp(void) {} void tearDown(void) {} @@ -82,7 +76,6 @@ void setup() RUN_TEST(test_event_config_preserves_identity_but_replaces_lora); RUN_TEST(test_boot_defers_persistence_until_config_is_verified); RUN_TEST(test_event_profile_requires_room_for_atomic_profile_writes); - RUN_TEST(test_normal_boot_discards_event_profile); exit(UNITY_END()); } From fed61a881464e72dffc75c608990c2ac54717b2f Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:33:16 -0700 Subject: [PATCH 07/11] fix: limit inactive profile migration to event firmware --- src/mesh/NodeDB.cpp | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 92ea4e19b9d..6017771127c 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -2597,7 +2597,7 @@ void NodeDB::loadFromDisk() const int segments[] = {SEGMENT_CONFIG, SEGMENT_MODULECONFIG, SEGMENT_CHANNELS, SEGMENT_DEVICESTATE, SEGMENT_NODEDATABASE}; int toSave = 0; - for (int i = 0; i < 5; i++) { + for (size_t i = 0; i < sizeof(segments) / sizeof(segments[0]); i++) { if (!EncryptedStorage::isEncrypted(filesToCheck[i])) { toSave |= segments[i]; } @@ -2615,11 +2615,23 @@ void NodeDB::loadFromDisk() } } - // Migrate inactive radio files explicitly: they may contain PSKs and are outside saveToDisk(). + // Backups are outside saveToDisk(), but can contain radio profile PSKs. #ifdef FSCom - const char *radioProfileFiles[] = {STANDARD_CONFIG_FILE_NAME, STANDARD_CHANNEL_FILE_NAME, STANDARD_BACKUP_FILE_NAME, - EVENT_CONFIG_FILE_NAME, EVENT_CHANNEL_FILE_NAME, EVENT_BACKUP_FILE_NAME}; - for (const char *fn : radioProfileFiles) { + spiLock->lock(); + const bool activeBackupExists = FSCom.exists(backupFileName); + spiLock->unlock(); + if (activeBackupExists && !EncryptedStorage::isEncrypted(backupFileName)) { + LOG_INFO("Migrating %s to encrypted storage", backupFileName); + EncryptedStorage::migrateFile(backupFileName); + } +#endif + + // Event firmware keeps the normal radio profile inactive, so migrate it separately. +#if USERPREFS_EVENT_MODE +#ifdef FSCom + const char *inactiveRadioProfileFiles[] = {STANDARD_CONFIG_FILE_NAME, STANDARD_CHANNEL_FILE_NAME, + STANDARD_BACKUP_FILE_NAME}; + for (const char *fn : inactiveRadioProfileFiles) { spiLock->lock(); const bool exists = FSCom.exists(fn); spiLock->unlock(); @@ -2628,6 +2640,7 @@ void NodeDB::loadFromDisk() EncryptedStorage::migrateFile(fn); } } +#endif #endif } #endif From e6a6722c4960a41a1188b07f34b9f20dd8948c7c Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sun, 26 Jul 2026 09:05:17 -0500 Subject: [PATCH 08/11] fix(event): make event-profile capacity check portable across filesystems The USERPREFS_EVENT_MODE storage preflight called FSCom.totalBytes() and FSCom.usedBytes(), which only exist on ESP32's LittleFS wrapper. Every other backend failed to compile once event mode was enabled: error: 'class InternalFileSystem' has no member named 'totalBytes' (nRF52) error: no member named 'totalBytes' in 'fs::FS' (Portduino) This was not caught earlier because the event block is behind #if USERPREFS_EVENT_MODE, and the existing unit tests only cover the pure helpers, which compile identically either way. Add fsTotalBytes()/fsUsedBytes() to FSCommon and implement them per backend: littlefs v1 traversal for nRF52 (Adafruit_LittleFS) and STM32 (STM32_LittleFS), FSInfo for RP2040, statvfs for Portduino, and the native methods for ESP32 and nRF54L15. The nRF52/STM32 path reports "full" if the traversal errors so the capacity check fails safe. Verified with USERPREFS_EVENT_MODE=1: native-macos test_event_profile_storage passes 5/5, and heltec-v3, rak4631, rak11310 and rak3172 all build clean. --- src/FSCommon.cpp | 77 +++++++++++++++++++++++++++++++++++++++++++++ src/FSCommon.h | 6 ++++ src/mesh/NodeDB.cpp | 4 +-- 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/FSCommon.cpp b/src/FSCommon.cpp index b7dbd670c9a..72c03a1b7e7 100644 --- a/src/FSCommon.cpp +++ b/src/FSCommon.cpp @@ -12,6 +12,83 @@ #include "SPILock.h" #include "configuration.h" +#if defined(ARCH_PORTDUINO) +#include +#endif + +#if defined(ARCH_NRF52) || defined(ARCH_STM32) +// Adafruit_LittleFS (nRF52) and STM32_LittleFS both wrap littlefs v1, which predates lfs_fs_size(). +// Count the blocks currently in use with lfs_traverse() instead. +static int fsCountBlockCb(void *ctx, lfs_block_t) +{ + *static_cast(ctx) += 1; + return 0; +} + +size_t fsTotalBytes() +{ + lfs_t *fs = FSCom._getFS(); + if (!fs || !fs->cfg) + return 0; + return (size_t)fs->cfg->block_count * (size_t)fs->cfg->block_size; +} + +size_t fsUsedBytes() +{ + lfs_t *fs = FSCom._getFS(); + if (!fs || !fs->cfg) + return 0; + size_t blocks = 0; + FSCom._lockFS(); + int err = lfs_traverse(fs, fsCountBlockCb, &blocks); + FSCom._unlockFS(); + if (err < 0) + return fsTotalBytes(); // report "full" so capacity checks fail safe + return blocks * (size_t)fs->cfg->block_size; +} +#elif defined(ARCH_RP2040) +// arduino-pico reports capacity through FSInfo rather than as methods. +size_t fsTotalBytes() +{ + FSInfo info; + return FSCom.info(info) ? (size_t)info.totalBytes : 0; +} + +size_t fsUsedBytes() +{ + FSInfo info; + return FSCom.info(info) ? (size_t)info.usedBytes : 0; +} +#elif defined(ARCH_PORTDUINO) +// Portduino is backed by the host filesystem; ask the OS about the volume holding the working directory. +size_t fsTotalBytes() +{ + struct statvfs st; + if (statvfs(".", &st) != 0) + return 0; + return (size_t)st.f_blocks * (size_t)st.f_frsize; +} + +size_t fsUsedBytes() +{ + struct statvfs st; + if (statvfs(".", &st) != 0) + return 0; + return (size_t)(st.f_blocks - st.f_bfree) * (size_t)st.f_frsize; +} +#else +// ESP32 LittleFS and the nRF54L15 wrapper expose these directly. +size_t fsTotalBytes() +{ + return FSCom.totalBytes(); +} + +size_t fsUsedBytes() +{ + return FSCom.usedBytes(); +} +#endif + // Software SPI is used by MUI so disable SD card here until it's also implemented #if defined(HAS_SDCARD) && !defined(SDCARD_USE_SOFT_SPI) #include diff --git a/src/FSCommon.h b/src/FSCommon.h index 03ad091befe..7daa57ad90d 100644 --- a/src/FSCommon.h +++ b/src/FSCommon.h @@ -56,6 +56,12 @@ using namespace Adafruit_LittleFS_Namespace; using namespace Adafruit_LittleFS_Namespace; #endif +// Filesystem capacity, in bytes. Only ESP32's LittleFS and the nRF54L15 wrapper expose totalBytes()/usedBytes() +// directly; the other backends need per-platform work (littlefs v1 traversal, FSInfo, statvfs), so callers must +// use these helpers rather than reaching into FSCom. +size_t fsTotalBytes(); +size_t fsUsedBytes(); + void fsInit(); bool renameFile(const char *pathFrom, const char *pathTo); bool fsFormat(); diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index a9db0a4e1c7..5fa686a245e 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -2215,8 +2215,8 @@ void NodeDB::loadFromDisk() spiLock->lock(); eventConfigMissing = !FSCom.exists(configFileName); if (eventConfigMissing) { - const size_t totalBytes = FSCom.totalBytes(); - const size_t usedBytes = FSCom.usedBytes(); + const size_t totalBytes = fsTotalBytes(); + const size_t usedBytes = fsUsedBytes(); eventProfileStorageUnavailable = !hasEventProfileStorageSpace(totalBytes, usedBytes); if (eventProfileStorageUnavailable) { LOG_ERROR("Event profile requires %u bytes free; only %u bytes available. Profile changes will not persist.", From baa606a162dcf7b7b19fba90319129e889bc2535 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sun, 26 Jul 2026 12:22:56 -0500 Subject: [PATCH 09/11] fix(event): use std::filesystem for Portduino capacity, bump native-suite-count Two CI fixes: - native-windows has no , so the Portduino branch of fsTotalBytes()/fsUsedBytes() broke the Windows build. Switch to std::filesystem::space(), which is already used under ARCH_PORTDUINO in HostMetrics.cpp and works on both POSIX and MinGW. Errors report "full" so the capacity check still fails safe. - This PR adds test/test_event_profile_storage, taking test/ from 40 to 41 suite directories, which trips the native-suite-count reconciliation check. --- src/FSCommon.cpp | 22 ++++++++++++---------- test/native-suite-count | 2 +- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/FSCommon.cpp b/src/FSCommon.cpp index 72c03a1b7e7..38b704e7381 100644 --- a/src/FSCommon.cpp +++ b/src/FSCommon.cpp @@ -13,7 +13,7 @@ #include "configuration.h" #if defined(ARCH_PORTDUINO) -#include +#include #endif #if defined(ARCH_NRF52) || defined(ARCH_STM32) @@ -60,21 +60,23 @@ size_t fsUsedBytes() return FSCom.info(info) ? (size_t)info.usedBytes : 0; } #elif defined(ARCH_PORTDUINO) -// Portduino is backed by the host filesystem; ask the OS about the volume holding the working directory. +// Portduino is backed by the host filesystem; ask the OS about the volume holding the working +// directory. std::filesystem keeps this working on native-windows too, where statvfs() does not +// exist. On error we report "full" so capacity checks fail safe. size_t fsTotalBytes() { - struct statvfs st; - if (statvfs(".", &st) != 0) - return 0; - return (size_t)st.f_blocks * (size_t)st.f_frsize; + std::error_code ec; + const auto info = std::filesystem::space(".", ec); + return ec ? 0 : (size_t)info.capacity; } size_t fsUsedBytes() { - struct statvfs st; - if (statvfs(".", &st) != 0) - return 0; - return (size_t)(st.f_blocks - st.f_bfree) * (size_t)st.f_frsize; + std::error_code ec; + const auto info = std::filesystem::space(".", ec); + if (ec || info.capacity < info.available) + return fsTotalBytes(); + return (size_t)(info.capacity - info.available); } #else // ESP32 LittleFS and the nRF54L15 wrapper expose these directly. diff --git a/test/native-suite-count b/test/native-suite-count index 425151f3a41..87523dd7a06 100644 --- a/test/native-suite-count +++ b/test/native-suite-count @@ -1 +1 @@ -40 +41 From 90f65c529697804583269b674f564fedd61ddf61 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sun, 26 Jul 2026 13:19:30 -0500 Subject: [PATCH 10/11] fix(event): scope boot-write deferral to the radio profile, address review Review feedback: - Copilot: saveProto()'s boot-write deferral applied to every file, so loadFromDisk()'s recovery writes (e.g. restoring owner fields into devicestate) were silently dropped and never retried, because the ctor CRC baselines are computed after loadFromDisk(). Deferral now applies only to the radio-profile files, via isRadioProfileFile(). - jp-bennett: eventConfigFromStandard() wrapped a struct copy plus one field assignment in a header helper with its own unit test. Inlined at its single call site and removed; the behaviour is covered end-to-end by hardware validation instead (RAK4631 normal -> event -> normal preserves NodeNum and public key while swapping LoRa, and restores the original channel byte-identically). - jp-bennett: the active-backup encrypted-storage migration ran for normal builds too, which changed non-event behaviour and contradicted the PR's stated event-only scope. Scoped to USERPREFS_EVENT_MODE; the adjacent block already covers the inactive standard backup in event builds. New tests (all verified to fail under mutation, not vacuous): - test_event_paths_never_collide_with_standard_or_shared_files: the core safety property. A path-table slip would make event firmware overwrite the user's real config, channels or backup, or capture a shared file like devicestate/nodedb. Nothing asserted this before. - test_only_radio_profile_files_defer_boot_writes: guards the deferral fix above so re-widening it fails loudly. - test_event_reservation_fits_smallest_supported_filesystem: the reservation is compile-time but must fit filesystems as small as 14 KiB (STM32WL) and 28 KiB (nRF52840). Protobuf growth pushing it past those would silently stop event profiles persisting - the exact failure mode confirmed by fault injection on a RAK4631. Verified with USERPREFS_EVENT_MODE both on and off: 7/7 tests pass, and rak4631, heltec-v3, rak11310 and rak3172 all build clean. --- src/mesh/NodeDB.cpp | 15 ++- src/mesh/NodeDB.h | 17 ++-- test/test_event_profile_storage/test_main.cpp | 93 +++++++++++++------ 3 files changed, 87 insertions(+), 38 deletions(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 5fa686a245e..68a2292fb8e 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -2429,7 +2429,9 @@ void NodeDB::loadFromDisk() const meshtastic_LocalConfig standardConfig = config; installDefaultConfig(true); const meshtastic_Config_LoRaConfig eventLora = config.lora; - config = eventConfigFromStandard(standardConfig, eventLora); + config = standardConfig; + config.has_lora = true; + config.lora = eventLora; state = LoadFileResult::LOAD_SUCCESS; initializedEventConfig = true; LOG_INFO("Initialized event config without modifying %s", STANDARD_CONFIG_FILE_NAME); @@ -2643,7 +2645,9 @@ void NodeDB::loadFromDisk() } } - // Backups are outside saveToDisk(), but can contain radio profile PSKs. + // Backups are outside saveToDisk(), but can contain radio profile PSKs. Only event builds + // introduce a second backup file, so leave normal-firmware backup handling unchanged. +#if USERPREFS_EVENT_MODE #ifdef FSCom spiLock->lock(); const bool activeBackupExists = FSCom.exists(backupFileName); @@ -2652,6 +2656,7 @@ void NodeDB::loadFromDisk() LOG_INFO("Migrating %s to encrypted storage", backupFileName); EncryptedStorage::migrateFile(backupFileName); } +#endif #endif // Event firmware keeps the normal radio profile inactive, so migrate it separately. @@ -2826,7 +2831,11 @@ bool NodeDB::saveProto(const char *filename, size_t protoSize, const pb_msgdesc_ bool fullAtomic) { - if (shouldDeferBootPersistence(bootInitializationInProgress, configLoadComplete, configDecodeFailed)) { + // Only the radio profile is at risk from an unverified config load, so only defer those writes. + // Devicestate/nodedb/module writes must still land, otherwise boot-time recovery (e.g. loadFromDisk() + // restoring owner fields) is dropped and never retried. + if (isRadioProfileFile(filename) && + shouldDeferBootPersistence(bootInitializationInProgress, configLoadComplete, configDecodeFailed)) { LOG_WARN("NodeDB: deferred boot write to %s until config recovery completes", filename); return true; } diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index b0c92023d58..7c88023b43f 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -140,15 +140,6 @@ constexpr bool hasEventProfileStorageSpace(size_t totalBytes, size_t usedBytes) return usedBytes <= totalBytes && totalBytes - usedBytes >= EVENT_PROFILE_STORAGE_RESERVATION_BYTES; } -inline meshtastic_LocalConfig eventConfigFromStandard(const meshtastic_LocalConfig &standard, - const meshtastic_Config_LoRaConfig &eventLora) -{ - meshtastic_LocalConfig eventConfig = standard; - eventConfig.has_lora = true; - eventConfig.lora = eventLora; - return eventConfig; -} - constexpr bool shouldDeferBootPersistence(bool bootInitializationInProgress, bool configLoadComplete, bool configDecodeFailed) { return bootInitializationInProgress && (!configLoadComplete || configDecodeFailed); @@ -165,6 +156,14 @@ static constexpr const char *backupFileName = RADIO_PROFILE_STORAGE.backup; static constexpr const char *uiconfigFileName = "/prefs/uiconfig.proto"; static constexpr const char *moduleConfigFileName = "/prefs/module.proto"; +// An unverified config load only endangers the radio profile, so only these files take part in +// boot-write deferral. Lives next to the path table above so the two cannot drift apart. +inline bool isRadioProfileFile(const char *filename) +{ + return strcmp(filename, configFileName) == 0 || strcmp(filename, channelFileName) == 0 || + strcmp(filename, backupFileName) == 0; +} + /// Given a node, return how many seconds in the past (vs now) that we last heard from it uint32_t sinceLastSeen(const meshtastic_NodeInfoLite *n); diff --git a/test/test_event_profile_storage/test_main.cpp b/test/test_event_profile_storage/test_main.cpp index 9013404b7ce..f38a4196912 100644 --- a/test/test_event_profile_storage/test_main.cpp +++ b/test/test_event_profile_storage/test_main.cpp @@ -21,31 +21,6 @@ void test_event_profile_uses_isolated_files() TEST_ASSERT_EQUAL_STRING("/backups/event-backup.proto", paths.backup); } -void test_event_config_preserves_identity_but_replaces_lora() -{ - meshtastic_LocalConfig standard = meshtastic_LocalConfig_init_zero; - standard.has_security = true; - standard.security.private_key.size = 1; - standard.security.private_key.bytes[0] = 0xA5; - standard.has_device = true; - standard.device.node_info_broadcast_secs = 600; - standard.has_lora = true; - standard.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; - standard.lora.channel_num = 20; - - auto eventLora = standard.lora; - eventLora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; - eventLora.channel_num = 3; - - const auto eventConfig = eventConfigFromStandard(standard, eventLora); - - TEST_ASSERT_EQUAL_UINT8(1, eventConfig.security.private_key.size); - TEST_ASSERT_EQUAL_HEX8(0xA5, eventConfig.security.private_key.bytes[0]); - TEST_ASSERT_EQUAL_UINT32(600, eventConfig.device.node_info_broadcast_secs); - TEST_ASSERT_EQUAL_UINT32(static_cast(eventLora.region), static_cast(eventConfig.lora.region)); - TEST_ASSERT_EQUAL_UINT32(3, eventConfig.lora.channel_num); -} - void test_boot_defers_persistence_until_config_is_verified() { TEST_ASSERT_TRUE(shouldDeferBootPersistence(true, false, false)); @@ -60,6 +35,70 @@ void test_event_profile_requires_room_for_atomic_profile_writes() TEST_ASSERT_FALSE(hasEventProfileStorageSpace(EVENT_PROFILE_STORAGE_RESERVATION_BYTES - 1, 0)); TEST_ASSERT_FALSE(hasEventProfileStorageSpace(EVENT_PROFILE_STORAGE_RESERVATION_BYTES, 1)); TEST_ASSERT_FALSE(hasEventProfileStorageSpace(1, 2)); + // A full filesystem must never look like it has room. + TEST_ASSERT_FALSE( + hasEventProfileStorageSpace(EVENT_PROFILE_STORAGE_RESERVATION_BYTES, EVENT_PROFILE_STORAGE_RESERVATION_BYTES)); +} + +// The whole point of the feature is that an event build never touches the user's real radio profile +// or the shared files. A copy/paste slip in the path table would silently overwrite them, which no +// amount of runtime testing would catch on a device that had never held a normal profile. +void test_event_paths_never_collide_with_standard_or_shared_files() +{ + const auto standard = radioProfileStoragePaths(false); + const auto event = radioProfileStoragePaths(true); + + TEST_ASSERT_NOT_EQUAL(0, strcmp(event.config, standard.config)); + TEST_ASSERT_NOT_EQUAL(0, strcmp(event.channels, standard.channels)); + TEST_ASSERT_NOT_EQUAL(0, strcmp(event.backup, standard.backup)); + + // The three event paths must also be distinct from each other. + TEST_ASSERT_NOT_EQUAL(0, strcmp(event.config, event.channels)); + TEST_ASSERT_NOT_EQUAL(0, strcmp(event.config, event.backup)); + TEST_ASSERT_NOT_EQUAL(0, strcmp(event.channels, event.backup)); + + // Files shared by both profiles must never be captured by either profile's paths. + const char *sharedFiles[] = {deviceStateFileName, nodeDatabaseFileName, moduleConfigFileName, uiconfigFileName, + legacyPrefFileName}; + for (const char *shared : sharedFiles) { + TEST_ASSERT_NOT_EQUAL(0, strcmp(event.config, shared)); + TEST_ASSERT_NOT_EQUAL(0, strcmp(event.channels, shared)); + TEST_ASSERT_NOT_EQUAL(0, strcmp(event.backup, shared)); + TEST_ASSERT_NOT_EQUAL(0, strcmp(standard.config, shared)); + TEST_ASSERT_NOT_EQUAL(0, strcmp(standard.channels, shared)); + TEST_ASSERT_NOT_EQUAL(0, strcmp(standard.backup, shared)); + } +} + +// Boot-write deferral must cover the radio profile and nothing else: widening it silently drops +// boot-time recovery writes (devicestate/nodedb) that are never retried. +void test_only_radio_profile_files_defer_boot_writes() +{ + TEST_ASSERT_TRUE(isRadioProfileFile(configFileName)); + TEST_ASSERT_TRUE(isRadioProfileFile(channelFileName)); + TEST_ASSERT_TRUE(isRadioProfileFile(backupFileName)); + + TEST_ASSERT_FALSE(isRadioProfileFile(deviceStateFileName)); + TEST_ASSERT_FALSE(isRadioProfileFile(nodeDatabaseFileName)); + TEST_ASSERT_FALSE(isRadioProfileFile(moduleConfigFileName)); + TEST_ASSERT_FALSE(isRadioProfileFile(uiconfigFileName)); +} + +// The reservation is compile-time, but the filesystems it has to fit in are tiny: nRF52840 +// InternalFS is 28 KiB and the STM32WL family is 14 KiB. If protobuf growth pushes the reservation +// past what those can spare, event builds silently stop persisting their profile. +void test_event_reservation_fits_smallest_supported_filesystem() +{ + constexpr size_t STM32WL_FILESYSTEM_BYTES = 14 * 1024; + constexpr size_t NRF52840_FILESYSTEM_BYTES = 28 * 1024; + + TEST_ASSERT_EQUAL_UINT32(2 * (meshtastic_LocalConfig_size + meshtastic_ChannelFile_size), + EVENT_PROFILE_STORAGE_RESERVATION_BYTES); + + // Leave at least half of the smallest filesystem for everything else under /prefs. + TEST_ASSERT_LESS_THAN_UINT32(STM32WL_FILESYSTEM_BYTES / 2, EVENT_PROFILE_STORAGE_RESERVATION_BYTES); + TEST_ASSERT_TRUE(hasEventProfileStorageSpace(NRF52840_FILESYSTEM_BYTES, 0)); + TEST_ASSERT_TRUE(hasEventProfileStorageSpace(STM32WL_FILESYSTEM_BYTES, 0)); } void setUp(void) {} @@ -73,9 +112,11 @@ void setup() UNITY_BEGIN(); RUN_TEST(test_standard_profile_uses_standard_files); RUN_TEST(test_event_profile_uses_isolated_files); - RUN_TEST(test_event_config_preserves_identity_but_replaces_lora); RUN_TEST(test_boot_defers_persistence_until_config_is_verified); RUN_TEST(test_event_profile_requires_room_for_atomic_profile_writes); + RUN_TEST(test_event_paths_never_collide_with_standard_or_shared_files); + RUN_TEST(test_only_radio_profile_files_defer_boot_writes); + RUN_TEST(test_event_reservation_fits_smallest_supported_filesystem); exit(UNITY_END()); } From 6c9c2db652dcf3e805223f67f642203c5e0f81cd Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein Date: Sun, 26 Jul 2026 13:35:18 -0700 Subject: [PATCH 11/11] fix: preserve event profile storage recovery --- src/mesh/NodeDB.cpp | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 68a2292fb8e..e02a8f52f16 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -2211,6 +2211,7 @@ void NodeDB::loadFromDisk() #if USERPREFS_EVENT_MODE // Seed only a missing event config; never overwrite normal files after a corrupt event config. bool eventConfigMissing = false; + eventProfileStorageUnavailable = false; #ifdef FSCom spiLock->lock(); eventConfigMissing = !FSCom.exists(configFileName); @@ -2654,7 +2655,10 @@ void NodeDB::loadFromDisk() spiLock->unlock(); if (activeBackupExists && !EncryptedStorage::isEncrypted(backupFileName)) { LOG_INFO("Migrating %s to encrypted storage", backupFileName); - EncryptedStorage::migrateFile(backupFileName); + if (!EncryptedStorage::migrateFile(backupFileName)) { + LOG_ERROR("Unable to migrate %s to encrypted storage", backupFileName); + storageCorruptThisLoad = true; + } } #endif #endif @@ -2670,7 +2674,10 @@ void NodeDB::loadFromDisk() spiLock->unlock(); if (exists && !EncryptedStorage::isEncrypted(fn)) { LOG_INFO("Migrating inactive radio profile %s to encrypted storage", fn); - EncryptedStorage::migrateFile(fn); + if (!EncryptedStorage::migrateFile(fn)) { + LOG_ERROR("Unable to migrate %s to encrypted storage", fn); + storageCorruptThisLoad = true; + } } } #endif