Add retained-RAM staging for nRF52 mOTA

This commit is contained in:
mikecarper committed 2026-09-05 12:08:21 -07:00
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)
@@ -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;
}
+283
View File
@@ -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()
+91 -1
View File
@@ -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] = {
+54
View File
@@ -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));
+26
View File
@@ -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()
+61
View File
@@ -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()