386 lines
17 KiB
Python
386 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
"""Exercise real nRF52 self verification and status/stats without device headers."""
|
|
|
|
from pathlib import Path
|
|
import os
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
|
|
from test_ota_self_metadata_cache import method
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
HARNESS = r'''
|
|
#include <helpers/ota/FirmwareInfo.h>
|
|
#include <helpers/ota/OtaByteIO.h>
|
|
#include <SHA256.h>
|
|
#include <cassert>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <iostream>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
namespace mesh {
|
|
struct Utils {
|
|
static void toHex(char* out, const uint8_t* data, size_t len) {
|
|
const char* digits = "0123456789abcdef";
|
|
for (size_t i = 0; i < len; ++i) {
|
|
out[i * 2] = digits[data[i] >> 4]; out[i * 2 + 1] = digits[data[i] & 15];
|
|
}
|
|
out[len * 2] = 0;
|
|
}
|
|
};
|
|
namespace ota {
|
|
static const uint32_t MOTA_NRF52_STAGE_CEILING_LEGACY = 0xD4000;
|
|
static const uint32_t MOTA_NRF52_STAGE_CEILING_EXPANDED = 0xED000;
|
|
static const uint32_t MOTA_NRF52_APP_END = 0xED000;
|
|
static const uint32_t MOTA_NRF52_FLASH_PAGE = 4096;
|
|
static uint32_t app_base = 0x26000, stage_ceiling = 0xD4000;
|
|
static std::vector<uint8_t> flash;
|
|
static unsigned scans = 0, hashes = 0;
|
|
static uint64_t hashed_bytes = 0;
|
|
static uint32_t clock_ms = 1000;
|
|
uint32_t mota_nrf52_app_base() { return app_base; }
|
|
uint32_t mota_nrf52_layout_stage_ceiling() { return stage_ceiling; }
|
|
@GEOMETRY@
|
|
|
|
static uint32_t effective_ceiling() {
|
|
#if defined(OTA_SD_STORE)
|
|
return MOTA_NRF52_APP_END;
|
|
#else
|
|
return stage_ceiling;
|
|
#endif
|
|
}
|
|
void mh8(uint8_t out[8], const uint8_t* data, size_t len) {
|
|
++hashes; hashed_bytes += len;
|
|
SHA256 sha; sha.update(data, len); sha.finalize(out, 8);
|
|
}
|
|
@SCANNER@
|
|
// Translate the real accessor's memory-mapped address at the scanner boundary, without changing
|
|
// either production function. This keeps the harness portable and compatible with sanitizers.
|
|
bool find_self_firmware(const uint8_t* region, uint32_t len, SelfFwInfo& out, bool verify_body) {
|
|
++scans;
|
|
assert(uintptr_t(region) == app_base && len == effective_ceiling() - app_base);
|
|
assert(len <= flash.size());
|
|
return scan_flash(flash.data(), len, out, verify_body);
|
|
}
|
|
@FRESH@
|
|
@DISPLAY@
|
|
|
|
struct OtaManager {
|
|
enum FetchState { IDLE, WANT_MANIFEST, WANT_LEAVES, VERIFYING_STAGED, FETCHING,
|
|
COMPLETE, FAILED, PAUSED };
|
|
enum FetchError { FETCH_ERROR_NONE, FETCH_ERROR_MANIFEST, FETCH_ERROR_HASH_ALGO,
|
|
FETCH_ERROR_VERSION, FETCH_ERROR_CODEC, FETCH_ERROR_GEOMETRY, FETCH_ERROR_TOO_LARGE,
|
|
FETCH_ERROR_STORAGE, FETCH_ERROR_INTEGRITY, FETCH_ERROR_MANIFEST_TIMEOUT,
|
|
FETCH_ERROR_LEAVES_TIMEOUT };
|
|
enum { AUTOFETCH_ANY = 2, AUTOFETCH_SIGNED = 1 };
|
|
struct ServeEntry { bool is_self; uint8_t mid[4]; uint32_t fw_version, have_count; };
|
|
FetchState state = IDLE;
|
|
FetchError error = FETCH_ERROR_NONE;
|
|
unsigned have = 0, total = 26;
|
|
uint8_t mid[4] = {1, 2, 3, 4};
|
|
FetchState fetchState() const { return state; }
|
|
FetchError fetchError() const { return error; }
|
|
unsigned blocksHave() const { return have; }
|
|
unsigned blocksTotal() const { return total; }
|
|
const uint8_t* fetchManifestId() const { return mid; }
|
|
bool fetched_is_bootloader() const { return false; }
|
|
uint32_t target() const { return 0x12345678; }
|
|
unsigned servedCount() const { return 0; }
|
|
const ServeEntry* servedEntry(unsigned) const { return nullptr; }
|
|
void servedDigest(uint8_t* out) const { memset(out, 0, 4); }
|
|
uint8_t autofetch() const { return 0; }
|
|
unsigned max_hops() const { return 2; }
|
|
};
|
|
struct OtaBlCaps { bool present = true; uint8_t storage_flags = 1; };
|
|
static const uint8_t OTA_BL_STORAGE_SD = 1;
|
|
struct Context {
|
|
OtaManager manager;
|
|
uint32_t session_started_ms = 1000;
|
|
char hw_id[33] = "test";
|
|
bool serving = false;
|
|
struct Allow { unsigned count() const { return 0; } } allow;
|
|
OtaBlCaps caps;
|
|
const OtaBlCaps& bootloaderAppCaps() const { return caps; }
|
|
};
|
|
uint32_t millis() { return clock_ms; }
|
|
unsigned ota_max_block_capability() { return 2048; }
|
|
const char* ota_target_env_name(uint32_t) { return "test"; }
|
|
uint8_t ota_bootloader_last_rc() { return 0xB8; }
|
|
bool ota_nrf52_boot_update_result(uint8_t) { return false; }
|
|
@CLI_HELPERS@
|
|
void status(Context& c, char* reply) { @STATUS@ }
|
|
void stats(Context& c, char* reply) { @STATS@ }
|
|
|
|
static SelfFwInfo image(uint32_t body, uint8_t tag) {
|
|
assert(body + ENDF_LEN <= effective_ceiling() - app_base);
|
|
flash.assign(effective_ceiling() - app_base, uint8_t(0xA0 + tag));
|
|
uint8_t* trailer = flash.data() + body;
|
|
memcpy(trailer, ENDF_MAGIC, 4);
|
|
wr_u32le(trailer + 4, body);
|
|
SHA256 sha; sha.update(flash.data(), body); sha.finalize(trailer + 8, 8);
|
|
wr_u32le(trailer + 16, 0x01110100u + tag);
|
|
wr_u32le(trailer + 20, 0xA0000000u + tag);
|
|
memset(trailer + 24, 'A' + tag, 32);
|
|
SelfFwInfo expected;
|
|
assert(scan_flash(flash.data(), flash.size(), expected, false));
|
|
return expected;
|
|
}
|
|
static void expect_info(const SelfFwInfo& actual, const SelfFwInfo& expected) {
|
|
assert(actual.valid && actual.body_len == expected.body_len);
|
|
assert(actual.image_len == expected.image_len && actual.endf_offset == expected.endf_offset);
|
|
assert(actual.fw_version == expected.fw_version && actual.target_id == expected.target_id);
|
|
assert(memcmp(actual.body_hash, expected.body_hash, 8) == 0);
|
|
assert(memcmp(actual.hw_id, expected.hw_id, 33) == 0);
|
|
}
|
|
static void expect_empty(const SelfFwInfo& info) {
|
|
assert(!info.valid && !info.body_len && !info.image_len && !info.endf_offset);
|
|
assert(!info.fw_version && !info.target_id);
|
|
for (uint8_t value : info.body_hash) assert(value == 0);
|
|
for (char value : info.hw_id) assert(value == 0);
|
|
}
|
|
} }
|
|
using namespace mesh::ota;
|
|
|
|
int main(int argc, char** argv) {
|
|
assert(argc == 2);
|
|
const std::string which = argv[1];
|
|
SelfFwInfo out;
|
|
if (which == "production") {
|
|
const SelfFwInfo expected = image(489u * 1024u, 1);
|
|
assert(ota_self_firmware_for_display(out));
|
|
expect_info(out, expected);
|
|
assert(scans == 1 && hashes == 1 && hashed_bytes == expected.body_len);
|
|
for (unsigned i = 0; i < 50; ++i) {
|
|
out = SelfFwInfo();
|
|
assert(ota_self_firmware_for_display(out));
|
|
expect_info(out, expected);
|
|
}
|
|
assert(scans == 1 && hashes == 1 && hashed_bytes == expected.body_len);
|
|
// Every caller receives a copy, not a mutable view of the cache.
|
|
out.valid = false; out.body_hash[0] ^= 1; out.hw_id[0] = '?';
|
|
assert(ota_self_firmware_for_display(out)); expect_info(out, expected);
|
|
std::cout << "489-KiB body verified once; zero scans or hashes for 50 display polls\n";
|
|
} else if (which == "base") {
|
|
image(4096, 1);
|
|
assert(ota_self_firmware_for_display(out));
|
|
app_base = 0x27000;
|
|
SelfFwInfo expected = image(8192, 2);
|
|
assert(ota_self_firmware_for_display(out)); expect_info(out, expected);
|
|
assert(scans == 2 && hashes == 2);
|
|
app_base = 0x26000;
|
|
expected = image(1024, 3);
|
|
assert(ota_self_firmware_for_display(out)); expect_info(out, expected);
|
|
assert(scans == 3 && hashes == 3);
|
|
} else if (which == "ceiling") {
|
|
SelfFwInfo expected = image(4096, 1);
|
|
assert(ota_self_firmware_for_display(out));
|
|
stage_ceiling = MOTA_NRF52_STAGE_CEILING_EXPANDED;
|
|
#if defined(OTA_SD_STORE)
|
|
// SD always scans through APP_END, irrespective of internal staging geometry.
|
|
assert(ota_self_firmware_for_display(out)); expect_info(out, expected);
|
|
stage_ceiling = 123;
|
|
assert(ota_self_firmware_for_display(out)); expect_info(out, expected);
|
|
assert(scans == 1 && hashes == 1);
|
|
#else
|
|
expected = image(8192, 2);
|
|
assert(ota_self_firmware_for_display(out)); expect_info(out, expected);
|
|
assert(scans == 2 && hashes == 2);
|
|
stage_ceiling = MOTA_NRF52_STAGE_CEILING_LEGACY;
|
|
expected = image(1024, 3);
|
|
assert(ota_self_firmware_for_display(out)); expect_info(out, expected);
|
|
assert(scans == 3 && hashes == 3);
|
|
stage_ceiling = 123;
|
|
assert(!ota_self_firmware_for_display(out)); expect_empty(out);
|
|
assert(scans == 3 && hashes == 3);
|
|
stage_ceiling = MOTA_NRF52_STAGE_CEILING_LEGACY;
|
|
expected = image(512, 4);
|
|
assert(ota_self_firmware_for_display(out)); expect_info(out, expected);
|
|
assert(scans == 4 && hashes == 4);
|
|
#endif
|
|
} else if (which == "invalid") {
|
|
image(4096, 1);
|
|
assert(ota_self_firmware_for_display(out));
|
|
++app_base; // Alignment failure must clear the previously successful snapshot.
|
|
assert(!ota_self_firmware_for_display(out)); expect_empty(out);
|
|
assert(scans == 1 && hashes == 1);
|
|
--app_base;
|
|
SelfFwInfo expected = image(1024, 2);
|
|
assert(ota_self_firmware_for_display(out)); expect_info(out, expected);
|
|
assert(scans == 2 && hashes == 2);
|
|
app_base = MOTA_NRF52_APP_END; // Empty/inverted application regions are invalid too.
|
|
assert(!ota_self_firmware_for_display(out)); expect_empty(out);
|
|
assert(scans == 2 && hashes == 2);
|
|
} else if (which == "retry") {
|
|
image(4096, 1);
|
|
flash[0] ^= 1;
|
|
assert(!ota_self_firmware_for_display(out)); expect_empty(out);
|
|
assert(!ota_self_firmware_for_display(out)); expect_empty(out);
|
|
assert(scans == 2 && hashes == 2);
|
|
SelfFwInfo expected = image(4096, 2);
|
|
assert(ota_self_firmware_for_display(out)); expect_info(out, expected);
|
|
assert(scans == 3 && hashes == 3);
|
|
app_base = 0x27000; // A failed new layout must never resurrect the old success.
|
|
image(4096, 3);
|
|
wr_u32le(flash.data() + 4096 + 4, 4095);
|
|
assert(!ota_self_firmware_for_display(out)); expect_empty(out);
|
|
assert(!ota_self_firmware_for_display(out)); expect_empty(out);
|
|
assert(scans == 5 && hashes == 3);
|
|
expected = image(1024, 4);
|
|
assert(ota_self_firmware_for_display(out)); expect_info(out, expected);
|
|
assert(scans == 6 && hashes == 4);
|
|
} else if (which == "integrity") {
|
|
const SelfFwInfo expected = image(4096, 1);
|
|
assert(ota_self_firmware_for_display(out));
|
|
flash[0] ^= 1; // A cached display must never weaken the fresh safety accessor.
|
|
assert(!ota_self_firmware(out)); expect_empty(out);
|
|
assert(scans == 2 && hashes == 2);
|
|
assert(ota_self_firmware_for_display(out)); expect_info(out, expected);
|
|
assert(scans == 2 && hashes == 2);
|
|
assert(!ota_self_firmware(out)); expect_empty(out);
|
|
assert(scans == 3 && hashes == 3);
|
|
} else if (which == "cli") {
|
|
image(489u * 1024u, 1);
|
|
Context c;
|
|
char reply[160];
|
|
status(c, reply); assert(strstr(reply, "no download"));
|
|
stats(c, reply); assert(strstr(reply, "fetch idle"));
|
|
assert(scans == 1 && hashes == 1);
|
|
for (unsigned i = 1; i <= 25; ++i) {
|
|
c.manager.state = OtaManager::FETCHING; c.manager.have = i;
|
|
clock_ms = 1000 + i * 1000;
|
|
char progress[20], age[20];
|
|
snprintf(progress, sizeof progress, "%u/26", i);
|
|
snprintf(age, sizeof age, "%us", i);
|
|
status(c, reply);
|
|
assert(strstr(reply, progress) && strstr(reply, age) && strstr(reply, "id=01020304"));
|
|
stats(c, reply);
|
|
assert(strstr(reply, progress) && strstr(reply, age) && strstr(reply, "id=01020304"));
|
|
}
|
|
c.manager.mid[0] = 10;
|
|
c.manager.state = OtaManager::FAILED; c.manager.error = OtaManager::FETCH_ERROR_INTEGRITY;
|
|
status(c, reply); assert(strstr(reply, "failed (integrity check)") && strstr(reply, "id=0a020304"));
|
|
stats(c, reply); assert(strstr(reply, "failed:integrity check") && strstr(reply, "id=0a020304"));
|
|
c.manager.state = OtaManager::COMPLETE; c.manager.have = 26;
|
|
status(c, reply); assert(strstr(reply, "ready to install 26/26"));
|
|
stats(c, reply); assert(strstr(reply, "fetch done 26/26"));
|
|
assert(scans == 1 && hashes == 1);
|
|
std::cout << "Live status/stats progress, state, MID and errors update without rescanning\n";
|
|
} else {
|
|
assert(false && "unknown test case");
|
|
}
|
|
}
|
|
'''
|
|
|
|
|
|
class OtaNrf52DisplayCacheTest(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.temp = tempfile.TemporaryDirectory(prefix="meshcore-nrf52-display-")
|
|
cls.addClassCleanup(cls.temp.cleanup)
|
|
self_source = (ROOT / "src/helpers/ota/OtaSelf.cpp").read_text(encoding="utf-8")
|
|
nrf_source = self_source[self_source.index("#elif defined(NRF52_PLATFORM)\n// nRF52 internal flash"):]
|
|
scanner = method((ROOT / "src/helpers/ota/FirmwareInfo.cpp").read_text(encoding="utf-8"),
|
|
"bool find_self_firmware(").replace("find_self_firmware(", "scan_flash(", 1)
|
|
layout = (ROOT / "src/helpers/ota/OtaFlashLayout_nrf52.h").read_text(encoding="utf-8")
|
|
geometry = method(layout, "inline bool mota_nrf52_stage_ceiling_valid(") + "\n" + method(
|
|
layout, "inline bool mota_nrf52_layout_valid(uint32_t app_base, uint32_t stage_ceiling)")
|
|
cli = (ROOT / "src/helpers/ota/OtaCli.cpp").read_text(encoding="utf-8")
|
|
status = method(cli, '} else if (*a == 0 || is_cmd(a, "status|st", &rest))')
|
|
stats = method(cli, '} else if (is_cmd(a, "stats", &rest))')
|
|
helpers = "\n".join(method(cli, signature) for signature in (
|
|
"static const char* state_word(", "static const char* state_short(",
|
|
"static const char* fetch_error_word(", "static void ver_str("))
|
|
source = HARNESS
|
|
for token, value in {
|
|
"GEOMETRY": geometry, "SCANNER": scanner,
|
|
"FRESH": method(nrf_source, "bool ota_self_firmware(SelfFwInfo& out)"),
|
|
"DISPLAY": method(self_source, "bool ota_self_firmware_for_display(SelfFwInfo& out)"),
|
|
"CLI_HELPERS": helpers,
|
|
"STATUS": status[status.index("{") + 1:-1],
|
|
"STATS": stats[stats.index("{") + 1:-1],
|
|
}.items():
|
|
source = source.replace("@" + token + "@", value)
|
|
cls.binaries = {}
|
|
for name, defines in (("internal", ()), ("sd", ("OTA_SD_STORE=1",))):
|
|
binary = Path(cls.temp.name) / (name + (".exe" if os.name == "nt" else ""))
|
|
sanitizers = [] if os.name == "nt" else ["-fsanitize=address,undefined", "-fno-omit-frame-pointer"]
|
|
built = subprocess.run([
|
|
# The real CLI deliberately truncates descriptive tails to its 160-byte reply cap.
|
|
"c++", "-std=c++11", "-Wall", "-Wextra", *sanitizers,
|
|
"-DNRF52_PLATFORM=1", *["-D" + define for define in defines],
|
|
"-I", str(ROOT / "src"), "-I", str(ROOT / "test/mocks"),
|
|
"-x", "c++", "-", "-o", str(binary),
|
|
], input=source, text=True, capture_output=True)
|
|
if built.returncode:
|
|
raise AssertionError(built.stderr)
|
|
cls.binaries[name] = binary
|
|
|
|
def run_case(self, case):
|
|
for storage, binary in self.binaries.items():
|
|
with self.subTest(storage=storage):
|
|
result = subprocess.run([str(binary), case], text=True, capture_output=True)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
|
|
def test_production_body_is_verified_once_for_fifty_polls(self):
|
|
self.run_case("production")
|
|
|
|
def test_application_base_changes_force_fresh_verification(self):
|
|
self.run_case("base")
|
|
|
|
def test_exact_storage_ceiling_is_used_for_cache_invalidation(self):
|
|
self.run_case("ceiling")
|
|
|
|
def test_invalid_geometry_clears_previous_success(self):
|
|
self.run_case("invalid")
|
|
|
|
def test_body_or_trailer_failures_remain_retryable(self):
|
|
self.run_case("retry")
|
|
|
|
def test_fresh_verification_still_detects_body_mutation_after_display_hit(self):
|
|
self.run_case("integrity")
|
|
|
|
def test_real_status_and_stats_poll_live_progress_without_hashing_again(self):
|
|
self.run_case("cli")
|
|
|
|
def test_unsupported_platform_display_delegates_empty_result(self):
|
|
self_source = (ROOT / "src/helpers/ota/OtaSelf.cpp").read_text(encoding="utf-8")
|
|
unsupported = self_source[self_source.index("#else\nbool ota_self_firmware(SelfFwInfo& out)"):]
|
|
source = r'''
|
|
#include <helpers/ota/OtaSelf.h>
|
|
#include <cassert>
|
|
namespace mesh { namespace ota {
|
|
@FRESH@
|
|
@DISPLAY@
|
|
} }
|
|
int main() {
|
|
mesh::ota::SelfFwInfo out;
|
|
out.valid = true; out.body_len = 42; out.hw_id[0] = 'X';
|
|
assert(!mesh::ota::ota_self_firmware_for_display(out));
|
|
assert(!out.valid && !out.body_len && !out.hw_id[0]);
|
|
}
|
|
'''
|
|
source = source.replace("@FRESH@", method(unsupported, "bool ota_self_firmware(SelfFwInfo& out)"))
|
|
source = source.replace("@DISPLAY@", method(self_source, "bool ota_self_firmware_for_display(SelfFwInfo& out)"))
|
|
sanitizers = [] if os.name == "nt" else ["-fsanitize=address,undefined"]
|
|
for platform in (None, "STM32_PLATFORM=1", "RP2040_PLATFORM=1"):
|
|
with self.subTest(platform=platform):
|
|
binary = Path(self.temp.name) / "unsupported.exe"
|
|
built = subprocess.run([
|
|
"c++", "-std=c++11", "-Wall", "-Wextra", "-Werror", *sanitizers,
|
|
*(["-D" + platform] if platform else []), "-I", str(ROOT / "src"),
|
|
"-x", "c++", "-", "-o", str(binary),
|
|
], input=source, text=True, capture_output=True)
|
|
self.assertEqual(built.returncode, 0, built.stderr)
|
|
result = subprocess.run([str(binary)], text=True, capture_output=True)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|