From df0e7c3f891be9024089aefc0a6b4d24d7135d66 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 16 Oct 2026 11:56:00 -0700 Subject: [PATCH] Preserve durable region policy on failed replacement saves --- .github/workflows/run-unit-tests.yml | 1 + src/helpers/RegionMap.cpp | 50 ++- .../region_map_transactions/legacy_writer.inc | 56 ++++ .../mocks/helpers/IdentityStore.h | 120 +++++++ .../region_map_transactions/mocks/sys/stat.h | 23 ++ .../fixtures/region_map_transactions/test.cpp | 317 ++++++++++++++++++ test/test_region_map_transactions.py | 109 ++++++ 7 files changed, 661 insertions(+), 15 deletions(-) create mode 100644 test/fixtures/region_map_transactions/legacy_writer.inc create mode 100644 test/fixtures/region_map_transactions/mocks/helpers/IdentityStore.h create mode 100644 test/fixtures/region_map_transactions/mocks/sys/stat.h create mode 100644 test/fixtures/region_map_transactions/test.cpp create mode 100644 test/test_region_map_transactions.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 8eda09132..3bc4545c6 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -184,6 +184,7 @@ jobs: - name: Verify parser, delivery, storage, permissions, clock and radio stability run: | python3 -B test/test_protocol_parser_bounds.py -v + python3 -B test/test_region_map_transactions.py -v python3 -B test/test_cli_error_bounds.py -v python3 -B test/test_common_cli_argument_bounds.py -v python3 -B test/test_flood_retry_bucket_cli.py -v diff --git a/src/helpers/RegionMap.cpp b/src/helpers/RegionMap.cpp index 0b5a8b216..3c7521cf4 100644 --- a/src/helpers/RegionMap.cpp +++ b/src/helpers/RegionMap.cpp @@ -1,8 +1,13 @@ #include "RegionMap.h" #include #include -#if defined(NRF52_PLATFORM) +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) #include +#elif defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) +#include +#endif +#if defined(ESP32_PLATFORM) +#include #endif #include @@ -62,25 +67,35 @@ static const char* skip_hash(const char* name) { return RegionNameUtils::canonical(name); } -#if !defined(NRF52_PLATFORM) +#if !defined(NRF52_PLATFORM) && !defined(STM32_PLATFORM) && !defined(ESP32_PLATFORM) && !defined(RP2040_PLATFORM) static File openWrite(FILESYSTEM* _fs, const char* filename) { - #if defined(STM32_PLATFORM) - _fs->remove(filename); - return _fs->open(filename, FILE_O_WRITE); - #elif defined(RP2040_PLATFORM) - return _fs->open(filename, "w"); - #else - return _fs->open(filename, "w", true); - #endif + return _fs->open(filename, "w", true); } #endif +static bool regionPathSupported(FILESYSTEM* fs, const char* path) { + if (fs == nullptr || path == nullptr || path[0] == 0) return false; +#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) + // Transaction siblings have 48 bytes, including the suffix and terminator. + if (strlen(path) > 43) return false; +#endif + return true; +} + bool RegionMap::load(FILESYSTEM* _fs, const char* path) { - if (_fs->exists(path ? path : "/regions2")) { + const char* target_path = path ? path : "/regions2"; + if (!regionPathSupported(_fs, target_path)) return false; +#if defined(ESP32_PLATFORM) + if (!mesh::ContactFileTransaction::recover( + _fs, target_path, mesh::filePresence)) return false; +#elif defined(RP2040_PLATFORM) + if (!mesh::ContactFileTransaction::recover(_fs, target_path)) return false; +#endif + if (_fs->exists(target_path)) { #if defined(RP2040_PLATFORM) - File file = _fs->open(path ? path : "/regions2", "r"); + File file = _fs->open(target_path, "r"); #else - File file = _fs->open(path ? path : "/regions2"); + File file = _fs->open(target_path); #endif if (file) { @@ -191,8 +206,13 @@ bool RegionMap::load(FILESYSTEM* _fs, const char* path) { bool RegionMap::save(FILESYSTEM* _fs, const char* path) { const char* target_path = path ? path : "/regions2"; -#if defined(NRF52_PLATFORM) + if (!regionPathSupported(_fs, target_path)) return false; +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) mesh::AtomicFileWriter file(_fs, target_path); +#elif defined(ESP32_PLATFORM) + mesh::ContactFileTransaction file(_fs, target_path, mesh::filePresence); +#elif defined(RP2040_PLATFORM) + mesh::ContactFileTransaction file(_fs, target_path); #else File file = openWrite(_fs, target_path); #endif @@ -218,7 +238,7 @@ bool RegionMap::save(FILESYSTEM* _fs, const char* path) { if (!success) break; // write failed } } -#if defined(NRF52_PLATFORM) +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) || defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) success = file.commit(success); #else file.close(); diff --git a/test/fixtures/region_map_transactions/legacy_writer.inc b/test/fixtures/region_map_transactions/legacy_writer.inc new file mode 100644 index 000000000..4fc642bf5 --- /dev/null +++ b/test/fixtures/region_map_transactions/legacy_writer.inc @@ -0,0 +1,56 @@ +// Exact pre-fix openWrite()/save() bodies from RegionMap.cpp. Keep the old +// destructive writer as an executable negative control for the durable-policy +// assertions, including STM32's removal before opening the replacement. +// Source RegionMap.cpp SHA256 before the fix: +// c6a1ec995dcd072462410476e04756c3ede95c28964244b76b84dafb6b1c963f +#if !defined(NRF52_PLATFORM) +static File openWrite(FILESYSTEM* _fs, const char* filename) { + #if defined(STM32_PLATFORM) + _fs->remove(filename); + return _fs->open(filename, FILE_O_WRITE); + #elif defined(RP2040_PLATFORM) + return _fs->open(filename, "w"); + #else + return _fs->open(filename, "w", true); + #endif +} +#endif + +bool RegionMap::save(FILESYSTEM* _fs, const char* path) { + const char* target_path = path ? path : "/regions2"; +#if defined(NRF52_PLATFORM) + mesh::AtomicFileWriter file(_fs, target_path); +#else + File file = openWrite(_fs, target_path); +#endif + if (file) { + uint8_t pad[128]; + memset(pad, 0, sizeof(pad)); + + bool success = file.write(pad, 3) == 3; // reserved header + success = success && file.write((uint8_t *) &default_id, sizeof(default_id)) == sizeof(default_id); + success = success && file.write((uint8_t *) &home_id, sizeof(home_id)) == sizeof(home_id); + success = success && file.write((uint8_t *) &wildcard.flags, sizeof(wildcard.flags)) == sizeof(wildcard.flags); + success = success && file.write((uint8_t *) &next_id, sizeof(next_id)) == sizeof(next_id); + + if (success) { + for (int i = 0; i < num_regions; i++) { + auto r = ®ions[i]; + + success = file.write((uint8_t *) &r->id, sizeof(r->id)) == sizeof(r->id); + success = success && file.write((uint8_t *) &r->parent, sizeof(r->parent)) == sizeof(r->parent); + success = success && file.write((uint8_t *) r->name, sizeof(r->name)) == sizeof(r->name); + success = success && file.write((uint8_t *) &r->flags, sizeof(r->flags)) == sizeof(r->flags); + success = success && file.write(pad, sizeof(pad)) == sizeof(pad); + if (!success) break; // write failed + } + } +#if defined(NRF52_PLATFORM) + success = file.commit(success); +#else + file.close(); +#endif + return success; + } + return false; // failed +} diff --git a/test/fixtures/region_map_transactions/mocks/helpers/IdentityStore.h b/test/fixtures/region_map_transactions/mocks/helpers/IdentityStore.h new file mode 100644 index 000000000..5cb8b97d2 --- /dev/null +++ b/test/fixtures/region_map_transactions/mocks/helpers/IdentityStore.h @@ -0,0 +1,120 @@ +#pragma once + +// Native adapter for the public File/FILESYSTEM operations used by RegionMap +// and the real AtomicFileWriter/ContactFileTransaction implementations. +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class MemoryFS; + +class File { + MemoryFS* fs_ = nullptr; + std::string path_; + size_t cursor_ = 0; +public: + File() = default; + explicit File(MemoryFS& fs) : fs_(&fs) {} + File(MemoryFS* fs, const char* path) : fs_(fs), path_(path) {} + bool open(const char* path, uint8_t mode); + operator bool() const { return fs_ != nullptr; } + size_t size() const; + bool isDirectory() const { return false; } + int read(uint8_t* data, size_t size); + int read() { uint8_t value; return read(&value, 1) == 1 ? value : -1; } + size_t write(const uint8_t* data, size_t size); + bool setBufferSize(size_t size) { return size != 0; } + void flush() {} + void close() { fs_ = nullptr; } +}; + +class MemoryFS { +public: + std::map> files; + std::set failed_rename_sources; + std::set metadata_errors; + bool fail_write_open = false; + bool fail_read_open = false; + int fail_write_after = -1; + size_t capacity = std::numeric_limits::max(); + unsigned write_calls = 0, open_writes = 0, remove_calls = 0, rename_calls = 0; +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) || defined(RP2040_PLATFORM) + bool rename_replaces = true; +#else + bool rename_replaces = false; +#endif + + size_t used() const { + size_t total = 0; + for (const auto& item : files) total += item.second.size(); + return total; + } + bool exists(const char* path) const { return files.count(path) != 0; } + bool remove(const char* path) { + ++remove_calls; + return files.erase(path) != 0; + } + bool rename(const char* from, const char* to) { + ++rename_calls; + if (failed_rename_sources.count(from) || !exists(from) + || (!rename_replaces && exists(to))) return false; + files[to] = files.at(from); + files.erase(from); + return true; + } + File open(const char* path, const char* mode = "r", bool = false) { + if (*mode == 'w') { + ++open_writes; + if (fail_write_open) return {}; + files[path].clear(); + } else if (fail_read_open) { + return {}; + } + if (!exists(path)) return {}; + return File(this, path); + } + File open(const char* path, uint8_t mode) { + return open(path, mode == 1 ? "w" : "r"); + } +}; + +inline MemoryFS* metadata_filesystem = nullptr; +enum { FILE_O_READ = 0, FILE_O_WRITE = 1 }; + +inline bool File::open(const char* path, uint8_t mode) { + if (!fs_) return false; + *this = fs_->open(path, mode); + return fs_ != nullptr; +} +inline size_t File::size() const { + return fs_ ? fs_->files.at(path_).size() : 0; +} +inline int File::read(uint8_t* data, size_t size) { + if (!fs_) return -1; + const auto& bytes = fs_->files.at(path_); + size = std::min(size, bytes.size() - cursor_); + if (size != 0) std::memcpy(data, bytes.data() + cursor_, size); + cursor_ += size; + return static_cast(size); +} +inline size_t File::write(const uint8_t* data, size_t size) { + if (!fs_) return 0; + ++fs_->write_calls; + auto& bytes = fs_->files.at(path_); + if (fs_->fail_write_after >= 0) { + const size_t limit = static_cast(fs_->fail_write_after); + size = bytes.size() >= limit ? 0 : std::min(size, limit - bytes.size()); + } + const size_t used = fs_->used(); + size = used >= fs_->capacity ? 0 : std::min(size, fs_->capacity - used); + if (size != 0) bytes.insert(bytes.end(), data, data + size); + return size; +} + +#define FILESYSTEM MemoryFS diff --git a/test/fixtures/region_map_transactions/mocks/sys/stat.h b/test/fixtures/region_map_transactions/mocks/sys/stat.h new file mode 100644 index 000000000..c33e246b9 --- /dev/null +++ b/test/fixtures/region_map_transactions/mocks/sys/stat.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include + +struct stat {}; + +inline int stat(const char* path, struct stat*) { + if (!metadata_filesystem || std::strncmp(path, "/spiffs", 7) != 0) { + errno = EIO; + return -1; + } + const std::string relative(path + 7); + if (metadata_filesystem->metadata_errors.count(relative)) { + errno = EIO; + return -1; + } + if (!metadata_filesystem->files.count(relative)) { + errno = ENOENT; + return -1; + } + return 0; +} diff --git a/test/fixtures/region_map_transactions/test.cpp b/test/fixtures/region_map_transactions/test.cpp new file mode 100644 index 000000000..eab0ad922 --- /dev/null +++ b/test/fixtures/region_map_transactions/test.cpp @@ -0,0 +1,317 @@ +// Compile the actual RegionMap declarations and selected implementation bodies +// with the real platform transaction helper, not a second serializer. +#include +#include +#include +#include +#include +#include +#include +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) +#define ATOMIC_FILE_WRITER_IMPLEMENTATION +#include +#else +#include +#if defined(ESP32_PLATFORM) +#include +#endif +#endif + +#include "region_under_test.inc" + +static void require(bool condition, const char* message) { + if (!condition) { + std::fprintf(stderr, "FAIL: %s\n", message); + std::exit(1); + } +} + +static void select(MemoryFS& fs) { metadata_filesystem = &fs; } + +static bool has_policy(RegionMap& regions, bool candidate) { + if (regions.getCount() != (candidate ? 4 : 3)) return false; + const auto* root = regions.findById(11); + const auto* private_region = regions.findById(27); + const auto* local = regions.findById(35); + const auto* added = regions.findById(50); + if (!root || !private_region || !local) return false; + if (std::strcmp(root->name, "#Mountain") || root->parent != 0 + || root->flags != (candidate ? 0 : REGION_DENY_FLOOD | REGION_DENY_DIRECT)) return false; + if (std::strcmp(private_region->name, "$Private") || private_region->parent != 11 + || private_region->flags != REGION_DENY_DIRECT) return false; + if (std::strcmp(local->name, "Local") || local->parent != 0 + || local->flags != REGION_DENY_FLOOD) return false; + if (candidate && (!added || std::strcmp(added->name, "Candidate") + || added->parent != 27 || added->flags != 0)) return false; + if (!candidate && added) return false; + if (!regions.getDefaultRegion() || !regions.getHomeRegion()) return false; + return regions.getWildcard().flags == (candidate ? 0 : REGION_DENY_FLOOD | REGION_DENY_DIRECT) + && regions.getDefaultRegion()->id == (candidate ? 35 : 11) + && regions.getHomeRegion()->id == (candidate ? 11 : 27); +} + +struct Scenario { + TransportKeyStore keys; + MemoryFS fs; + RegionMap original{keys}; + RegionMap candidate{keys}; + std::vector baseline; + std::vector replacement; + std::string path; + + explicit Scenario(const char* target = "/regions2") : path(target) { + select(fs); + RegionEntry* root = original.putRegion("#Mountain", 0, 11); + RegionEntry* private_region = original.putRegion("$Private", 11, 27); + RegionEntry* local = original.putRegion("Local", 0, 35); + require(root && private_region && local, "create baseline region hierarchy"); + root->flags = REGION_DENY_FLOOD | REGION_DENY_DIRECT; + private_region->flags = REGION_DENY_DIRECT; + local->flags = REGION_DENY_FLOOD; + original.getWildcard().flags = REGION_DENY_FLOOD | REGION_DENY_DIRECT; + original.setDefaultRegion(root); + original.setHomeRegion(private_region); + require(original.save(&fs, path.c_str()), "write baseline regions"); + baseline = fs.files.at(path); + require(baseline.size() == 10 + 3 * 164, "legacy region image size unchanged"); + require(candidate.load(&fs, path.c_str()), "load candidate from durable baseline"); + candidate.findById(11)->flags = 0; + candidate.getWildcard().flags = 0; + candidate.setDefaultRegion(candidate.findById(35)); + candidate.setHomeRegion(candidate.findById(11)); + RegionEntry* added = candidate.putRegion("Candidate", 27, 50); + require(added != nullptr, "create candidate region"); + added->flags = 0; + MemoryFS healthy; + select(healthy); + require(candidate.save(&healthy, path.c_str()), "write candidate reference"); + replacement = healthy.files.at(path); + select(fs); + } + + void reboot(MemoryFS& storage, bool expected_candidate = false) { + select(storage); + RegionMap restarted(keys); + const bool loaded = restarted.load(&storage, path.c_str()); + require(loaded && has_policy(restarted, expected_candidate), + expected_candidate ? "durable candidate region/default/home/wildcard policy survived" + : "durable old region/default/home/wildcard policy survived"); + // The next auto-assigned ID is also durable state, not merely recomputed + // policy. Explicit IDs above leave 36 or 51 as the next allocation. + const RegionEntry* next = restarted.putRegion("AfterReboot", 0); + require(next && next->id == (expected_candidate ? 51 : 36), + "durable next region ID survived"); + require(storage.files.at(path) == (expected_candidate ? replacement : baseline), + "exact durable public image retained"); + } +}; + +static void healthy_save_and_reboot() { + Scenario test; + test.reboot(test.fs); + require(test.original.save(&test.fs), "repeated healthy region save"); + test.reboot(test.fs); + require(test.candidate.save(&test.fs), "publish changed region policy"); + test.reboot(test.fs, true); + require(!test.fs.exists("/regions2.tmp") && !test.fs.exists("/regions2.bak"), + "successful save cleaned transaction siblings"); +} + +static void invalid_inputs_do_not_mutate() { + Scenario test; + const auto before = test.fs.files; + const unsigned opens = test.fs.open_writes; + const unsigned removes = test.fs.remove_calls; + const unsigned renames = test.fs.rename_calls; + require(!test.candidate.save(nullptr), "null filesystem save refused"); + require(!test.candidate.load(nullptr), "null filesystem load refused"); + require(!test.candidate.save(&test.fs, ""), "empty target save refused"); + require(!test.candidate.load(&test.fs, ""), "empty target load refused"); + require(test.fs.files == before && test.fs.open_writes == opens + && test.fs.remove_calls == removes && test.fs.rename_calls == renames, + "invalid input left namespace untouched"); + require(has_policy(test.candidate, true), "invalid input retained caller's RAM policy"); + test.reboot(test.fs); +} + +static void refusal_and_short_writes() { + Scenario test; + { + MemoryFS storage = test.fs; + select(storage); + storage.fail_write_open = true; + require(!test.candidate.save(&storage), "replacement open refusal reported"); + storage.fail_write_open = false; + test.reboot(storage); + } + // Exhaust every byte offset. This includes every reserved/header field, + // every region field/padding boundary, and clean record-boundary prefixes. + for (size_t boundary = 0; boundary < test.replacement.size(); ++boundary) { + MemoryFS storage = test.fs; + select(storage); + storage.fail_write_after = static_cast(boundary); + require(!test.candidate.save(&storage), "ordinary short write reported"); + storage.fail_write_after = -1; + test.reboot(storage); + require(!storage.exists("/regions2.tmp"), "short write cleaned staging file"); + require(has_policy(test.candidate, true), "save failure retained caller's RAM policy"); + } + // A full filesystem is independent of the File::write short-write hook: + // committed bytes consume capacity before each staging write is attempted. + for (size_t available = 0; available < test.replacement.size(); ++available) { + MemoryFS storage = test.fs; + select(storage); + storage.capacity = storage.used() + available; + require(!test.candidate.save(&storage), "full-storage write failure reported"); + storage.capacity = std::numeric_limits::max(); + test.reboot(storage); + } + std::printf("PASS: %zu short-write offsets and %zu full-storage capacities\n", + test.replacement.size(), test.replacement.size()); +} + +static void staged_readback_and_publication_failure() { + Scenario test; + { + MemoryFS storage = test.fs; + select(storage); + storage.fail_read_open = true; + require(!test.candidate.save(&storage), "staged readback refusal reported"); + storage.fail_read_open = false; + test.reboot(storage); + } + { + MemoryFS storage = test.fs; + select(storage); + storage.failed_rename_sources.insert("/regions2.tmp"); + require(!test.candidate.save(&storage), "known unchanged publication reported"); + storage.failed_rename_sources.clear(); + test.reboot(storage); + } +#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) + { + MemoryFS storage = test.fs; + select(storage); + storage.failed_rename_sources.insert("/regions2"); + require(!test.candidate.save(&storage), "backup move refusal reported"); + storage.failed_rename_sources.clear(); + test.reboot(storage); + } + { + MemoryFS storage = test.fs; + select(storage); + storage.failed_rename_sources.insert("/regions2.tmp"); + storage.failed_rename_sources.insert("/regions2.bak"); + require(!test.candidate.save(&storage), "failed publication and rollback reported"); + require(!storage.exists("/regions2") && storage.files.at("/regions2.bak") == test.baseline, + "rollback failure retained old recovery image"); + storage.failed_rename_sources.clear(); + test.reboot(storage); + } + { + MemoryFS storage = test.fs; + select(storage); + require(storage.rename("/regions2", "/regions2.bak"), "simulate reset in backup gap"); + storage.files["/regions2.tmp"] = test.replacement; + test.reboot(storage); + require(!storage.exists("/regions2.bak"), "fresh load recovered backup gap"); + } +#endif +} + +static void custom_path_isolation() { + Scenario test("/regions-custom"); + test.fs.files["/regions2"] = {0x12, 0x34}; + test.fs.files["/regions2.tmp"] = {0x56}; + test.fs.files["/regions2.bak"] = {0x78}; + const auto unrelated = test.fs.files; + test.fs.fail_write_after = 9; + require(!test.candidate.save(&test.fs, test.path.c_str()), "custom path short write reported"); + test.fs.fail_write_after = -1; + test.reboot(test.fs); + for (const char* name : {"/regions2", "/regions2.tmp", "/regions2.bak"}) { + require(test.fs.files.at(name) == unrelated.at(name), "custom path left default artifacts alone"); + } +#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) + require(test.fs.rename(test.path.c_str(), (test.path + ".bak").c_str()), + "simulate custom backup gap"); + test.fs.files[test.path + ".tmp"] = test.replacement; + test.reboot(test.fs); + for (const char* name : {"/regions2", "/regions2.tmp", "/regions2.bak"}) { + require(test.fs.files.at(name) == unrelated.at(name), "custom recovery left default artifacts alone"); + } + // ContactFileTransaction has 48 bytes for each sibling pathname, including + // its suffix and terminator. Reject overlong paths before any recovery or + // staging can address a truncated name belonging to another file. + const std::string limit_path = "/" + std::string(42, 'x'); + Scenario limit(limit_path.c_str()); + require(limit.candidate.save(&limit.fs, limit.path.c_str()), "43-byte custom target fits"); + limit.reboot(limit.fs, true); + for (size_t length : {size_t(44), size_t(80), size_t(128)}) { + const std::string path = "/" + std::string(length - 1, 'y'); + MemoryFS storage; + select(storage); + storage.files[path] = test.baseline; + storage.files[path + ".tmp"] = test.replacement; + storage.files[path + ".bak"] = test.baseline; + storage.files[(path + ".tmp").substr(0, 47)] = {0x66}; + storage.files[(path + ".bak").substr(0, 47)] = {0x77}; + const auto before = storage.files; + require(!test.candidate.save(&storage, path.c_str()), "overlong custom save refused"); + RegionMap restarted(test.keys); + require(!restarted.load(&storage, path.c_str()), "overlong custom load refused"); + require(storage.files == before && storage.open_writes == 0 + && storage.remove_calls == 0 && storage.rename_calls == 0, + "overlong custom path left all complete and truncated artifacts untouched"); + } +#endif +} + +#if defined(ESP32_PLATFORM) +static void esp_metadata_errors_fail_closed() { + Scenario test; + for (const char* failed : {"/regions2", "/regions2.bak", "/regions2.tmp"}) { + MemoryFS storage = test.fs; + select(storage); + storage.files["/regions2.tmp"] = {0x61}; + const auto before = storage.files; + storage.metadata_errors.insert(failed); + require(!test.candidate.save(&storage), "ESP metadata error refused save"); + require(storage.files == before && storage.open_writes == test.fs.open_writes + && storage.remove_calls == test.fs.remove_calls + && storage.rename_calls == test.fs.rename_calls, + "ESP metadata error caused no namespace mutation"); + storage.metadata_errors.clear(); + test.reboot(storage); + } + for (const char* failed : {"/regions2", "/regions2.bak"}) { + MemoryFS storage = test.fs; + select(storage); + require(storage.rename("/regions2", "/regions2.bak"), "create metadata recovery gap"); + storage.files["/regions2.tmp"] = test.replacement; + storage.metadata_errors.insert(failed); + const auto before = storage.files; + RegionMap restarted(test.keys); + require(!restarted.load(&storage), "ESP metadata error refused recovery load"); + require(storage.files == before, "ESP metadata error retained backup and candidate bytes"); + storage.metadata_errors.clear(); + test.reboot(storage); + } +} +#endif + +int main(int argc, char** argv) { + if (argc == 2 && std::strcmp(argv[1], "short-write") == 0) { + refusal_and_short_writes(); + return 0; + } + healthy_save_and_reboot(); + invalid_inputs_do_not_mutate(); + refusal_and_short_writes(); + staged_readback_and_publication_failure(); + custom_path_isolation(); +#if defined(ESP32_PLATFORM) + esp_metadata_errors_fail_closed(); +#endif + std::puts("PASS: actual RegionMap durable transaction and reboot policy"); +} diff --git a/test/test_region_map_transactions.py b/test/test_region_map_transactions.py new file mode 100644 index 000000000..2b60dad29 --- /dev/null +++ b/test/test_region_map_transactions.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Run actual RegionMap load/save with real platform transaction helpers.""" + +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +from cpp_source import body + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "test/fixtures/region_map_transactions" + + +def region_implementation(legacy=False): + source = (ROOT / "src/helpers/RegionMap.cpp").read_text(encoding="ascii") + # Retain the exact source helper block between name canonicalization and + # load(), including its platform guards, rather than reproducing recovery + # or path validation in the harness. + helpers_start = source.index("static const char* skip_hash(") + helpers_end = source.index("bool RegionMap::load(", helpers_start) + helpers = source[helpers_start:helpers_end] + if legacy and "static File openWrite(" in helpers: + helpers = helpers.replace(body(helpers, "static File openWrite("), "") + signatures = ( + "RegionMap::RegionMap(", "bool RegionMap::is_name_char(", + "bool RegionMap::load(", "RegionEntry* RegionMap::putRegion(", + "RegionEntry* RegionMap::findByName(", "RegionEntry* RegionMap::findById(", + "RegionEntry* RegionMap::getHomeRegion(", "void RegionMap::setHomeRegion(", + "RegionEntry* RegionMap::getDefaultRegion(", "void RegionMap::setDefaultRegion(", + "bool RegionMap::wouldCreateCycle(", + ) + generated = helpers + "\n" + "\n".join(body(source, signature) for signature in signatures) + generated += "\n" + (FIXTURE / "legacy_writer.inc").read_text(encoding="ascii") if legacy else ( + "\n" + body(source, "bool RegionMap::save(")) + text_helpers = (ROOT / "src/helpers/TxtDataHelpers.cpp").read_text(encoding="ascii") + return generated + "\n" + body(text_helpers, "void StrHelper::strncpy(") + + +class RegionMapTransactionTest(unittest.TestCase): + def run_platform(self, platform, legacy=False): + compiler = shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler, "a native C++ compiler is required") + with tempfile.TemporaryDirectory(prefix="meshcore-region-transactions-") as temporary: + work = Path(temporary) + (work / "region_under_test.inc").write_text(region_implementation(legacy), encoding="ascii") + # Relocate the unchanged real transaction header so its quoted + # platform IdentityStore include resolves to the native adapter. + # PersistentStoreFormat remains the real production header. + (work / "contact_transaction_under_test.h").write_text( + (ROOT / "src/helpers/ContactFileTransaction.h").read_text(encoding="ascii"), + encoding="ascii") + (work / "IdentityStore.h").write_text( + '#include \n', encoding="ascii") + (work / "PersistentStoreFormat.h").write_text( + '#include \n', encoding="ascii") + executable = work / "test" + command = [ + compiler, "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", "-Wno-sign-compare", "-Wno-unused-function", + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie", + "-D" + platform + "=1", "-I", str(work), "-I", str(FIXTURE / "mocks"), + "-I", str(ROOT / "test/mocks"), "-I", str(ROOT / "src"), + str(FIXTURE / "test.cpp"), "-o", str(executable), + ] + compiled = subprocess.run(command, capture_output=True, text=True, timeout=60) + self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr) + environment = os.environ.copy() + environment["ASAN_OPTIONS"] = "detect_leaks=1:abort_on_error=1" + environment["UBSAN_OPTIONS"] = "halt_on_error=1:print_stacktrace=1" + arguments = [str(executable)] + (["short-write"] if legacy else []) + result = subprocess.run(arguments, capture_output=True, text=True, + timeout=60, env=environment) + if legacy: + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("FAIL: durable old region/default/home/wildcard policy survived", result.stderr) + self.assertNotIn("AddressSanitizer", result.stderr) + self.assertNotIn("runtime error:", result.stderr) + else: + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("PASS: actual RegionMap durable transaction and reboot policy", result.stdout) + self.assertIn("666 short-write offsets and 666 full-storage capacities", result.stdout) + + def test_nrf52_durable_transactions(self): + self.run_platform("NRF52_PLATFORM") + + def test_stm32_durable_transactions(self): + self.run_platform("STM32_PLATFORM") + + def test_esp32_durable_transactions(self): + self.run_platform("ESP32_PLATFORM") + + def test_rp2040_durable_transactions(self): + self.run_platform("RP2040_PLATFORM") + + def test_old_esp32_truncation_is_detected(self): + self.run_platform("ESP32_PLATFORM", legacy=True) + + def test_old_rp2040_truncation_is_detected(self): + self.run_platform("RP2040_PLATFORM", legacy=True) + + def test_old_stm32_removal_is_detected(self): + self.run_platform("STM32_PLATFORM", legacy=True) + + +if __name__ == "__main__": + unittest.main()