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
Vendored
+20
@@ -49,6 +49,7 @@ struct Prefs {
|
||||
uint32_t rx_ps_rx_us=1000, rx_ps_sleep_us=1000;
|
||||
bool cad_enabled=false, rx_boosted_gain=false;
|
||||
uint8_t extra_sf[4]={};
|
||||
uint8_t flood_max=16, flood_max_advert=16, flood_max_unscoped=16;
|
||||
};
|
||||
struct Callbacks {
|
||||
unsigned saves=0, tx_calls=0, gain_calls=0;
|
||||
@@ -175,5 +176,24 @@ int main() {
|
||||
assert(cli.callbacks.saves==saves);
|
||||
cli.call(sender,"set unknown.setting 1",reply);
|
||||
assert(!strcmp(reply,"unknown config") && cli.callbacks.saves==saves);
|
||||
for (const char* key : {"flood.max", "flood.max.advert", "flood.max.unscoped"}) {
|
||||
for (const char* invalid : {"", "invalid", "65", "256", "-1", "1tail",
|
||||
"1.5", "4294967296", "999999999999999999999"}) {
|
||||
saves=cli.callbacks.saves;
|
||||
snprintf(text,sizeof(text),"set %s %s",key,invalid); cli.call(sender,text,reply);
|
||||
assert(!strncmp(reply,"Error",5) && cli.callbacks.saves==saves);
|
||||
assert(cli.prefs.flood_max==16 && cli.prefs.flood_max_advert==16
|
||||
&& cli.prefs.flood_max_unscoped==16);
|
||||
}
|
||||
}
|
||||
for (const char* key : {"flood.max", "flood.max.advert", "flood.max.unscoped"}) {
|
||||
for (unsigned value : {0u, 1u, 64u}) {
|
||||
saves=cli.callbacks.saves;
|
||||
snprintf(text,sizeof(text),"set %s %u",key,value); cli.call(sender,text,reply);
|
||||
assert(!strcmp(reply,"OK") && cli.callbacks.saves==saves+1);
|
||||
snprintf(text,sizeof(text),"get %s",key); cli.call(sender,text,reply);
|
||||
unsigned actual=1000; assert(sscanf(reply,"> %u",&actual)==1 && actual==value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,9 @@ static std::string query(const ClientACL& acl, const char* command, bool local =
|
||||
}
|
||||
|
||||
static void empty_and_single_entry() {
|
||||
FakeFilesystem fs; mesh::LocalIdentity self;
|
||||
ClientACL acl;
|
||||
acl.load(&fs, self);
|
||||
CHECK(query(acl, "get acl") == "ACL: empty");
|
||||
CHECK(query(acl, "get acl 1") == "ACL: empty");
|
||||
CHECK(query(acl, "get acl 2") == "Err - ACL page range: 1-1");
|
||||
@@ -79,7 +81,9 @@ static void empty_and_single_entry() {
|
||||
}
|
||||
|
||||
static void skips_inactive_and_preserves_full_keys() {
|
||||
FakeFilesystem fs; mesh::LocalIdentity self;
|
||||
ClientACL acl;
|
||||
acl.load(&fs, self);
|
||||
std::vector<std::string> active;
|
||||
for (unsigned i = 0; i < 9; ++i) {
|
||||
auto* client = add(acl, i, i % 2 == 0 ? uint8_t(i + 1) : 0);
|
||||
@@ -92,7 +96,9 @@ static void skips_inactive_and_preserves_full_keys() {
|
||||
}
|
||||
|
||||
static void full_table_pages() {
|
||||
FakeFilesystem fs; mesh::LocalIdentity self;
|
||||
ClientACL acl;
|
||||
acl.load(&fs, self);
|
||||
for (unsigned i = 0; i < MAX_CLIENTS; ++i) add(acl, i, PERM_ACL_ADMIN);
|
||||
const unsigned pages = (MAX_CLIENTS + 1) / 2;
|
||||
for (unsigned page = 1; page <= pages; ++page) {
|
||||
@@ -108,7 +114,9 @@ static void full_table_pages() {
|
||||
}
|
||||
|
||||
static void rejects_bad_pages() {
|
||||
FakeFilesystem fs; mesh::LocalIdentity self;
|
||||
ClientACL acl;
|
||||
acl.load(&fs, self);
|
||||
add(acl, 0, 3);
|
||||
for (const char* command : {
|
||||
"get acl 0", "get acl -1", "get acl +1", "get acl 1x", "get acl 1 2",
|
||||
@@ -124,7 +132,9 @@ static void rejects_bad_pages() {
|
||||
}
|
||||
|
||||
static void matching_and_local_fallback() {
|
||||
FakeFilesystem fs; mesh::LocalIdentity self;
|
||||
ClientACL acl;
|
||||
acl.load(&fs, self);
|
||||
char reply[157] = "unchanged";
|
||||
for (const char* command : {"", "get", "get ac", "get aclx", "get acl.other", "set acl"}) {
|
||||
CHECK(!mesh::cli::handleACLGet(acl, command, reply, sizeof(reply), false));
|
||||
@@ -136,7 +146,9 @@ static void matching_and_local_fallback() {
|
||||
}
|
||||
|
||||
static void bounds_never_truncate_keys() {
|
||||
FakeFilesystem fs; mesh::LocalIdentity self;
|
||||
ClientACL acl;
|
||||
acl.load(&fs, self);
|
||||
add(acl, 0, 3);
|
||||
add(acl, 1, 3);
|
||||
const auto expected = query(acl, "get acl");
|
||||
@@ -174,7 +186,9 @@ static void listing_does_not_mutate_acl() {
|
||||
}
|
||||
|
||||
static void actual_role_dispatch_handles_radio_and_local() {
|
||||
FakeFilesystem fs; mesh::LocalIdentity self;
|
||||
ClientACL acl;
|
||||
acl.load(&fs, self);
|
||||
auto* admin = add(acl, 0, PERM_ACL_ADMIN);
|
||||
add(acl, 1, PERM_ACL_READ_WRITE);
|
||||
const auto expected = query(acl, "get acl");
|
||||
@@ -208,7 +222,9 @@ static void actual_role_dispatch_handles_radio_and_local() {
|
||||
}
|
||||
|
||||
static void repeater_delegation_stays_denied() {
|
||||
FakeFilesystem fs; mesh::LocalIdentity self;
|
||||
ClientACL acl;
|
||||
acl.load(&fs, self);
|
||||
auto* sender = add(acl, 0, 3);
|
||||
for (unsigned permissions = 0; permissions <= 255; ++permissions) {
|
||||
sender->permissions = uint8_t(permissions);
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#define MESH_DEBUG_PRINTLN(...) ((void)0)
|
||||
|
||||
class FakeFilesystem;
|
||||
inline FakeFilesystem* metadata_filesystem = nullptr;
|
||||
struct FakeFileHandle {
|
||||
FakeFilesystem* fs;
|
||||
std::string path;
|
||||
@@ -38,6 +39,7 @@ public:
|
||||
|
||||
class FakeFilesystem {
|
||||
public:
|
||||
FakeFilesystem() { metadata_filesystem = this; }
|
||||
std::map<std::string, std::vector<uint8_t>> files;
|
||||
std::set<std::string> unreadable;
|
||||
std::set<std::string> directories_on_read;
|
||||
@@ -54,6 +56,10 @@ public:
|
||||
size_t bytes_written = 0;
|
||||
size_t directory_closes = 0;
|
||||
size_t missing_read_opens = 0;
|
||||
bool metadata_error = false;
|
||||
std::string fail_read_path;
|
||||
size_t fail_read_open = 0;
|
||||
size_t fail_read_after = 0;
|
||||
|
||||
bool exists(const char* path) const { return files.count(path) != 0; }
|
||||
File open(const char* path, const char* mode = "r", bool = false) {
|
||||
@@ -94,6 +100,9 @@ inline size_t File::size() const {
|
||||
}
|
||||
inline int File::read(uint8_t* output, size_t length) {
|
||||
if (!*this || handle->directory) return 0;
|
||||
if (handle->fs->fail_read_path == handle->path
|
||||
&& handle->fs->read_open_count[handle->path] == handle->fs->fail_read_open
|
||||
&& handle->position >= handle->fs->fail_read_after) return 0;
|
||||
auto found = handle->fs->files.find(handle->path);
|
||||
if (found == handle->fs->files.end() || handle->position >= found->second.size()) return 0;
|
||||
length = std::min(length, found->second.size() - handle->position);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
#pragma once
|
||||
#include <cerrno>
|
||||
#include <cstring>
|
||||
struct stat {};
|
||||
inline int stat(const char* path, struct stat*) {
|
||||
if (!metadata_filesystem || metadata_filesystem->metadata_error) {
|
||||
errno = EIO;
|
||||
return -1;
|
||||
}
|
||||
if (strncmp(path, "/spiffs", 7) != 0) { errno = EINVAL; return -1; }
|
||||
if (!metadata_filesystem->files.count(path + 7)) { errno = ENOENT; return -1; }
|
||||
return 0;
|
||||
}
|
||||
+42
-1
@@ -475,9 +475,50 @@ static void allocation_failure_preserves_saved_clients() {
|
||||
CHECK(recovered.getNumClients() == 1 && recovered.getClient(KEY, PUB_KEY_SIZE));
|
||||
}
|
||||
|
||||
static void incomplete_acl_load_is_never_authoritative() {
|
||||
for (unsigned fault = 0; fault < 5; ++fault) {
|
||||
FakeFilesystem fs;
|
||||
ClientACL original;
|
||||
original.load(&fs, SELF);
|
||||
CHECK(original.putClient(mesh::Identity(KEY), PERM_ACL_ADMIN));
|
||||
CHECK(original.putClient(mesh::Identity(SECOND_KEY), PERM_ACL_REGION_MGR));
|
||||
CHECK(original.save(&fs));
|
||||
const auto image = fs.files["/s_contacts"];
|
||||
fs.read_open_count.clear();
|
||||
if (fault < 2) {
|
||||
fs.fail_read_path = "/s_contacts";
|
||||
fs.fail_read_open = 2; // integrity pass succeeds, actual load fails
|
||||
fs.fail_read_after = fault == 0 ? 0 : CONTACT_RECORD_SIZE;
|
||||
} else if (fault == 2) {
|
||||
fs.unreadable.insert("/s_contacts");
|
||||
} else if (fault == 3) {
|
||||
fs.metadata_error = true;
|
||||
} else {
|
||||
fs.files["/s_contacts"].resize(CONTACT_RECORD_SIZE + 5);
|
||||
}
|
||||
const auto retained = fs.files["/s_contacts"];
|
||||
ClientACL partial;
|
||||
partial.load(&fs, SELF);
|
||||
CHECK(partial.getNumClients() == 0);
|
||||
CHECK(!partial.putClient(mesh::Identity(ARCHIVED_KEY), PERM_ACL_ADMIN));
|
||||
CHECK(!partial.applyPermissions(SELF, KEY, PUB_KEY_SIZE, PERM_ACL_ADMIN));
|
||||
CHECK(!partial.save(&fs));
|
||||
CHECK(!partial.authorizeLoginTimestamp(KEY, 900, 0, PERM_ACL_ADMIN));
|
||||
CHECK(fs.files["/s_contacts"] == retained);
|
||||
fs.fail_read_path.clear(); fs.unreadable.clear(); fs.metadata_error = false;
|
||||
fs.files["/s_contacts"] = image;
|
||||
partial.load(&fs, SELF); // explicit retry can read the original authority
|
||||
CHECK(partial.getNumClients() == 2);
|
||||
CHECK(partial.getClient(KEY, PUB_KEY_SIZE)->isAdmin());
|
||||
CHECK(partial.getClient(SECOND_KEY, PUB_KEY_SIZE)->isRegionMgr());
|
||||
CHECK(partial.save(&fs));
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
const struct { const char* name; void (*run)(); } tests[] = {
|
||||
{"allocation failure preserves clients", allocation_failure_preserves_saved_clients},
|
||||
{"incomplete ACL is not authoritative", incomplete_acl_load_is_never_authoritative},
|
||||
{"missing read differs from empty file", missing_read_is_not_empty_file},
|
||||
{"first admin and monotonic retries", first_admin_and_retries},
|
||||
{"reboot preserves ceiling", reboot_preserves_ceiling},
|
||||
@@ -507,5 +548,5 @@ int main() {
|
||||
test.run();
|
||||
std::printf("PASS: %s\n", test.name);
|
||||
}
|
||||
std::puts("25 ClientACL SPIFFS checks passed");
|
||||
std::puts("26 ClientACL SPIFFS checks passed");
|
||||
}
|
||||
+10
-4
@@ -26,6 +26,7 @@ public:
|
||||
size_t read(uint8_t* bytes, size_t length);
|
||||
size_t write(const uint8_t* bytes, size_t length);
|
||||
size_t size() const;
|
||||
int available() const { return static_cast<int>(size() - position); }
|
||||
void flush() {}
|
||||
void close() { fs = nullptr; }
|
||||
};
|
||||
@@ -37,12 +38,13 @@ public:
|
||||
size_t max_write = std::numeric_limits<size_t>::max();
|
||||
size_t max_read = std::numeric_limits<size_t>::max();
|
||||
std::string fail_open;
|
||||
unsigned fail_open_remaining = std::numeric_limits<unsigned>::max();
|
||||
int stat_error = 0;
|
||||
unsigned fail_rename = 0, renames = 0, writes = 0;
|
||||
std::vector<Files> snapshots;
|
||||
bool exists(const char* path) const {
|
||||
#if defined(ESP32_PLATFORM)
|
||||
if (fail_open == path) return false; // ESP32 VFS exists opens the file.
|
||||
if (fail_open == path && fail_open_remaining != 0) return false; // ESP32 VFS exists opens the file.
|
||||
#endif
|
||||
return files.count(path) != 0;
|
||||
}
|
||||
@@ -52,7 +54,10 @@ public:
|
||||
void _unlockFS() {}
|
||||
FakeFilesystem* _getFS() { return this; }
|
||||
File open(const char* path, const char* mode = "r", bool = false) {
|
||||
if (fail_open == path) return File();
|
||||
if (fail_open == path && fail_open_remaining != 0) {
|
||||
--fail_open_remaining;
|
||||
return File();
|
||||
}
|
||||
if (*mode != 'r') {
|
||||
files[path].clear();
|
||||
return File(this, path, true);
|
||||
@@ -61,9 +66,9 @@ public:
|
||||
}
|
||||
bool rename(const char* from, const char* to) {
|
||||
++renames;
|
||||
if (renames == fail_rename || !exists(from)) return false;
|
||||
if (renames == fail_rename || files.count(from) == 0) return false;
|
||||
#if defined(ESP32_PLATFORM)
|
||||
if (exists(to)) return false;
|
||||
if (files.count(to) != 0) return false;
|
||||
#endif
|
||||
files[to] = files.at(from);
|
||||
files.erase(from);
|
||||
@@ -153,6 +158,7 @@ class DataStore {
|
||||
bool _channel_load_incomplete = false;
|
||||
bool _uncached_contact_load_incomplete = false;
|
||||
struct IdentityAdapter {
|
||||
bool recover(const char*) { return true; }
|
||||
bool load(const char*, mesh::LocalIdentity&) {
|
||||
File file = filesystem.open(identity_path);
|
||||
uint8_t data[96];
|
||||
|
||||
@@ -210,7 +210,8 @@ class CLISettingsContractTest(unittest.TestCase):
|
||||
source = COMMON.read_text(encoding='utf-8')
|
||||
setter, getter = methods(source, 'void CommonCLI::handle')
|
||||
keys = ['radio', 'freq', 'af', 'dutycycle', 'int.thresh', 'cad', 'radio.rxgain',
|
||||
'tx', 'rxdelay', 'agc.reset.interval', 'multi.acks', 'txdelay', 'direct.txdelay']
|
||||
'tx', 'rxdelay', 'agc.reset.interval', 'multi.acks', 'txdelay', 'direct.txdelay',
|
||||
'flood.max', 'flood.max.advert', 'flood.max.unscoped']
|
||||
# Preserve the actual conditions and bodies. The only excluded branches
|
||||
# are unrelated settings needing board-specific dependencies.
|
||||
set_blocks = [extract_braced(setter, 'if (strncmp(config, "path.hash.mode", 14)')]
|
||||
|
||||
@@ -25,8 +25,8 @@ class ClientAclSpiffsTest(unittest.TestCase):
|
||||
self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr)
|
||||
checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
|
||||
self.assertIn("25 ClientACL SPIFFS checks passed", checked.stdout)
|
||||
self.assertEqual(checked.stdout.count("PASS:"), 25)
|
||||
self.assertIn("26 ClientACL SPIFFS checks passed", checked.stdout)
|
||||
self.assertEqual(checked.stdout.count("PASS:"), 26)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -330,11 +330,12 @@ int main() {
|
||||
disk.autofetch=2; disk.autoinstall=1;
|
||||
uint8_t key[32]={71}; disk.allow.add(key);
|
||||
ota_set_context_config_loader(load);
|
||||
assert(calls==0 && !ota_context_if_active()); // no eager heap or queue claim
|
||||
assert(calls==1 && !ota_context_if_active()); // policy read, no eager heap/queue claim
|
||||
assert(ota_hop_limit()==7); // idle relay decisions use the saved limit too
|
||||
ota_begin_context(123, send, nullptr, "test", nullptr);
|
||||
for (int i=0; i<3; ++i) {
|
||||
assert(ota_acquire_context(nullptr, 0));
|
||||
assert(calls==i+1 && ota_ctx().manager.max_hops()==7);
|
||||
assert(calls==i+2 && ota_ctx().manager.max_hops()==7);
|
||||
assert(ota_ctx().manager.advert_mins()==17 && ota_ctx().manager.checkpoint_blocks()==32);
|
||||
assert(ota_ctx().allow.contains(key));
|
||||
assert(ota_ctx().manager.autofetch()==0 && ota_ctx().autoinstall==0);
|
||||
|
||||
@@ -13,6 +13,7 @@ HARNESS = r'''
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <new>
|
||||
#include <cerrno>
|
||||
#include <helpers/IdentityStore.h>
|
||||
#include "ContactFileTransaction.h"
|
||||
#if defined(STM32_PLATFORM) || defined(NRF52_PLATFORM)
|
||||
@@ -26,6 +27,7 @@ struct DataStore {
|
||||
FILESYSTEM* _fs=&fs;
|
||||
bool _prefs_load_incomplete=false, _primary_storage_unavailable=false;
|
||||
bool _secondary_authority_unknown=false;
|
||||
const char* _prefs_recovery_source=nullptr;
|
||||
DataStore(){
|
||||
#if defined(STM32_PLATFORM) || defined(NRF52_PLATFORM)
|
||||
fs.rename_replaces=true;
|
||||
@@ -84,12 +86,22 @@ int main(int argc,char** argv){
|
||||
if(fault==3)boot.fs.fail_read_after=214;
|
||||
if(fault==4)boot.fs.files[path].resize(87);
|
||||
const auto durable=boot.fs.files[path];
|
||||
#if defined(ESP32_PLATFORM)
|
||||
assert(boot.loadPrefs(live,lat,lon)); // unrecoverable settings permit defaults
|
||||
#else
|
||||
assert(!boot.loadPrefs(live,lat,lon));
|
||||
#endif
|
||||
assert(live.node_name[0]==0&&lat==1&&lon==2);
|
||||
assert(boot.fs.files[path]==durable); // no destructive read-side reset
|
||||
boot.fs.fail_read_after=-1;boot.fs.fail_read_open=false;
|
||||
#if defined(ESP32_PLATFORM)
|
||||
assert(boot.savePrefs(live,lat,lon));
|
||||
assert(boot.fs.files["/new_prefs"]!=disk);
|
||||
#else
|
||||
assert(!boot.loadPrefs(live,lat,lon)); // quarantine lasts this boot
|
||||
assert(!boot.savePrefs(live,lat,lon));
|
||||
assert(boot.fs.files[path]==durable);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
DataStore fresh;CompanionNodePrefs defaults;double lat=0,lon=0;
|
||||
@@ -103,11 +115,20 @@ int main(int argc,char** argv){
|
||||
assert(store.fs.files["/new_prefs.bak"]==disk);
|
||||
DataStore failed_boot;failed_boot.fs=store.fs;
|
||||
CompanionNodePrefs loaded;double lat=0,lon=0;
|
||||
#if defined(ESP32_PLATFORM)
|
||||
assert(failed_boot.loadPrefs(loaded,lat,lon)); // verified backup is usable in RAM
|
||||
assert(loaded.freq==original.freq&&loaded.ble_pin==original.ble_pin);
|
||||
assert(!failed_boot.savePrefs(loaded,lat,lon)); // preserve source while rename fails
|
||||
assert(failed_boot.fs.files["/new_prefs.bak"]==disk);
|
||||
failed_boot.fs.fail_rename_from.clear();
|
||||
assert(failed_boot.savePrefs(loaded,lat,lon));
|
||||
#else
|
||||
assert(!failed_boot.loadPrefs(loaded,lat,lon));
|
||||
failed_boot.fs.fail_rename_from.clear();
|
||||
assert(!failed_boot.loadPrefs(loaded,lat,lon));
|
||||
assert(!failed_boot.savePrefs(changed,42.3,-121.2));
|
||||
assert(failed_boot.fs.files["/new_prefs.bak"]==disk);
|
||||
#endif
|
||||
DataStore reboot;reboot.fs=failed_boot.fs;
|
||||
assert(reboot.loadPrefs(loaded,lat,lon));
|
||||
assert(loaded.freq==original.freq&&loaded.ble_pin==original.ble_pin);
|
||||
@@ -125,11 +146,15 @@ int main(int argc,char** argv){
|
||||
} else if(scenario==4){
|
||||
DataStore legacy;legacy.fs.files["/node_prefs"]=disk;
|
||||
legacy.fs.fail_write_after=17;
|
||||
#if defined(ESP32_PLATFORM)
|
||||
legacy.fs.fail_rename_from={"/node_prefs"};
|
||||
#endif
|
||||
CompanionNodePrefs loaded;double lat=0,lon=0;
|
||||
assert(legacy.loadPrefs(loaded,lat,lon));
|
||||
assert(loaded.freq==original.freq);
|
||||
assert(legacy.fs.files["/node_prefs"]==disk&&!legacy.fs.exists("/new_prefs"));
|
||||
legacy.fs.fail_write_after=-1;
|
||||
legacy.fs.fail_rename_from.clear();
|
||||
assert(legacy.loadPrefs(loaded,lat,lon));
|
||||
assert(!legacy.fs.exists("/node_prefs")&&legacy.fs.files["/new_prefs"]==disk);
|
||||
}
|
||||
@@ -137,10 +162,31 @@ int main(int argc,char** argv){
|
||||
'''
|
||||
|
||||
|
||||
def esp_recovery_helpers(source):
|
||||
"""Compile production recovery, substituting only its OS metadata call.
|
||||
|
||||
SPIFFS namespace presence is independent of open/read failures. Binding
|
||||
the fake syscall to the filesystem argument also supports several device
|
||||
instances in the same host test without a process-global mount mock.
|
||||
"""
|
||||
presence = method(source, 'static bool companionPathPresence(')
|
||||
presence = presence.replace('struct stat info;', 'struct FixtureStat info;')
|
||||
presence = presence.replace('::stat(vfs_path, &info)', 'fixtureStat(fs, vfs_path, &info)')
|
||||
return '''
|
||||
#if defined(ESP32_PLATFORM)
|
||||
struct FixtureStat {};
|
||||
static int fixtureStat(FILESYSTEM* fs, const char* path, FixtureStat*) {
|
||||
assert(!strncmp(path, "/spiffs", 7));
|
||||
if (!fs->files.count(path + 7)) { errno=ENOENT; return -1; }
|
||||
return 0;
|
||||
}
|
||||
''' + presence + '\n' + method(source, 'static bool promoteCompanionRecoveryFile(') + '\n#endif\n'
|
||||
|
||||
|
||||
class CompanionPreferencesTransactionTests(unittest.TestCase):
|
||||
def test_real_preferences_io_failures(self):
|
||||
source = (ROOT / 'examples/companion_radio/DataStore.cpp').read_text()
|
||||
methods = '\n'.join(method(source, signature) for signature in (
|
||||
methods = esp_recovery_helpers(source) + '\n'.join(method(source, signature) for signature in (
|
||||
'bool DataStore::loadPrefs(', 'bool DataStore::loadPrefsInt(',
|
||||
'bool DataStore::savePrefs('))
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
|
||||
@@ -8,6 +8,7 @@ import tempfile
|
||||
import unittest
|
||||
|
||||
from test_companion_preferences_transaction import HARNESS as PREFERENCES_HARNESS
|
||||
from test_companion_preferences_transaction import esp_recovery_helpers
|
||||
from test_replay_reset_integration import extract_braced
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -189,7 +190,7 @@ class CompanionPrimaryRadioPersistenceTests(unittest.TestCase):
|
||||
return nonfinite.group() + '\n' + clamp.group()
|
||||
|
||||
support = PREFERENCES_HARNESS[:PREFERENCES_HARNESS.index('int main(')]
|
||||
support = support.replace('@METHODS@', '\n'.join(extract_braced(datastore, sig) for sig in (
|
||||
support = support.replace('@METHODS@', esp_recovery_helpers(datastore) + '\n'.join(extract_braced(datastore, sig) for sig in (
|
||||
'bool DataStore::loadPrefs(', 'bool DataStore::loadPrefsInt(', 'bool DataStore::savePrefs(')))
|
||||
replacements = {
|
||||
'@SAVE_PREFS@': extract_braced(header, 'bool savePrefs()'),
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Fault-inject gain restoration in actual Companion startup/TempRadio methods."""
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from test_replay_reset_integration import extract_braced
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE = ROOT / 'examples/companion_radio/MyMesh.cpp'
|
||||
|
||||
HARNESS = r'''
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <deque>
|
||||
#define MESH_DEBUG_PRINTLN(...) ((void)0)
|
||||
namespace mesh { enum class RadioParamApplyResult { APPLIED, BUSY, FAILED }; }
|
||||
using Apply = mesh::RadioParamApplyResult;
|
||||
static uint32_t now_ms=100;
|
||||
template<typename T> T nextResult(std::deque<T>& results,T fallback) {
|
||||
if(results.empty())return fallback;
|
||||
T result=results.front();results.pop_front();return result;
|
||||
}
|
||||
struct Prefs {
|
||||
float freq=915,bw=250;
|
||||
uint8_t sf=7,cr=5,rx_boosted_gain=0;
|
||||
bool radio_fem_rxgain=false,radio_fem_txgain=false,cad_enabled=false;
|
||||
bool rx_powersaving_enabled=false;
|
||||
uint32_t rx_ps_rx_us=1000,rx_ps_sleep_us=2000;
|
||||
int tx_power_dbm=22,cad_scan_timeout_ms=100;
|
||||
void* getCustom(){return nullptr;}
|
||||
};
|
||||
struct Board {
|
||||
void attachDynamicPrefs(void*){}
|
||||
bool setLoRaFemLnaEnabled(bool){return true;}
|
||||
bool setLoRaFemPaGainEnabled(bool){return true;}
|
||||
bool canControlLoRaFemLna(){return false;}
|
||||
bool isLoRaFemLnaEnabled(){return false;}
|
||||
} board;
|
||||
struct Driver {
|
||||
bool gain=false,supported=true;
|
||||
int power=0;
|
||||
unsigned gain_calls=0,power_calls=0;
|
||||
std::deque<bool> gain_results,power_results;
|
||||
bool supportsRxBoostedGainMode(){return supported;}
|
||||
bool setRxBoostedGainMode(bool value){
|
||||
++gain_calls;
|
||||
bool ok=nextResult(gain_results,true);
|
||||
if(ok)gain=value;
|
||||
return ok;
|
||||
}
|
||||
bool getRxBoostedGainMode(){return gain;}
|
||||
bool setTxPower(int value){
|
||||
++power_calls;
|
||||
bool ok=nextResult(power_results,true);
|
||||
if(ok)power=value;
|
||||
return ok;
|
||||
}
|
||||
bool supportsRxPowerSaving(){return false;}
|
||||
bool setRxPowerSaving(bool,uint32_t,uint32_t){return true;}
|
||||
void setCADScanTimeoutMillis(int){}
|
||||
} radio_driver;
|
||||
struct Radio {
|
||||
void setCADEnabled(bool){}
|
||||
void recalibrateNoiseFloor(){}
|
||||
} radio;
|
||||
struct Clock { uint32_t getMillis(){return now_ms;} } clock_source;
|
||||
@RETRY_DELAY@
|
||||
struct MyMesh {
|
||||
Prefs _prefs;
|
||||
Radio* _radio=&radio;
|
||||
Clock* _ms=&clock_source;
|
||||
bool _radio_available=true,saved_radio_apply_pending=false;
|
||||
bool _temp_radio_applied=false,command_radio_apply_pending=false,outbound=false;
|
||||
uint32_t _temp_radio_set_at=0,_temp_radio_revert_at=0,_temp_radio_retry_at=0;
|
||||
uint32_t radio_apply_retry_at=0;
|
||||
uint8_t _temp_radio_failures=0,radio_apply_failures=0;
|
||||
float _temp_radio_freq=916,_temp_radio_bw=125,live_freq=0;
|
||||
uint8_t _temp_radio_sf=8,_temp_radio_cr=6,_temp_radio_preamble=16;
|
||||
unsigned applies=0,saves=0;
|
||||
std::deque<Apply> apply_results;
|
||||
MyMesh(){now_ms=100;radio_driver=Driver();}
|
||||
bool hasOutbound(){return outbound;}
|
||||
bool millisHasNowPassed(uint32_t at){return int32_t(now_ms-at)>0;}
|
||||
uint32_t futureMillis(uint32_t delay){return now_ms+delay;}
|
||||
Apply tryApplyRadioParams(float freq,float,uint8_t,uint8_t,bool=false,uint16_t=0){
|
||||
++applies;
|
||||
Apply result=nextResult(apply_results,Apply::APPLIED);
|
||||
if(result==Apply::APPLIED)live_freq=freq;
|
||||
return result;
|
||||
}
|
||||
bool savePrefs(){++saves;return false;}
|
||||
void configureRadioFromPrefs();
|
||||
bool applySavedRadioParams();
|
||||
bool applyAndSaveRxBoostedGain(bool);
|
||||
#if COMPANION_FEATURE_TEMP_RADIO
|
||||
void serviceTempRadio();
|
||||
#endif
|
||||
void recover(){@RECOVERY@}
|
||||
};
|
||||
@METHODS@
|
||||
|
||||
static void checkStartupAndRetry(){
|
||||
{
|
||||
MyMesh m;m._prefs.rx_boosted_gain=1;
|
||||
m.configureRadioFromPrefs();
|
||||
assert(radio_driver.gain && !m.saved_radio_apply_pending);
|
||||
assert(radio_driver.power==m._prefs.tx_power_dbm && m.saves==0);
|
||||
}
|
||||
for(bool desired : {false,true}){
|
||||
MyMesh m;m._prefs.rx_boosted_gain=desired;radio_driver.gain=!desired;
|
||||
radio_driver.gain_results={false,false,true};
|
||||
m.configureRadioFromPrefs();
|
||||
assert(radio_driver.gain!=desired && m.saved_radio_apply_pending);
|
||||
m.recover();
|
||||
assert(m.saved_radio_apply_pending && m.radio_apply_retry_at>now_ms);
|
||||
unsigned calls=radio_driver.gain_calls;
|
||||
m.recover();
|
||||
assert(radio_driver.gain_calls==calls); // Respect backoff, do not spin.
|
||||
now_ms=m.radio_apply_retry_at+1;m.recover();
|
||||
assert(radio_driver.gain==desired && !m.saved_radio_apply_pending);
|
||||
assert(!m.radio_apply_retry_at && !m.radio_apply_failures && m.saves==0);
|
||||
}
|
||||
{
|
||||
MyMesh m;m.apply_results={Apply::BUSY};
|
||||
m.configureRadioFromPrefs();
|
||||
assert(m.saved_radio_apply_pending && radio_driver.gain_calls==0);
|
||||
m.recover();assert(!m.saved_radio_apply_pending);
|
||||
}
|
||||
{
|
||||
MyMesh m;radio_driver.power_results={false};
|
||||
m.configureRadioFromPrefs();
|
||||
assert(m.saved_radio_apply_pending);
|
||||
m.recover();assert(!m.saved_radio_apply_pending);
|
||||
}
|
||||
{
|
||||
MyMesh m;m._radio_available=false;
|
||||
m.configureRadioFromPrefs();
|
||||
assert(!m.saved_radio_apply_pending && !m.applies && !radio_driver.gain_calls);
|
||||
}
|
||||
{
|
||||
MyMesh m;radio_driver.supported=false;radio_driver.gain_results={false};
|
||||
m.configureRadioFromPrefs();
|
||||
assert(!m.saved_radio_apply_pending && !radio_driver.gain_calls);
|
||||
m.saved_radio_apply_pending=true;m.recover();
|
||||
assert(!m.saved_radio_apply_pending && !radio_driver.gain_calls);
|
||||
}
|
||||
}
|
||||
|
||||
#if COMPANION_FEATURE_TEMP_RADIO
|
||||
static void checkTemporaryOverrideCoexistence(){
|
||||
for(int phase=0;phase<3;++phase){
|
||||
MyMesh m;m._prefs.rx_boosted_gain=1;radio_driver.gain_results={false};
|
||||
m.configureRadioFromPrefs();assert(m.saved_radio_apply_pending);
|
||||
if(phase==0)m._temp_radio_set_at=now_ms+1500;
|
||||
if(phase==1)m._temp_radio_applied=true;
|
||||
if(phase==2)m._temp_radio_revert_at=now_ms+60000;
|
||||
unsigned applies=m.applies,gain_calls=radio_driver.gain_calls;
|
||||
m.recover();
|
||||
assert(m.saved_radio_apply_pending && m.applies==applies);
|
||||
assert(radio_driver.gain_calls==gain_calls); // Never override a lease.
|
||||
m._temp_radio_set_at=0;m._temp_radio_applied=false;m._temp_radio_revert_at=0;
|
||||
m.recover();assert(!m.saved_radio_apply_pending && radio_driver.gain);
|
||||
}
|
||||
}
|
||||
|
||||
static void checkTempRadioRestoration(){
|
||||
for(bool rollback_pending : {false,true}){
|
||||
MyMesh m;m._temp_radio_applied=true;m._temp_radio_revert_at=now_ms;
|
||||
if(rollback_pending){
|
||||
radio_driver.gain_results={true,false};
|
||||
assert(!m.applyAndSaveRxBoostedGain(true));
|
||||
assert(radio_driver.gain && m.saved_radio_apply_pending);
|
||||
assert(!m._prefs.rx_boosted_gain);
|
||||
}else{
|
||||
m._prefs.rx_boosted_gain=1;
|
||||
}
|
||||
radio_driver.gain_results={false,true};
|
||||
m.serviceTempRadio();
|
||||
assert(m._temp_radio_applied && m._temp_radio_revert_at);
|
||||
assert(m._temp_radio_retry_at>now_ms);
|
||||
assert(m.saved_radio_apply_pending==rollback_pending);
|
||||
assert(radio_driver.gain!=bool(m._prefs.rx_boosted_gain));
|
||||
unsigned applies=m.applies,gain_calls=radio_driver.gain_calls;
|
||||
m.recover();m.serviceTempRadio();
|
||||
assert(m.applies==applies && radio_driver.gain_calls==gain_calls);
|
||||
now_ms=m._temp_radio_retry_at;m.serviceTempRadio();
|
||||
assert(radio_driver.gain==bool(m._prefs.rx_boosted_gain));
|
||||
assert(!m._temp_radio_applied && !m._temp_radio_revert_at);
|
||||
assert(!m._temp_radio_retry_at && !m._temp_radio_failures);
|
||||
assert(!m.saved_radio_apply_pending && m.live_freq==m._prefs.freq);
|
||||
assert(m.saves==unsigned(rollback_pending)); // Restoring never saves settings.
|
||||
}
|
||||
{
|
||||
MyMesh m;m._temp_radio_applied=true;m._temp_radio_revert_at=now_ms;
|
||||
m.saved_radio_apply_pending=true;radio_driver.supported=false;
|
||||
radio_driver.gain_results={false};m.serviceTempRadio();
|
||||
assert(!m._temp_radio_revert_at && !m.saved_radio_apply_pending);
|
||||
assert(!radio_driver.gain_calls);
|
||||
}
|
||||
{
|
||||
MyMesh m;m._temp_radio_applied=true;m._temp_radio_revert_at=now_ms;
|
||||
m.outbound=true;m.serviceTempRadio();
|
||||
assert(!m.applies && !radio_driver.gain_calls && m._temp_radio_revert_at);
|
||||
m.outbound=false;m.serviceTempRadio();assert(!m._temp_radio_revert_at);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
int main(){
|
||||
checkStartupAndRetry();
|
||||
#if COMPANION_FEATURE_TEMP_RADIO
|
||||
checkTemporaryOverrideCoexistence();
|
||||
checkTempRadioRestoration();
|
||||
#endif
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class CompanionRadioGainRestoreTests(unittest.TestCase):
|
||||
def test_production_gain_restore_keeps_failed_work_pending(self):
|
||||
text = SOURCE.read_text(encoding='utf-8')
|
||||
methods = '\n'.join(extract_braced(text, signature) for signature in (
|
||||
'void MyMesh::configureRadioFromPrefs(',
|
||||
'bool MyMesh::applySavedRadioParams(',
|
||||
'bool MyMesh::applyAndSaveRxBoostedGain(',
|
||||
))
|
||||
methods += '\n#if COMPANION_FEATURE_TEMP_RADIO\n'
|
||||
methods += extract_braced(text, 'void MyMesh::serviceTempRadio(')
|
||||
methods += '\n#endif\n'
|
||||
replacements = {
|
||||
'@METHODS@': methods,
|
||||
'@RETRY_DELAY@': extract_braced(text, 'static uint32_t nextRadioApplyRetryDelay('),
|
||||
'@RECOVERY@': extract_braced(text, 'if (!command_radio_apply_pending && saved_radio_apply_pending && !hasOutbound()'),
|
||||
}
|
||||
harness = HARNESS
|
||||
for marker, value in replacements.items():
|
||||
harness = harness.replace(marker, value)
|
||||
with tempfile.TemporaryDirectory(prefix='mesh-companion-gain-restore-') as tmp:
|
||||
cpp = Path(tmp) / 'test.cpp'
|
||||
binary = Path(tmp) / ('test.exe' if os.name == 'nt' else 'test')
|
||||
cpp.write_text(harness, encoding='utf-8')
|
||||
for temporary_radio in (0, 1):
|
||||
with self.subTest(temporary_radio=temporary_radio):
|
||||
subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17',
|
||||
'-Wall', '-Wextra', '-Werror',
|
||||
f'-DCOMPANION_FEATURE_TEMP_RADIO={temporary_radio}',
|
||||
str(cpp), '-o', str(binary)], check=True)
|
||||
subprocess.run([str(binary)], check=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -4,6 +4,7 @@ import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from test_radio_receive_contract import method
|
||||
from test_companion_preferences_transaction import esp_recovery_helpers
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -11,6 +12,7 @@ HARNESS = r'''
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <cerrno>
|
||||
#include <vector>
|
||||
#include <RadioProfiles.h>
|
||||
#include <helpers/CLICommandUtils.h>
|
||||
@@ -45,6 +47,7 @@ struct DataStore {
|
||||
FILESYSTEM fs;
|
||||
bool _channel_load_incomplete=false;
|
||||
bool _uncached_contact_load_incomplete=false;
|
||||
const char* _channel_recovery_source=nullptr;
|
||||
bool hasIncompleteContactLoad() const;
|
||||
FILESYSTEM* _getContactsChannelsFS() { return &fs; }
|
||||
File openRead(FILESYSTEM* fs, const char* name) { return fs->open(name,"r"); }
|
||||
@@ -123,8 +126,8 @@ int main() {
|
||||
assert(disk.size()==MAX_GROUP_CHANNELS*68);
|
||||
assert(disk[0]==mesh::encodeRadioTxPolicy(mesh::RADIO_TX_BOTH));
|
||||
assert(disk[68]==mesh::encodeRadioTxPolicy(mesh::RADIO_TX_SECONDARY));
|
||||
// Loading is all-or-nothing. An unreadable/partial file must not become a
|
||||
// default channel table that a later phone or CLI update can persist over it.
|
||||
// Loading is all-or-nothing. ESP32 retries recovery and then permits an
|
||||
// explicitly requested defaults save; the other backends remain fail-closed.
|
||||
for(int fault : {0,1,2,3,4}) {
|
||||
MyMesh boot;
|
||||
boot.store.fs.files["/channels2"]=disk;
|
||||
@@ -136,12 +139,22 @@ int main() {
|
||||
if(fault==4) boot.store.fs.files["/channels2"].resize((MAX_GROUP_CHANNELS+1)*68);
|
||||
auto durable=boot.store.fs.files["/channels2"];
|
||||
boot.store.loadChannels(&boot);
|
||||
#if defined(ESP32_PLATFORM)
|
||||
assert(!boot.store.hasIncompleteContactLoad());
|
||||
#else
|
||||
assert(boot.store.hasIncompleteContactLoad());
|
||||
#endif
|
||||
assert(!strcmp(boot.channels[0].name,"keep existing"));
|
||||
assert(!boot.channels[1].name[0]);
|
||||
boot.store.fs.fail_read_open=false;boot.store.fs.fail_read_after=-1;
|
||||
#if defined(ESP32_PLATFORM)
|
||||
assert(boot.store.fs.files["/channels2"]==durable);
|
||||
assert(boot.canMutateContacts()&&boot.store.saveChannels(&boot));
|
||||
assert(boot.store.fs.files["/channels2"]!=disk);
|
||||
#else
|
||||
assert(!boot.canMutateContacts()&&!boot.store.saveChannels(&boot));
|
||||
assert(boot.store.fs.files["/channels2"]==durable);
|
||||
#endif
|
||||
}
|
||||
MyMesh fresh;fresh.store.loadChannels(&fresh);
|
||||
assert(!fresh.store.hasIncompleteContactLoad()); // absent file is a fresh boot
|
||||
@@ -179,12 +192,22 @@ int main() {
|
||||
assert(node.store.fs.files["/channels2.bak"]==disk);
|
||||
MyMesh failed_boot;failed_boot.store.fs=node.store.fs;
|
||||
failed_boot.store.loadChannels(&failed_boot);
|
||||
#if defined(ESP32_PLATFORM)
|
||||
assert(!failed_boot.store.hasIncompleteContactLoad());
|
||||
assert(failed_boot.channels[0].channel.tx_radio==mesh::RADIO_TX_BOTH);
|
||||
assert(!failed_boot.store.saveChannels(&failed_boot));
|
||||
assert(failed_boot.store.fs.files["/channels2.bak"]==disk);
|
||||
failed_boot.store.fs.fail_rename_from.clear();
|
||||
assert(failed_boot.store.saveChannels(&failed_boot));
|
||||
assert(failed_boot.store.fs.files["/channels2"]==disk);
|
||||
#else
|
||||
assert(failed_boot.store.hasIncompleteContactLoad());
|
||||
failed_boot.store.fs.fail_rename_from.clear();
|
||||
failed_boot.store.loadChannels(&failed_boot);
|
||||
assert(failed_boot.store.hasIncompleteContactLoad());
|
||||
assert(!failed_boot.store.saveChannels(&failed_boot));
|
||||
assert(failed_boot.store.fs.files["/channels2.bak"]==disk);
|
||||
#endif
|
||||
node.store.fs.fail_rename_from.clear();
|
||||
node.store.loadChannels(&node);
|
||||
assert(!node.store.hasIncompleteContactLoad());
|
||||
@@ -217,7 +240,7 @@ class CompanionTxRoutingTest(unittest.TestCase):
|
||||
def test_cli_resolution_persistence_and_failures(self):
|
||||
companion = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text()
|
||||
store = (ROOT / 'examples/companion_radio/DataStore.cpp').read_text()
|
||||
methods = method(companion, 'bool MyMesh::handleTxRoutingCommand(')
|
||||
methods = esp_recovery_helpers(store) + method(companion, 'bool MyMesh::handleTxRoutingCommand(')
|
||||
methods += '\n' + method(store, 'void DataStore::loadChannels(')
|
||||
methods += '\n' + method(store, 'bool DataStore::saveChannels(')
|
||||
methods += '\n' + method(store, 'bool DataStore::hasIncompleteContactLoad(')
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Production identity transactions and bounded Companion settings recovery."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from test_replay_reset_integration import extract_braced
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def fixture_prefix():
|
||||
text = (ROOT / 'test/fixtures/companion_uncached_storage/test.cpp').read_text()
|
||||
return text[:text.index('struct Host {')]
|
||||
|
||||
|
||||
def compile_run(program, platform):
|
||||
with tempfile.TemporaryDirectory(prefix='mesh-storage-recovery-') as directory:
|
||||
directory = Path(directory)
|
||||
(directory / 'FS.h').write_text('#pragma once\nnamespace fs { using FS = FakeFilesystem; }\n')
|
||||
(directory / 'Adafruit_LittleFS.h').write_text(
|
||||
'#pragma once\nusing Adafruit_LittleFS = FakeFilesystem;\n'
|
||||
'namespace Adafruit_LittleFS_Namespace {}\n')
|
||||
cpp, exe = directory / 'test.cpp', directory / 'test'
|
||||
cpp.write_text(program)
|
||||
compiled = subprocess.run([
|
||||
'c++', '-std=c++17', '-O1', '-g', '-fsanitize=address,undefined',
|
||||
'-fno-pie', '-no-pie', '-DMESH_CONTACT_CACHE=0', '-D' + platform + '=1',
|
||||
'-I', str(directory), '-I', str(ROOT / 'test/fixtures/contact_cache/mocks'),
|
||||
'-I', str(ROOT / 'src'), '-I', str(ROOT / 'lib/ed25519'),
|
||||
'-I', str(ROOT / 'examples/companion_radio'), str(cpp), '-o', str(exe),
|
||||
], capture_output=True, text=True, timeout=60)
|
||||
if compiled.returncode:
|
||||
raise AssertionError(compiled.stdout + compiled.stderr)
|
||||
run = subprocess.run([str(exe)], capture_output=True, text=True, timeout=30)
|
||||
if run.returncode:
|
||||
raise AssertionError(run.stdout + run.stderr)
|
||||
|
||||
|
||||
class IdentityAndSettingsRecovery(unittest.TestCase):
|
||||
def test_identity_atomic_esp32(self):
|
||||
self.check_identity('ESP32_PLATFORM')
|
||||
|
||||
def test_identity_atomic_rp2040(self):
|
||||
self.check_identity('RP2040_PLATFORM')
|
||||
|
||||
def test_identity_atomic_stm32(self):
|
||||
self.check_identity('STM32_PLATFORM')
|
||||
|
||||
def check_identity(self, platform):
|
||||
source = (ROOT / 'src/helpers/IdentityStore.cpp').read_text()
|
||||
presence = (ROOT / 'src/helpers/FilePresence.h').read_text()
|
||||
program = fixture_prefix() + '\n#include <helpers/IdentityStore.h>\n'
|
||||
program += 'namespace mesh { template <typename Filesystem>\n' + extract_braced(
|
||||
presence, 'bool filePresence(') + '\n}\n'
|
||||
for signature in ('bool IdentityStore::recover(',
|
||||
'bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id)',
|
||||
'bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id, const char display_name[])'):
|
||||
program += extract_braced(source, signature) + '\n'
|
||||
program += r'''
|
||||
int main() {
|
||||
for (bool display : {false, true}) {
|
||||
for (unsigned fault = 0; fault < 4; ++fault) {
|
||||
filesystem = FakeFilesystem();
|
||||
IdentityStore store(filesystem, "");
|
||||
mesh::LocalIdentity old_id, new_id;
|
||||
memset(old_id.pub_key, 7, PUB_KEY_SIZE); memset(new_id.pub_key, 9, PUB_KEY_SIZE);
|
||||
assert(display ? store.save("main", old_id, "old") : store.save("main", old_id));
|
||||
const auto old = filesystem.files["/main.id"];
|
||||
if (fault == 0) filesystem.max_write = 3;
|
||||
if (fault == 1) filesystem.max_read = 3;
|
||||
if (fault == 2) filesystem.fail_open = "/main.id.tmp";
|
||||
if (fault == 3) filesystem.fail_rename = filesystem.renames +
|
||||
#if defined(STM32_PLATFORM)
|
||||
1;
|
||||
#else
|
||||
2;
|
||||
#endif
|
||||
assert(!(display ? store.save("main", new_id, "new") : store.save("main", new_id)));
|
||||
assert(filesystem.files["/main.id"] == old);
|
||||
filesystem.max_write = filesystem.max_read = std::numeric_limits<size_t>::max();
|
||||
filesystem.fail_open.clear(); filesystem.fail_rename = 0;
|
||||
assert(display ? store.save("main", new_id, "new") : store.save("main", new_id));
|
||||
assert(filesystem.files["/main.id"] != old);
|
||||
}
|
||||
}
|
||||
#if !defined(STM32_PLATFORM)
|
||||
filesystem = FakeFilesystem();
|
||||
filesystem.files["/main.id.bak"] = std::vector<uint8_t>(96, 7);
|
||||
IdentityStore recovering(filesystem, "");
|
||||
filesystem.fail_rename = 1;
|
||||
assert(!recovering.recover("main") && filesystem.files.count("/main.id.bak"));
|
||||
filesystem.fail_rename = 0;
|
||||
assert(recovering.recover("main") && filesystem.files["/main.id"].size() == 96);
|
||||
#endif
|
||||
}
|
||||
'''
|
||||
compile_run(program, platform)
|
||||
|
||||
def test_esp32_preferences_and_channels_recover_or_reset(self):
|
||||
store = (ROOT / 'examples/companion_radio/DataStore.cpp').read_text()
|
||||
prefs = (ROOT / 'examples/companion_radio/NodePrefs.h').read_text()
|
||||
fields = prefs[prefs.index('class CompanionNodePrefs {'):prefs.index('\nprivate:')]
|
||||
program = fixture_prefix() + r'''
|
||||
#include <helpers/BluetoothMac.h>
|
||||
#include "BluetoothName.h"
|
||||
''' + fields + '\n};\n' + r'''
|
||||
struct ChannelDetails { struct { uint8_t secret[32] = {}; uint8_t tx_radio = 0; } channel; char name[32] = {}; };
|
||||
#define MAX_GROUP_CHANNELS 40
|
||||
struct DataStoreHost {
|
||||
std::vector<ChannelDetails> channels;
|
||||
bool onChannelLoaded(uint8_t index, const ChannelDetails& channel) {
|
||||
if (index >= channels.size()) channels.resize(index + 1);
|
||||
channels[index] = channel; return true;
|
||||
}
|
||||
bool getChannelForSave(uint8_t index, ChannelDetails& channel) {
|
||||
if (index >= channels.size()) return false;
|
||||
channel = channels[index]; return true;
|
||||
}
|
||||
};
|
||||
struct DataStore {
|
||||
FakeFilesystem* _fs = &filesystem;
|
||||
bool _prefs_load_incomplete = false, _channel_load_incomplete = false;
|
||||
const char* _prefs_recovery_source = nullptr;
|
||||
const char* _channel_recovery_source = nullptr;
|
||||
FakeFilesystem* _getContactsChannelsFS() { return _fs; }
|
||||
File openRead(FakeFilesystem* fs, const char* path) { return fs->open(path); }
|
||||
bool loadPrefs(CompanionNodePrefs&, double&, double&);
|
||||
bool loadPrefsInt(const char*, CompanionNodePrefs&, double&, double&);
|
||||
bool savePrefs(const CompanionNodePrefs&, double, double);
|
||||
void loadChannels(DataStoreHost*);
|
||||
bool saveChannels(DataStoreHost*);
|
||||
};
|
||||
'''
|
||||
for signature in ('static bool companionPathPresence(',
|
||||
'static bool promoteCompanionRecoveryFile(',
|
||||
'bool DataStore::loadPrefs(', 'bool DataStore::loadPrefsInt(',
|
||||
'bool DataStore::savePrefs(', 'void DataStore::loadChannels(',
|
||||
'bool DataStore::saveChannels('):
|
||||
program += extract_braced(store, signature) + '\n'
|
||||
program += r'''
|
||||
int main() {
|
||||
CompanionNodePrefs saved; strcpy(saved.node_name, "saved"); saved.ble_pin = 654321;
|
||||
DataStore writer; assert(writer.savePrefs(saved, 11, 22));
|
||||
const auto prefs_image = filesystem.files["/new_prefs"];
|
||||
DataStoreHost original; ChannelDetails channel; strcpy(channel.name, "private");
|
||||
channel.channel.secret[0] = 7; original.channels.push_back(channel);
|
||||
assert(writer.saveChannels(&original)); const auto channel_image = filesystem.files["/channels2"];
|
||||
for (bool channels : {false, true}) {
|
||||
const char* path = channels ? "/channels2" : "/new_prefs";
|
||||
const std::string backup = std::string(path) + ".bak";
|
||||
const auto& image = channels ? channel_image : prefs_image;
|
||||
for (unsigned fault = 0; fault < 8; ++fault) {
|
||||
filesystem = FakeFilesystem(); filesystem.files[path] = image;
|
||||
if (fault == 0) { filesystem.fail_open = path; filesystem.fail_open_remaining = 1; }
|
||||
if (fault == 1 || fault == 2 || fault == 3) {
|
||||
filesystem.files[path] = {1}; filesystem.files[backup] = image;
|
||||
if (fault == 2) filesystem.fail_rename = 1;
|
||||
if (fault == 3 && !channels) { filesystem.files.erase(backup); filesystem.files["/node_prefs"] = image; }
|
||||
}
|
||||
if (fault == 4) filesystem.files[path] = {1};
|
||||
if (fault == 5) filesystem.max_read = 1;
|
||||
if (fault == 6) filesystem.stat_error = EIO;
|
||||
if (fault == 7) filesystem.fail_open = path;
|
||||
DataStore reader; CompanionNodePrefs restored; strcpy(restored.node_name, "default");
|
||||
double lat = 1, lon = 2; DataStoreHost loaded;
|
||||
if (channels) reader.loadChannels(&loaded);
|
||||
else assert(reader.loadPrefs(restored, lat, lon));
|
||||
const bool recovered = fault < 4;
|
||||
if (channels) {
|
||||
assert(loaded.channels.size() == (recovered ? 1 : 0));
|
||||
if (recovered) assert(!strcmp(loaded.channels[0].name, "private") && loaded.channels[0].channel.secret[0] == 7);
|
||||
} else {
|
||||
assert(!strcmp(restored.node_name, recovered ? "saved" : "default"));
|
||||
assert(restored.ble_pin == (recovered ? 654321u : 0u));
|
||||
}
|
||||
// Neither failed reads nor failed recovery publication erase the only
|
||||
// verified backup. Once I/O recovers, a normal save is allowed.
|
||||
if (fault == 2) assert(filesystem.files[backup] == image);
|
||||
if (fault >= 4) assert(filesystem.files[path] == (fault == 4 ? std::vector<uint8_t>{1} : image));
|
||||
if (fault != 7) filesystem.fail_open.clear();
|
||||
filesystem.fail_rename = 0;
|
||||
filesystem.stat_error = 0; filesystem.max_read = std::numeric_limits<size_t>::max();
|
||||
if (channels) {
|
||||
if (!recovered) loaded.channels.push_back(channel);
|
||||
assert(reader.saveChannels(&loaded));
|
||||
filesystem.fail_open.clear();
|
||||
DataStore again; DataStoreHost check; again.loadChannels(&check); assert(check.channels.size() == 1);
|
||||
} else {
|
||||
assert(reader.savePrefs(restored, lat, lon));
|
||||
filesystem.fail_open.clear();
|
||||
DataStore again; CompanionNodePrefs check; assert(again.loadPrefs(check, lat, lon));
|
||||
assert(!strcmp(check.node_name, restored.node_name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
'''
|
||||
compile_run(program, 'ESP32_PLATFORM')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -74,10 +74,8 @@ public:
|
||||
++abort_calls;
|
||||
_open = false;
|
||||
_staging.clear();
|
||||
// Mirrors MQTTPrefsFileStore: after finish(), a failed commit may already
|
||||
// have moved the old primary to .bak, so the verified temp is recovery
|
||||
// data rather than disposable staging.
|
||||
if (_owns_temp && !_finished) _files.erase("/mqtt_prefs.tmp");
|
||||
// Explicit rejection discards this candidate, even after finish().
|
||||
if (_owns_temp) _files.erase("/mqtt_prefs.tmp");
|
||||
_finished = false;
|
||||
_owns_temp = false;
|
||||
}
|
||||
@@ -277,11 +275,15 @@ public:
|
||||
}
|
||||
|
||||
// Inject ordinary operation failures (as distinct from a power cut). A
|
||||
// failed temp rename leaves both the verified temp and old backup intact.
|
||||
// failed temp rename restores the old image and discards the candidate.
|
||||
bool publish(bool fail_backup_rename, bool fail_temp_rename, bool fail_cleanup) {
|
||||
writeVerifiedTemp();
|
||||
if (fail_backup_rename || !rename("/mqtt_prefs", "/mqtt_prefs.bak")) return false;
|
||||
if (fail_temp_rename || !rename("/mqtt_prefs.tmp", "/mqtt_prefs")) return false;
|
||||
if (fail_temp_rename || !rename("/mqtt_prefs.tmp", "/mqtt_prefs")) {
|
||||
rename("/mqtt_prefs.bak", "/mqtt_prefs");
|
||||
_files.erase("/mqtt_prefs.tmp");
|
||||
return false;
|
||||
}
|
||||
if (!fail_cleanup) _files.erase("/mqtt_prefs.bak");
|
||||
return true; // backup cleanup is intentionally non-fatal after publish
|
||||
}
|
||||
@@ -296,13 +298,8 @@ public:
|
||||
const Recovery::Action action = Recovery::select(
|
||||
stateFor("/mqtt_prefs", primary), stateFor("/mqtt_prefs.tmp", temp),
|
||||
stateFor("/mqtt_prefs.bak", backup));
|
||||
if (action == Recovery::Action::PromoteTemp) {
|
||||
rename("/mqtt_prefs.tmp", "/mqtt_prefs");
|
||||
// Match production: once a usable temp becomes primary, every backup is
|
||||
// stale and is cleared so a second save can start this boot.
|
||||
if (temp == Recovery::FileState::Usable && backup != Recovery::FileState::Missing) {
|
||||
_files.erase("/mqtt_prefs.bak");
|
||||
}
|
||||
if (action == Recovery::Action::DiscardTemp) {
|
||||
_files.erase("/mqtt_prefs.tmp");
|
||||
return;
|
||||
}
|
||||
if (action == Recovery::Action::PromoteBackup) {
|
||||
@@ -376,7 +373,7 @@ TEST(MQTTPrefsAtomicStore, AnyFailureAbortsAndPreservesExistingSource) {
|
||||
InMemoryStore store(test_case.point);
|
||||
EXPECT_EQ(test_case.expected, run(&store));
|
||||
EXPECT_EQ(source, store.source());
|
||||
EXPECT_EQ(test_case.point == FailurePoint::Commit, store.tempExists());
|
||||
EXPECT_FALSE(store.tempExists());
|
||||
EXPECT_EQ(1, store.begin_calls);
|
||||
EXPECT_EQ(test_case.writes, store.write_calls);
|
||||
EXPECT_EQ(test_case.finishes, store.finish_calls);
|
||||
@@ -539,8 +536,8 @@ TEST(MQTTPrefsAtomicStore, SpiffsPowerCutsAtEveryPublishBoundaryLeaveRecoverable
|
||||
} cases[] = {
|
||||
// Temp has not become the committed image yet, so the old primary wins.
|
||||
{SpiffsMqttTransaction::Boundary::BeforeBackupRename, SpiffsMqttTransaction::oldImage()},
|
||||
// Old primary is .bak and verified new temp wins the recovery race.
|
||||
{SpiffsMqttTransaction::Boundary::AfterBackupRename, SpiffsMqttTransaction::newImage()},
|
||||
// Not published yet: the previous committed backup still wins.
|
||||
{SpiffsMqttTransaction::Boundary::AfterBackupRename, SpiffsMqttTransaction::oldImage()},
|
||||
{SpiffsMqttTransaction::Boundary::AfterPrimaryRename, SpiffsMqttTransaction::newImage()},
|
||||
{SpiffsMqttTransaction::Boundary::AfterBackupCleanup, SpiffsMqttTransaction::newImage()},
|
||||
};
|
||||
@@ -573,12 +570,12 @@ TEST(MQTTPrefsAtomicStore, RecoveredUsablePrimaryClearsOpaqueTransactionArtifact
|
||||
{
|
||||
SpiffsMqttTransaction store;
|
||||
store.cutAt(SpiffsMqttTransaction::Boundary::AfterBackupRename);
|
||||
// A current-format temp wins; the old backup need not be decodable to be
|
||||
// stale once that usable temp owns the primary name.
|
||||
// An opaque backup was the previous committed image. A known-format temp
|
||||
// must not replace it, and saving remains held for operator recovery.
|
||||
store.recover(Recovery::FileState::Usable, Recovery::FileState::Usable,
|
||||
Recovery::FileState::Preserve);
|
||||
EXPECT_EQ(SpiffsMqttTransaction::newImage(), store.primary());
|
||||
EXPECT_TRUE(store.canStartSave());
|
||||
EXPECT_EQ(SpiffsMqttTransaction::oldImage(), store.primary());
|
||||
EXPECT_FALSE(store.canStartSave());
|
||||
}
|
||||
{
|
||||
SpiffsMqttTransaction store;
|
||||
@@ -604,11 +601,11 @@ TEST(MQTTPrefsAtomicStore, SpiffsRenameAndCleanupFailuresRemainRecoverable) {
|
||||
{
|
||||
SpiffsMqttTransaction store;
|
||||
EXPECT_FALSE(store.publish(false, true, false));
|
||||
EXPECT_FALSE(store.has("/mqtt_prefs"));
|
||||
EXPECT_TRUE(store.has("/mqtt_prefs.tmp"));
|
||||
EXPECT_TRUE(store.has("/mqtt_prefs.bak"));
|
||||
EXPECT_TRUE(store.has("/mqtt_prefs"));
|
||||
EXPECT_FALSE(store.has("/mqtt_prefs.tmp"));
|
||||
EXPECT_FALSE(store.has("/mqtt_prefs.bak"));
|
||||
store.recover();
|
||||
EXPECT_EQ(SpiffsMqttTransaction::newImage(), store.primary());
|
||||
EXPECT_EQ(SpiffsMqttTransaction::oldImage(), store.primary());
|
||||
EXPECT_FALSE(store.has("/mqtt_prefs.tmp"));
|
||||
EXPECT_FALSE(store.has("/mqtt_prefs.bak"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Exercise production MQTT commit/recovery after rejected saves and power loss."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from test_common_prefs_commit import HARNESS
|
||||
from test_replay_reset_integration import extract_braced
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class MqttPrefsCommitTest(unittest.TestCase):
|
||||
def test_rejected_changes_never_publish_during_recovery(self):
|
||||
source = (ROOT / 'src/helpers/CommonCLI.cpp').read_text()
|
||||
filesystem = HARNESS[:HARNESS.index('@STORE@')]
|
||||
# ESP32 exists() opens the file, but native rename/stat do not. Model
|
||||
# those separately so a failed read cannot masquerade as absence.
|
||||
filesystem = filesystem.replace(
|
||||
'bool exists(const char* path) const { return files.count(path) != 0; }',
|
||||
'bool exists(const char* path) const { return files.count(path) != 0 '
|
||||
'&& !faults.count(std::string("open:r:") + path); }')
|
||||
filesystem = filesystem.replace(
|
||||
'|| !exists(from) || exists(to)',
|
||||
'|| !files.count(from) || files.count(to)')
|
||||
filesystem = filesystem.replace(
|
||||
'if (!exists(path)) return {};', 'if (!files.count(path)) return {};')
|
||||
filesystem = filesystem.replace(
|
||||
'bool short_write = false;', 'MemoryFS();\n bool short_write = false;')
|
||||
filesystem = filesystem.replace(
|
||||
'size_t write(const uint8_t*, size_t);',
|
||||
'size_t write(const uint8_t*, size_t);\n'
|
||||
' size_t read(uint8_t* out, size_t count) {\n'
|
||||
' if (!valid) return 0;\n'
|
||||
' count = count < bytes->size() ? count : bytes->size();\n'
|
||||
' for (size_t i=0;i<count;++i) out[i]=(*bytes)[i];\n'
|
||||
' return count;\n'
|
||||
' }')
|
||||
program = filesystem + r'''
|
||||
#include <helpers/MQTTPrefsRecovery.h>
|
||||
#include <helpers/FilePresence.h>
|
||||
#define MESH_DEBUG_PRINTLN(...) ((void)0)
|
||||
static MemoryFS* stat_fs = nullptr;
|
||||
MemoryFS::MemoryFS() { stat_fs=this; }
|
||||
extern "C" int stat(const char* absolute, struct stat*) noexcept {
|
||||
const std::string path=std::string(absolute).substr(7); // /spiffs
|
||||
if (stat_fs->faults.count("stat:" + path)) { errno=EIO;return -1; }
|
||||
if (!stat_fs->files.count(path)) { errno=ENOENT;return -1; }
|
||||
return 0;
|
||||
}
|
||||
// Codec layout correctness has separate native tests. Exercise the actual
|
||||
// production presence/read classifier with a small opaque-layout marker.
|
||||
struct MQTTPrefsHeader { uint8_t bytes[4]; };
|
||||
namespace MQTTPrefsCodec {
|
||||
struct Plan { bool preserve_file; };
|
||||
Plan classify(const uint8_t* prefix,size_t read,size_t size) {
|
||||
return {size < sizeof(MQTTPrefsHeader) || read != sizeof(MQTTPrefsHeader)
|
||||
|| prefix[0] == 0xff};
|
||||
}
|
||||
}
|
||||
'''
|
||||
program += extract_braced(source, 'static File openMqttPrefsRead(') + '\n'
|
||||
program += extract_braced(source, 'static MQTTPrefsRecovery::FileState mqttPrefsFileState(') + '\n'
|
||||
program += extract_braced(source, 'static bool recoverMqttPrefsFiles(') + '\n'
|
||||
program += extract_braced(source, 'class MQTTPrefsFileStore') + ';\n'
|
||||
program += r'''
|
||||
const std::vector<uint8_t> previous = {1, 2, 3, 4};
|
||||
const std::vector<uint8_t> candidate = {9, 8, 7, 6, 5};
|
||||
bool save(MemoryFS& fs) {
|
||||
MQTTPrefsFileStore store(&fs);
|
||||
return MQTTPrefsAtomicStore::imageCommitted(MQTTPrefsAtomicStore::writeImage(
|
||||
store, [&](MQTTPrefsFileStore& target) {
|
||||
return target.write(candidate.data(), candidate.size()) == candidate.size();
|
||||
}));
|
||||
}
|
||||
int main() {
|
||||
// Individual failures retain the published previous configuration.
|
||||
for (const char* fault : {"open:w:/mqtt_prefs.tmp", "open:r:/mqtt_prefs.tmp",
|
||||
"rename:/mqtt_prefs:/mqtt_prefs.bak",
|
||||
"rename:/mqtt_prefs.tmp:/mqtt_prefs"}) {
|
||||
MemoryFS fs; fs.put("/mqtt_prefs", previous); fs.faults.insert(fault);
|
||||
assert(!save(fs));
|
||||
assert(fs.get("/mqtt_prefs") == previous);
|
||||
fs.faults.clear();
|
||||
assert(!recoverMqttPrefsFiles(&fs));
|
||||
assert(fs.get("/mqtt_prefs") == previous);
|
||||
assert(save(fs)); // no reboot needed after a transient failure
|
||||
assert(fs.get("/mqtt_prefs") == candidate);
|
||||
}
|
||||
{
|
||||
MemoryFS fs; fs.put("/mqtt_prefs", previous); fs.short_write=true;
|
||||
assert(!save(fs)); assert(fs.get("/mqtt_prefs") == previous);
|
||||
fs.short_write=false;
|
||||
assert(save(fs));
|
||||
}
|
||||
// Compound failure: publish, rollback, and scratch removal all fail.
|
||||
for (bool reboot : {false, true}) {
|
||||
MemoryFS fs; fs.put("/mqtt_prefs", previous);
|
||||
fs.faults = {"rename:/mqtt_prefs.tmp:/mqtt_prefs",
|
||||
"rename:/mqtt_prefs.bak:/mqtt_prefs", "remove:/mqtt_prefs.tmp"};
|
||||
assert(!save(fs));
|
||||
assert(!fs.exists("/mqtt_prefs") && fs.get("/mqtt_prefs.bak") == previous);
|
||||
assert(fs.get("/mqtt_prefs.tmp") == candidate);
|
||||
fs.faults.clear();
|
||||
if (reboot) {
|
||||
assert(!recoverMqttPrefsFiles(&fs));
|
||||
assert(fs.get("/mqtt_prefs") == previous);
|
||||
} else {
|
||||
// A subsequent attempted save can finish rollback itself. Refuse the new
|
||||
// write, then verify that begin() recovered only the old published image.
|
||||
fs.faults.insert("open:w:/mqtt_prefs.tmp");
|
||||
assert(!save(fs)); assert(fs.get("/mqtt_prefs") == previous);
|
||||
}
|
||||
}
|
||||
// A rejected first save has no backup; it must never turn into a saved image.
|
||||
{
|
||||
MemoryFS fs;
|
||||
fs.faults = {"rename:/mqtt_prefs.tmp:/mqtt_prefs", "remove:/mqtt_prefs.tmp"};
|
||||
assert(!save(fs)); assert(fs.exists("/mqtt_prefs.tmp"));
|
||||
fs.faults.clear(); assert(!recoverMqttPrefsFiles(&fs));
|
||||
assert(!fs.exists("/mqtt_prefs") && !fs.exists("/mqtt_prefs.tmp"));
|
||||
assert(save(fs)); assert(fs.get("/mqtt_prefs") == candidate);
|
||||
}
|
||||
// Power loss at each publication boundary: only a named primary is committed.
|
||||
for (unsigned boundary=0; boundary<4; ++boundary) {
|
||||
MemoryFS fs; fs.put("/mqtt_prefs", previous); fs.put("/mqtt_prefs.tmp", candidate);
|
||||
if (boundary >= 1) assert(fs.rename("/mqtt_prefs", "/mqtt_prefs.bak"));
|
||||
if (boundary >= 2) assert(fs.rename("/mqtt_prefs.tmp", "/mqtt_prefs"));
|
||||
if (boundary >= 3) assert(fs.remove("/mqtt_prefs.bak"));
|
||||
assert(!recoverMqttPrefsFiles(&fs));
|
||||
assert(fs.get("/mqtt_prefs") == (boundary < 2 ? previous : candidate));
|
||||
}
|
||||
// Failure to clean a backup after a successful publication is not rejection.
|
||||
{
|
||||
MemoryFS fs; fs.put("/mqtt_prefs", previous);
|
||||
fs.faults.insert("remove:/mqtt_prefs.bak");
|
||||
assert(save(fs)); assert(fs.get("/mqtt_prefs") == candidate);
|
||||
fs.faults.clear(); assert(!recoverMqttPrefsFiles(&fs));
|
||||
assert(fs.get("/mqtt_prefs") == candidate);
|
||||
assert(!fs.exists("/mqtt_prefs.bak"));
|
||||
}
|
||||
// Preserve opaque committed primary/backup images, not an unpublished temp.
|
||||
for (bool backup : {false, true}) {
|
||||
MemoryFS fs; const char* path = backup ? "/mqtt_prefs.bak" : "/mqtt_prefs";
|
||||
const std::vector<uint8_t> opaque = {0xff, 2, 3, 4};
|
||||
fs.put(path, opaque); fs.put("/mqtt_prefs.tmp", candidate);
|
||||
assert(recoverMqttPrefsFiles(&fs));
|
||||
assert(fs.get("/mqtt_prefs") == opaque);
|
||||
assert(!save(fs)); assert(fs.get("/mqtt_prefs") == opaque);
|
||||
}
|
||||
// A failed open is not a missing committed file, even when exists() says so.
|
||||
for (bool backup : {false, true}) {
|
||||
MemoryFS fs; const char* path = backup ? "/mqtt_prefs.bak" : "/mqtt_prefs";
|
||||
fs.put(path, previous); fs.put("/mqtt_prefs.tmp", candidate);
|
||||
fs.faults.insert(std::string("open:r:") + path);
|
||||
assert(!fs.exists(path));
|
||||
assert(mqttPrefsFileState(&fs,path) == MQTTPrefsRecovery::FileState::Preserve);
|
||||
assert(recoverMqttPrefsFiles(&fs));
|
||||
assert(fs.get("/mqtt_prefs") == previous);
|
||||
fs.faults.insert("open:r:/mqtt_prefs");
|
||||
assert(!save(fs)); assert(fs.get("/mqtt_prefs") == previous);
|
||||
assert(fs.get("/mqtt_prefs.tmp") == candidate);
|
||||
fs.faults.clear(); assert(!recoverMqttPrefsFiles(&fs));
|
||||
assert(fs.get("/mqtt_prefs") == previous);
|
||||
assert(save(fs)); assert(fs.get("/mqtt_prefs") == candidate);
|
||||
}
|
||||
// Metadata I/O failures also hold the existing image; retry can recover.
|
||||
{
|
||||
MemoryFS fs; fs.put("/mqtt_prefs",previous);
|
||||
fs.faults.insert("stat:/mqtt_prefs");
|
||||
assert(recoverMqttPrefsFiles(&fs)); assert(!save(fs));
|
||||
assert(fs.get("/mqtt_prefs") == previous && !fs.exists("/mqtt_prefs.tmp"));
|
||||
fs.faults.clear(); assert(save(fs));
|
||||
}
|
||||
// Failed recovery holds artifacts and refuses a new write.
|
||||
{
|
||||
MemoryFS fs; fs.put("/mqtt_prefs.bak", previous); fs.put("/mqtt_prefs.tmp", candidate);
|
||||
fs.faults.insert("rename:/mqtt_prefs.bak:/mqtt_prefs");
|
||||
assert(recoverMqttPrefsFiles(&fs)); assert(!save(fs));
|
||||
assert(fs.get("/mqtt_prefs.bak") == previous && fs.get("/mqtt_prefs.tmp") == candidate);
|
||||
}
|
||||
puts("MQTT rejected-save and power-loss recovery scenarios passed");
|
||||
}
|
||||
'''
|
||||
with tempfile.TemporaryDirectory(prefix='mesh-mqtt-commit-') as directory:
|
||||
cpp, exe = Path(directory) / 'test.cpp', Path(directory) / 'test'
|
||||
cpp.write_text(program)
|
||||
subprocess.run(['g++', '-std=c++17', '-Wall', '-Wextra', '-Werror',
|
||||
'-DESP32_PLATFORM',
|
||||
'-fsanitize=address,undefined', '-fno-pie', '-no-pie',
|
||||
'-I', str(ROOT / 'src'), str(cpp), '-o', str(exe)], check=True)
|
||||
subprocess.run([str(exe)], check=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,282 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise production OTA identity refresh and deferred persistent policy load."""
|
||||
from pathlib import Path
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from test_replay_reset_integration import extract_braced
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
HARNESS = r'''
|
||||
#include <helpers/ota/OtaContext.h>
|
||||
#include <helpers/ota/OtaProtocol.h>
|
||||
#include <cassert>
|
||||
#include <new>
|
||||
#include <vector>
|
||||
#include "vectors.h"
|
||||
using namespace mesh::ota;
|
||||
static bool allocation_fails = false;
|
||||
static unsigned allocation_attempts = 0;
|
||||
void* operator new(std::size_t size, const std::nothrow_t&) noexcept {
|
||||
++allocation_attempts;
|
||||
return allocation_fails ? nullptr : ::operator new(size);
|
||||
}
|
||||
namespace mesh { namespace ota {
|
||||
bool ota_self_firmware(SelfFwInfo& info) { info = SelfFwInfo(); return false; }
|
||||
} }
|
||||
static std::vector<uint8_t> wire;
|
||||
static bool send(void*, const uint8_t* data, uint16_t length, bool) {
|
||||
wire.assign(data, data + length); return true;
|
||||
}
|
||||
static AdvMsg beacon(OtaManager& manager) {
|
||||
manager.announce();
|
||||
AdvMsg adv{};
|
||||
assert(decode_adv(wire.data(), wire.size(), adv));
|
||||
return adv;
|
||||
}
|
||||
#if defined(OTA_SHARED_COMPANION_QUEUE)
|
||||
static OtaContext* borrowed_context = nullptr;
|
||||
static OtaContext* acquire(void*) {
|
||||
borrowed_context = new (std::nothrow) OtaContext;
|
||||
return borrowed_context;
|
||||
}
|
||||
static void release(void*) { delete borrowed_context; borrowed_context = nullptr; }
|
||||
#endif
|
||||
struct NodePrefs {
|
||||
uint8_t ota_autofetch=2, ota_max_hops=7, ota_autoinstall=1, ota_signer_count=1;
|
||||
uint16_t ota_checkpoint_blocks=99, ota_advert_interval=180;
|
||||
uint8_t ota_signers[MAX_OTA_SIGNERS][32]={{42}};
|
||||
};
|
||||
struct CommonCLI {
|
||||
NodePrefs prefs;
|
||||
NodePrefs* _prefs = &prefs;
|
||||
void syncOtaConfigFromPrefs();
|
||||
};
|
||||
@SYNC@
|
||||
struct TestIdentity { uint8_t pub_key[32] = {}; };
|
||||
namespace mesh {
|
||||
static bool generation_ok = true;
|
||||
static unsigned generation_calls = 0;
|
||||
static bool hasReservedIdentityPrefix(const TestIdentity& id) { return !id.pub_key[0]; }
|
||||
static bool generateUsableLocalIdentity(TestIdentity& id, int) {
|
||||
++generation_calls;
|
||||
id.pub_key[0] = 0x91; return generation_ok;
|
||||
}
|
||||
static void discardESP32TrueRandom() {}
|
||||
}
|
||||
struct Store {
|
||||
bool load_ok = true, can_create = true, save_ok = true;
|
||||
unsigned saves = 0;
|
||||
bool loadMainIdentity(TestIdentity& id) { id.pub_key[0] = load_ok ? 0x12 : 0; return load_ok; }
|
||||
bool canCreateMainIdentity() const { return can_create; }
|
||||
bool saveMainIdentity(const TestIdentity&) { ++saves; return save_ok; }
|
||||
};
|
||||
struct Companion {
|
||||
Store store;
|
||||
Store* _store = &store;
|
||||
TestIdentity self_id;
|
||||
struct Board { bool rebooted = false; void reboot() { rebooted = true; } } board;
|
||||
int radio_new_identity = 0;
|
||||
bool accepted = false;
|
||||
void loadIdentity() {
|
||||
@STARTUP@
|
||||
accepted = true;
|
||||
}
|
||||
bool ok_reply = false;
|
||||
void writeOKFrame() { ok_reply = true; }
|
||||
void importIdentity(const TestIdentity& identity) {
|
||||
@IMPORT@
|
||||
}
|
||||
};
|
||||
static void checkPolicy(const OtaContext& c, const NodePrefs& prefs) {
|
||||
assert(c.manager.max_hops() == prefs.ota_max_hops);
|
||||
assert(c.manager.checkpoint_blocks() == prefs.ota_checkpoint_blocks);
|
||||
assert(c.manager.advert_mins() == prefs.ota_advert_interval);
|
||||
assert(c.allow.count() == prefs.ota_signer_count);
|
||||
assert(c.allow.contains(prefs.ota_signers[0]));
|
||||
#if defined(OTA_SEEDER_ONLY)
|
||||
assert(c.manager.autofetch() == 0 && c.autoinstall == 0);
|
||||
#else
|
||||
assert(c.manager.autofetch() == prefs.ota_autofetch);
|
||||
assert(c.autoinstall == prefs.ota_autoinstall);
|
||||
#endif
|
||||
}
|
||||
int main() {
|
||||
#if defined(OTA_SHARED_COMPANION_QUEUE)
|
||||
ota_set_context_storage(nullptr, acquire, release);
|
||||
#endif
|
||||
uint8_t initial_id[32] = {};
|
||||
ota_begin_context(SIM_TARGET_ID, send, nullptr, "test", initial_id);
|
||||
|
||||
// Loaded, generated, and failed startup identities run the real Companion block.
|
||||
Companion loaded;
|
||||
loaded.loadIdentity();
|
||||
assert(loaded.accepted && !loaded.board.rebooted && loaded.store.saves == 0);
|
||||
#if OTA_DYNAMIC_CONTEXT
|
||||
assert(!ota_context_if_active()); // an identity refresh must not claim storage
|
||||
#endif
|
||||
assert(ota_acquire_context(nullptr, 0));
|
||||
assert(beacon(ota_ctx().manager).seeder_id[0] == 0x12);
|
||||
Companion generated;
|
||||
generated.store.load_ok = false;
|
||||
generated.loadIdentity();
|
||||
assert(generated.accepted && generated.store.saves == 1);
|
||||
assert(beacon(ota_ctx().manager).seeder_id[0] == 0x91);
|
||||
Companion blocked;
|
||||
blocked.store.load_ok = false; blocked.store.can_create = false;
|
||||
blocked.loadIdentity();
|
||||
assert(!blocked.accepted && blocked.board.rebooted && blocked.store.saves == 0);
|
||||
assert(beacon(ota_ctx().manager).seeder_id[0] == 0x91);
|
||||
Companion failed_save;
|
||||
failed_save.store.load_ok = false; failed_save.store.save_ok = false;
|
||||
failed_save.loadIdentity();
|
||||
assert(!failed_save.accepted && failed_save.board.rebooted);
|
||||
assert(beacon(ota_ctx().manager).seeder_id[0] == 0x91);
|
||||
|
||||
// Changing identity must preserve current fetch progress and the served set.
|
||||
auto& manager = ota_ctx().manager;
|
||||
assert(manager.serve(SIM_MOTA_1K, SIM_MOTA_1K_LEN));
|
||||
MotaManifest manifest;
|
||||
assert(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest));
|
||||
assert(manager.pull_archive(manifest.merkle_root, SIM_TARGET_ID) == OtaManager::PULL_STARTED);
|
||||
const auto prior_state = manager.fetchState();
|
||||
assert(prior_state != OtaManager::IDLE);
|
||||
TestIdentity imported; imported.pub_key[0] = 0x34;
|
||||
loaded.importIdentity(imported);
|
||||
assert(loaded.ok_reply && loaded.self_id.pub_key[0] == 0x34);
|
||||
assert(manager.fetchState() == prior_state && manager.servedCount() == 1);
|
||||
assert(beacon(manager).seeder_id[0] == 0x34);
|
||||
loaded.store.save_ok = false; loaded.ok_reply = false;
|
||||
imported.pub_key[0] = 0x56;
|
||||
loaded.importIdentity(imported);
|
||||
assert(!loaded.ok_reply && loaded.self_id.pub_key[0] == 0x34);
|
||||
assert(beacon(manager).seeder_id[0] == 0x34);
|
||||
ota_refresh_seeder_identity(nullptr);
|
||||
assert(beacon(manager).seeder_id[0] == 0x34);
|
||||
|
||||
// Two actual identities retain separate discovery records.
|
||||
OtaManager receiver;
|
||||
receiver.begin(SIM_TARGET_ID, send, nullptr);
|
||||
AdvMsg adv = beacon(manager);
|
||||
adv.n_motas = 1;
|
||||
uint8_t packet[256];
|
||||
receiver.on_message(packet, encode_adv(packet, sizeof(packet), adv));
|
||||
generated.loadIdentity();
|
||||
adv = beacon(manager); adv.n_motas = 1;
|
||||
receiver.on_message(packet, encode_adv(packet, sizeof(packet), adv));
|
||||
assert(receiver.sourceCount() == 2);
|
||||
manager.reset_session();
|
||||
#if OTA_DYNAMIC_CONTEXT
|
||||
ota_release_context_if_idle(false);
|
||||
assert(!ota_context_if_active());
|
||||
assert(ota_acquire_context(nullptr, 0));
|
||||
assert(beacon(ota_ctx().manager).seeder_id[0] == 0x91);
|
||||
ota_release_context_if_idle(false);
|
||||
#endif
|
||||
|
||||
// Persisted policy registration works while allocation is impossible, and
|
||||
// policy/keys remain available on every later successful workspace claim.
|
||||
CommonCLI cli;
|
||||
allocation_fails = true;
|
||||
const auto attempts = allocation_attempts;
|
||||
cli.syncOtaConfigFromPrefs();
|
||||
assert(allocation_attempts == attempts);
|
||||
assert(ota_hop_limit() == cli.prefs.ota_max_hops);
|
||||
#if OTA_DYNAMIC_CONTEXT
|
||||
assert(!ota_context_if_active());
|
||||
assert(!ota_acquire_context(nullptr, 0));
|
||||
assert(!ota_context_if_active());
|
||||
#endif
|
||||
allocation_fails = false;
|
||||
for (unsigned cycle = 0; cycle < 3; ++cycle) {
|
||||
assert(ota_acquire_context(nullptr, 0));
|
||||
checkPolicy(ota_ctx(), cli.prefs);
|
||||
#if OTA_DYNAMIC_CONTEXT
|
||||
ota_release_context_if_idle(false);
|
||||
assert(!ota_context_if_active());
|
||||
++cli.prefs.ota_advert_interval; // a later saved value, no stale startup copy
|
||||
#endif
|
||||
}
|
||||
// Re-registering against an active session refreshes policy without reset.
|
||||
assert(ota_acquire_context(nullptr, 0));
|
||||
assert(ota_ctx().manager.pull_archive(manifest.merkle_root, SIM_TARGET_ID)
|
||||
== OtaManager::PULL_STARTED);
|
||||
auto state = ota_ctx().manager.fetchState();
|
||||
cli.prefs.ota_max_hops = 2;
|
||||
cli.syncOtaConfigFromPrefs();
|
||||
assert(ota_ctx().manager.fetchState() == state);
|
||||
checkPolicy(ota_ctx(), cli.prefs);
|
||||
ota_ctx().manager.reset_session();
|
||||
#if OTA_DYNAMIC_CONTEXT
|
||||
ota_release_context_if_idle(false);
|
||||
#endif
|
||||
ota_set_context_config_loader(nullptr);
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class OtaIdentityPolicyTest(unittest.TestCase):
|
||||
def test_static_heap_and_borrowed_contexts_preserve_identity_and_policy(self):
|
||||
companion = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
|
||||
begin = extract_braced(companion, "void MyMesh::begin(")
|
||||
startup = begin[begin.index("const bool identity_loaded"):
|
||||
begin.index("// if name is provided")]
|
||||
self.assertLess(begin.index("BaseChatMesh::begin()"),
|
||||
begin.index("ota_refresh_seeder_identity(self_id.pub_key)"))
|
||||
imported = companion[companion.index("cmd_frame[0] == CMD_IMPORT_PRIVATE_KEY"):]
|
||||
# Execute the real publication prefix. Contact-cache refresh after its
|
||||
# acknowledgement is unrelated to the OTA identity being checked here.
|
||||
imported = extract_braced(imported, "if (_store->saveMainIdentity(identity))")
|
||||
imported = imported[:imported.index("// re-load contacts")] + "}\n"
|
||||
sync = extract_braced((ROOT / "src/helpers/CommonCLI.cpp").read_text(),
|
||||
"void CommonCLI::syncOtaConfigFromPrefs()")
|
||||
source_text = HARNESS.replace("@SYNC@", sync).replace(
|
||||
"@STARTUP@", startup).replace("@IMPORT@", imported)
|
||||
with tempfile.TemporaryDirectory(prefix="ota-identity-policy-") as directory:
|
||||
path = Path(directory)
|
||||
source = path / "test.cpp"
|
||||
source.write_text(source_text)
|
||||
(path / "vectors.h").write_text('#include "' +
|
||||
(ROOT / "test/test_ota/mota_vectors.h").as_posix() + '"\n')
|
||||
sanitizers = [] if os.name == "nt" else ["-fsanitize=address,undefined"]
|
||||
tinf = path / "tinf.o"
|
||||
subprocess.run([shutil.which("cc") or "gcc", "-DENABLE_OTA=1",
|
||||
*sanitizers, "-c", str(ROOT / "src/helpers/ota/OtaTinf.c"),
|
||||
"-o", str(tinf)], check=True)
|
||||
for mode in ("static", "heap", "shared"):
|
||||
for seeder in (False, True):
|
||||
if mode == "shared" and not seeder:
|
||||
continue # borrowing the queue is source-only by design
|
||||
with self.subTest(mode=mode, seeder=seeder):
|
||||
# Production installers use a flash-backed fetch store.
|
||||
# A small RAM stand-in avoids platform flash APIs while
|
||||
# keeping the real heap-context budget assertion valid.
|
||||
flags = ["-DENABLE_OTA=1", "-DESP32_PLATFORM=1",
|
||||
"-DOTA_FETCH_BUF_SIZE=4096"]
|
||||
if mode == "heap":
|
||||
flags += ["-DOTA_HEAP_CONTEXT=1"]
|
||||
if mode == "shared":
|
||||
flags += ["-DOTA_SHARED_COMPANION_QUEUE=1",
|
||||
"-DCOMPANION_RADIO_FULL=1"]
|
||||
if seeder:
|
||||
flags += ["-DOTA_SEEDER_ONLY=1"]
|
||||
binary = path / "identity-policy.exe"
|
||||
sources = ["OtaContext.cpp", "OtaManager.cpp", "OtaProtocol.cpp",
|
||||
"MotaContainer.cpp", "MerkleTree.cpp", "OtaDeflate.cpp"]
|
||||
result = subprocess.run([
|
||||
"c++", "-std=c++17", *sanitizers, *flags,
|
||||
"-I", str(ROOT / "src"), "-I", str(ROOT / "test/mocks"),
|
||||
str(source),
|
||||
*[str(ROOT / "src/helpers/ota" / name) for name in sources],
|
||||
str(ROOT / "src/Utils.cpp"), str(tinf), "-o", str(binary),
|
||||
], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
subprocess.run([str(binary)], check=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user