Files
MeshCore/test/test_ota_identity_policy.py
T

285 lines
12 KiB
Python

#!/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),
str(ROOT / "test/fixtures/ota_security/identity_verify.cpp"),
"-lcrypto", "-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()