Preserve durable region policy on failed replacement saves
This commit is contained in:
1 parent
4b81a9d5e0
commit
df0e7c3f89
7 files changed
+661
-15
No files matched your search
@@ -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
|
||||
|
||||
+35
-15
@@ -1,8 +1,13 @@
|
||||
#include "RegionMap.h"
|
||||
#include <helpers/RegionNameUtils.h>
|
||||
#include <helpers/TxtDataHelpers.h>
|
||||
#if defined(NRF52_PLATFORM)
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
#include <helpers/AtomicFileWriter.h>
|
||||
#elif defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)
|
||||
#include <helpers/ContactFileTransaction.h>
|
||||
#endif
|
||||
#if defined(ESP32_PLATFORM)
|
||||
#include <helpers/FilePresence.h>
|
||||
#endif
|
||||
#include <SHA256.h>
|
||||
|
||||
@@ -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<FILESYSTEM>)) 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<FILESYSTEM>);
|
||||
#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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
#pragma once
|
||||
|
||||
// Native adapter for the public File/FILESYSTEM operations used by RegionMap
|
||||
// and the real AtomicFileWriter/ContactFileTransaction implementations.
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<std::string, std::vector<uint8_t>> files;
|
||||
std::set<std::string> failed_rename_sources;
|
||||
std::set<std::string> metadata_errors;
|
||||
bool fail_write_open = false;
|
||||
bool fail_read_open = false;
|
||||
int fail_write_after = -1;
|
||||
size_t capacity = std::numeric_limits<size_t>::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<int>(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<size_t>(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
|
||||
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstring>
|
||||
|
||||
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;
|
||||
}
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
// Compile the actual RegionMap declarations and selected implementation bodies
|
||||
// with the real platform transaction helper, not a second serializer.
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <helpers/RegionMap.h>
|
||||
#include <helpers/RegionNameUtils.h>
|
||||
#include <helpers/TxtDataHelpers.h>
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
#define ATOMIC_FILE_WRITER_IMPLEMENTATION
|
||||
#include <helpers/AtomicFileWriter.h>
|
||||
#else
|
||||
#include <contact_transaction_under_test.h>
|
||||
#if defined(ESP32_PLATFORM)
|
||||
#include <helpers/FilePresence.h>
|
||||
#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<uint8_t> baseline;
|
||||
std::vector<uint8_t> 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<int>(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<size_t>::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");
|
||||
}
|
||||
@@ -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 <helpers/IdentityStore.h>\n', encoding="ascii")
|
||||
(work / "PersistentStoreFormat.h").write_text(
|
||||
'#include <helpers/PersistentStoreFormat.h>\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()
|
||||
Reference in new issue
Block a user