ota: nRF52 mOTA flash-layout safety net + tests

Route both begin() and reopen() through a pure mota_nrf52_stage_plan() that bottom-aligns a received .mota below the filesystem region (trailer ending at FS_START, where the bootloader scans) and refuses any size that would overrun that ceiling or overlap the running image — the single place the FS/prefs-safe bounds are enforced. Add compile-time static_asserts pinning the flash-layout ordering (app < staging ceiling < bootloader; in-place workspace ends at/below the ceiling) so an inconsistent constant fails the build instead of silently corrupting user prefs. Cover the pure planner in test/test_ota/test_ota_flashplan.cpp.
This commit is contained in:
Valentin Kivachuk Burda committed 2026-07-02 19:03:06 +02:00
1 parent 49820b57aa
commit 4a601486ac
3 files changed
+120 -7

No files matched your search

+28
View File
@@ -35,5 +35,33 @@ static const uint32_t MOTA_NRF52_INPLACE_MEMORY = 0x00098000u; // 608 KB (APP_B
static const uint32_t MOTA_NRF52_BL_START = 0x000F4000u;
static const uint32_t MOTA_NRF52_BL_END = 0x000FE000u;
// Compile-time layout-ordering invariants. If a constant above is edited inconsistently these fail the
// BUILD rather than silently letting a stage/apply corrupt the filesystem (user prefs) or the app.
static_assert((MOTA_NRF52_APP_BASE % MOTA_NRF52_FLASH_PAGE) == 0, "APP_BASE must be page-aligned");
static_assert((MOTA_NRF52_FS_START % MOTA_NRF52_FLASH_PAGE) == 0, "FS_START must be page-aligned");
static_assert(MOTA_NRF52_APP_BASE < MOTA_NRF52_FS_START, "app must precede the staging ceiling");
static_assert(MOTA_NRF52_FS_START < MOTA_NRF52_BL_START, "staging (+FS) must end below the bootloader");
static_assert(MOTA_NRF52_BL_START < MOTA_NRF52_BL_END, "bootloader region must be non-empty");
// The in-place apply workspace [APP_BASE, APP_BASE+INPLACE_MEMORY) must end at/below the staging ceiling,
// so an in-place apply never writes into ExtraFS/InternalFS (where user prefs live).
static_assert(MOTA_NRF52_APP_BASE + MOTA_NRF52_INPLACE_MEMORY <= MOTA_NRF52_FS_START,
"in-place apply workspace must end at or below the staging ceiling");
// Plan where to stage a received `.mota` of `total_size` bytes. It is placed bottom-aligned so its
// 5-byte trailer ends exactly at FS_START (the bootloader scans downward from there), and it must sit
// ENTIRELY within [app_end, FS_START): above the running image (`app_end` = APP_BASE + its EndF image_len)
// and below the filesystem region (ExtraFS/InternalFS — where user prefs live, assumed immutable).
// Returns false (and leaves out_start untouched) if it does not fit. This is the SINGLE place the FS
// ceiling + app-collision bounds are enforced; begin()/reopen() both go through it. Pure — no flash I/O —
// so it is unit-tested natively in test/test_ota/test_ota_flashplan.cpp.
inline bool mota_nrf52_stage_plan(uint32_t total_size, uint32_t app_end, uint32_t& out_start) {
const uint32_t capacity = MOTA_NRF52_FS_START - MOTA_NRF52_APP_BASE;
if (total_size < 13 || total_size > capacity) return false; // 13 = header(8)+trailer(5); must fit below FS
uint32_t start = (MOTA_NRF52_FS_START - total_size) & ~(MOTA_NRF52_FLASH_PAGE - 1); // bottom-align down
if (start < app_end) return false; // would overlap the running image
out_start = start;
return true;
}
} // namespace ota
} // namespace mesh
+7 -7
View File
@@ -56,16 +56,16 @@ uint8_t* OtaStoreFlashNrf52::write_slot(uint32_t pos) {
bool OtaStoreFlashNrf52::begin(uint32_t total_size) {
clear();
if (total_size < 13 || total_size > capacity()) return false; // 13 = header(8) + trailer(5)
// bottom-align against FS_START so the trailer ends exactly at FS_START (bootloader scans for it)
uint32_t start = align_down(MOTA_NRF52_FS_START - total_size, PG);
// never collide with the running application image (its extent comes from its EndF trailer)
uint32_t app_end = MOTA_NRF52_APP_BASE;
SelfFwInfo fi;
if (ota_self_firmware(fi) && fi.valid) app_end = MOTA_NRF52_APP_BASE + fi.image_len;
if (start < app_end) return false;
// bottom-align below FS_START + reject if it won't fit above the running image (the FS/prefs-safe
// bounds check; pure + unit-tested in test/test_ota/test_ota_flashplan.cpp)
uint32_t start;
if (!mota_nrf52_stage_plan(total_size, app_end, start)) return false;
_write_start = start;
_total = total_size;
@@ -139,8 +139,8 @@ bool OtaStoreFlashNrf52::reopen() {
const uint8_t* p = (const uint8_t*)(uintptr_t)start;
if (memcmp(p, MOTA_MAGIC, 4) != 0) continue;
uint32_t total = rd_u32le(p + 4);
if (total < 13 || total > capacity()) continue;
if (align_down(MOTA_NRF52_FS_START - total, PG) != start) continue; // must match begin()'s placement
uint32_t want; // must be valid + placed exactly where begin() would have staged it (same bounds fn)
if (!mota_nrf52_stage_plan(total, app_end, want) || want != start) continue;
_write_start = start;
_total = total;
memcpy(_meta_page, p, PG); // load page 0 (header+manifest+leaves) into RAM to continue
+85
View File
@@ -0,0 +1,85 @@
#include <gtest/gtest.h>
#include "helpers/ota/OtaFlashLayout_nrf52.h"
using namespace mesh::ota;
// These lock down the nRF52 (RAK4631) single-slot staging geometry that OtaStoreFlashNrf52::begin()/
// reopen() rely on. A received `.mota` is placed bottom-aligned below the filesystem region — ExtraFS
// (0xD4000) / InternalFS (0xED000), where the node's user preferences live — and above the running image.
// The prefs region is assumed IMMUTABLE (its bytes are outside the served/hashed self-image), so staging
// or an in-place apply must never reach into it. If a layout constant or the placement math is edited
// inconsistently, these fail here instead of silently corrupting prefs / the app on real hardware.
static constexpr uint32_t CAP = MOTA_NRF52_FS_START - MOTA_NRF52_APP_BASE; // 0xAE000, 696 KB
// A typical running image (~520 KB) leaves room; the container lands strictly within (app_end, FS_START].
TEST(OtaFlashPlan, StagesBelowFilesystemAndAboveApp) {
uint32_t app_end = MOTA_NRF52_APP_BASE + 520u * 1024u;
uint32_t start = 0xDEADBEEF;
ASSERT_TRUE(mota_nrf52_stage_plan(64u * 1024u, app_end, start));
EXPECT_GE(start, app_end); // never overlaps the running image
EXPECT_LE(start + 64u * 1024u, MOTA_NRF52_FS_START); // never reaches into ExtraFS/InternalFS/prefs
EXPECT_EQ(start % MOTA_NRF52_FLASH_PAGE, 0u); // page-aligned (the flash erase unit)
}
// Bottom-aligned: the container's trailer ends AT FS_START (the bootloader scans downward from there),
// so start is the page-aligned FS_START - total_size and sits within one page of the ceiling.
TEST(OtaFlashPlan, BottomAlignedTrailerEndsAtCeiling) {
uint32_t start = 0;
uint32_t total = 100000;
ASSERT_TRUE(mota_nrf52_stage_plan(total, MOTA_NRF52_APP_BASE, start));
EXPECT_EQ(start, (MOTA_NRF52_FS_START - total) & ~(MOTA_NRF52_FLASH_PAGE - 1));
EXPECT_LE(start + total, MOTA_NRF52_FS_START);
EXPECT_GT(start + total, MOTA_NRF52_FS_START - MOTA_NRF52_FLASH_PAGE); // within one page of the ceiling
}
// An exactly-capacity container (no app below it) fills the whole staging region; one byte more never fits.
TEST(OtaFlashPlan, RejectsOversizedContainer) {
uint32_t start = 0;
ASSERT_TRUE(mota_nrf52_stage_plan(CAP, MOTA_NRF52_APP_BASE, start));
EXPECT_EQ(start, MOTA_NRF52_APP_BASE); // exactly fills [APP_BASE, FS_START)
EXPECT_EQ(start + CAP, MOTA_NRF52_FS_START);
EXPECT_FALSE(mota_nrf52_stage_plan(CAP + 1, MOTA_NRF52_APP_BASE, start));
}
// A container that would fit below FS_START on its own but overlaps the running image is refused —
// the pull fails cleanly rather than corrupting the app that is currently executing.
TEST(OtaFlashPlan, RejectsCollisionWithRunningImage) {
uint32_t app_end = MOTA_NRF52_FS_START - 8u * 1024u; // only 8 KB free below the ceiling
uint32_t start = 0;
EXPECT_TRUE(mota_nrf52_stage_plan(4u * 1024u, app_end, start)); // 4 KB fits in the 8 KB gap
EXPECT_GE(start, app_end);
EXPECT_FALSE(mota_nrf52_stage_plan(16u * 1024u, app_end, start)); // 16 KB does not
}
// Minimum container is header(8)+trailer(5)=13 bytes; anything smaller is not a container.
TEST(OtaFlashPlan, RejectsUndersizedContainer) {
uint32_t start = 0;
EXPECT_FALSE(mota_nrf52_stage_plan(12, MOTA_NRF52_APP_BASE, start));
EXPECT_TRUE(mota_nrf52_stage_plan(13, MOTA_NRF52_APP_BASE, start));
}
// The user-preferences filesystems (ExtraFS @ 0xD4000, InternalFS @ 0xED000) are entirely ABOVE any
// staged container AND above the in-place apply workspace — the immutability the shared-firmware hashes
// depend on. Hard-code the FS addresses here (independent of the layout header) so a future edit to
// FS_START that drifts into a filesystem is caught.
TEST(OtaFlashPlan, PrefsRegionNeverStaged) {
const uint32_t EXTRAFS_START = 0xD4000u; // companion ExtraFS (CustomLFS(0xD4000, 0x19000))
const uint32_t INTERNALFS_START = 0xED000u; // primary LittleFS (holds /com_prefs)
EXPECT_LE(MOTA_NRF52_FS_START, EXTRAFS_START); // staging ceiling at/below the first filesystem
EXPECT_LT(EXTRAFS_START, INTERNALFS_START);
// the largest possible staged container still ends at the ceiling, never into a filesystem
uint32_t start = 0;
ASSERT_TRUE(mota_nrf52_stage_plan(CAP, MOTA_NRF52_APP_BASE, start));
EXPECT_LE(start + CAP, EXTRAFS_START);
// and the in-place apply workspace ends below the filesystem too
EXPECT_LE(MOTA_NRF52_APP_BASE + MOTA_NRF52_INPLACE_MEMORY, EXTRAFS_START);
}
// out_start is only written on success — a rejected plan must not clobber the caller's variable.
TEST(OtaFlashPlan, LeavesOutputUntouchedOnReject) {
uint32_t start = 0x1234ABCD;
EXPECT_FALSE(mota_nrf52_stage_plan(CAP + 1, MOTA_NRF52_APP_BASE, start));
EXPECT_EQ(start, 0x1234ABCDu);
}