fix: harden LoRa OTA chain and storage
This commit is contained in:
1 parent
400aa710a2
commit
b40d2e6cb2
29 files changed
+7497
-528
No files matched your search
@@ -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"
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user