diff --git a/src/helpers/ota/OtaFlashLayout_nrf52.h b/src/helpers/ota/OtaFlashLayout_nrf52.h index 289348fd..97cf6bf8 100644 --- a/src/helpers/ota/OtaFlashLayout_nrf52.h +++ b/src/helpers/ota/OtaFlashLayout_nrf52.h @@ -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 diff --git a/src/helpers/ota/OtaStoreFlashNrf52.cpp b/src/helpers/ota/OtaStoreFlashNrf52.cpp index 51979e2a..e1f0aa2f 100644 --- a/src/helpers/ota/OtaStoreFlashNrf52.cpp +++ b/src/helpers/ota/OtaStoreFlashNrf52.cpp @@ -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 diff --git a/test/test_ota/test_ota_flashplan.cpp b/test/test_ota/test_ota_flashplan.cpp new file mode 100644 index 00000000..785e5d7c --- /dev/null +++ b/test/test_ota/test_ota_flashplan.cpp @@ -0,0 +1,85 @@ +#include + +#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); +}