diff --git a/src/FSCommon.cpp b/src/FSCommon.cpp index b7dbd670c9a..38b704e7381 100644 --- a/src/FSCommon.cpp +++ b/src/FSCommon.cpp @@ -12,6 +12,85 @@ #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. 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() +{ + std::error_code ec; + const auto info = std::filesystem::space(".", ec); + return ec ? 0 : (size_t)info.capacity; +} + +size_t fsUsedBytes() +{ + 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. +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 a8de3cdd927..e02a8f52f16 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -638,6 +638,7 @@ NodeDB::NodeDB() #endif sortMeshDB(); saveToDisk(saveWhat); + bootInitializationInProgress = false; } /** @@ -2193,6 +2194,41 @@ void NodeDB::loadFromDisk() migrationSavePending = false; configDecodeFailed = false; + configLoadComplete = false; + +#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 +#endif + +#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); + if (eventConfigMissing) { + 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.", + static_cast(EVENT_PROFILE_STORAGE_RESERVATION_BYTES), + static_cast(totalBytes >= usedBytes ? totalBytes - usedBytes : 0)); + } + } + spiLock->unlock(); +#endif + bool initializedEventConfig = false; +#endif meshtastic_Config_SecurityConfig backupSecurity = meshtastic_Config_SecurityConfig_init_zero; @@ -2382,6 +2418,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(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 + // 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 = 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); + } else { + // 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; + } + } +#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 @@ -2403,6 +2464,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. @@ -2443,6 +2505,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; @@ -2557,7 +2628,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]; } @@ -2574,6 +2645,43 @@ void NodeDB::loadFromDisk() EncryptedStorage::migrateFile(fn); } } + + // 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); + spiLock->unlock(); + if (activeBackupExists && !EncryptedStorage::isEncrypted(backupFileName)) { + LOG_INFO("Migrating %s to encrypted storage", backupFileName); + if (!EncryptedStorage::migrateFile(backupFileName)) { + LOG_ERROR("Unable to migrate %s to encrypted storage", backupFileName); + storageCorruptThisLoad = true; + } + } +#endif +#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(); + if (exists && !EncryptedStorage::isEncrypted(fn)) { + LOG_INFO("Migrating inactive radio profile %s to encrypted storage", fn); + if (!EncryptedStorage::migrateFile(fn)) { + LOG_ERROR("Unable to migrate %s to encrypted storage", fn); + storageCorruptThisLoad = true; + } + } + } +#endif +#endif } #endif @@ -2708,8 +2816,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[] = {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); @@ -2729,6 +2838,15 @@ bool NodeDB::saveProto(const char *filename, size_t protoSize, const pb_msgdesc_ bool fullAtomic) { + // 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; + } + // 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()) { @@ -2981,6 +3099,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 00c0cc3b6f5..7c88023b43f 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -112,11 +112,57 @@ 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 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; + const char *channels; + const char *backup; +}; + +constexpr RadioProfileStoragePaths radioProfileStoragePaths(bool eventMode) +{ + 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}; +} + +// 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 >= EVENT_PROFILE_STORAGE_RESERVATION_BYTES; +} + +constexpr bool shouldDeferBootPersistence(bool bootInitializationInProgress, bool configLoadComplete, bool configDecodeFailed) +{ + return bootInitializationInProgress && (!configLoadComplete || configDecodeFailed); +} + +#if USERPREFS_EVENT_MODE +static constexpr auto RADIO_PROFILE_STORAGE = radioProfileStoragePaths(true); +#else +static constexpr auto RADIO_PROFILE_STORAGE = radioProfileStoragePaths(false); +#endif +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"; -static constexpr const char *channelFileName = "/prefs/channels.proto"; -static constexpr const char *backupFileName = "/backups/backup.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); @@ -560,6 +606,14 @@ 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; + // 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 + // 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 lastFullEvictionMs = 0; // when we last evicted to admit a new node, once the db is full uint32_t lastBackupAttempt = 0; // when we last tried a backup automatically or manually 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 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..f38a4196912 --- /dev/null +++ b/test/test_event_profile_storage/test_main.cpp @@ -0,0 +1,124 @@ +#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_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 test_event_profile_requires_room_for_atomic_profile_writes() +{ + 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)); + // 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) {} + +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_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()); +} + +void loop() {} +}