Fix storage recovery and deferred runtime state
Recover usable ESP32 preferences and channels before permitting replacement; keep identity and ACL persistence transactional and complete. Preserve MQTT commit boundaries, restore OTA identity and policy after deferred allocation, retain radio gain retries, and reject invalid flood limits. Add production-path fault regressions and CI coverage.
This commit is contained in:
1 parent
86defc840c
commit
e2a1828987
32 files changed
+1546
-189
No files matched your search
+63
-20
@@ -4,6 +4,8 @@
|
||||
#include "ClientLoginPersistence.h"
|
||||
#include "ClientPathPersistence.h"
|
||||
#include "FileRead.h"
|
||||
#include "FilePresence.h"
|
||||
#include <initializer_list>
|
||||
#if defined(NRF52_PLATFORM)
|
||||
#include "AtomicFileWriter.h"
|
||||
#endif
|
||||
@@ -570,7 +572,14 @@ void ClientACL::load(FILESYSTEM* fs, const mesh::LocalIdentity& self_id) {
|
||||
_fs = fs;
|
||||
num_clients = 0;
|
||||
login_replay_store_available = false;
|
||||
acl_load_complete = false;
|
||||
if (_fs == NULL) return;
|
||||
bool primary_exists = false, backup_exists = false;
|
||||
if (!mesh::filePresence(_fs, mesh::CLIENT_ACL_PRIMARY_PATH, primary_exists)
|
||||
|| !mesh::filePresence(_fs, mesh::CLIENT_ACL_BACKUP_PATH, backup_exists)) {
|
||||
return;
|
||||
}
|
||||
const bool durable_acl_expected = primary_exists || backup_exists;
|
||||
#if defined(NRF52_PLATFORM)
|
||||
// AtomicFileWriter may leave only a harmless temp image when reset before
|
||||
// rename. The live image remains authoritative.
|
||||
@@ -595,11 +604,22 @@ void ClientACL::load(FILESYSTEM* fs, const mesh::LocalIdentity& self_id) {
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
if (_fs->exists("/s_contacts")) {
|
||||
if (!mesh::filePresence(_fs, mesh::CLIENT_ACL_PRIMARY_PATH, primary_exists)
|
||||
|| (durable_acl_expected && !primary_exists)) return;
|
||||
if (primary_exists) {
|
||||
File file = openRead(_fs, "/s_contacts");
|
||||
if (file) {
|
||||
bool full = false;
|
||||
while (!full) {
|
||||
size_t remaining = file.size();
|
||||
#if !defined(NRF52_PLATFORM)
|
||||
// Versioned SPIFFS images finish with a verified CRC trailer, not a
|
||||
// partially readable extra contact. The data pass must consume every
|
||||
// byte before it, even if the earlier integrity pass succeeded.
|
||||
if (remaining >= 8 && (remaining - 8) % CONTACT_RECORD_SIZE == 0) {
|
||||
remaining -= 8;
|
||||
}
|
||||
#endif
|
||||
bool complete = true;
|
||||
while (remaining != 0) {
|
||||
ClientInfo c;
|
||||
uint8_t pub_key[32];
|
||||
uint8_t unused[2];
|
||||
@@ -608,6 +628,11 @@ void ClientACL::load(FILESYSTEM* fs, const mesh::LocalIdentity& self_id) {
|
||||
c.alt_path_len = OUT_PATH_UNKNOWN;
|
||||
c.observed_path_len = OUT_PATH_UNKNOWN;
|
||||
|
||||
static const size_t base_size = 32 + 1 + 4 + 2 + 1 + 64 + PUB_KEY_SIZE;
|
||||
if (remaining < base_size || num_clients >= capacity) {
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
bool success = (file.read(pub_key, 32) == 32);
|
||||
success = success && (file.read((uint8_t *) &c.permissions, 1) == 1);
|
||||
success = success && (file.read((uint8_t *) &c.extra.room.sync_since, 4) == 4);
|
||||
@@ -615,12 +640,20 @@ void ClientACL::load(FILESYSTEM* fs, const mesh::LocalIdentity& self_id) {
|
||||
success = success && (file.read((uint8_t *)&c.out_path_len, 1) == 1);
|
||||
success = success && (file.read(c.out_path, 64) == 64);
|
||||
success = success && (file.read(c.shared_secret, PUB_KEY_SIZE) == PUB_KEY_SIZE); // will be recalculated below
|
||||
if (success && unused[0] >= CONTACT_RECORD_VERSION_ALT_PATH) {
|
||||
size_t record_size = base_size;
|
||||
if (success && unused[0] > CONTACT_RECORD_VERSION_ALT_PATH) success = false;
|
||||
if (success && unused[0] == CONTACT_RECORD_VERSION_ALT_PATH) {
|
||||
record_size += 1 + 64;
|
||||
success = remaining >= record_size;
|
||||
success = success && (file.read((uint8_t *)&c.alt_path_len, 1) == 1);
|
||||
success = success && (file.read(c.alt_path, 64) == 64);
|
||||
}
|
||||
|
||||
if (!success) break; // EOF
|
||||
if (!success) {
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
remaining -= record_size;
|
||||
|
||||
c.id = mesh::Identity(pub_key);
|
||||
c.out_path_is_persistable = true;
|
||||
@@ -640,15 +673,21 @@ void ClientACL::load(FILESYSTEM* fs, const mesh::LocalIdentity& self_id) {
|
||||
c.last_timestamp = UINT32_MAX;
|
||||
}
|
||||
self_id.calcSharedSecret(c.shared_secret, pub_key); // recalculate shared secrets in case our private key changed
|
||||
if (num_clients < capacity) {
|
||||
clients[num_clients++] = c;
|
||||
} else {
|
||||
full = true;
|
||||
}
|
||||
clients[num_clients++] = c;
|
||||
}
|
||||
file.close();
|
||||
if (!complete) {
|
||||
// A prefix is not an authoritative ACL. Keep flash untouched and
|
||||
// refuse admission/mutations until an explicit reload or reboot can
|
||||
// read the complete image; never save the prefix over its source.
|
||||
num_clients = 0;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
acl_load_complete = true;
|
||||
}
|
||||
|
||||
bool ClientACL::authorizeLoginTimestamp(
|
||||
@@ -656,7 +695,8 @@ bool ClientACL::authorizeLoginTimestamp(
|
||||
uint32_t sender_timestamp,
|
||||
uint32_t runtime_last_timestamp,
|
||||
uint8_t login_permissions) {
|
||||
if (_fs == NULL || pubkey == NULL || !login_replay_store_available) {
|
||||
if (_fs == NULL || pubkey == NULL || !login_replay_store_available
|
||||
|| !acl_load_complete) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -692,7 +732,7 @@ bool ClientACL::authorizeLoginTimestamp(
|
||||
|
||||
bool ClientACL::save(FILESYSTEM* fs, bool (*filter)(ClientInfo*)) {
|
||||
// A failed allocation is not an empty ACL. Preserve the stored managers.
|
||||
if (capacity == 0 || fs == NULL) return false;
|
||||
if (capacity == 0 || fs == NULL || !acl_load_complete) return false;
|
||||
_fs = fs;
|
||||
#if defined(NRF52_PLATFORM)
|
||||
mesh::AtomicFileWriter file(_fs, "/s_contacts");
|
||||
@@ -797,17 +837,18 @@ bool ClientACL::save(FILESYSTEM* fs, bool (*filter)(ClientInfo*)) {
|
||||
|
||||
bool ClientACL::clear() {
|
||||
if (!_fs) return false; // no filesystem, nothing to clear
|
||||
if (_fs->exists("/s_contacts")) {
|
||||
_fs->remove("/s_contacts");
|
||||
for (const char* path : {mesh::CLIENT_ACL_PRIMARY_PATH,
|
||||
mesh::CLIENT_ACL_TEMP_PATH,
|
||||
mesh::CLIENT_ACL_BACKUP_PATH}) {
|
||||
bool present = false;
|
||||
if (!mesh::filePresence(_fs, path, present)) return false;
|
||||
if (present && !_fs->remove(path)) return false;
|
||||
if (!mesh::filePresence(_fs, path, present) || present) return false;
|
||||
}
|
||||
if (_fs->exists("/s_contacts.tmp")) _fs->remove("/s_contacts.tmp");
|
||||
if (_fs->exists("/s_contacts.bak")) _fs->remove("/s_contacts.bak");
|
||||
const bool files_cleared = !_fs->exists("/s_contacts")
|
||||
&& !_fs->exists("/s_contacts.tmp")
|
||||
&& !_fs->exists("/s_contacts.bak");
|
||||
if (clients) memset(clients, 0, sizeof(ClientInfo) * (size_t)capacity);
|
||||
num_clients = 0;
|
||||
return files_cleared;
|
||||
acl_load_complete = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
ClientInfo* ClientACL::getClient(const uint8_t* pubkey, int key_len) {
|
||||
@@ -819,6 +860,7 @@ ClientInfo* ClientACL::getClient(const uint8_t* pubkey, int key_len) {
|
||||
}
|
||||
|
||||
ClientInfo* ClientACL::putClient(const mesh::Identity& id, uint8_t init_perms) {
|
||||
if (!acl_load_complete) return NULL;
|
||||
uint32_t min_time = 0xFFFFFFFF;
|
||||
ClientInfo* oldest = NULL;
|
||||
for (int i = 0; i < num_clients; i++) {
|
||||
@@ -847,6 +889,7 @@ ClientInfo* ClientACL::putClient(const mesh::Identity& id, uint8_t init_perms) {
|
||||
}
|
||||
|
||||
bool ClientACL::applyPermissions(const mesh::LocalIdentity& self_id, const uint8_t* pubkey, int key_len, uint8_t perms) {
|
||||
if (!acl_load_complete) return false;
|
||||
if (pubkey == NULL || key_len <= 0 || key_len > PUB_KEY_SIZE) return false;
|
||||
ClientInfo* c;
|
||||
if ((perms & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST) { // guest role is not persisted in contacts
|
||||
|
||||
@@ -68,6 +68,7 @@ class ClientACL {
|
||||
int capacity; // 0 when the table could not be allocated
|
||||
int num_clients;
|
||||
bool login_replay_store_available;
|
||||
bool acl_load_complete;
|
||||
|
||||
public:
|
||||
// MAX_CLIENTS entries run to several kilobytes. Classic ESP32's link-time
|
||||
@@ -83,6 +84,7 @@ public:
|
||||
if (clients) memset(clients, 0, sizeof(ClientInfo) * (size_t)capacity);
|
||||
num_clients = 0;
|
||||
login_replay_store_available = false;
|
||||
acl_load_complete = false;
|
||||
}
|
||||
~ClientACL() { delete[] clients; }
|
||||
ClientACL(const ClientACL&) = delete;
|
||||
|
||||
+47
-45
@@ -48,6 +48,7 @@
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
#include "bridges/MQTTBridge.h"
|
||||
#include "CommonPrefsRecovery.h"
|
||||
#include "FilePresence.h"
|
||||
#include "MQTTDefaults.h"
|
||||
#include "MQTTPrefsAtomicStore.h"
|
||||
#include "MQTTPrefsCodec.h"
|
||||
@@ -875,18 +876,24 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) {
|
||||
}
|
||||
|
||||
#if defined(ENABLE_OTA)
|
||||
// Push the persisted OTA policy + signer allowlist into the running OtaContext (called after load).
|
||||
// Register the existing preferences as the source of OTA policy. Registration
|
||||
// does not need a workspace; a later allocation retry must still restore keys.
|
||||
void CommonCLI::syncOtaConfigFromPrefs() {
|
||||
if (!mesh::ota::ota_acquire_context(nullptr, 0)) return;
|
||||
mesh::ota::OtaContext& c = mesh::ota::ota_ctx();
|
||||
c.manager.set_autofetch(_prefs->ota_autofetch);
|
||||
c.manager.set_checkpoint_blocks(_prefs->ota_checkpoint_blocks);
|
||||
c.manager.set_advert_mins(_prefs->ota_advert_interval);
|
||||
c.manager.set_max_hops(_prefs->ota_max_hops);
|
||||
c.autoinstall = _prefs->ota_autoinstall;
|
||||
c.allow.clear();
|
||||
for (uint8_t i = 0; i < _prefs->ota_signer_count && i < MAX_OTA_SIGNERS; i++)
|
||||
c.allow.add(_prefs->ota_signers[i]);
|
||||
// Mesh owns one process-wide OTA context. Its role's NodePrefs outlives that
|
||||
// context and already holds the durable policy, so retain only its pointer.
|
||||
static const NodePrefs* ota_prefs = nullptr;
|
||||
ota_prefs = _prefs;
|
||||
mesh::ota::ota_set_context_config_loader([](mesh::ota::OtaConfigState& state) {
|
||||
state.autofetch = ota_prefs->ota_autofetch;
|
||||
state.checkpoint = ota_prefs->ota_checkpoint_blocks;
|
||||
state.advert = ota_prefs->ota_advert_interval;
|
||||
state.hops = ota_prefs->ota_max_hops;
|
||||
state.autoinstall = ota_prefs->ota_autoinstall;
|
||||
state.allow.clear();
|
||||
for (uint8_t i = 0; i < ota_prefs->ota_signer_count && i < MAX_OTA_SIGNERS; i++)
|
||||
state.allow.add(ota_prefs->ota_signers[i]);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1810,7 +1817,9 @@ static const char* mqttJsonImportResultName(MQTTPrefsJsonImport::Result result)
|
||||
}
|
||||
|
||||
static MQTTPrefsRecovery::FileState mqttPrefsFileState(FILESYSTEM* fs, const char* path) {
|
||||
if (!fs->exists(path)) return MQTTPrefsRecovery::FileState::Missing;
|
||||
bool present = false;
|
||||
if (!mesh::filePresence(fs, path, present)) return MQTTPrefsRecovery::FileState::Preserve;
|
||||
if (!present) return MQTTPrefsRecovery::FileState::Missing;
|
||||
File file = openMqttPrefsRead(fs, path);
|
||||
if (!file) return MQTTPrefsRecovery::FileState::Preserve;
|
||||
const size_t file_size = file.size();
|
||||
@@ -1843,21 +1852,7 @@ static bool recoverMqttPrefsFiles(FILESYSTEM* fs) {
|
||||
if (temp != MQTTPrefsRecovery::FileState::Missing) fs->remove("/mqtt_prefs.tmp");
|
||||
if (backup != MQTTPrefsRecovery::FileState::Missing) fs->remove("/mqtt_prefs.bak");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (action == MQTTPrefsRecovery::Action::PromoteTemp) {
|
||||
if (fs->rename("/mqtt_prefs.tmp", "/mqtt_prefs")) {
|
||||
// A usable temp is now the committed primary. Its backup is necessarily
|
||||
// a stale transaction artifact, even if this firmware cannot decode it.
|
||||
if (temp == MQTTPrefsRecovery::FileState::Usable &&
|
||||
backup != MQTTPrefsRecovery::FileState::Missing) {
|
||||
fs->remove("/mqtt_prefs.bak");
|
||||
}
|
||||
MESH_DEBUG_PRINTLN("MQTT: recovered /mqtt_prefs from transaction temp");
|
||||
return false;
|
||||
}
|
||||
MESH_DEBUG_PRINTLN("MQTT: could not recover /mqtt_prefs temp; files preserved");
|
||||
return true;
|
||||
return primary == MQTTPrefsRecovery::FileState::Preserve;
|
||||
}
|
||||
if (action == MQTTPrefsRecovery::Action::PromoteBackup) {
|
||||
if (fs->rename("/mqtt_prefs.bak", "/mqtt_prefs")) {
|
||||
@@ -1868,11 +1863,16 @@ static bool recoverMqttPrefsFiles(FILESYSTEM* fs) {
|
||||
fs->remove("/mqtt_prefs.tmp");
|
||||
}
|
||||
MESH_DEBUG_PRINTLN("MQTT: recovered /mqtt_prefs from transaction backup");
|
||||
return false;
|
||||
return backup == MQTTPrefsRecovery::FileState::Preserve;
|
||||
}
|
||||
MESH_DEBUG_PRINTLN("MQTT: could not recover /mqtt_prefs backup; files preserved");
|
||||
return true;
|
||||
}
|
||||
if (action == MQTTPrefsRecovery::Action::DiscardTemp) {
|
||||
// No published image exists. A first-save candidate is still uncommitted,
|
||||
// even when fully written, and must not become active after a rejected save.
|
||||
return !fs->remove("/mqtt_prefs.tmp");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1894,10 +1894,9 @@ public:
|
||||
_open = false;
|
||||
_owns_temp = false;
|
||||
_bytes_written = 0;
|
||||
// Recovery owns stale artifacts. Do not delete them here: a failed commit
|
||||
// may have moved the old primary to .bak and left a verified temp that the
|
||||
// next boot must choose between. Refusing the save is safer than erasing an
|
||||
// image this firmware cannot decode.
|
||||
// Retry interrupted cleanup/rollback without requiring a reboot. Recovery
|
||||
// never publishes a rejected candidate or replaces an opaque primary.
|
||||
if (recoverMqttPrefsFiles(_fs)) return false;
|
||||
if (_fs->exists("/mqtt_prefs.tmp") || _fs->exists("/mqtt_prefs.bak")) return false;
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
_file = _fs->open("/mqtt_prefs.tmp", FILE_O_WRITE);
|
||||
@@ -1939,13 +1938,17 @@ public:
|
||||
if (!_finished) return false;
|
||||
// SPIFFS refuses rename(tmp, existing_dest). Move the existing image to a
|
||||
// recoverable backup first, then publish temp into the now-empty primary.
|
||||
// Never remove either image after a failed boundary; boot recovery selects
|
||||
// the completed temp or restores the backup.
|
||||
// A rejected publication must keep the previous image authoritative.
|
||||
if (_fs->exists("/mqtt_prefs.bak")) return false;
|
||||
if (_fs->exists("/mqtt_prefs") && !_fs->rename("/mqtt_prefs", "/mqtt_prefs.bak")) {
|
||||
return false;
|
||||
}
|
||||
if (!_fs->rename("/mqtt_prefs.tmp", "/mqtt_prefs")) return false;
|
||||
if (!_fs->rename("/mqtt_prefs.tmp", "/mqtt_prefs")) {
|
||||
if (_fs->exists("/mqtt_prefs.bak")) {
|
||||
_fs->rename("/mqtt_prefs.bak", "/mqtt_prefs");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Cleanup failure is non-fatal: the new primary is published and recovery
|
||||
// will remove a known-good stale backup on a later boot.
|
||||
if (_fs->exists("/mqtt_prefs.bak")) _fs->remove("/mqtt_prefs.bak");
|
||||
@@ -1955,10 +1958,9 @@ public:
|
||||
void abort() {
|
||||
if (_open) _file.close();
|
||||
_open = false;
|
||||
// Once finish() has verified the temp, commit may already have moved the
|
||||
// primary to .bak. Keep the temp on a commit failure so recovery can
|
||||
// publish it (or fall back to .bak) after reset.
|
||||
if (_owns_temp && !_finished && _fs->exists("/mqtt_prefs.tmp")) {
|
||||
// Explicit rejection is not a successful commit. If cleanup or rollback
|
||||
// also fails, boot recovery still chooses the old backup, never this temp.
|
||||
if (_owns_temp && _fs->exists("/mqtt_prefs.tmp")) {
|
||||
_fs->remove("/mqtt_prefs.tmp");
|
||||
}
|
||||
_finished = false;
|
||||
@@ -3807,8 +3809,8 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
|
||||
strcpy(reply, "Error, must be 0-2");
|
||||
}
|
||||
} else if (memcmp(config, "flood.max.unscoped ", 19) == 0) {
|
||||
uint8_t m = atoi(&config[19]);
|
||||
if (m <= 64) {
|
||||
uint32_t m;
|
||||
if (mesh::cli::parseUnsignedIntegerStrict(&config[19], m) && m <= 64) {
|
||||
_prefs->flood_max_unscoped = m;
|
||||
savePrefs();
|
||||
strcpy(reply, "OK");
|
||||
@@ -3816,8 +3818,8 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
|
||||
strcpy(reply, "Error, max 64");
|
||||
}
|
||||
} else if (memcmp(config, "flood.max.advert ", 17) == 0) {
|
||||
uint8_t m = atoi(&config[17]);
|
||||
if (m <= 64) {
|
||||
uint32_t m;
|
||||
if (mesh::cli::parseUnsignedIntegerStrict(&config[17], m) && m <= 64) {
|
||||
_prefs->flood_max_advert = m;
|
||||
savePrefs();
|
||||
strcpy(reply, "OK");
|
||||
@@ -3825,8 +3827,8 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
|
||||
strcpy(reply, "Error, max 64");
|
||||
}
|
||||
} else if (memcmp(config, "flood.max ", 10) == 0) {
|
||||
uint8_t m = atoi(&config[10]);
|
||||
if (m <= 64) {
|
||||
uint32_t m;
|
||||
if (mesh::cli::parseUnsignedIntegerStrict(&config[10], m) && m <= 64) {
|
||||
_prefs->flood_max = m;
|
||||
savePrefs();
|
||||
strcpy(reply, "OK");
|
||||
|
||||
@@ -775,7 +775,7 @@ class CommonCLI {
|
||||
bool saveMQTTPrefs(FILESYSTEM* fs);
|
||||
#endif
|
||||
#if defined(ENABLE_OTA)
|
||||
void syncOtaConfigFromPrefs(); // persisted OTA policy + signer allowlist -> running OtaContext
|
||||
void syncOtaConfigFromPrefs(); // persisted OTA policy + signer allowlist -> idle/active OtaContext
|
||||
#endif
|
||||
|
||||
void handleRegionCmd(char* command, char* reply);
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
|
||||
namespace mesh {
|
||||
class ContactFileTransaction {
|
||||
public:
|
||||
using PresenceProbe = bool (*)(FILESYSTEM*, const char*, bool&);
|
||||
private:
|
||||
FILESYSTEM* _fs;
|
||||
const char* _target;
|
||||
char _temp[48];
|
||||
@@ -19,17 +22,30 @@ class ContactFileTransaction {
|
||||
uint32_t _crc = 0xffffffff;
|
||||
bool _ok = false;
|
||||
bool _finished = false;
|
||||
PresenceProbe _presence;
|
||||
static bool probe(FILESYSTEM* fs, const char* path, bool& present,
|
||||
PresenceProbe presence) {
|
||||
if (presence) return presence(fs, path, present);
|
||||
present = fs->exists(path);
|
||||
return true;
|
||||
}
|
||||
public:
|
||||
static bool recover(FILESYSTEM* fs, const char* target) {
|
||||
static bool recover(FILESYSTEM* fs, const char* target,
|
||||
PresenceProbe presence = nullptr) {
|
||||
char backup[48];
|
||||
snprintf(backup, sizeof(backup), "%s.bak", target);
|
||||
if (fs->exists(target)) return true;
|
||||
return !fs->exists(backup) || fs->rename(backup, target);
|
||||
bool target_exists = false, backup_exists = false;
|
||||
if (!probe(fs, target, target_exists, presence)
|
||||
|| !probe(fs, backup, backup_exists, presence)) return false;
|
||||
if (target_exists) return true;
|
||||
return !backup_exists || fs->rename(backup, target);
|
||||
}
|
||||
ContactFileTransaction(FILESYSTEM* fs, const char* target) : _fs(fs), _target(target) {
|
||||
ContactFileTransaction(FILESYSTEM* fs, const char* target,
|
||||
PresenceProbe presence = nullptr)
|
||||
: _fs(fs), _target(target), _presence(presence) {
|
||||
snprintf(_temp, sizeof(_temp), "%s.tmp", target);
|
||||
snprintf(_backup, sizeof(_backup), "%s.bak", target);
|
||||
if (!recover(fs, target)) return;
|
||||
if (!recover(fs, target, presence)) return;
|
||||
if (fs->exists(_temp) && !fs->remove(_temp)) return;
|
||||
#if defined(RP2040_PLATFORM)
|
||||
_file = fs->open(_temp, "w");
|
||||
@@ -67,9 +83,12 @@ public:
|
||||
}
|
||||
if (verify) verify.close();
|
||||
ok = ok && crc == _crc;
|
||||
if (ok && _fs->exists(_backup)) ok = _fs->remove(_backup);
|
||||
bool backup_exists = false, target_exists = false;
|
||||
if (ok) ok = probe(_fs, _backup, backup_exists, _presence)
|
||||
&& probe(_fs, _target, target_exists, _presence);
|
||||
if (ok && backup_exists) ok = _fs->remove(_backup);
|
||||
bool backed_up = false;
|
||||
if (ok && _fs->exists(_target)) {
|
||||
if (ok && target_exists) {
|
||||
ok = _fs->rename(_target, _backup);
|
||||
backed_up = ok;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdio.h>
|
||||
#if defined(ESP32_PLATFORM)
|
||||
#include <errno.h>
|
||||
#include <sys/stat.h>
|
||||
#endif
|
||||
|
||||
namespace mesh {
|
||||
// Unlike ESP32 FS::exists(), this probe does not open the file. A failed read
|
||||
// must not turn a previously committed image into an apparently fresh store.
|
||||
template <typename Filesystem>
|
||||
bool filePresence(Filesystem* fs, const char* path, bool& present) {
|
||||
if (fs == nullptr || path == nullptr) return false;
|
||||
#if defined(ESP32_PLATFORM)
|
||||
char absolute[128];
|
||||
const int length = snprintf(absolute, sizeof(absolute), "/spiffs%s", path);
|
||||
if (length < 0 || static_cast<size_t>(length) >= sizeof(absolute)) return false;
|
||||
struct stat info;
|
||||
const int result = ::stat(absolute, &info);
|
||||
if (result != 0 && errno != ENOENT) return false;
|
||||
present = result == 0;
|
||||
#elif defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
struct lfs_info info;
|
||||
fs->_lockFS();
|
||||
const int result = lfs_stat(fs->_getFS(), path, &info);
|
||||
fs->_unlockFS();
|
||||
if (result != 0 && result != LFS_ERR_NOENT) return false;
|
||||
present = result == 0;
|
||||
#else
|
||||
// Arduino-Pico exposes only boolean metadata status. Its exists() does not
|
||||
// open the file, but cannot distinguish absent files from metadata errors.
|
||||
present = fs->exists(path);
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
} // namespace mesh
|
||||
@@ -1,10 +1,30 @@
|
||||
#include "IdentityStore.h"
|
||||
|
||||
#if defined(NRF52_PLATFORM)
|
||||
#include "FilePresence.h"
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
#include "AtomicFileWriter.h"
|
||||
#else
|
||||
#include "ContactFileTransaction.h"
|
||||
#endif
|
||||
|
||||
bool IdentityStore::recover(const char* name) {
|
||||
#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)
|
||||
char filename[40], backup[48];
|
||||
if (snprintf(filename, sizeof(filename), "%s/%s.id", _dir, name)
|
||||
>= (int)sizeof(filename)) return false;
|
||||
snprintf(backup, sizeof(backup), "%s.bak", filename);
|
||||
bool primary_exists = false, backup_exists = false;
|
||||
if (!mesh::filePresence(_fs, filename, primary_exists)
|
||||
|| !mesh::filePresence(_fs, backup, backup_exists)) return false;
|
||||
if (!primary_exists && backup_exists) return _fs->rename(backup, filename);
|
||||
#else
|
||||
(void)name;
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
|
||||
bool IdentityStore::load(const char *name, mesh::LocalIdentity& id) {
|
||||
if (!recover(name)) return false;
|
||||
bool loaded = false;
|
||||
char filename[40];
|
||||
sprintf(filename, "%s/%s.id", _dir, name);
|
||||
@@ -23,6 +43,7 @@ bool IdentityStore::load(const char *name, mesh::LocalIdentity& id) {
|
||||
}
|
||||
|
||||
bool IdentityStore::load(const char *name, mesh::LocalIdentity& id, char display_name[], int max_name_sz) {
|
||||
if (!recover(name)) return false;
|
||||
bool loaded = false;
|
||||
char filename[40];
|
||||
sprintf(filename, "%s/%s.id", _dir, name);
|
||||
@@ -50,44 +71,30 @@ bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id) {
|
||||
char filename[40];
|
||||
sprintf(filename, "%s/%s.id", _dir, name);
|
||||
|
||||
#if defined(NRF52_PLATFORM)
|
||||
if (!recover(name)) return false;
|
||||
uint8_t key_data[PRV_KEY_SIZE + PUB_KEY_SIZE];
|
||||
if (id.writeTo(key_data, sizeof(key_data)) != sizeof(key_data)) return false;
|
||||
|
||||
// LocalIdentity's byte-buffer export is private-key then public-key, while
|
||||
// the historical file format is public-key then private-key.
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
mesh::AtomicFileWriter writer(_fs, filename);
|
||||
#else
|
||||
mesh::ContactFileTransaction writer(_fs, filename, mesh::filePresence<FILESYSTEM>);
|
||||
#endif
|
||||
const bool wrote = writer
|
||||
&& writer.write(&key_data[PRV_KEY_SIZE], PUB_KEY_SIZE) == PUB_KEY_SIZE
|
||||
&& writer.write(key_data, PRV_KEY_SIZE) == PRV_KEY_SIZE;
|
||||
const bool success = writer.commit(wrote);
|
||||
MESH_DEBUG_PRINTLN("IdentityStore::save() atomic write - %s", success ? "OK" : "Err");
|
||||
return success;
|
||||
#elif defined(STM32_PLATFORM)
|
||||
_fs->remove(filename);
|
||||
File file = _fs->open(filename, FILE_O_WRITE);
|
||||
#elif defined(RP2040_PLATFORM)
|
||||
File file = _fs->open(filename, "w");
|
||||
#else
|
||||
File file = _fs->open(filename, "w", true);
|
||||
#endif
|
||||
#if !defined(NRF52_PLATFORM)
|
||||
if (file) {
|
||||
bool success = id.writeTo(file);
|
||||
file.close();
|
||||
MESH_DEBUG_PRINTLN("IdentityStore::save() write - %s", success ? "OK" : "Err");
|
||||
return success;
|
||||
}
|
||||
#endif
|
||||
MESH_DEBUG_PRINTLN("IdentityStore::save() failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id, const char display_name[]) {
|
||||
char filename[40];
|
||||
sprintf(filename, "%s/%s.id", _dir, name);
|
||||
|
||||
#if defined(NRF52_PLATFORM)
|
||||
if (!recover(name)) return false;
|
||||
uint8_t key_data[PRV_KEY_SIZE + PUB_KEY_SIZE];
|
||||
if (id.writeTo(key_data, sizeof(key_data)) != sizeof(key_data)) return false;
|
||||
uint8_t display_data[32];
|
||||
@@ -96,34 +103,14 @@ bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id, const
|
||||
if (display_len > sizeof(display_data) - 1) display_len = sizeof(display_data) - 1;
|
||||
memcpy(display_data, display_name, display_len);
|
||||
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
mesh::AtomicFileWriter writer(_fs, filename);
|
||||
#else
|
||||
mesh::ContactFileTransaction writer(_fs, filename, mesh::filePresence<FILESYSTEM>);
|
||||
#endif
|
||||
const bool wrote = writer
|
||||
&& writer.write(&key_data[PRV_KEY_SIZE], PUB_KEY_SIZE) == PUB_KEY_SIZE
|
||||
&& writer.write(key_data, PRV_KEY_SIZE) == PRV_KEY_SIZE
|
||||
&& writer.write(display_data, sizeof(display_data)) == sizeof(display_data);
|
||||
return writer.commit(wrote);
|
||||
#elif defined(STM32_PLATFORM)
|
||||
_fs->remove(filename);
|
||||
File file = _fs->open(filename, FILE_O_WRITE);
|
||||
#elif defined(RP2040_PLATFORM)
|
||||
File file = _fs->open(filename, "w");
|
||||
#else
|
||||
File file = _fs->open(filename, "w", true);
|
||||
#endif
|
||||
#if !defined(NRF52_PLATFORM)
|
||||
if (file) {
|
||||
bool success = id.writeTo(file);
|
||||
|
||||
uint8_t tmp[32];
|
||||
memset(tmp, 0, sizeof(tmp));
|
||||
int n = strlen(display_name);
|
||||
if (n > sizeof(tmp)-1) n = sizeof(tmp)-1;
|
||||
memcpy(tmp, display_name, n);
|
||||
success = success && file.write(tmp, sizeof(tmp)) == sizeof(tmp);
|
||||
|
||||
file.close();
|
||||
return success;
|
||||
}
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
@@ -19,6 +19,9 @@ public:
|
||||
|
||||
void begin() {
|
||||
if (_dir && _dir[0] == '/') { _fs->mkdir(_dir); } }
|
||||
// Recover an interrupted non-replacing rename before deciding this is a
|
||||
// fresh identity. False means the previous image must remain protected.
|
||||
bool recover(const char* name);
|
||||
bool load(const char *name, mesh::LocalIdentity& id);
|
||||
bool load(const char *name, mesh::LocalIdentity& id, char display_name[], int max_name_sz);
|
||||
bool save(const char *name, const mesh::LocalIdentity& id);
|
||||
|
||||
@@ -19,8 +19,8 @@ enum class FileState : uint8_t {
|
||||
enum class Action : uint8_t {
|
||||
None,
|
||||
KeepPrimary,
|
||||
PromoteTemp,
|
||||
PromoteBackup,
|
||||
DiscardTemp,
|
||||
};
|
||||
|
||||
inline Action select(FileState primary, FileState temp, FileState backup) {
|
||||
@@ -29,20 +29,11 @@ inline Action select(FileState primary, FileState temp, FileState backup) {
|
||||
// by this firmware.
|
||||
if (primary != FileState::Missing) return Action::KeepPrimary;
|
||||
|
||||
// A completed temp is the new image and wins over the old backup.
|
||||
if (temp == FileState::Usable) return Action::PromoteTemp;
|
||||
|
||||
// If temp is opaque but a known-good backup exists, boot from the backup.
|
||||
// The caller may discard the opaque temp once that usable backup has become
|
||||
// primary. Otherwise, rename the opaque temp into the empty primary name so
|
||||
// the normal loader can hold it.
|
||||
if (temp == FileState::Preserve) {
|
||||
return backup == FileState::Usable ? Action::PromoteBackup : Action::PromoteTemp;
|
||||
}
|
||||
|
||||
// No temp survived. The backup is the only recoverable image, even when it
|
||||
// is a newer layout that this firmware must preserve rather than decode.
|
||||
// Until temp has its final name, it is not committed. A failed publication
|
||||
// may have been reported to the caller, so never activate it during recovery.
|
||||
// The backup remains authoritative, including an opaque newer layout.
|
||||
if (backup != FileState::Missing) return Action::PromoteBackup;
|
||||
if (temp != FileState::Missing) return Action::DiscardTemp;
|
||||
return Action::None;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,14 +11,6 @@ namespace {
|
||||
bool (*context_config_loader)(OtaConfigState&) = nullptr;
|
||||
}
|
||||
|
||||
void ota_set_context_config_loader(bool (*load)(OtaConfigState&)) {
|
||||
context_config_loader = load;
|
||||
if (auto* context = ota_context_if_active()) {
|
||||
OtaConfigState restored;
|
||||
if (load && load(restored)) restored.apply(*context);
|
||||
}
|
||||
}
|
||||
|
||||
#if OTA_DYNAMIC_CONTEXT
|
||||
namespace {
|
||||
OtaContext* active_context = nullptr;
|
||||
@@ -79,7 +71,13 @@ void ota_begin_context(uint32_t target, OtaSend send, void* ctx,
|
||||
saved_send_ctx = ctx;
|
||||
strncpy(saved_hw, hw ? hw : "", sizeof(saved_hw) - 1);
|
||||
saved_hw[sizeof(saved_hw) - 1] = 0;
|
||||
if (seeder_id) memcpy(saved_seeder_id, seeder_id, sizeof(saved_seeder_id));
|
||||
ota_refresh_seeder_identity(seeder_id);
|
||||
}
|
||||
|
||||
void ota_refresh_seeder_identity(const uint8_t* seeder_id) {
|
||||
if (!seeder_id) return;
|
||||
memcpy(saved_seeder_id, seeder_id, sizeof(saved_seeder_id));
|
||||
if (active_context) active_context->manager.set_seeder_id(seeder_id);
|
||||
}
|
||||
|
||||
bool ota_acquire_context(char* reply, size_t cap) {
|
||||
@@ -159,10 +157,35 @@ bool ota_acquire_context(char*, size_t) { return true; }
|
||||
void ota_begin_context(uint32_t target, OtaSend send, void* ctx,
|
||||
const char* hw, const uint8_t* seeder_id) {
|
||||
ota_ctx().begin(target, send, ctx, hw);
|
||||
ota_refresh_seeder_identity(seeder_id);
|
||||
}
|
||||
void ota_refresh_seeder_identity(const uint8_t* seeder_id) {
|
||||
ota_ctx().manager.set_seeder_id(seeder_id);
|
||||
}
|
||||
uint8_t ota_hop_limit() { return ota_ctx().manager.max_hops(); }
|
||||
#endif
|
||||
|
||||
void ota_set_context_config_loader(bool (*load)(OtaConfigState&)) {
|
||||
context_config_loader = load;
|
||||
OtaConfigState restored;
|
||||
if (!load || !load(restored)) return;
|
||||
#if OTA_DYNAMIC_CONTEXT
|
||||
// The mesh also consults the hop limit while no OTA workspace is allocated.
|
||||
// Seed the existing idle policy cache, without claiming heap/queue storage.
|
||||
#if defined(OTA_SEEDER_ONLY)
|
||||
saved_autofetch = OtaManager::AUTOFETCH_OFF;
|
||||
saved_autoinstall = OtaContext::AUTOINSTALL_OFF;
|
||||
#else
|
||||
saved_autofetch = restored.autofetch;
|
||||
saved_autoinstall = restored.autoinstall;
|
||||
#endif
|
||||
saved_checkpoint = restored.checkpoint;
|
||||
saved_advert = restored.advert;
|
||||
saved_hops = restored.hops;
|
||||
saved_allow = restored.allow;
|
||||
#endif
|
||||
if (auto* context = ota_context_if_active()) restored.apply(*context);
|
||||
}
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
@@ -706,11 +706,15 @@ OtaContext& ota_ctx(); // process-wide context
|
||||
// Dynamic builds return null outside an acquired workspace. Static builds
|
||||
// always return their process-wide context.
|
||||
OtaContext* ota_context_if_active();
|
||||
// Optional role-specific policy reload; registration never allocates a context.
|
||||
// Optional role-specific policy reload; registration updates idle/active policy
|
||||
// without allocating a context. A failed load leaves the prior policy intact.
|
||||
void ota_set_context_config_loader(bool (*load)(OtaConfigState&));
|
||||
bool ota_acquire_context(char* reply, size_t cap);
|
||||
void ota_begin_context(uint32_t target, OtaSend send, void* ctx,
|
||||
const char* hw, const uint8_t* seeder_id);
|
||||
// Identity can become available after Mesh::begin or change through key import.
|
||||
// Refresh both active and future OTA sessions without resetting transfer state.
|
||||
void ota_refresh_seeder_identity(const uint8_t* seeder_id);
|
||||
uint8_t ota_hop_limit();
|
||||
|
||||
#if defined(OTA_SHARED_COMPANION_QUEUE)
|
||||
|
||||
Reference in new issue
Block a user