Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions src/FSCommon.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,85 @@
#include "SPILock.h"
#include "configuration.h"

#if defined(ARCH_PORTDUINO)
#include <filesystem>
#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<size_t *>(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 <SD.h>
Expand Down
6 changes: 6 additions & 0 deletions src/FSCommon.h
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
137 changes: 134 additions & 3 deletions src/mesh/NodeDB.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,7 @@ NodeDB::NodeDB()
#endif
sortMeshDB();
saveToDisk(saveWhat);
bootInitializationInProgress = false;
}

/**
Expand Down Expand Up @@ -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<unsigned>(EVENT_PROFILE_STORAGE_RESERVATION_BYTES),
static_cast<unsigned>(totalBytes >= usedBytes ? totalBytes - usedBytes : 0));
}
}
spiLock->unlock();
#endif
bool initializedEventConfig = false;
#endif

meshtastic_Config_SecurityConfig backupSecurity = meshtastic_Config_SecurityConfig_init_zero;

Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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];
}
Expand All @@ -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
Comment thread
RCGV1 marked this conversation as resolved.
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

Expand Down Expand Up @@ -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);
Expand All @@ -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()) {
Expand Down Expand Up @@ -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;
Expand Down
60 changes: 57 additions & 3 deletions src/mesh/NodeDB.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion test/native-suite-count
Original file line number Diff line number Diff line change
@@ -1 +1 @@
40
41
Loading
Loading