Add retained-RAM staging for nRF52 mOTA
This commit is contained in:
1 parent
23bf83277c
commit
49dcba5c6e
25 files changed
+2071
-83
No files matched your search
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
int flash_nrf5x_write(uint32_t address, const void* data, uint32_t length);
|
||||
void flash_nrf5x_flush(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
|
||||
#define __DMB() std::atomic_signal_fence(std::memory_order_seq_cst)
|
||||
#define __DSB() std::atomic_signal_fence(std::memory_order_seq_cst)
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#if defined(_WIN32)
|
||||
#include <windows.h>
|
||||
#else
|
||||
#include <sys/mman.h>
|
||||
#endif
|
||||
|
||||
// Exercise the production nRF52 store, including its fixed-address flash and
|
||||
// reset-retained SRAM bridge, with process-local mappings at the target
|
||||
// addresses. Compile-time test overrides model the application linker bounds
|
||||
// without relying on platform-specific absolute linker symbols.
|
||||
|
||||
static bool g_fail_next_flash_write = false;
|
||||
static uint32_t g_flash_writes = 0;
|
||||
|
||||
extern "C" int flash_nrf5x_write(uint32_t address, const void* data,
|
||||
uint32_t length) {
|
||||
++g_flash_writes;
|
||||
if (g_fail_next_flash_write) {
|
||||
g_fail_next_flash_write = false;
|
||||
return -1;
|
||||
}
|
||||
std::memcpy(reinterpret_cast<void*>(static_cast<uintptr_t>(address)), data,
|
||||
length);
|
||||
return static_cast<int>(length);
|
||||
}
|
||||
|
||||
extern "C" void flash_nrf5x_flush(void) {}
|
||||
|
||||
#include "../../../src/helpers/ota/OtaStoreFlashNrf52.cpp"
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
bool ota_self_firmware(SelfFwInfo& out) {
|
||||
out = SelfFwInfo();
|
||||
out.valid = true;
|
||||
out.image_len = 0x00080000u;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool mota_parse_manifest(const uint8_t*, uint32_t, MotaManifest&) {
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
|
||||
#define CHECK(condition) do { if (!(condition)) { \
|
||||
std::fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #condition); \
|
||||
std::exit(1); \
|
||||
} } while (0)
|
||||
|
||||
static constexpr uintptr_t FLASH_MAP_START = 0x00020000u;
|
||||
static constexpr size_t FLASH_MAP_SIZE = 0x000E0000u;
|
||||
static constexpr uintptr_t RAM_MAP_START = 0x20000000u;
|
||||
static constexpr size_t RAM_MAP_SIZE = 0x00040000u;
|
||||
|
||||
static void map_target_region(uintptr_t address, size_t length) {
|
||||
#if defined(_WIN32)
|
||||
void* const mapped = VirtualAlloc(reinterpret_cast<void*>(address), length,
|
||||
MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
|
||||
#else
|
||||
void* const mapped = mmap(reinterpret_cast<void*>(address), length,
|
||||
PROT_READ | PROT_WRITE,
|
||||
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1, 0);
|
||||
if (mapped == MAP_FAILED) {
|
||||
std::perror("mmap");
|
||||
std::exit(1);
|
||||
}
|
||||
#endif
|
||||
CHECK(mapped == reinterpret_cast<void*>(address));
|
||||
}
|
||||
|
||||
static void install_bootloader_capabilities() {
|
||||
using namespace mesh::ota;
|
||||
auto* const bootloader = reinterpret_cast<uint8_t*>(
|
||||
static_cast<uintptr_t>(MOTA_NRF52_BL_START));
|
||||
std::memset(bootloader, 0xFF,
|
||||
MOTA_NRF52_BL_END - MOTA_NRF52_BL_START);
|
||||
|
||||
std::memcpy(bootloader, OTA_BL_MAGIC, sizeof(OTA_BL_MAGIC));
|
||||
std::memset(bootloader + 8, 0, 8);
|
||||
bootloader[8] = 3;
|
||||
bootloader[10] = 1u << 2;
|
||||
bootloader[12] = OTA_BL_STORAGE_STAGE_CEILING;
|
||||
|
||||
uint8_t* const ram_cap = bootloader + 32;
|
||||
std::memcpy(ram_cap, MOTA_RAM_CAP_MAGIC, sizeof(MOTA_RAM_CAP_MAGIC));
|
||||
mota_hybrid_wr16(ram_cap + 8, MOTA_RAM_CAP_ABI);
|
||||
mota_hybrid_wr16(ram_cap + 10, MOTA_HYBRID_AUTH_LEN);
|
||||
mota_hybrid_wr32(ram_cap + 12, MOTA_NRF52_HYBRID_RAM_SIZE);
|
||||
}
|
||||
|
||||
static std::vector<uint8_t> patterned_container(uint32_t total) {
|
||||
using namespace mesh::ota;
|
||||
std::vector<uint8_t> bytes(total);
|
||||
for (uint32_t i = 0; i < total; ++i)
|
||||
bytes[i] = static_cast<uint8_t>((i * 37u + 11u) & 0xFFu);
|
||||
std::memcpy(bytes.data(), MOTA_MAGIC, sizeof(MOTA_MAGIC));
|
||||
bytes[4] = static_cast<uint8_t>(total);
|
||||
bytes[5] = static_cast<uint8_t>(total >> 8);
|
||||
bytes[6] = static_cast<uint8_t>(total >> 16);
|
||||
bytes[7] = static_cast<uint8_t>(total >> 24);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
static void reset_target_memory() {
|
||||
std::memset(reinterpret_cast<void*>(FLASH_MAP_START), 0xFF, FLASH_MAP_SIZE);
|
||||
std::memset(reinterpret_cast<void*>(RAM_MAP_START), 0, RAM_MAP_SIZE);
|
||||
install_bootloader_capabilities();
|
||||
g_fail_next_flash_write = false;
|
||||
g_flash_writes = 0;
|
||||
}
|
||||
|
||||
static void full_hybrid_lifecycle_crosses_the_backing_boundary() {
|
||||
using namespace mesh::ota;
|
||||
reset_target_memory();
|
||||
const uint32_t total = MOTA_NRF52_HYBRID_RAM_SIZE +
|
||||
MOTA_NRF52_FLASH_PAGE + 123u;
|
||||
const uint32_t payload_offset = 205u;
|
||||
const uint32_t payload_size = total - payload_offset - 5u;
|
||||
const auto bytes = patterned_container(total);
|
||||
uint8_t hash[32];
|
||||
for (uint8_t i = 0; i < sizeof(hash); ++i) hash[i] = i;
|
||||
|
||||
OtaStoreFlashNrf52 store;
|
||||
CHECK(store.plan_layout(false, 0x81000u, payload_offset, payload_size, false));
|
||||
CHECK(store.begin(total));
|
||||
CHECK(store.is_hybrid());
|
||||
CHECK(store.write_start() == 0x000EB000u);
|
||||
CHECK(store.flash_len() == 2u * MOTA_NRF52_FLASH_PAGE);
|
||||
CHECK(store.ram_len() == total - store.flash_len());
|
||||
|
||||
CHECK(store.write(0, bytes.data(), total));
|
||||
std::vector<uint8_t> readback(total);
|
||||
CHECK(store.read(0, readback.data(), total));
|
||||
CHECK(readback == bytes);
|
||||
CHECK(store.finalize());
|
||||
CHECK(store.data() == nullptr);
|
||||
CHECK(store.read(0, readback.data(), total));
|
||||
CHECK(readback == bytes);
|
||||
|
||||
auto* const auth = reinterpret_cast<uint8_t*>(MOTA_HYBRID_AUTH_ADDR);
|
||||
for (uint32_t i = 0; i < MOTA_HYBRID_AUTH_LEN; ++i) CHECK(auth[i] == 0);
|
||||
CHECK(store.approve_for_application(hash));
|
||||
CHECK(std::memcmp(reinterpret_cast<void*>(store.write_start() + 8u +
|
||||
MOTA_OFF_APPROVAL), APPROVAL_YES,
|
||||
sizeof(APPROVAL_YES)) == 0);
|
||||
for (uint32_t i = 0; i < MOTA_HYBRID_AUTH_LEN; ++i) CHECK(auth[i] == 0);
|
||||
CHECK(store.publish_hybrid_handoff());
|
||||
CHECK(mota_hybrid_auth_valid(auth));
|
||||
CHECK(mota_hybrid_rd32(auth + 16u) == total);
|
||||
CHECK(mota_hybrid_rd32(auth + 20u) == store.write_start());
|
||||
CHECK(mota_hybrid_rd32(auth + 24u) == store.flash_len());
|
||||
CHECK(mota_hybrid_rd32(auth + 28u) == store.ram_len());
|
||||
CHECK(std::memcmp(auth + 32u, hash, sizeof(hash)) == 0);
|
||||
}
|
||||
|
||||
static void split_trailer_is_committed_to_flash_and_ram() {
|
||||
using namespace mesh::ota;
|
||||
reset_target_memory();
|
||||
const uint32_t total = MOTA_NRF52_FLASH_PAGE + 1u;
|
||||
const uint32_t payload_offset = 205u;
|
||||
const auto bytes = patterned_container(total);
|
||||
uint8_t hash[32] = {0xA5};
|
||||
|
||||
OtaStoreFlashNrf52 store;
|
||||
CHECK(store.plan_layout(false, 0x80000u, payload_offset,
|
||||
total - payload_offset - 5u, false));
|
||||
CHECK(store.begin(total));
|
||||
CHECK(store.flash_len() == MOTA_NRF52_FLASH_PAGE);
|
||||
CHECK(store.ram_len() == 1u);
|
||||
CHECK(store.write(0, bytes.data(), total));
|
||||
CHECK(store.finalize());
|
||||
|
||||
const uint32_t trailer = total - 5u;
|
||||
CHECK(std::memcmp(reinterpret_cast<void*>(store.write_start() + trailer),
|
||||
bytes.data() + trailer, 4u) == 0);
|
||||
CHECK(*reinterpret_cast<uint8_t*>(MOTA_NRF52_HYBRID_RAM_START) ==
|
||||
bytes.back());
|
||||
CHECK(store.approve_for_application(hash));
|
||||
CHECK(store.publish_hybrid_handoff());
|
||||
}
|
||||
|
||||
static void failed_persistence_never_publishes_a_handoff() {
|
||||
using namespace mesh::ota;
|
||||
reset_target_memory();
|
||||
const uint32_t total = MOTA_NRF52_FLASH_PAGE + 1u;
|
||||
const uint32_t payload_offset = 205u;
|
||||
const auto bytes = patterned_container(total);
|
||||
uint8_t hash[32] = {0x33};
|
||||
|
||||
OtaStoreFlashNrf52 store;
|
||||
CHECK(store.plan_layout(false, 0x80000u, payload_offset,
|
||||
total - payload_offset - 5u, false));
|
||||
CHECK(store.begin(total));
|
||||
CHECK(store.write(0, bytes.data(), total));
|
||||
g_fail_next_flash_write = true;
|
||||
CHECK(!store.finalize());
|
||||
CHECK(!store.approve_for_application(hash));
|
||||
CHECK(!store.publish_hybrid_handoff());
|
||||
const auto* const auth = reinterpret_cast<const uint8_t*>(
|
||||
MOTA_HYBRID_AUTH_ADDR);
|
||||
for (uint32_t i = 0; i < MOTA_HYBRID_AUTH_LEN; ++i) CHECK(auth[i] == 0);
|
||||
}
|
||||
|
||||
static void hybrid_state_is_one_shot_and_not_reopened_after_restart() {
|
||||
using namespace mesh::ota;
|
||||
reset_target_memory();
|
||||
const uint32_t total = MOTA_NRF52_FLASH_PAGE + 1u;
|
||||
const uint32_t payload_offset = 205u;
|
||||
const auto bytes = patterned_container(total);
|
||||
uint8_t hash[32] = {0x77};
|
||||
|
||||
OtaStoreFlashNrf52 store;
|
||||
CHECK(store.plan_layout(false, 0x80000u, payload_offset,
|
||||
total - payload_offset - 5u, false));
|
||||
CHECK(store.begin(total));
|
||||
CHECK(store.write(0, bytes.data(), total));
|
||||
CHECK(store.approve_for_application(hash));
|
||||
CHECK(store.publish_hybrid_handoff());
|
||||
CHECK(mota_hybrid_auth_valid(reinterpret_cast<const uint8_t*>(
|
||||
MOTA_HYBRID_AUTH_ADDR)));
|
||||
|
||||
OtaStoreFlashNrf52 after_restart;
|
||||
CHECK(!mota_hybrid_auth_valid(reinterpret_cast<const uint8_t*>(
|
||||
MOTA_HYBRID_AUTH_ADDR)));
|
||||
CHECK(!after_restart.reopen());
|
||||
CHECK(after_restart.staged_size() == 0u);
|
||||
}
|
||||
|
||||
int main() {
|
||||
map_target_region(FLASH_MAP_START, FLASH_MAP_SIZE);
|
||||
map_target_region(RAM_MAP_START, RAM_MAP_SIZE);
|
||||
full_hybrid_lifecycle_crosses_the_backing_boundary();
|
||||
std::puts("PASS: hybrid boundary lifecycle");
|
||||
split_trailer_is_committed_to_flash_and_ram();
|
||||
std::puts("PASS: split trailer");
|
||||
failed_persistence_never_publishes_a_handoff();
|
||||
std::puts("PASS: persistence failure stays closed");
|
||||
hybrid_state_is_one_shot_and_not_reopened_after_restart();
|
||||
std::puts("PASS: restart cannot reopen volatile suffix");
|
||||
std::puts("4 OtaStoreFlashNrf52 hybrid lifecycle checks passed");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build-wiring and memory-map contracts for the nRF52840 hybrid mOTA linkers."""
|
||||
|
||||
import ast
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import runpy
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "nrf52_internal_bootloader_link.py"
|
||||
V6_TARGET = "RAK_3401_repeater_lora_ota_no_external_sensors"
|
||||
V7_TARGET = "KeepteenLT1_repeater_lora_ota_no_external_sensors"
|
||||
|
||||
|
||||
class FakeBoardConfig:
|
||||
def __init__(self, values):
|
||||
self.values = dict(values)
|
||||
self.updates = []
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self.values.get(key, default)
|
||||
|
||||
def update(self, key, value):
|
||||
self.values[key] = value
|
||||
self.updates.append((key, value))
|
||||
|
||||
|
||||
class FakeEnvironment:
|
||||
def __init__(self, project, pioenv, board):
|
||||
self.values = {
|
||||
"PROJECT_DIR": str(project),
|
||||
"PIOENV": pioenv,
|
||||
}
|
||||
self.board = board
|
||||
self.cppdefines = []
|
||||
self.replacements = {}
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self.values[key]
|
||||
|
||||
def BoardConfig(self):
|
||||
return self.board
|
||||
|
||||
def AppendUnique(self, **values):
|
||||
self.assert_only_key(values, "CPPDEFINES")
|
||||
for value in values["CPPDEFINES"]:
|
||||
if value not in self.cppdefines:
|
||||
self.cppdefines.append(value)
|
||||
|
||||
def Replace(self, **values):
|
||||
self.replacements.update(values)
|
||||
|
||||
@staticmethod
|
||||
def assert_only_key(values, expected):
|
||||
if set(values) != {expected}:
|
||||
raise AssertionError(f"unexpected fake-SCons call: {values!r}")
|
||||
|
||||
|
||||
def run_selector(*, pioenv, board_values, project=ROOT, requested=False):
|
||||
board = FakeBoardConfig(board_values)
|
||||
environment = FakeEnvironment(project, pioenv, board)
|
||||
process_environment = (
|
||||
{"MESHCORE_NRF52_INTERNAL_BOOTLOADER_UPDATE": "1"}
|
||||
if requested else {}
|
||||
)
|
||||
output = io.StringIO()
|
||||
with mock.patch.dict(os.environ, process_environment, clear=True):
|
||||
with contextlib.redirect_stdout(output):
|
||||
runpy.run_path(
|
||||
str(SCRIPT),
|
||||
init_globals={
|
||||
"Import": lambda name: None,
|
||||
"env": environment,
|
||||
},
|
||||
)
|
||||
return environment, output.getvalue()
|
||||
|
||||
|
||||
def integer_expression(expression):
|
||||
"""Evaluate the integer +/- expressions used by these MEMORY declarations."""
|
||||
tree = ast.parse(expression.strip(), mode="eval")
|
||||
|
||||
def evaluate(node):
|
||||
if isinstance(node, ast.Expression):
|
||||
return evaluate(node.body)
|
||||
if isinstance(node, ast.Constant) and type(node.value) is int:
|
||||
return node.value
|
||||
if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)):
|
||||
value = evaluate(node.operand)
|
||||
return value if isinstance(node.op, ast.UAdd) else -value
|
||||
if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub)):
|
||||
left = evaluate(node.left)
|
||||
right = evaluate(node.right)
|
||||
return left + right if isinstance(node.op, ast.Add) else left - right
|
||||
raise ValueError(f"unsupported linker expression: {expression!r}")
|
||||
|
||||
return evaluate(tree)
|
||||
|
||||
|
||||
def memory_regions(path):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
match = re.search(r"\bMEMORY\s*\{(?P<body>.*?)\}\s*SECTIONS\b", text, re.S)
|
||||
if not match:
|
||||
raise AssertionError(f"no MEMORY block in {path}")
|
||||
regions = {}
|
||||
pattern = re.compile(
|
||||
r"^\s*(?P<name>[A-Za-z_]\w*)\s*\([^)]*\)\s*:\s*"
|
||||
r"ORIGIN\s*=\s*(?P<origin>.*?),\s*LENGTH\s*=\s*(?P<length>.*?)\s*$",
|
||||
re.M,
|
||||
)
|
||||
for region in pattern.finditer(match.group("body")):
|
||||
regions[region.group("name")] = (
|
||||
integer_expression(region.group("origin")),
|
||||
integer_expression(region.group("length")),
|
||||
)
|
||||
return regions
|
||||
|
||||
|
||||
def c_integer(path, name):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
match = re.search(
|
||||
rf"\b{re.escape(name)}\s*=\s*(0x[0-9A-Fa-f]+|[0-9]+)u?\s*;",
|
||||
text,
|
||||
)
|
||||
if not match:
|
||||
raise AssertionError(f"cannot find {name} in {path}")
|
||||
return int(match.group(1), 0)
|
||||
|
||||
|
||||
class Nrf52InternalBootloaderSelectorTest(unittest.TestCase):
|
||||
def test_direct_v6_target_selects_hybrid_linker_and_both_features(self):
|
||||
environment, output = run_selector(
|
||||
pioenv=V6_TARGET,
|
||||
board_values={
|
||||
"build.mcu": "nrf52840",
|
||||
"build.ldscript": "boards/nrf52840_s140_v6.ld",
|
||||
},
|
||||
)
|
||||
expected = str(ROOT / "boards" / "nrf52840_s140_v6_mota64.ld")
|
||||
self.assertEqual(
|
||||
environment.cppdefines,
|
||||
["OTA_INTERNAL_BOOTLOADER_UPDATE", "OTA_HYBRID_RAM_STORE"],
|
||||
)
|
||||
self.assertEqual(environment.board.values["build.ldscript"], expected)
|
||||
self.assertEqual(environment.replacements, {"LDSCRIPT_PATH": expected})
|
||||
self.assertIn("nrf52840_s140_v6_mota64.ld", output)
|
||||
|
||||
def test_arduino_v7_fallback_selects_v7_hybrid_linker(self):
|
||||
environment, output = run_selector(
|
||||
pioenv=V7_TARGET,
|
||||
board_values={
|
||||
"build.mcu": "NRF52840",
|
||||
"build.arduino.ldscript": "nrf52840_s140_v7.ld",
|
||||
},
|
||||
)
|
||||
expected = str(ROOT / "boards" / "nrf52840_s140_v7_mota64.ld")
|
||||
self.assertEqual(environment.board.updates, [("build.ldscript", expected)])
|
||||
self.assertEqual(environment.replacements["LDSCRIPT_PATH"], expected)
|
||||
self.assertIn("nrf52840_s140_v7_mota64.ld", output)
|
||||
|
||||
def test_build_sh_environment_flag_activates_a_synthetic_alias(self):
|
||||
environment, _ = run_selector(
|
||||
pioenv="Heltec_tower_v2_repeater",
|
||||
requested=True,
|
||||
board_values={
|
||||
"build.mcu": "nrf52840",
|
||||
"build.ldscript": "boards/nrf52840_s140_v6.ld",
|
||||
},
|
||||
)
|
||||
self.assertIn("OTA_HYBRID_RAM_STORE", environment.cppdefines)
|
||||
self.assertTrue(environment.replacements["LDSCRIPT_PATH"].endswith("_v6_mota64.ld"))
|
||||
|
||||
def test_unselected_nrf52_environment_is_untouched(self):
|
||||
environment, output = run_selector(
|
||||
pioenv="RAK_3401_repeater",
|
||||
board_values={
|
||||
"build.mcu": "nrf52840",
|
||||
"build.ldscript": "boards/nrf52840_s140_v6.ld",
|
||||
},
|
||||
)
|
||||
self.assertEqual(environment.cppdefines, [])
|
||||
self.assertEqual(environment.board.updates, [])
|
||||
self.assertEqual(environment.replacements, {})
|
||||
self.assertEqual(output, "")
|
||||
|
||||
def test_wrong_mcu_and_nonstandard_linkers_fail_closed(self):
|
||||
cases = (
|
||||
("nrf52832", "boards/nrf52840_s140_v6.ld"),
|
||||
("nrf52840", "boards/nrf52840_s140_v6_extrafs.ld"),
|
||||
("nrf52840", "boards/custom_nrf52840_s140_v6.ld"),
|
||||
("nrf52840", ""),
|
||||
)
|
||||
for mcu, linker in cases:
|
||||
with self.subTest(mcu=mcu, linker=linker):
|
||||
with self.assertRaisesRegex(RuntimeError, "normal S140 v6/v7"):
|
||||
run_selector(
|
||||
pioenv=V6_TARGET,
|
||||
board_values={
|
||||
"build.mcu": mcu,
|
||||
"build.ldscript": linker,
|
||||
},
|
||||
)
|
||||
|
||||
def test_missing_selected_linker_fails_before_mutating_board(self):
|
||||
board = FakeBoardConfig({
|
||||
"build.mcu": "nrf52840",
|
||||
"build.ldscript": "nrf52840_s140_v6.ld",
|
||||
})
|
||||
environment = FakeEnvironment(ROOT, V6_TARGET, board)
|
||||
with mock.patch.object(Path, "is_file", return_value=False):
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
with self.assertRaisesRegex(RuntimeError, "missing mOTA hybrid linker"):
|
||||
runpy.run_path(
|
||||
str(SCRIPT),
|
||||
init_globals={
|
||||
"Import": lambda name: None,
|
||||
"env": environment,
|
||||
},
|
||||
)
|
||||
self.assertEqual(board.updates, [])
|
||||
self.assertEqual(environment.replacements, {})
|
||||
|
||||
|
||||
class Nrf52HybridLinkerContractTest(unittest.TestCase):
|
||||
def test_v6_and_v7_maps_match_runtime_handoff_abi(self):
|
||||
handoff = ROOT / "src" / "helpers" / "ota" / "OtaHybridHandoff.h"
|
||||
layout = ROOT / "src" / "helpers" / "ota" / "OtaFlashLayout_nrf52.h"
|
||||
auth_start = c_integer(handoff, "MOTA_HYBRID_AUTH_ADDR")
|
||||
auth_len = c_integer(handoff, "MOTA_HYBRID_AUTH_LEN")
|
||||
arena_start = c_integer(layout, "MOTA_NRF52_HYBRID_RAM_START")
|
||||
arena_len = c_integer(layout, "MOTA_NRF52_HYBRID_RAM_SIZE")
|
||||
|
||||
for version, flash_start in (("v6", 0x26000), ("v7", 0x27000)):
|
||||
with self.subTest(version=version):
|
||||
hybrid_path = ROOT / "boards" / f"nrf52840_s140_{version}_mota64.ld"
|
||||
normal_path = ROOT / "boards" / f"nrf52840_s140_{version}.ld"
|
||||
hybrid = memory_regions(hybrid_path)
|
||||
normal = memory_regions(normal_path)
|
||||
|
||||
self.assertEqual(hybrid["FLASH"], normal["FLASH"])
|
||||
self.assertEqual(hybrid["FLASH"], (flash_start, 0xED000 - flash_start))
|
||||
self.assertEqual(hybrid["PERSISTENT_RAM"], (0x20006000, 8 + auth_len))
|
||||
self.assertEqual(auth_start, 0x20006000 + 8)
|
||||
self.assertEqual(hybrid["RAM"][0], auth_start + auth_len)
|
||||
self.assertEqual(hybrid["RAM"][0] + hybrid["RAM"][1], arena_start)
|
||||
self.assertEqual(hybrid["MOTA_RAM"], (arena_start, arena_len))
|
||||
self.assertEqual(arena_start + arena_len, 0x20040000)
|
||||
self.assertEqual(
|
||||
normal["RAM"][1] - hybrid["RAM"][1],
|
||||
arena_len + auth_len,
|
||||
)
|
||||
|
||||
def test_linkers_export_and_assert_every_fixed_handoff_boundary(self):
|
||||
required = (
|
||||
"__mota_hybrid_handoff_start__ = ADDR(.persistent) + SIZEOF(.persistent);",
|
||||
"__mota_hybrid_handoff_end__ = ORIGIN(PERSISTENT_RAM) + LENGTH(PERSISTENT_RAM);",
|
||||
"ASSERT(ADDR(.persistent) == 0x20006000,",
|
||||
"ASSERT(SIZEOF(.persistent) == 8,",
|
||||
"ASSERT(__mota_hybrid_handoff_start__ == 0x20006008,",
|
||||
"ASSERT(__mota_hybrid_handoff_end__ == 0x20006050,",
|
||||
"ASSERT(__mota_hybrid_handoff_end__ - __mota_hybrid_handoff_start__ == 72,",
|
||||
"ASSERT(ORIGIN(RAM) == __mota_hybrid_handoff_end__,",
|
||||
"ASSERT(ORIGIN(RAM) + LENGTH(RAM) == ORIGIN(MOTA_RAM),",
|
||||
"ASSERT(ORIGIN(MOTA_RAM) + LENGTH(MOTA_RAM) == 0x20040000,",
|
||||
".mota_ram (NOLOAD)",
|
||||
'INCLUDE "nrf52_common.ld"',
|
||||
)
|
||||
for version in ("v6", "v7"):
|
||||
with self.subTest(version=version):
|
||||
path = ROOT / "boards" / f"nrf52840_s140_{version}_mota64.ld"
|
||||
text = path.read_text(encoding="utf-8")
|
||||
for contract in required:
|
||||
self.assertIn(contract, text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -39,7 +39,7 @@ TEST(OtaBootResult, AcceptsOnlyOtafixApplyDiagnostics) {
|
||||
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xB0), 0xB0);
|
||||
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xB8), 0xB8);
|
||||
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xBC), 0xBC);
|
||||
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xBD), 0xBD); // SD authorization failure
|
||||
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xBD), 0xBD); // retained-source authorization/handoff failure
|
||||
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xC0), 0xC0);
|
||||
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xC8), 0xC8);
|
||||
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xCF), 0xCF);
|
||||
@@ -673,6 +673,96 @@ TEST(OtaBootPackage, CapabilityScannerRejectsAnOtherwiseValidUnalignedMarker) {
|
||||
EXPECT_FALSE(ota_bl_caps_scan_aligned(image, sizeof(image), true, qspi_profile).present);
|
||||
}
|
||||
|
||||
TEST(OtaHybridCapability, RequiresOneExactAlignedMOTARAMARecord) {
|
||||
uint8_t marker[16] = {0};
|
||||
memcpy(marker, MOTA_RAM_CAP_MAGIC, sizeof(MOTA_RAM_CAP_MAGIC));
|
||||
mota_hybrid_wr16(marker + 8u, MOTA_RAM_CAP_ABI);
|
||||
mota_hybrid_wr16(marker + 10u, MOTA_HYBRID_AUTH_LEN);
|
||||
mota_hybrid_wr32(marker + 12u, MOTA_NRF52_HYBRID_RAM_SIZE);
|
||||
|
||||
OtaRamCaps parsed;
|
||||
ASSERT_TRUE(ota_ram_caps_marker_parse(marker, parsed));
|
||||
EXPECT_TRUE(ota_bootloader_supports_hybrid(parsed));
|
||||
EXPECT_EQ(parsed.abi, MOTA_RAM_CAP_ABI);
|
||||
EXPECT_EQ(parsed.record_len, MOTA_HYBRID_AUTH_LEN);
|
||||
EXPECT_EQ(parsed.arena_size, MOTA_NRF52_HYBRID_RAM_SIZE);
|
||||
|
||||
uint8_t image[64];
|
||||
memset(image, 0xFF, sizeof(image));
|
||||
memcpy(image + 1u, marker, sizeof(marker));
|
||||
EXPECT_FALSE(ota_ram_caps_scan_aligned(image, sizeof(image)).present);
|
||||
|
||||
memset(image, 0xFF, sizeof(image));
|
||||
memcpy(image + 4u, marker, sizeof(marker));
|
||||
EXPECT_TRUE(ota_bootloader_supports_hybrid(
|
||||
ota_ram_caps_scan_aligned(image, sizeof(image))));
|
||||
|
||||
// Two valid capabilities are ambiguous; a malformed decoy is ignored.
|
||||
memcpy(image + 24u, marker, sizeof(marker));
|
||||
EXPECT_FALSE(ota_ram_caps_scan_aligned(image, sizeof(image)).present);
|
||||
image[24u + 12u] ^= 1u;
|
||||
EXPECT_TRUE(ota_ram_caps_scan_aligned(image, sizeof(image)).present);
|
||||
|
||||
uint8_t bad[sizeof(marker)];
|
||||
memcpy(bad, marker, sizeof(bad));
|
||||
bad[8] = 2u;
|
||||
EXPECT_FALSE(ota_ram_caps_marker_parse(bad, parsed));
|
||||
memcpy(bad, marker, sizeof(bad));
|
||||
bad[10] = (uint8_t)(MOTA_HYBRID_AUTH_LEN - 1u);
|
||||
EXPECT_FALSE(ota_ram_caps_marker_parse(bad, parsed));
|
||||
memcpy(bad, marker, sizeof(bad));
|
||||
bad[12] ^= 1u;
|
||||
EXPECT_FALSE(ota_ram_caps_marker_parse(bad, parsed));
|
||||
}
|
||||
|
||||
TEST(OtaHybridHandoff, EncodesExactGeometryAndRejectsCorruption) {
|
||||
const uint32_t total = 17u * MOTA_NRF52_FLASH_PAGE;
|
||||
uint32_t start = 0, flash = 0, ram = 0;
|
||||
ASSERT_TRUE(mota_nrf52_hybrid_stage_plan(
|
||||
total, MOTA_NRF52_APP_BASE_S140_V6,
|
||||
MOTA_NRF52_APP_BASE_S140_V6 + 512u * 1024u,
|
||||
MOTA_NRF52_STAGE_CEILING_EXPANDED, start, flash, ram));
|
||||
ASSERT_EQ(flash, MOTA_NRF52_FLASH_PAGE);
|
||||
ASSERT_EQ(ram, MOTA_NRF52_HYBRID_RAM_SIZE);
|
||||
|
||||
uint8_t hash[32];
|
||||
for (uint8_t i = 0; i < sizeof(hash); ++i) hash[i] = (uint8_t)(i * 7u);
|
||||
uint8_t record[MOTA_HYBRID_AUTH_LEN];
|
||||
ASSERT_TRUE(mota_hybrid_auth_encode(
|
||||
record, total, start, flash, ram, hash));
|
||||
EXPECT_TRUE(mota_hybrid_auth_valid(record));
|
||||
EXPECT_EQ(memcmp(record, MOTA_HYBRID_AUTH_MAGIC, 8u), 0);
|
||||
EXPECT_EQ(mota_hybrid_rd16(record + 8u), MOTA_HYBRID_AUTH_VERSION);
|
||||
EXPECT_EQ(mota_hybrid_rd16(record + 10u), MOTA_HYBRID_AUTH_LEN);
|
||||
EXPECT_EQ(record[12], MOTA_HYBRID_AUTH_PURPOSE_APP);
|
||||
EXPECT_EQ(record[13], MOTA_HYBRID_AUTH_FORMAT_APP);
|
||||
EXPECT_EQ(mota_hybrid_rd32(record + 16u), total);
|
||||
EXPECT_EQ(mota_hybrid_rd32(record + 20u), start);
|
||||
EXPECT_EQ(mota_hybrid_rd32(record + 24u), flash);
|
||||
EXPECT_EQ(mota_hybrid_rd32(record + 28u), ram);
|
||||
EXPECT_EQ(memcmp(record + 32u, hash, sizeof(hash)), 0);
|
||||
const uint32_t crc = mota_hybrid_rd32(record + 64u);
|
||||
EXPECT_EQ(crc, mota_hybrid_crc32(record, 64u));
|
||||
EXPECT_EQ(mota_hybrid_rd32(record + 68u), ~crc);
|
||||
|
||||
record[32] ^= 1u;
|
||||
EXPECT_FALSE(mota_hybrid_auth_valid(record));
|
||||
record[32] ^= 1u;
|
||||
record[68] ^= 1u;
|
||||
EXPECT_FALSE(mota_hybrid_auth_valid(record));
|
||||
|
||||
// The descriptor accepts only the frozen minimum flash charge and ED000
|
||||
// ceiling. Legacy-D4000 or overcharged splits cannot be authorized.
|
||||
EXPECT_FALSE(mota_hybrid_auth_encode(
|
||||
record, total, start - MOTA_NRF52_FLASH_PAGE,
|
||||
flash + MOTA_NRF52_FLASH_PAGE,
|
||||
ram - MOTA_NRF52_FLASH_PAGE, hash));
|
||||
EXPECT_FALSE(mota_hybrid_auth_encode(
|
||||
record, total,
|
||||
MOTA_NRF52_STAGE_CEILING_LEGACY - MOTA_NRF52_FLASH_PAGE,
|
||||
MOTA_NRF52_FLASH_PAGE, ram, hash));
|
||||
}
|
||||
|
||||
TEST(OtaBootPackage, LegacyAndCurrentBootloadersHaveSeparateCapabilityViews) {
|
||||
const uint8_t internal_profile = OTA_BL_PROFILE_INTERNAL_BOOT_UPDATE;
|
||||
const uint8_t legacy_marker[16] = {
|
||||
|
||||
@@ -183,6 +183,60 @@ TEST(OtaFlashPlan, ExpandedCeilingAddsExactly100KiBForV6AndV7) {
|
||||
EXPECT_EQ(start + CAP_V7_EXPANDED, EXPANDED);
|
||||
}
|
||||
|
||||
TEST(OtaFlashPlan, HybridUsesTheFrozenFlashChargeAndRamSuffix) {
|
||||
uint32_t start = 0, flash = 0, ram = 0;
|
||||
const uint32_t page = MOTA_NRF52_FLASH_PAGE;
|
||||
ASSERT_TRUE(mota_nrf52_hybrid_stage_plan(
|
||||
page + 1u, APP_V6, APP_END_V6, EXPANDED, start, flash, ram));
|
||||
EXPECT_EQ(start, EXPANDED - page);
|
||||
EXPECT_EQ(flash, page);
|
||||
EXPECT_EQ(ram, 1u);
|
||||
|
||||
ASSERT_TRUE(mota_nrf52_hybrid_stage_plan(
|
||||
17u * page, APP_V6, APP_END_V6, EXPANDED, start, flash, ram));
|
||||
EXPECT_EQ(start, EXPANDED - page);
|
||||
EXPECT_EQ(flash, page);
|
||||
EXPECT_EQ(ram, MOTA_NRF52_HYBRID_RAM_SIZE);
|
||||
|
||||
ASSERT_TRUE(mota_nrf52_hybrid_stage_plan(
|
||||
17u * page + 1u, APP_V6, APP_END_V6,
|
||||
EXPANDED, start, flash, ram));
|
||||
EXPECT_EQ(start, EXPANDED - 2u * page);
|
||||
EXPECT_EQ(flash, 2u * page);
|
||||
EXPECT_EQ(ram, 15u * page + 1u);
|
||||
|
||||
ASSERT_TRUE(mota_nrf52_hybrid_stage_plan(
|
||||
17u * page, APP_V7, APP_END_V7,
|
||||
EXPANDED, start, flash, ram));
|
||||
EXPECT_EQ(start, EXPANDED - page);
|
||||
EXPECT_EQ(flash, page);
|
||||
EXPECT_EQ(ram, MOTA_NRF52_HYBRID_RAM_SIZE);
|
||||
}
|
||||
|
||||
TEST(OtaFlashPlan, HybridFailsClosedOutsideItsExactProfile) {
|
||||
uint32_t start = 0xAAAAAAAAu, flash = 0xBBBBBBBBu, ram = 0xCCCCCCCCu;
|
||||
const uint32_t page = MOTA_NRF52_FLASH_PAGE;
|
||||
EXPECT_FALSE(mota_nrf52_hybrid_stage_plan(
|
||||
MOTA_NRF52_CONTAINER_MIN_SIZE, APP_V6, APP_END_V6,
|
||||
EXPANDED, start, flash, ram));
|
||||
EXPECT_FALSE(mota_nrf52_hybrid_stage_plan(
|
||||
page, APP_V6, APP_END_V6, EXPANDED, start, flash, ram));
|
||||
EXPECT_FALSE(mota_nrf52_hybrid_stage_plan(
|
||||
17u * page, APP_V6, APP_END_V6, LEGACY, start, flash, ram));
|
||||
EXPECT_FALSE(mota_nrf52_hybrid_stage_plan(
|
||||
17u * page + 1u, APP_V6, EXPANDED - page,
|
||||
EXPANDED, start, flash, ram));
|
||||
EXPECT_FALSE(mota_nrf52_hybrid_stage_plan(
|
||||
17u * page, 0x00028000u, 0x00028000u,
|
||||
EXPANDED, start, flash, ram));
|
||||
EXPECT_FALSE(mota_nrf52_hybrid_stage_plan(
|
||||
UINT32_MAX, APP_V6, APP_END_V6,
|
||||
EXPANDED, start, flash, ram));
|
||||
EXPECT_EQ(start, 0xAAAAAAAAu);
|
||||
EXPECT_EQ(flash, 0xBBBBBBBBu);
|
||||
EXPECT_EQ(ram, 0xCCCCCCCCu);
|
||||
}
|
||||
|
||||
TEST(OtaFlashPlan, RejectsAppOutsideSelectedRegion) {
|
||||
uint32_t start = 0x1234ABCD;
|
||||
EXPECT_FALSE(mota_nrf52_stage_plan(4096, APP_V6, LEGACY + 1, LEGACY, start));
|
||||
|
||||
@@ -61,6 +61,32 @@ class OtaDevRebootGuardContractTest(unittest.TestCase):
|
||||
reboot_at = maintenance.index("ota::ota_reboot_to_apply()")
|
||||
self.assertLess(guard_at, reboot_at)
|
||||
|
||||
def test_dev_clear_cannot_mutate_an_armed_update(self):
|
||||
cli = (ROOT / "src/helpers/ota/OtaCli.cpp").read_text()
|
||||
dev = function_body(
|
||||
cli,
|
||||
"static bool handle_dev(const char* d, char* reply, OtaContext& c) {",
|
||||
)
|
||||
|
||||
clear_at = dev.index('strncmp(d, "clear", 5)')
|
||||
clear = dev[
|
||||
clear_at : dev.index(
|
||||
'} else {\n strcpy(reply, "ota dev:', clear_at
|
||||
)
|
||||
]
|
||||
guard_at = clear.index("if (c.apply_pending)")
|
||||
mutations = (
|
||||
"c.manager.clear_primary()",
|
||||
"c.releaseServeBuffer()",
|
||||
"c.manager.reset_session()",
|
||||
"c.fetch_store.discard()",
|
||||
)
|
||||
for mutation in mutations:
|
||||
self.assertLess(guard_at, clear.index(mutation), mutation)
|
||||
guard = clear[guard_at : clear.index("const bool was_folder")]
|
||||
self.assertIn('strcpy(reply, "ERR update is armed; reboot is pending")', guard)
|
||||
self.assertIn("return true", guard)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Execute the real nRF52 flash/SRAM hybrid staging store on host mappings."""
|
||||
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FIXTURE = ROOT / "test/fixtures/ota_store_flash_nrf52_hybrid"
|
||||
|
||||
|
||||
class OtaStoreFlashNrf52HybridTest(unittest.TestCase):
|
||||
def test_real_store_hybrid_lifecycle(self):
|
||||
compiler = shutil.which("g++") or shutil.which("clang++")
|
||||
if compiler is None:
|
||||
self.skipTest("a host C++17 compiler is required")
|
||||
# Build outside the checkout. On Windows an application-control policy
|
||||
# may deny linker output or execution from newly-created repository
|
||||
# subdirectories, and a denied executable can leave that directory
|
||||
# locked during TemporaryDirectory cleanup.
|
||||
with tempfile.TemporaryDirectory(prefix="meshcore-ota-hybrid-") as directory:
|
||||
binary = Path(directory) / "ota-store-hybrid.exe"
|
||||
compiled = subprocess.run(
|
||||
[
|
||||
compiler,
|
||||
"-std=c++17",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Werror",
|
||||
"-DNRF52_PLATFORM=1",
|
||||
"-DOTA_FLASH_STORE=1",
|
||||
"-DOTA_HYBRID_RAM_STORE=1",
|
||||
"-DMOTA_NRF52_TEST_APP_BASE=0x00026000u",
|
||||
"-DMOTA_NRF52_TEST_LAYOUT_STAGE_CEILING=0x000ED000u",
|
||||
f"-I{FIXTURE / 'mocks'}",
|
||||
f"-I{ROOT / 'src'}",
|
||||
str(FIXTURE / "test_ota_store_flash_nrf52_hybrid.cpp"),
|
||||
"-o",
|
||||
str(binary),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr)
|
||||
checked = subprocess.run(
|
||||
[str(binary)], capture_output=True, text=True, timeout=10
|
||||
)
|
||||
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
|
||||
self.assertIn(
|
||||
"4 OtaStoreFlashNrf52 hybrid lifecycle checks passed",
|
||||
checked.stdout,
|
||||
)
|
||||
self.assertEqual(checked.stdout.count("PASS:"), 4)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user