365 lines
17 KiB
Python
365 lines
17 KiB
Python
"""Compile production Tower context policy and reboot dispatch on the host."""
|
|
|
|
from pathlib import Path
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def method(source, signature):
|
|
start = source.index(signature)
|
|
opening = source.index("{", start)
|
|
depth = 1
|
|
end = opening + 1
|
|
while depth:
|
|
depth += (source[end] == "{") - (source[end] == "}")
|
|
end += 1
|
|
return source[start:end]
|
|
|
|
|
|
CONTEXT_SETUP = r'''
|
|
#include <cassert>
|
|
#include <cstdint>
|
|
#include <cstring>
|
|
#define NRF52_PLATFORM 1
|
|
#define OTA_SD_STORE 1
|
|
#define OTA_FLASH_STORE 1
|
|
#define OTA_SD_BOOTLOADER_UPDATE 1
|
|
#define OTA_TOWER_AUTO_STORE 1
|
|
#define MOTA_MIGRATION_TARGET_ID 0x77
|
|
#define MESHCORE_OTA_DEVICE_DEFLATE 1
|
|
#include "helpers/ota/OtaSelfServePolicy.h"
|
|
using namespace mesh::ota;
|
|
using OtaSend = void (*)();
|
|
struct MotaManifest { bool signature_valid = false; };
|
|
struct SignerPolicy { bool allowed = true; mutable unsigned checks = 0; };
|
|
[[maybe_unused]] static bool ota_manifest_trusted(const MotaManifest& manifest,
|
|
const SignerPolicy& allow) {
|
|
++allow.checks;
|
|
return allow.allowed && manifest.signature_valid;
|
|
}
|
|
using OtaManifestAdmit = bool (*)(void*, const MotaManifest&);
|
|
struct Context;
|
|
using OtaContext = Context;
|
|
using CodecFunction = void (*)();
|
|
static constexpr uint8_t CODEC_DETOOLS_INPLACE = 2;
|
|
static constexpr size_t MOTA_MFL = 197;
|
|
static void ota_transport_inflate() {}
|
|
static void ota_transport_deflate() {}
|
|
struct SelfFwInfo {
|
|
bool valid = false;
|
|
uint32_t target_id = 0, fw_version = 0;
|
|
char hw_id[33] = {};
|
|
};
|
|
static SelfFwInfo current;
|
|
static bool ota_self_firmware(SelfFwInfo& out) { out = current; return current.valid; }
|
|
struct OtaBlCaps { bool valid = true; };
|
|
static bool ota_bootloader_self_update_caps_valid(const OtaBlCaps& caps) { return caps.valid; }
|
|
struct OtaStoreSdNrf52 {};
|
|
struct TowerStore {
|
|
enum Mode { Unselected, Sd, Internal, Unsafe };
|
|
Mode mode = Unselected, fitted = Sd;
|
|
bool usable = true;
|
|
unsigned selection_calls = 0, probes = 0;
|
|
OtaStoreSdNrf52 sd;
|
|
bool selectStorage() {
|
|
++selection_calls;
|
|
if (mode == Unselected) { ++probes; mode = fitted; }
|
|
return usable && mode != Unsafe;
|
|
}
|
|
bool usesExternal() const { return mode == Sd; }
|
|
bool usesInternal() const { return mode == Internal; }
|
|
OtaStoreSdNrf52& sdStore() { return sd; }
|
|
void resetSelection() { mode = Unselected; }
|
|
};
|
|
struct Cache {
|
|
OtaStoreSdNrf52* owner = nullptr;
|
|
unsigned attachments = 0;
|
|
void attach(OtaStoreSdNrf52& store) { ++attachments; owner = &store; }
|
|
};
|
|
struct Manager {
|
|
bool full = true, bootloader = true, archive = false, enforce_version = false;
|
|
uint8_t codec = 0xFF;
|
|
uint32_t target = 0, migration = 0, floor = 0;
|
|
CodecFunction encoder = ota_transport_deflate, decoder = nullptr;
|
|
TowerStore* destination = nullptr;
|
|
OtaManifestAdmit admission = nullptr;
|
|
void* admission_context = nullptr;
|
|
void set_manifest_admission(OtaManifestAdmit callback, void* context) {
|
|
admission = callback; admission_context = context;
|
|
}
|
|
const uint8_t* primary = nullptr;
|
|
unsigned clear_calls = 0, folder_sources = 3;
|
|
void begin(uint32_t id, OtaSend, void*) { target = id; }
|
|
void set_auto_migration_target(uint32_t id) { migration = id; }
|
|
void set_transport_deflate_decoder(CodecFunction fn) { decoder = fn; }
|
|
void set_transport_deflate_encoder(CodecFunction fn) { encoder = fn; }
|
|
void set_auto_version_floor(uint32_t version, bool enforce) { floor = version; enforce_version = enforce; }
|
|
void set_accept_full(bool value) { full = value; }
|
|
void set_accept_bootloader(bool value) { bootloader = value; }
|
|
void set_apply_codec(uint8_t value) { codec = value; }
|
|
void set_fetch_store(TowerStore* value) { destination = value; }
|
|
void set_archive_interest(bool value) { archive = value; }
|
|
bool servingPrimaryManifest(const uint8_t* manifest) const { return primary == manifest; }
|
|
void clear_primary() { ++clear_calls; primary = nullptr; }
|
|
};
|
|
struct Context {
|
|
SignerPolicy allow;
|
|
TowerStore fetch_store;
|
|
Cache sd_cache;
|
|
Manager manager;
|
|
bool fetch_to_folder = true, self_serve_supported = true, serving = false;
|
|
char hw_id[33] = {};
|
|
uint8_t serve_self_manifest[MOTA_MFL] = {};
|
|
OtaBlCaps caps;
|
|
unsigned caps_reads = 0;
|
|
const OtaBlCaps& bootloaderUpdateCaps() { ++caps_reads; return caps; }
|
|
'''
|
|
|
|
CONTEXT_CASES = r'''
|
|
};
|
|
static void send() {}
|
|
int main() {
|
|
// Startup adopts authenticated self identity without selecting/mounting SD
|
|
// or enabling a receiver capability that depends on the absent probe.
|
|
current.valid = true; current.target_id = 0x1234; current.fw_version = 0x01170109;
|
|
strcpy(current.hw_id, "Heltec_tower_v2");
|
|
Context sd;
|
|
sd.begin(0x55, send, nullptr, "fallback");
|
|
assert(sd.manager.admission && sd.manager.admission_context == &sd);
|
|
const MotaManifest signed_manifest{true}, forged_manifest{false};
|
|
assert(sd.manager.admission(sd.manager.admission_context, signed_manifest));
|
|
assert(!sd.manager.admission(sd.manager.admission_context, forged_manifest));
|
|
sd.allow.allowed = false;
|
|
assert(!sd.manager.admission(sd.manager.admission_context, signed_manifest));
|
|
sd.allow.allowed = true;
|
|
assert(sd.allow.checks == 3);
|
|
assert(sd.fetch_store.probes == 0 && sd.fetch_store.selection_calls == 0);
|
|
assert(sd.caps_reads == 0 && !sd.manager.full && !sd.manager.bootloader);
|
|
assert(!sd.self_serve_supported && sd.manager.encoder == nullptr);
|
|
assert(sd.manager.decoder == ota_transport_inflate && sd.manager.codec == 2);
|
|
assert(sd.manager.target == 0x1234 && sd.manager.floor == 0x01170109);
|
|
assert(sd.manager.enforce_version && sd.manager.migration == 0x77);
|
|
assert(!sd.fetch_to_folder && strcmp(sd.hw_id, "Heltec_tower_v2") == 0);
|
|
assert(sd.manager.destination == &sd.fetch_store);
|
|
assert(sd.sd_cache.owner == &sd.fetch_store.sd && sd.sd_cache.attachments == 1);
|
|
|
|
assert(sd.prepareTowerStorage());
|
|
assert(sd.fetch_store.probes == 1 && sd.manager.full && sd.manager.bootloader);
|
|
assert(sd.self_serve_supported && sd.manager.encoder == ota_transport_deflate);
|
|
assert(sd.manager.archive && sd.manager.clear_calls == 0);
|
|
sd.manager.primary = sd.serve_self_manifest; sd.serving = true;
|
|
// Mid-session disappearance does not change the selected backend or clear
|
|
// a successfully offered image: the actual store handles subsequent I/O.
|
|
sd.fetch_store.fitted = TowerStore::Internal;
|
|
for (unsigned i = 0; i < 5; ++i) assert(sd.prepareTowerStorage());
|
|
assert(sd.fetch_store.mode == TowerStore::Sd && sd.fetch_store.probes == 1);
|
|
assert(sd.serving && sd.manager.primary == sd.serve_self_manifest);
|
|
|
|
// A legacy app-capable SD loader does not receive privileged packages.
|
|
sd.caps.valid = false;
|
|
assert(sd.prepareTowerStorage());
|
|
assert(sd.manager.full && sd.self_serve_supported && !sd.manager.bootloader);
|
|
|
|
// A deliberate selection reset removes only our automatic primary image.
|
|
// Attached folder sources are independent and must remain available.
|
|
sd.fetch_store.resetSelection();
|
|
assert(sd.prepareTowerStorage());
|
|
assert(sd.fetch_store.mode == TowerStore::Internal && sd.fetch_store.probes == 2);
|
|
assert(!sd.manager.full && !sd.manager.bootloader && !sd.manager.archive);
|
|
assert(!sd.self_serve_supported && sd.manager.encoder == nullptr);
|
|
assert(!sd.serving && sd.manager.primary == nullptr && sd.manager.clear_calls == 1);
|
|
assert(sd.manager.folder_sources == 3);
|
|
for (unsigned i = 0; i < 5; ++i) assert(sd.prepareTowerStorage());
|
|
assert(sd.manager.clear_calls == 1 && sd.fetch_store.probes == 2);
|
|
|
|
// `ota dev serve self` deliberately exports an internal-fallback base for
|
|
// delta capture. Repeated maintenance/status preparation must preserve that
|
|
// explicit diagnostic offer while automatic self-serving stays disabled.
|
|
sd.manager.primary = sd.serve_self_manifest; sd.serving = true;
|
|
for (unsigned i = 0; i < 5; ++i) assert(sd.prepareTowerStorage());
|
|
assert(sd.serving && sd.manager.primary == sd.serve_self_manifest);
|
|
assert(!sd.self_serve_supported && sd.manager.encoder == nullptr);
|
|
assert(!sd.manager.full && !sd.manager.bootloader && !sd.manager.archive);
|
|
assert(sd.manager.clear_calls == 1 && sd.fetch_store.probes == 2);
|
|
|
|
uint8_t manual_manifest[MOTA_MFL] = {};
|
|
sd.manager.primary = manual_manifest; sd.serving = true;
|
|
assert(sd.prepareTowerStorage());
|
|
assert(sd.manager.primary == manual_manifest && sd.serving);
|
|
assert(sd.manager.clear_calls == 1 && sd.manager.folder_sources == 3);
|
|
|
|
// Unknown storage and a failed compatibility check disable every optional
|
|
// capability even if the store still reports a physically present SD card.
|
|
Context unsafe;
|
|
unsafe.fetch_store.fitted = TowerStore::Unsafe;
|
|
unsafe.begin(0x55, send, nullptr);
|
|
assert(unsafe.manager.admission && unsafe.manager.admission_context == &unsafe);
|
|
unsafe.allow.allowed = false;
|
|
assert(!unsafe.manager.admission(unsafe.manager.admission_context, signed_manifest));
|
|
assert(sd.manager.admission(sd.manager.admission_context, signed_manifest));
|
|
assert(unsafe.allow.checks == 1 && sd.allow.checks == 4);
|
|
assert(!unsafe.prepareTowerStorage());
|
|
assert(!unsafe.manager.full && !unsafe.manager.bootloader && !unsafe.manager.archive);
|
|
assert(!unsafe.self_serve_supported && unsafe.manager.encoder == nullptr);
|
|
Context incompatible;
|
|
incompatible.fetch_store.fitted = TowerStore::Sd;
|
|
incompatible.fetch_store.usable = false;
|
|
incompatible.begin(0x55, send, nullptr);
|
|
assert(!incompatible.prepareTowerStorage());
|
|
assert(!incompatible.manager.full && !incompatible.manager.bootloader);
|
|
assert(!incompatible.self_serve_supported && incompatible.manager.encoder == nullptr);
|
|
|
|
current = SelfFwInfo();
|
|
Context legacy;
|
|
legacy.begin(0x55, send, nullptr, "fallback");
|
|
assert(legacy.manager.target == 0x55 && legacy.manager.floor == 0);
|
|
assert(strcmp(legacy.hw_id, "fallback") == 0 && legacy.fetch_store.probes == 0);
|
|
}
|
|
'''
|
|
|
|
HANDOFF_SETUP = r'''
|
|
#include <cassert>
|
|
#include <cstdint>
|
|
#define OTA_TOWER_AUTO_STORE 1
|
|
#define OTA_SD_STORE 1
|
|
#define OTA_FLASH_STORE 1
|
|
#define OTA_SD_BOOTLOADER_UPDATE 1
|
|
static constexpr uint8_t GPREGRET2_OTA_STAGE_LEGACY = 0xD4;
|
|
static constexpr uint8_t GPREGRET2_OTA_STAGE_EXPANDED = 0xED;
|
|
static constexpr uint8_t GPREGRET2_OTA_STAGE_SD = 0x53;
|
|
static constexpr uint8_t GPREGRET2_OTA_STAGE_HYBRID = 0xA6;
|
|
static constexpr uint8_t GPREGRET2_OTA_STAGE_QSPI = 0x51;
|
|
static constexpr uint8_t GPREGRET_OTA_APPLY = 0x6A;
|
|
static constexpr uint8_t GPREGRET_OTA_BOOTLOADER_UPDATE = 0x6B;
|
|
struct Store {
|
|
bool hybrid = false, authorized = true;
|
|
unsigned published = 0;
|
|
bool is_hybrid() const { return hybrid; }
|
|
bool publish_hybrid_handoff() { ++published; return authorized; }
|
|
};
|
|
static Store* g_nrf52_apply_store = nullptr;
|
|
static uint32_t ceiling = 0xED000;
|
|
static bool reset_cleared = true;
|
|
static uint8_t request = 0, source = 0;
|
|
static unsigned resets = 0;
|
|
[[maybe_unused]] static uint32_t ota_nrf52_effective_stage_ceiling() { return ceiling; }
|
|
[[maybe_unused]] static uint8_t mota_nrf52_flash_stage_handoff(uint32_t n) { return n == 0xED000 ? 0xED : 0xD4; }
|
|
[[maybe_unused]] static bool ota_nrf52_clear_reset_reasons() { return reset_cleared; }
|
|
[[maybe_unused]] static void ota_nrf52_set_reset_handoff(uint8_t r, uint8_t s) { request = r; source = s; }
|
|
[[maybe_unused]] static void NVIC_SystemReset() { ++resets; }
|
|
'''
|
|
|
|
HANDOFF_CASES = r'''
|
|
int main() {
|
|
ota_reboot_to_apply();
|
|
assert(request == 0x6A && source == 0x53 && resets == 1);
|
|
Store internal;
|
|
g_nrf52_apply_store = &internal;
|
|
ota_reboot_to_apply();
|
|
assert(request == 0x6A && source == 0xED && resets == 2);
|
|
ceiling = 0xD4000;
|
|
ota_reboot_to_apply();
|
|
assert(request == 0x6A && source == 0xD4 && resets == 3);
|
|
g_nrf52_apply_store = nullptr;
|
|
ota_reboot_to_apply();
|
|
assert(request == 0x6A && source == 0x53 && resets == 4);
|
|
// Shared legacy handling remains fail closed if a retained source cannot be
|
|
// authorized. Tower's qualified application never enables this RAM store.
|
|
internal.hybrid = true; internal.authorized = false;
|
|
g_nrf52_apply_store = &internal;
|
|
ota_reboot_to_apply();
|
|
assert(request == 0 && source == 0xBD && resets == 5);
|
|
internal.authorized = true; reset_cleared = false;
|
|
ota_reboot_to_apply();
|
|
assert(request == 0 && source == 0xBD && resets == 6 && internal.published == 1);
|
|
}
|
|
'''
|
|
|
|
|
|
class TowerAutoIntegrationTest(unittest.TestCase):
|
|
def compile_run(self, source, expected=True):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
path = Path(directory)
|
|
cpp = path / "integration.cpp"
|
|
cpp.write_text(source, encoding="ascii")
|
|
executable = path / "integration"
|
|
subprocess.run(["g++", "-std=c++17", "-Wall", "-Wextra", "-Werror",
|
|
"-I", str(ROOT / "src"), str(cpp), "-o", str(executable)],
|
|
check=True, timeout=30)
|
|
result = subprocess.run([str(executable)], cwd=path, capture_output=True,
|
|
text=True, timeout=10)
|
|
self.assertEqual(result.returncode == 0, expected, result.stderr)
|
|
if not expected:
|
|
self.assertIn("Assertion", result.stderr)
|
|
|
|
def test_production_context_startup_and_deferred_storage_policy(self):
|
|
context = (ROOT / "src/helpers/ota/OtaContext.h").read_text()
|
|
methods = "\n".join(method(context, signature) for signature in (
|
|
"OtaStoreSdNrf52& sdStagingStore()",
|
|
"bool prepareTowerStorage()",
|
|
"void begin(uint32_t target_id, OtaSend send, void* ctx",
|
|
))
|
|
source = CONTEXT_SETUP + methods + CONTEXT_CASES
|
|
self.compile_run(source)
|
|
admission = """ manager.set_manifest_admission([](void* context, const MotaManifest& manifest) {
|
|
return ota_manifest_trusted(manifest, static_cast<OtaContext*>(context)->allow);
|
|
}, this);"""
|
|
missing_admission = methods.replace(admission, "")
|
|
self.assertNotEqual(missing_admission, methods)
|
|
self.compile_run(CONTEXT_SETUP + missing_admission + CONTEXT_CASES, expected=False)
|
|
wrong_context = methods.replace("}, this);", "}, nullptr);")
|
|
self.assertNotEqual(wrong_context, methods)
|
|
self.compile_run(CONTEXT_SETUP + wrong_context + CONTEXT_CASES, expected=False)
|
|
# Negative controls prove the fixture catches the original startup
|
|
# probe hazard and policy that accidentally advertises incompatible SD.
|
|
eager = methods.replace("manager.begin(target_id, send, ctx);",
|
|
"fetch_store.selectStorage(); manager.begin(target_id, send, ctx);")
|
|
self.compile_run(CONTEXT_SETUP + eager + CONTEXT_CASES, expected=False)
|
|
permissive = methods.replace("usable && fetch_store.usesExternal()",
|
|
"fetch_store.usesExternal()")
|
|
self.compile_run(CONTEXT_SETUP + permissive + CONTEXT_CASES, expected=False)
|
|
# The old unconditional cleanup erased explicit internal diagnostic
|
|
# offers on the very next maintenance loop, blocking base capture.
|
|
unconditional_clear = methods.replace(
|
|
" const bool was_self_serve_supported = self_serve_supported;\n", "").replace(
|
|
"was_self_serve_supported && !self_serve_supported &&",
|
|
"!self_serve_supported &&")
|
|
self.assertNotEqual(unconditional_clear, methods)
|
|
self.compile_run(CONTEXT_SETUP + unconditional_clear + CONTEXT_CASES,
|
|
expected=False)
|
|
|
|
def test_production_reset_handoff_follows_approved_application_store(self):
|
|
apply = (ROOT / "src/helpers/ota/OtaApply.cpp").read_text()
|
|
production = method(apply, "void ota_reboot_to_apply() { // public:")
|
|
self.compile_run(HANDOFF_SETUP + production + HANDOFF_CASES)
|
|
old_dispatch = production.replace(
|
|
"#if defined(OTA_RAK_AUTO_STORE) || defined(OTA_TOWER_AUTO_STORE)",
|
|
"#if defined(OTA_RAK_AUTO_STORE)", 1)
|
|
# Removing Tower from adaptive dispatch must break the runtime fixture;
|
|
# it would always request SD even after internal-flash approval.
|
|
self.compile_run(HANDOFF_SETUP + old_dispatch + HANDOFF_CASES, expected=False)
|
|
|
|
def test_selection_precedes_live_resume_and_cli_codec_checks(self):
|
|
mesh = (ROOT / "src/Mesh.cpp").read_text()
|
|
maintenance = method(mesh, "void __attribute__((noinline)) Mesh::serviceLoopMaintenance()")
|
|
self.assertLess(maintenance.index("prepareTowerStorage()"),
|
|
maintenance.index("resumeStaged(nullptr)"))
|
|
cli = (ROOT / "src/helpers/ota/OtaCli.cpp").read_text()
|
|
self.assertLess(cli.index("c.prepareTowerStorage()"), cli.index("c.manager.codecOk(selcodec)"))
|
|
context = (ROOT / "src/helpers/ota/OtaContext.h").read_text()
|
|
boot_apply = method(context, "bool apply_fetched_bootloader(")
|
|
self.assertIn("if (!fetch_store.usesExternal())", boot_apply)
|
|
self.assertIn("fetch_store.sdStore(), allow", boot_apply)
|
|
apply = (ROOT / "src/helpers/ota/OtaApply.cpp").read_text()
|
|
sd_apply = method(apply, "bool ota_apply_mota_nrf52(OtaStoreSdNrf52& store")
|
|
self.assertLess(sd_apply.index("g_nrf52_apply_store = nullptr"),
|
|
sd_apply.index("ota_apply_mota_nrf52_external"))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|