Verify fallback namespace moves before accepting publication or rollback. Reload readable contact images at a safe host boundary, discard only unreadable contact data after bounded retries, and permit volatile operation when storage cannot recover. Preserve unrelated stores and fail explicit durability requests truthfully. Keep existing flash, RAM, OTA and capability limits by streaming nRF52 preferences through the validated wire table and sharing identical contact deadline resets. Add fault-injection, sanitizer, legacy-image parity and CLI lifecycle regressions.
304 lines
15 KiB
Python
304 lines
15 KiB
Python
"""Exercise value-only snapshots using the real adapter-bearing preferences."""
|
|
from pathlib import Path
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
|
|
from test_companion_preferences_transaction import HARNESS, esp_recovery_helpers
|
|
from test_radio_receive_contract import method
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
TESTS = r'''
|
|
#include <type_traits>
|
|
#include <vector>
|
|
@FIELD_CHECKS@
|
|
|
|
static void seedValues(CompanionNodePrefs& prefs, uint32_t& random) {
|
|
auto fill = [&](void* destination, size_t size) {
|
|
auto* bytes = static_cast<uint8_t*>(destination);
|
|
for (size_t i = 0; i < size; ++i) {
|
|
random = random * 1664525u + 1013904223u;
|
|
bytes[i] = static_cast<uint8_t>(random >> 24);
|
|
}
|
|
};
|
|
@SEED_FIELDS@
|
|
}
|
|
|
|
static void equalValues(const CompanionNodePrefs& a,
|
|
const CompanionNodePrefs& b) {
|
|
@COMPARE_FIELDS@
|
|
}
|
|
|
|
static size_t adapterOffset(const CompanionNodePrefs& prefs) {
|
|
// The value helper does not copy the adapters or their inter-field padding.
|
|
// Use the complete object's byte representation for bounded observation.
|
|
const uintptr_t first = reinterpret_cast<uintptr_t>(&prefs);
|
|
const uintptr_t last = reinterpret_cast<uintptr_t>(&prefs.tx_ssh);
|
|
assert(last >= first && last - first < sizeof(prefs));
|
|
return static_cast<size_t>(last - first) + sizeof(prefs.tx_ssh);
|
|
}
|
|
|
|
static std::vector<uint8_t> adapterBytes(const CompanionNodePrefs& prefs) {
|
|
const auto* bytes = reinterpret_cast<const uint8_t*>(&prefs);
|
|
return {bytes + adapterOffset(prefs), bytes + sizeof(prefs)};
|
|
}
|
|
|
|
static void expectTag(CompanionNodePrefs& prefs, const char* expected) {
|
|
char value[32] = {};
|
|
assert(prefs.getCustom()->getByKey("snapshot_tag", value, sizeof(value)));
|
|
assert(!strcmp(value, expected));
|
|
}
|
|
|
|
int main() {
|
|
uint32_t random = 0x19283746;
|
|
for (unsigned trial = 0; trial < 64; ++trial) {
|
|
CompanionNodePrefs source, snapshot;
|
|
seedValues(source, random);
|
|
source.getRadioPrefs()->setFreq(915.25f);
|
|
assert(source.getCustom()->setByKey("snapshot_tag", "source"));
|
|
assert(snapshot.getCustom()->setByKey("snapshot_tag", "snapshot"));
|
|
snapshot.clearDirty();
|
|
assert(source.isDirty() && !snapshot.isDirty());
|
|
auto source_adapters = adapterBytes(source);
|
|
auto snapshot_adapters = adapterBytes(snapshot);
|
|
assert(snapshot.copyPersistedValuesFrom(source));
|
|
equalValues(source, snapshot);
|
|
assert(adapterBytes(source) == source_adapters);
|
|
assert(adapterBytes(snapshot) == snapshot_adapters);
|
|
assert(source.isDirty() && !snapshot.isDirty());
|
|
expectTag(source, "source"); expectTag(snapshot, "snapshot");
|
|
const auto* bytes = reinterpret_cast<const uint8_t*>(&snapshot);
|
|
std::vector<uint8_t> before_self(bytes, bytes + sizeof(snapshot));
|
|
assert(snapshot.copyPersistedValuesFrom(snapshot));
|
|
assert(!memcmp(&snapshot, before_self.data(), before_self.size()));
|
|
// A normally constructed scratch adapter must still own the scratch
|
|
// object, including its dynamic-config fallback after copying values.
|
|
snapshot.getRadioPrefs()->setFreq(910.5f);
|
|
assert(snapshot.freq == 910.5f && source.freq == 915.25f);
|
|
source.radio_fem_txgain = 0;
|
|
assert(snapshot.getCustom()->setByKey("fem_txgain", "1"));
|
|
assert(snapshot.radio_fem_txgain == 1 && source.radio_fem_txgain == 0);
|
|
source.getRadioPrefs()->setFreq(920.5f);
|
|
assert(source.freq == 920.5f && snapshot.freq == 910.5f);
|
|
}
|
|
|
|
unsigned comparisons = 0;
|
|
for (unsigned trial = 0; trial < 3; ++trial) {
|
|
CompanionNodePrefs saved;
|
|
seedValues(saved, random);
|
|
saved.bluetooth_enabled = static_cast<uint8_t>(trial);
|
|
DataStore image_store;
|
|
assert(image_store.savePrefs(saved, 47.125, -122.25));
|
|
auto image = image_store.fs.files["/new_prefs"];
|
|
// The frozen reference remains authoritative only for the historical
|
|
// prefix. Checked new-footer behavior has dedicated persistence tests.
|
|
image.resize(image.size()-mesh::companion::traffic::FooterSize);
|
|
// Test every possible old/torn image length, including every officially
|
|
// supported append boundary and unknown lengths beyond the current tail.
|
|
for (size_t length = 0; length <= image.size() + 5; ++length) {
|
|
auto disk = image;
|
|
disk.resize(length);
|
|
for (size_t quota = 0; quota <= length; ++quota) {
|
|
DataStore actual_store, legacy_store;
|
|
actual_store.fs.files["/new_prefs"] = disk;
|
|
legacy_store.fs.files["/new_prefs"] = disk;
|
|
actual_store.fs.fail_read_after = static_cast<int>(quota);
|
|
legacy_store.fs.fail_read_after = static_cast<int>(quota);
|
|
CompanionNodePrefs actual, legacy;
|
|
seedValues(actual, random);
|
|
assert(legacy.copyPersistedValuesFrom(actual));
|
|
actual.getRadioPrefs()->setFreq(915.25f);
|
|
legacy.getRadioPrefs()->setFreq(915.25f);
|
|
assert(actual.getCustom()->setByKey("snapshot_tag", "actual"));
|
|
assert(legacy.getCustom()->setByKey("snapshot_tag", "legacy"));
|
|
auto actual_adapters = adapterBytes(actual);
|
|
auto legacy_adapters = adapterBytes(legacy);
|
|
double lat = 1, lon = 2, legacy_lat = 1, legacy_lon = 2;
|
|
READ_CALLS.clear(); AVAILABLE_CALLS = 0;
|
|
const bool expected = legacy_store.loadPrefsLegacy(
|
|
"/new_prefs", legacy, legacy_lat, legacy_lon);
|
|
const auto expected_reads = READ_CALLS;
|
|
const unsigned expected_available = AVAILABLE_CALLS;
|
|
READ_CALLS.clear(); AVAILABLE_CALLS = 0;
|
|
const bool got = actual_store.loadPrefsInt(
|
|
"/new_prefs", actual, lat, lon);
|
|
assert(got == expected);
|
|
assert(READ_CALLS == expected_reads);
|
|
// The common footer reader adds one EOF probe after any recognized
|
|
// historical image; no original field read or ordering changes.
|
|
assert(AVAILABLE_CALLS == expected_available+(expected_reads.empty()?0:1));
|
|
if (length == 84 && quota == 84) {
|
|
// The original mandatory prefix has 21 reads, ending at ble_pin.
|
|
// This order is an independent wire contract, not the new table.
|
|
const std::vector<size_t> mandatory = {
|
|
4, 32, 4, 8, 8, 4, 1, 1, 1, 1, 4, 1, 1, 1, 1,
|
|
4, 1, 1, 1, 1, 4};
|
|
assert(expected && READ_CALLS == mandatory);
|
|
unsigned optional_fields = 46;
|
|
#ifdef TBEAM_1W
|
|
optional_fields += 3;
|
|
#endif
|
|
#if defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
|
|
optional_fields += 2;
|
|
#endif
|
|
#if defined(RAK_4631)
|
|
optional_fields += 1; // Checked peripheral footer has its own EOF probe.
|
|
#endif
|
|
// Every absent optional field observes EOF, then the completed
|
|
// image receives the existing final trailing-byte check.
|
|
assert(AVAILABLE_CALLS == optional_fields + 2);
|
|
}
|
|
if(got)legacy.tx_media=legacy.tx_ssh=mesh::RADIO_TX_AUTO;
|
|
equalValues(actual, legacy);
|
|
assert(!memcmp(&lat, &legacy_lat, sizeof(lat)));
|
|
assert(!memcmp(&lon, &legacy_lon, sizeof(lon)));
|
|
assert(actual_store.fs.files["/new_prefs"] == disk);
|
|
assert(legacy_store.fs.files["/new_prefs"] == disk);
|
|
assert(adapterBytes(actual) == actual_adapters);
|
|
assert(adapterBytes(legacy) == legacy_adapters);
|
|
assert(actual.isDirty() && legacy.isDirty());
|
|
expectTag(actual, "actual"); expectTag(legacy, "legacy");
|
|
auto legacy_txgain = legacy.radio_fem_txgain;
|
|
assert(actual.getCustom()->setByKey("fem_txgain", "1"));
|
|
assert(actual.radio_fem_txgain == 1
|
|
&& legacy.radio_fem_txgain == legacy_txgain);
|
|
++comparisons;
|
|
}
|
|
}
|
|
}
|
|
printf("PASS: %u actual-loader value/adapter/failure comparisons\n", comparisons);
|
|
}
|
|
'''
|
|
|
|
|
|
class CompanionPrefsValuesTests(unittest.TestCase):
|
|
def test_value_only_copy_and_full_loader_match_legacy(self):
|
|
source = (ROOT / 'examples/companion_radio/DataStore.cpp').read_text()
|
|
loader = method(source, 'bool DataStore::loadPrefsInt(')
|
|
# A frozen sequential reader, not a clone of the current field table,
|
|
# is the authority for all old/torn images and read failure traces.
|
|
legacy = (ROOT / 'test/fixtures/companion_prefs_storage/legacy_loader.inc').read_text()
|
|
self.assertNotIn('copyPersistedValuesFrom', legacy)
|
|
# Keep the frozen scalar prefix independent of the new GNU table.
|
|
# The RAK-only checked tail was published later; add it explicitly
|
|
# to this test-local reference without rewriting the frozen fixture.
|
|
legacy = legacy.replace(' loaded_prefs.lost_reply = 0;',
|
|
' loaded_prefs.lost_reply = 0;\n'
|
|
'#if defined(RAK_4631)\n loaded_prefs.peripherals_shared = 0;\n#endif')
|
|
size_check = ' if (prefs_size < MIN_PREFS_SIZE || !known_size) {'
|
|
self.assertEqual(legacy.count(size_check), 1)
|
|
legacy = legacy.replace(size_check,
|
|
'#if defined(RAK_4631)\n'
|
|
' known_size = known_size || prefs_size == 244;\n'
|
|
'#endif\n' + size_check)
|
|
trailing_check = ' // Any bytes left over form only part of a historically appended field.'
|
|
self.assertEqual(legacy.count(trailing_check), 1)
|
|
legacy = legacy.replace(trailing_check,
|
|
'#if defined(RAK_4631)\n'
|
|
' if (file.available() != 0) {\n'
|
|
' uint8_t footer[8] = {};\n'
|
|
' success = success && readField(footer, sizeof(footer))\n'
|
|
' && mesh::peripherals::decodeFooter(footer, sizeof(footer),\n'
|
|
' loaded_prefs.peripherals_shared);\n'
|
|
' }\n#endif\n\n' + trailing_check)
|
|
methods = esp_recovery_helpers(source) + '\n'.join((loader, legacy,
|
|
method(source, 'bool DataStore::savePrefs(')))
|
|
code = HARNESS.split('int main(')[0].replace('@METHODS@', methods)
|
|
code = code.replace(' bool loadPrefsInt(const char*, CompanionNodePrefs&, double&, double&);',
|
|
' bool loadPrefsInt(const char*, CompanionNodePrefs&, double&, double&);\n'
|
|
' bool loadPrefsLegacy(const char*, CompanionNodePrefs&, double&, double&);')
|
|
prefs = (ROOT / 'examples/companion_radio/NodePrefs.h').read_text()
|
|
prefix = prefs[prefs.index('class CompanionNodePrefs {'):prefs.index('\nprivate:')]
|
|
declarations = re.findall(
|
|
r'^\s*(?:float|char|u?int(?:8|16|32)_t)\s+([A-Za-z_][A-Za-z_0-9]*)',
|
|
prefix, re.M)
|
|
self.assertEqual(declarations[0], 'airtime_factor')
|
|
self.assertEqual(declarations[-2:], ['tx_media', 'tx_ssh'])
|
|
optional = {
|
|
'fan_mode': 'TBEAM_1W', 'fan_lo': 'TBEAM_1W', 'fan_hi': 'TBEAM_1W',
|
|
'wifi_ssid': 'defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)',
|
|
'wifi_pwd': 'defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)',
|
|
'peripherals_shared': 'RAK_4631',
|
|
}
|
|
|
|
def fields(template):
|
|
rows = []
|
|
for name in declarations:
|
|
row = template.format(name=name)
|
|
if name in optional:
|
|
condition = optional[name]
|
|
if not condition.startswith('defined('):
|
|
condition = 'defined(' + condition + ')'
|
|
row = '#if ' + condition + '\n' + row + '\n#endif'
|
|
rows.append(row)
|
|
return '\n'.join(rows)
|
|
|
|
tests = TESTS.replace('@FIELD_CHECKS@', fields(
|
|
'static_assert(std::is_trivially_copyable<decltype(CompanionNodePrefs::{name})>::value,\n'
|
|
' "A persisted snapshot member must remain a value");'))
|
|
tests = tests.replace('@SEED_FIELDS@', fields('fill(&prefs.{name}, sizeof(prefs.{name}));'))
|
|
tests = tests.replace('@COMPARE_FIELDS@', fields(
|
|
'assert(!memcmp(&a.{name}, &b.{name}, sizeof(a.{name})));'))
|
|
with tempfile.TemporaryDirectory(prefix='meshcore-prefs-values-') as directory:
|
|
work = Path(directory)
|
|
(work / 'Utils.h').write_text('''#pragma once
|
|
#include <Arduino.h>
|
|
#include <cassert>
|
|
namespace mesh { struct Utils {
|
|
static void printHex(Stream&,const uint8_t*,size_t){assert(false);}
|
|
static void fromHex(uint8_t*,size_t,const char*){assert(false);}
|
|
}; }
|
|
''')
|
|
(work / 'platform_shim.h').write_text('''#include <cstdlib>
|
|
#include <cstdio>
|
|
inline char* utoa(unsigned int value,char* output,int base){
|
|
if(base!=10)abort();sprintf(output,"%u",value);return output;
|
|
}
|
|
''')
|
|
transaction = (ROOT / 'src/helpers/ContactFileTransaction.h').read_text()
|
|
(work / 'ContactFileTransaction.h').write_text(transaction.replace(
|
|
'#include "IdentityStore.h"', '#include <helpers/IdentityStore.h>'))
|
|
# Instrument only the existing filesystem boundary. Both actual
|
|
# readers and the real transactional writer remain unchanged.
|
|
fs = (ROOT / 'test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h').read_text()
|
|
fs = fs.replace('class MemoryFS;',
|
|
'static std::vector<size_t> READ_CALLS;\n'
|
|
'static unsigned AVAILABLE_CALLS = 0;\nclass MemoryFS;')
|
|
fs = fs.replace('size_t available() const { return size() - cursor_; }',
|
|
'size_t available() const { ++AVAILABLE_CALLS;return size() - cursor_; }')
|
|
fs = fs.replace('inline int File::read(uint8_t* data, size_t size) {',
|
|
'inline int File::read(uint8_t* data, size_t size) {\n'
|
|
' READ_CALLS.push_back(size);')
|
|
(work / 'helpers').mkdir()
|
|
(work / 'helpers/IdentityStore.h').write_text(fs)
|
|
cpp = work / 'test.cpp'
|
|
cpp.write_text(code + tests)
|
|
for platform in ('STM32_PLATFORM', 'NRF52_PLATFORM', 'NRF52_PLATFORM,RAK_4631', 'ESP32_PLATFORM', 'RP2040_PLATFORM',
|
|
'ESP32_PLATFORM,TBEAM_1W', 'RP2040_PLATFORM,ENABLE_WIFI_INTERFACE'):
|
|
with self.subTest(platform=platform):
|
|
binary = work / 'test'
|
|
sanitizers = [] if os.name == 'nt' else [
|
|
'-fsanitize=address,undefined', '-fno-sanitize-recover=all',
|
|
'-fno-pie', '-no-pie']
|
|
built = subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17',
|
|
*['-D' + flag + '=1' for flag in platform.split(',')],
|
|
*sanitizers, '-include', str(work / 'platform_shim.h'),
|
|
'-I', str(work), '-I', str(ROOT / 'test/fixtures/radio_profiles/mocks'),
|
|
'-I', str(ROOT / 'test/mocks'), '-I', str(ROOT / 'src'),
|
|
'-I', str(ROOT / 'src/helpers'), '-I', str(ROOT), str(cpp),
|
|
*[str(ROOT / 'src/helpers' / name) for name in (
|
|
'ConfigSerializer.cpp', 'DynamicConfigSerializer.cpp',
|
|
'CommonRadioPrefs.cpp', 'TxtDataHelpers.cpp')],
|
|
'-o', str(binary)], capture_output=True, text=True)
|
|
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
|
|
self.assertNotIn('offsetof', built.stderr)
|
|
ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=60)
|
|
self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|