fix: harden LoRa OTA chain and storage

This commit is contained in:
mikecarper committed 2026-08-29 06:58:48 -07:00
1 parent 400aa710a2
commit b40d2e6cb2
29 files changed
+7497 -528

No files matched your search

+19
View File
@@ -783,4 +783,23 @@ expectations=" ${BUILD_EXPECTATIONS[*]} "
[[ "$expectations" != *"companion.network_terminal="* ]] \
|| fail "nRF52 Full contract promised an ESP32-only network terminal"
BUILD_PROFILE_FOR_TARGET=standard
for rak_target in \
RAK_3401_repeater_lora_ota_no_external_sensors \
RAK_4631_repeater_lora_ota_no_external_sensors; do
BUILD_CAPABILITIES=()
BUILD_REDUCTIONS=()
BUILD_EXPECTATIONS=()
declare_build_capability_contract "$rak_target" NRF52_PLATFORM
expectations=" ${BUILD_EXPECTATIONS[*]} "
[[ "$expectations" == *"sensor.gps=meshcore.capability.rak_wisblock_gps.v1"* ]] \
|| fail "$rak_target reduced OTA contract omitted retained GPS"
[[ "$expectations" != *"sensor.gps=gps setloc"* ]] \
|| fail "$rak_target reduced OTA contract still accepts generic GPS CLI text"
for ina in 219 226 260 3221; do
[[ "$expectations" == *"sensor.ina${ina}=INA${ina}"* ]] \
|| fail "$rak_target reduced OTA contract omitted retained INA${ina}"
done
done
echo "test_build_profiles: OK"
+69
View File
@@ -1274,6 +1274,60 @@ TEST(OtaStoreRamTest, RandomAccessAndErasedSentinel) {
for (int i = 0; i < 8; i++) EXPECT_EQ(rd[i], 0xFF);
}
TEST(OtaStoreRamTest, ClearKeepsResumeFixtureButDiscardConsumesHeader) {
OtaStoreRam<4096> s;
constexpr uint32_t total = 8u + MOTA_MFL + 5u;
uint8_t header[8] = {'m', 'O', 'T', 'A',
static_cast<uint8_t>(total),
static_cast<uint8_t>(total >> 8),
static_cast<uint8_t>(total >> 16),
static_cast<uint8_t>(total >> 24)};
ASSERT_TRUE(s.begin(total));
ASSERT_TRUE(s.write(0, header, sizeof(header)));
// clear() remains the cheap RAM-session reset used by reboot/resume tests.
s.clear();
EXPECT_TRUE(s.reopen());
EXPECT_EQ(s.staged_size(), total);
// User-facing cancellation uses discard(), which must prevent a fresh
// reopen even for the native in-process persistence model.
EXPECT_TRUE(s.discard());
EXPECT_EQ(s.staged_size(), 0u);
EXPECT_FALSE(s.reopen());
}
TEST(OtaStoreRamTest, SeederSizedStoreDiscardsWithoutAssumingAHeaderFits) {
// OTA_SEEDER_ONLY intentionally uses OtaStoreRam<1>: it needs a valid
// manager destination object but never stages firmware locally.
OtaStoreRam<1> s;
EXPECT_TRUE(s.discard());
EXPECT_EQ(s.staged_size(), 0u);
EXPECT_FALSE(s.reopen());
}
namespace {
class SessionOnlyOtaStore : public OtaStore {
public:
bool begin(uint32_t) override { return false; }
bool write(uint32_t, const uint8_t*, uint32_t) override { return false; }
bool read(uint32_t, uint8_t*, uint32_t) const override { return false; }
uint32_t capacity() const override { return 0; }
uint32_t staged_size() const override { return 0; }
void clear() override { cleared = true; }
bool cleared = false;
};
} // namespace
TEST(OtaStoreDiscardTest, ConservativeDefaultCannotClaimDurableInvalidation) {
SessionOnlyOtaStore store;
EXPECT_FALSE(store.discard());
EXPECT_TRUE(store.cleared);
}
// --- merkle proof GENERATION (server side) matches the Python oracle ---------------------------
TEST(OtaMerkle, GenProofMatchesPythonAndVerifies) {
@@ -1605,6 +1659,21 @@ TEST(OtaServe, ClearPrimaryInvalidatesCallerOwnedView) {
EXPECT_FALSE(manager.remove_source(&folder));
}
TEST(OtaServe, FetchSessionResetPreservesIndependentPrimaryView) {
OtaManager manager;
manager.begin(0, nullptr, nullptr);
ASSERT_TRUE(manager.serve(SIM_MOTA, SIM_MOTA_LEN));
ASSERT_EQ(manager.servedCount(), 1);
ASSERT_TRUE(manager.servedEntry(0)->is_self);
// `ota cancel` resets only the receive side. A served image (and its
// caller-owned backing buffer) must remain registered until the serving
// side explicitly calls clear_primary().
manager.reset_session();
ASSERT_EQ(manager.servedCount(), 1);
EXPECT_TRUE(manager.servedEntry(0)->is_self);
}
TEST(OtaTransfer, TwoManagersFullTransfer) {
g_q.clear();
OtaManager server, client;
+302
View File
@@ -2,6 +2,7 @@
#include <cstring>
#include "helpers/ota/OtaFlashLayout_nrf52.h"
#include "helpers/ota/OtaFlashLayout_esp32.h"
#include "helpers/ota/OtaStoreQspiNrf52.h"
using namespace mesh::ota;
@@ -318,6 +319,307 @@ TEST(OtaFlashPlan, ReopenBoundsUntrustedTotalBeforeManifestRead) {
8u + 197u + 5u));
}
namespace {
struct FakeStagedHeader {
uint32_t address;
uint32_t total;
bool present;
bool invalidate_ok;
bool invalidated;
};
struct FakeStagedFlash {
FakeStagedHeader* headers;
size_t count;
uint32_t invalidate_calls;
};
bool fake_read_staged_header(void* context, uint32_t address,
uint32_t& total) {
FakeStagedFlash* flash = static_cast<FakeStagedFlash*>(context);
for (size_t i = 0; i < flash->count; ++i) {
FakeStagedHeader& header = flash->headers[i];
if (header.address == address && header.present) {
total = header.total;
return true;
}
}
return false;
}
bool fake_invalidate_staged_header(void* context, uint32_t address) {
FakeStagedFlash* flash = static_cast<FakeStagedFlash*>(context);
++flash->invalidate_calls;
for (size_t i = 0; i < flash->count; ++i) {
FakeStagedHeader& header = flash->headers[i];
if (header.address != address || !header.present) continue;
if (!header.invalidate_ok) return false;
header.present = false;
header.invalidated = true;
return true;
}
return false;
}
} // namespace
TEST(OtaFlashDiscard, FreshIdleObjectStillInvalidatesPersistedHeader) {
// The callback context models flash only: there is deliberately no live
// OtaStore session or staged_size. Cancellation after reboot must still
// find the bottom-aligned persistent header.
FakeStagedHeader headers[] = {
{0xE2000u, EXPANDED - 0xE2000u, true, true, false},
};
FakeStagedFlash flash{headers, 1u, 0u};
uint32_t invalidated = 99u;
EXPECT_TRUE(mota_nrf52_discard_staged_headers(
APP_V6, APP_END_V6, EXPANDED, &flash, fake_read_staged_header,
fake_invalidate_staged_header, &invalidated));
EXPECT_EQ(invalidated, 1u);
EXPECT_EQ(flash.invalidate_calls, 1u);
EXPECT_TRUE(headers[0].invalidated);
// Durable discard is idempotent: a new scan cannot reopen or re-invalidate
// the header that the first call consumed.
invalidated = 99u;
EXPECT_TRUE(mota_nrf52_discard_staged_headers(
APP_V6, APP_END_V6, EXPANDED, &flash, fake_read_staged_header,
fake_invalidate_staged_header, &invalidated));
EXPECT_EQ(invalidated, 0u);
EXPECT_EQ(flash.invalidate_calls, 1u);
}
TEST(OtaFlashDiscard, InvalidatesEveryCurrentAndLegacyLayoutHeader) {
FakeStagedHeader headers[] = {
// Two expanded-ceiling containers from transfers of different sizes.
{0xE2000u, EXPANDED - 0xE2000u, true, true, false},
{0xD8000u, EXPANDED - 0xD8000u, true, true, false},
// A retained container from the older D4000 ceiling geometry.
{0xC0000u, LEGACY - 0xC0000u, true, true, false},
// Magic at a page whose total does not bottom-align there is not a
// reopenable store header and must remain untouched.
{0xD9000u, 0x8000u, true, true, false},
};
FakeStagedFlash flash{headers, 4u, 0u};
uint32_t invalidated = 0;
EXPECT_TRUE(mota_nrf52_discard_staged_headers(
APP_V6, APP_END_V6, EXPANDED, &flash, fake_read_staged_header,
fake_invalidate_staged_header, &invalidated));
EXPECT_EQ(invalidated, 3u);
EXPECT_EQ(flash.invalidate_calls, 3u);
EXPECT_TRUE(headers[0].invalidated);
EXPECT_TRUE(headers[1].invalidated);
EXPECT_TRUE(headers[2].invalidated);
EXPECT_TRUE(headers[3].present);
EXPECT_FALSE(headers[3].invalidated);
}
TEST(OtaFlashDiscard, ReportsFailureButContinuesInvalidatingOtherHeaders) {
FakeStagedHeader headers[] = {
{0xE2000u, EXPANDED - 0xE2000u, true, false, false},
{0xD8000u, EXPANDED - 0xD8000u, true, true, false},
};
FakeStagedFlash flash{headers, 2u, 0u};
uint32_t invalidated = 0;
EXPECT_FALSE(mota_nrf52_discard_staged_headers(
APP_V6, APP_END_V6, EXPANDED, &flash, fake_read_staged_header,
fake_invalidate_staged_header, &invalidated));
EXPECT_EQ(invalidated, 1u);
EXPECT_EQ(flash.invalidate_calls, 2u);
EXPECT_TRUE(headers[0].present);
EXPECT_TRUE(headers[1].invalidated);
}
TEST(OtaFlashDiscard, UnsafeBoundsFailBeforeAnyFlashCallback) {
FakeStagedHeader headers[] = {
{0xE2000u, EXPANDED - 0xE2000u, true, true, false},
};
FakeStagedFlash flash{headers, 1u, 0u};
uint32_t invalidated = 99u;
EXPECT_FALSE(mota_nrf52_discard_staged_headers(
APP_V6, EXPANDED + 1u, EXPANDED, &flash, fake_read_staged_header,
fake_invalidate_staged_header, &invalidated));
EXPECT_EQ(invalidated, 0u);
EXPECT_EQ(flash.invalidate_calls, 0u);
EXPECT_TRUE(headers[0].present);
EXPECT_FALSE(mota_nrf52_discard_staged_headers(
APP_V6, APP_END_V6, EXPANDED, &flash, nullptr,
fake_invalidate_staged_header, &invalidated));
EXPECT_EQ(flash.invalidate_calls, 0u);
}
TEST(OtaEsp32FlashPlan, FullAndDeltaPlacementStaySectorAligned) {
constexpr uint32_t partition = 2u * 1024u * 1024u;
constexpr uint32_t sector = 4096u;
constexpr uint32_t meta_capacity = 65536u;
MotaEsp32StageLayout full;
ASSERT_TRUE(mota_esp32_stage_layout(
partition, sector, meta_capacity, true, 1024u * 1024u, 40000u,
1024u * 1024u, full));
EXPECT_EQ(full.total, 40000u + 1024u * 1024u + 5u);
EXPECT_EQ(full.meta_flush, 40960u);
EXPECT_EQ(full.meta_part, partition - full.meta_flush);
EXPECT_EQ(full.meta_part % sector, 0u);
EXPECT_EQ(full.pay_part0, 0u);
MotaEsp32StageLayout delta;
ASSERT_TRUE(mota_esp32_stage_layout(
partition, sector, meta_capacity, false, 900000u, 1234u,
100000u, delta));
EXPECT_EQ(delta.total, 101239u);
EXPECT_EQ(delta.meta_span, sector);
EXPECT_EQ(delta.meta_part, delta.write_start);
EXPECT_EQ(delta.write_start % sector, 0u);
EXPECT_EQ(delta.pay_part0, delta.write_start + sector);
}
TEST(OtaEsp32FlashPlan, RejectsOverflowAndUnfittableMetadata) {
MotaEsp32StageLayout layout;
EXPECT_FALSE(mota_esp32_stage_layout(
4097u, 4096u, 65536u, true, 1u, 200u, 1u, layout));
EXPECT_FALSE(mota_esp32_stage_layout(
2u * 1024u * 1024u, 4096u, 65536u, true, 1u, UINT32_MAX,
1u, layout));
EXPECT_FALSE(mota_esp32_stage_layout(
32768u, 4096u, 65536u, true, 1u, 40000u, 1u, layout));
EXPECT_FALSE(mota_esp32_stage_layout(
2u * 1024u * 1024u, 4096u, 65536u, false,
2u * 1024u * 1024u, 1000u, 1000u, layout));
}
TEST(OtaEsp32FlashPlan, RejectsMalformedOrOversizedFullPayloadBeforeErase) {
constexpr uint32_t partition = 2u * 1024u * 1024u;
constexpr uint32_t sector = 4096u;
constexpr uint32_t meta_capacity = 65536u;
MotaEsp32StageLayout layout;
// FULL means that the payload is exactly the final application image.
EXPECT_FALSE(mota_esp32_stage_layout(
partition, sector, meta_capacity, true, 100000u, 2000u,
99999u, layout));
EXPECT_FALSE(mota_esp32_stage_layout(
partition, sector, meta_capacity, true, 100000u, 2000u,
100001u, layout));
// The logical container and rounded metadata/payload regions must both fit
// wholly inside the inactive slot before OtaStoreFlashEsp32::begin().
EXPECT_FALSE(mota_esp32_stage_layout(
partition, sector, meta_capacity, true, partition, 2000u,
partition, layout));
EXPECT_FALSE(mota_esp32_stage_layout(
8192u, sector, meta_capacity, true, 4097u, 1u, 4097u,
layout));
}
namespace {
struct FakeEspStagedHeader {
uint32_t offset;
bool reopenable;
bool probe_ok;
bool invalidate_ok;
bool invalidated;
};
struct FakeEspPartition {
FakeEspStagedHeader* headers;
size_t count;
uint32_t probe_calls;
uint32_t invalidate_calls;
};
bool fake_probe_esp_header(void* context, uint32_t offset,
bool& reopenable) {
FakeEspPartition* partition = static_cast<FakeEspPartition*>(context);
++partition->probe_calls;
reopenable = false;
for (size_t i = 0; i < partition->count; ++i) {
FakeEspStagedHeader& header = partition->headers[i];
if (header.offset != offset || header.invalidated) continue;
reopenable = header.reopenable;
return header.probe_ok;
}
return true;
}
bool fake_invalidate_esp_header(void* context, uint32_t offset) {
FakeEspPartition* partition = static_cast<FakeEspPartition*>(context);
++partition->invalidate_calls;
for (size_t i = 0; i < partition->count; ++i) {
FakeEspStagedHeader& header = partition->headers[i];
if (header.offset != offset || header.invalidated) continue;
if (!header.invalidate_ok) return false;
header.invalidated = true;
return true;
}
return false;
}
} // namespace
TEST(OtaEsp32FlashDiscard, FreshIdleScanInvalidatesEveryReopenableHeader) {
constexpr uint32_t sector = 4096u;
constexpr uint32_t partition_size = 6u * sector;
FakeEspStagedHeader headers[] = {
{5u * sector, true, true, true, false},
{3u * sector, false, true, true, false},
{1u * sector, true, true, true, false},
};
FakeEspPartition partition{headers, 3u, 0u, 0u};
uint32_t invalidated = 99u;
EXPECT_TRUE(mota_esp32_discard_staged_headers(
partition_size, sector, &partition, fake_probe_esp_header,
fake_invalidate_esp_header, &invalidated));
EXPECT_EQ(partition.probe_calls, 6u);
EXPECT_EQ(partition.invalidate_calls, 2u);
EXPECT_EQ(invalidated, 2u);
EXPECT_TRUE(headers[0].invalidated);
EXPECT_FALSE(headers[1].invalidated);
EXPECT_TRUE(headers[2].invalidated);
}
TEST(OtaEsp32FlashDiscard, IoAndInvalidateFailuresAreReportedAfterFullScan) {
constexpr uint32_t sector = 4096u;
constexpr uint32_t partition_size = 6u * sector;
FakeEspStagedHeader headers[] = {
{4u * sector, false, false, true, false},
{3u * sector, true, true, false, false},
{1u * sector, true, true, true, false},
};
FakeEspPartition partition{headers, 3u, 0u, 0u};
uint32_t invalidated = 0u;
EXPECT_FALSE(mota_esp32_discard_staged_headers(
partition_size, sector, &partition, fake_probe_esp_header,
fake_invalidate_esp_header, &invalidated));
EXPECT_EQ(partition.probe_calls, 6u);
EXPECT_EQ(partition.invalidate_calls, 2u);
EXPECT_EQ(invalidated, 1u);
EXPECT_FALSE(headers[1].invalidated);
EXPECT_TRUE(headers[2].invalidated);
}
TEST(OtaEsp32FlashDiscard, InvalidScanArgumentsFailWithoutCallbacks) {
FakeEspPartition partition{nullptr, 0u, 0u, 0u};
uint32_t invalidated = 99u;
EXPECT_FALSE(mota_esp32_discard_staged_headers(
4095u, 4096u, &partition, fake_probe_esp_header,
fake_invalidate_esp_header, &invalidated));
EXPECT_FALSE(mota_esp32_discard_staged_headers(
4097u, 4096u, &partition, fake_probe_esp_header,
fake_invalidate_esp_header, &invalidated));
EXPECT_EQ(invalidated, 0u);
EXPECT_EQ(partition.probe_calls, 0u);
EXPECT_EQ(partition.invalidate_calls, 0u);
}
// out_start is only written on success - a rejected plan must not clobber the caller's variable.
TEST(OtaFlashPlan, LeavesOutputUntouchedOnReject) {
uint32_t start = 0x1234ABCD;