mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-26 17:38:16 +00:00
Expose the Full Companion terminal over TCP and add an authenticated, replay-resistant host command service with documented CPU temperature, reboot, and allowlisted program examples.\n\nImprove LoRa OTA diagnostics and persistent CLI handling, reduce clock correction drift to ten minutes, and make mOTA stream flush behavior an explicit transport policy with regression coverage.
3193 lines
139 KiB
C++
3193 lines
139 KiB
C++
#include <gtest/gtest.h>
|
|
#include <array>
|
|
#include <vector>
|
|
#include <cstring>
|
|
#include <type_traits>
|
|
|
|
#include "helpers/ota/MotaContainer.h"
|
|
#include "helpers/ota/MerkleTree.h"
|
|
#include "helpers/ota/BlockBitmap.h"
|
|
#include "helpers/ota/Multihash.h"
|
|
#include "helpers/ota/FirmwareInfo.h"
|
|
#include "helpers/ota/MotaSeederProto.h"
|
|
#include "helpers/ota/MotaSourceSerial.h"
|
|
#include "helpers/ota/FolderMotaStore.h"
|
|
#include "helpers/ota/SignerAllowlist.h"
|
|
#include "helpers/ota/OtaStore.h"
|
|
#include "helpers/ota/OtaProtocol.h"
|
|
#include "helpers/ota/OtaManager.h"
|
|
#include "helpers/ota/OtaApply.h"
|
|
#include "helpers/ota/OtaBlInfo.h"
|
|
#include "helpers/ota/OtaFlashLayout_nrf52.h"
|
|
#include "helpers/ota/OtaSdBootToken.h"
|
|
#include "helpers/ota/OtaSdAuthRecord.h"
|
|
#include "mota_vectors.h" // auto-generated by tools/mota/gen_vectors.py
|
|
|
|
extern "C" {
|
|
#include "helpers/ota/detools/detools.h" // vendored detools 0.53.0 embeddable decoder
|
|
}
|
|
|
|
using namespace mesh::ota;
|
|
|
|
static std::vector<uint8_t> boot_manifest_bytes();
|
|
|
|
TEST(OtaBootResult, AcceptsOnlyOtafixApplyDiagnostics) {
|
|
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0x90), 0x90);
|
|
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0x9F), 0x9F);
|
|
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(0xC0), 0xC0);
|
|
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xC8), 0xC8);
|
|
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xCF), 0xCF);
|
|
EXPECT_TRUE(ota_nrf52_boot_update_result(0xC8));
|
|
EXPECT_FALSE(ota_nrf52_boot_update_result(0xB8));
|
|
|
|
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0x00), 0x00);
|
|
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0x4C), 0x00); // shutdown reason
|
|
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0x51), 0x00); // QSPI handoff
|
|
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0x53), 0x00); // SD handoff
|
|
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xD4), 0x00); // legacy handoff
|
|
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xED), 0x00); // expanded handoff
|
|
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xE0), 0x00); // retired handoff remains non-result
|
|
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xBD), 0x00);
|
|
}
|
|
|
|
TEST(OtaPolicy, TrustedAutoInstallIsStrictlyForwardOnly) {
|
|
EXPECT_FALSE(ota_trusted_auto_version_allows(0, 0x01170104u));
|
|
EXPECT_FALSE(ota_trusted_auto_version_allows(0x01170103u, 0));
|
|
EXPECT_FALSE(ota_trusted_auto_version_allows(0x01170103u, 0x01170102u));
|
|
EXPECT_FALSE(ota_trusted_auto_version_allows(0x01170103u, 0x01170103u));
|
|
EXPECT_TRUE(ota_trusted_auto_version_allows(0x01170103u, 0x01170104u));
|
|
}
|
|
|
|
TEST(OtaBootPackage, StorageProfilesAndSdGeometryAreExact) {
|
|
EXPECT_EQ(OTA_BL_PROFILE_SD_BOOT_UPDATE, 0x09u);
|
|
EXPECT_EQ(OTA_BL_PROFILE_INTERNAL_BOOT_UPDATE, 0x0Au);
|
|
EXPECT_EQ(OTA_BL_PROFILE_QSPI_BOOT_UPDATE, 0x0Eu);
|
|
EXPECT_EQ(OTA_BL_REQUIRED_APP_CODEC_MASK, 0x0005u);
|
|
EXPECT_EQ(GPREGRET2_OTA_STAGE_SD, 0x53u);
|
|
EXPECT_TRUE(ota_bootloader_image_geometry_valid(
|
|
OTA_BOOT_IMAGE_SIZE, OTA_BOOT_IMAGE_SIZE));
|
|
EXPECT_FALSE(ota_bootloader_image_geometry_valid(
|
|
OTA_BOOT_IMAGE_SIZE - 1u, OTA_BOOT_IMAGE_SIZE));
|
|
EXPECT_FALSE(ota_bootloader_image_geometry_valid(
|
|
OTA_BOOT_IMAGE_SIZE, OTA_BOOT_IMAGE_SIZE - 1u));
|
|
EXPECT_TRUE(ota_bootloader_scratch_headroom_valid(
|
|
true, MOTA_NRF52_APP_BASE_S140_V6,
|
|
OTA_BOOT_SCRATCH_START - MOTA_NRF52_APP_BASE_S140_V6,
|
|
OTA_BOOT_SCRATCH_START));
|
|
EXPECT_FALSE(ota_bootloader_scratch_headroom_valid(
|
|
false, MOTA_NRF52_APP_BASE_S140_V6, 1u, OTA_BOOT_SCRATCH_START));
|
|
EXPECT_FALSE(ota_bootloader_scratch_headroom_valid(
|
|
true, MOTA_NRF52_APP_BASE_S140_V6,
|
|
OTA_BOOT_SCRATCH_START - MOTA_NRF52_APP_BASE_S140_V6 + 1u,
|
|
OTA_BOOT_SCRATCH_START));
|
|
|
|
const uint32_t safe_span = OTA_BOOT_SCRATCH_START - MOTA_NRF52_APP_BASE_S140_V6;
|
|
const uint32_t live_size = safe_span - 0x1000u;
|
|
EXPECT_TRUE(ota_bootloader_scratch_bank_geometry_valid(
|
|
OTA_BOOT_BANK_ERASED_FALLBACK, 0xFFFFu, UINT32_MAX, live_size, safe_span));
|
|
EXPECT_TRUE(ota_bootloader_scratch_bank_geometry_valid(
|
|
OTA_BOOT_BANK_VALID_APP, 0u, UINT32_MAX, live_size, safe_span));
|
|
EXPECT_TRUE(ota_bootloader_scratch_bank_geometry_valid(
|
|
OTA_BOOT_BANK_VALID_APP, 1u, live_size, live_size, safe_span));
|
|
EXPECT_FALSE(ota_bootloader_scratch_bank_geometry_valid(
|
|
OTA_BOOT_BANK_VALID_APP, 1u, live_size - 1u, live_size, safe_span));
|
|
EXPECT_FALSE(ota_bootloader_scratch_bank_geometry_valid(
|
|
OTA_BOOT_BANK_VALID_APP, 1u, safe_span + 1u, live_size, safe_span));
|
|
EXPECT_FALSE(ota_bootloader_scratch_bank_geometry_valid(
|
|
0x00FFu, 0u, 0u, live_size, safe_span));
|
|
}
|
|
|
|
TEST(OtaSdAuth, RetainedRecordBindsPurposeGeometryAndContainerDigest) {
|
|
uint8_t digest[32];
|
|
for (uint8_t i = 0; i < sizeof(digest); ++i) digest[i] = (uint8_t)(0x40u + i);
|
|
uint8_t record[MOTA_SD_AUTH_LEN];
|
|
const uint32_t total = 41330u;
|
|
const uint32_t sectors = (total + 511u) / 512u;
|
|
ASSERT_TRUE(mota_sd_auth_encode(record, MOTA_SD_AUTH_PURPOSE_BOOTLOADER,
|
|
MOTA_BOOT_FORMAT_VER, 2048u, sectors,
|
|
total, 8000000u, digest));
|
|
EXPECT_EQ(0, memcmp(record, "MOTASDA2", 8));
|
|
EXPECT_EQ(mota_sd_auth_rd16(record + 8), 2u);
|
|
EXPECT_EQ(mota_sd_auth_rd16(record + 10), 72u);
|
|
EXPECT_EQ(record[12], MOTA_SD_AUTH_PURPOSE_BOOTLOADER);
|
|
EXPECT_EQ(record[13], MOTA_BOOT_FORMAT_VER);
|
|
EXPECT_EQ(mota_sd_auth_rd32(record + 16), 2048u);
|
|
EXPECT_EQ(mota_sd_auth_rd32(record + 20), sectors);
|
|
EXPECT_EQ(mota_sd_auth_rd32(record + 24), total);
|
|
EXPECT_EQ(mota_sd_auth_rd32(record + 28), 8000000u);
|
|
EXPECT_EQ(0, memcmp(record + 32, digest, sizeof(digest)));
|
|
EXPECT_TRUE(mota_sd_auth_valid(record));
|
|
|
|
record[32] ^= 1u;
|
|
EXPECT_FALSE(mota_sd_auth_valid(record));
|
|
record[32] ^= 1u;
|
|
EXPECT_FALSE(mota_sd_auth_encode(record, MOTA_SD_AUTH_PURPOSE_APP,
|
|
MOTA_BOOT_FORMAT_VER, 2048u, sectors,
|
|
total, 8000000u, digest));
|
|
EXPECT_FALSE(mota_sd_auth_encode(record, MOTA_SD_AUTH_PURPOSE_APP,
|
|
MOTA_APP_FORMAT_VER, 2048u, sectors + 1u,
|
|
total, 8000000u, digest));
|
|
}
|
|
|
|
TEST(OtaBootPackage, ContinuityExtensionIsBackwardCompatibleAndStrictlyMonotonic) {
|
|
uint8_t envelope[OTA_BOOT_ENVELOPE_SIZE] = {0};
|
|
wr_u32le(envelope, OTA_BOOT_MANIFEST_MAGIC0);
|
|
wr_u32le(envelope + 4, OTA_BOOT_MANIFEST_MAGIC1);
|
|
envelope[8] = (uint8_t)OTA_BOOT_MANIFEST_VERSION;
|
|
envelope[10] = (uint8_t)OTA_BOOT_MANIFEST_SIZE;
|
|
wr_u32le(envelope + 12, OTA_BOOT_IMAGE_START);
|
|
wr_u32le(envelope + 16, OTA_BOOT_IMAGE_SIZE);
|
|
wr_u32le(envelope + 20, 0x239A0071u);
|
|
memcpy(envelope + 24, "TOWER_V2_OTA", 12);
|
|
wr_u32le(envelope + 44, OTA_BOOT_CONTINUITY_MAGIC0);
|
|
wr_u32le(envelope + 48, OTA_BOOT_CONTINUITY_MAGIC1);
|
|
envelope[52] = (uint8_t)OTA_BOOT_CONTINUITY_VERSION;
|
|
envelope[54] = (uint8_t)OTA_BOOT_CONTINUITY_SIZE;
|
|
const uint32_t candidate_version = 0x0117010Du;
|
|
wr_u32le(envelope + 56, candidate_version);
|
|
envelope[60] = (uint8_t)OTA_BOOT_CONTINUITY_FAMILY_S140;
|
|
envelope[62] = 0xB6;
|
|
wr_u32le(envelope + 64, MOTA_NRF52_APP_BASE_S140_V6);
|
|
envelope[68] = (uint8_t)OTA_BOOT_CONTINUITY_LAYOUT_ABI;
|
|
|
|
OtaBootloaderIdentity candidate;
|
|
ASSERT_TRUE(ota_bootloader_manifest_parse(envelope, 0x100u, candidate,
|
|
sizeof(envelope)));
|
|
ASSERT_TRUE(candidate.continuity_present);
|
|
EXPECT_EQ(candidate.boot_version, candidate_version);
|
|
EXPECT_EQ(candidate.softdevice_family, 140u);
|
|
EXPECT_EQ(candidate.softdevice_fwid, 0x00B6u);
|
|
EXPECT_EQ(candidate.app_base, MOTA_NRF52_APP_BASE_S140_V6);
|
|
|
|
OtaBootloaderIdentity legacy = candidate;
|
|
legacy.continuity_present = false;
|
|
legacy.crc_ok = true;
|
|
EXPECT_FALSE(ota_bootloader_sd_retained_auth_ready(
|
|
legacy, 140u, 0x00B6u, MOTA_NRF52_APP_BASE_S140_V6, 1u));
|
|
candidate.crc_ok = true;
|
|
EXPECT_TRUE(ota_bootloader_sd_retained_auth_ready(
|
|
candidate, 140u, 0x00B6u, MOTA_NRF52_APP_BASE_S140_V6, 1u));
|
|
EXPECT_EQ(ota_bootloader_continuity_gate(
|
|
legacy, candidate, candidate_version, 140u, 0x00B6u,
|
|
MOTA_NRF52_APP_BASE_S140_V6, 1u),
|
|
OTA_BOOT_CONTINUITY_OK); // one-time v1 bootstrap
|
|
EXPECT_EQ(ota_bootloader_continuity_gate(
|
|
legacy, candidate, candidate_version + 1u, 140u, 0x00B6u,
|
|
MOTA_NRF52_APP_BASE_S140_V6, 1u),
|
|
OTA_BOOT_CONTINUITY_OUTER_VERSION);
|
|
|
|
OtaBootloaderIdentity installed = candidate;
|
|
installed.boot_version = candidate_version - 1u;
|
|
EXPECT_EQ(ota_bootloader_continuity_gate(
|
|
installed, candidate, candidate_version, 140u, 0x00B6u,
|
|
MOTA_NRF52_APP_BASE_S140_V6, 1u),
|
|
OTA_BOOT_CONTINUITY_OK);
|
|
installed.boot_version = candidate_version;
|
|
EXPECT_EQ(ota_bootloader_continuity_gate(
|
|
installed, candidate, candidate_version, 140u, 0x00B6u,
|
|
MOTA_NRF52_APP_BASE_S140_V6, 1u),
|
|
OTA_BOOT_CONTINUITY_NOT_NEWER);
|
|
EXPECT_EQ(ota_bootloader_continuity_gate(
|
|
legacy, candidate, candidate_version, 140u, 0x00B7u,
|
|
MOTA_NRF52_APP_BASE_S140_V6, 1u),
|
|
OTA_BOOT_CONTINUITY_PLATFORM);
|
|
|
|
EXPECT_FALSE(ota_bootloader_version_valid(0x02040100u));
|
|
EXPECT_FALSE(ota_bootloader_version_valid(UINT32_MAX));
|
|
EXPECT_TRUE(ota_bootloader_version_valid(0x020401FFu));
|
|
candidate.boot_version = UINT32_MAX;
|
|
EXPECT_EQ(ota_bootloader_continuity_gate(
|
|
legacy, candidate, UINT32_MAX, 140u, 0x00B6u,
|
|
MOTA_NRF52_APP_BASE_S140_V6, 1u),
|
|
OTA_BOOT_CONTINUITY_OUTER_VERSION);
|
|
candidate.boot_version = candidate_version;
|
|
|
|
envelope[48] ^= 1u;
|
|
EXPECT_FALSE(ota_bootloader_manifest_parse(envelope, 0x100u, candidate,
|
|
sizeof(envelope)));
|
|
}
|
|
|
|
TEST(OtaBootPackage, CandidateContinuityEnvelopeHasOneCanonicalFinalOffset) {
|
|
auto make_image = [](uint32_t manifest_offset) {
|
|
std::vector<uint8_t> image(OTA_BOOT_IMAGE_SIZE, 0xFF);
|
|
wr_u32le(image.data(), OTA_NRF52840_RAM_END);
|
|
wr_u32le(image.data() + 4, OTA_BOOT_IMAGE_START + 0x101u);
|
|
uint8_t* caps = image.data() + 0x400u;
|
|
memset(caps, 0, 16u);
|
|
memcpy(caps, "MOTABLDR", 8u);
|
|
caps[8] = MOTA_BOOT_FORMAT_VER;
|
|
caps[10] = (uint8_t)OTA_BL_REQUIRED_APP_CODEC_MASK;
|
|
caps[12] = OTA_BL_PROFILE_SD_BOOT_UPDATE;
|
|
uint8_t* envelope = image.data() + manifest_offset;
|
|
memset(envelope, 0, OTA_BOOT_ENVELOPE_SIZE);
|
|
wr_u32le(envelope, OTA_BOOT_MANIFEST_MAGIC0);
|
|
wr_u32le(envelope + 4, OTA_BOOT_MANIFEST_MAGIC1);
|
|
envelope[8] = (uint8_t)OTA_BOOT_MANIFEST_VERSION;
|
|
envelope[10] = (uint8_t)OTA_BOOT_MANIFEST_SIZE;
|
|
wr_u32le(envelope + 12, OTA_BOOT_IMAGE_START);
|
|
wr_u32le(envelope + 16, OTA_BOOT_IMAGE_SIZE);
|
|
wr_u32le(envelope + 20, OTA_XIAO_BOARD_ID_BASE);
|
|
memcpy(envelope + 24, OTA_XIAO_BOOT_DEVICE_NAME,
|
|
OTA_BOOT_DEVICE_NAME_SIZE);
|
|
wr_u32le(envelope + 44, OTA_BOOT_CONTINUITY_MAGIC0);
|
|
wr_u32le(envelope + 48, OTA_BOOT_CONTINUITY_MAGIC1);
|
|
envelope[52] = (uint8_t)OTA_BOOT_CONTINUITY_VERSION;
|
|
envelope[54] = (uint8_t)OTA_BOOT_CONTINUITY_SIZE;
|
|
wr_u32le(envelope + 56, 0x0117010Du);
|
|
envelope[60] = (uint8_t)OTA_BOOT_CONTINUITY_FAMILY_S140;
|
|
envelope[62] = 0x23u;
|
|
envelope[63] = 0x01u;
|
|
wr_u32le(envelope + 64, MOTA_NRF52_APP_BASE_S140_V7);
|
|
envelope[68] = (uint8_t)OTA_BOOT_CONTINUITY_LAYOUT_ABI;
|
|
wr_u32le(envelope + 40,
|
|
ota_boot_image_crc32(image.data(), image.size(), manifest_offset + 40));
|
|
return image;
|
|
};
|
|
|
|
OtaBootloaderIdentity identity;
|
|
auto canonical = make_image(OTA_BOOT_CANDIDATE_MANIFEST_OFFSET);
|
|
ASSERT_TRUE(ota_bootloader_candidate_identity_from_image(
|
|
canonical.data(), canonical.size(), identity));
|
|
EXPECT_TRUE(ota_bootloader_candidate_identity_canonical(identity));
|
|
EXPECT_EQ(identity.manifest_offset, 0x9FB4u);
|
|
|
|
auto relocated = make_image(0x8000u);
|
|
ASSERT_TRUE(ota_bootloader_identity_from_image(
|
|
relocated.data(), relocated.size(), identity)); // installed/legacy scanner remains generic
|
|
EXPECT_FALSE(ota_bootloader_candidate_identity_canonical(identity));
|
|
EXPECT_FALSE(ota_bootloader_candidate_identity_from_image(
|
|
relocated.data(), relocated.size(), identity));
|
|
|
|
OtaStoreRam<OTA_BOOT_IMAGE_SIZE> store;
|
|
OtaBootloaderCapsMarker caps;
|
|
ASSERT_TRUE(store.begin((uint32_t)canonical.size()));
|
|
ASSERT_TRUE(store.write(0, canonical.data(), (uint32_t)canonical.size()));
|
|
EXPECT_TRUE(ota_bootloader_external_image_metadata(
|
|
store, 0, OTA_BL_PROFILE_SD_BOOT_UPDATE, identity, caps));
|
|
EXPECT_EQ(identity.manifest_offset, OTA_BOOT_CANDIDATE_MANIFEST_OFFSET);
|
|
|
|
// A second CRC-valid base identity still counts when its adjacent BLM2
|
|
// extension is only half present. These coupled values are the fixed point
|
|
// for this deterministic image. Counting continuity first would wrongly
|
|
// discard the decoy and disagree with a deployed legacy updater.
|
|
auto ambiguous = canonical;
|
|
const uint32_t corrupt_decoy = 0x200u;
|
|
memcpy(ambiguous.data() + corrupt_decoy,
|
|
ambiguous.data() + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET,
|
|
OTA_BOOT_MANIFEST_SIZE);
|
|
wr_u32le(ambiguous.data() + corrupt_decoy + 44, OTA_BOOT_CONTINUITY_MAGIC0);
|
|
wr_u32le(ambiguous.data() + corrupt_decoy + 48, 0x21444142u); // "BAD!"
|
|
wr_u32le(ambiguous.data() + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 40,
|
|
0x614E59E1u);
|
|
wr_u32le(ambiguous.data() + corrupt_decoy + 40, 0x4A03F7A2u);
|
|
ASSERT_EQ(ota_boot_image_crc32(
|
|
ambiguous.data(), ambiguous.size(),
|
|
OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 40), 0x614E59E1u);
|
|
ASSERT_EQ(ota_boot_image_crc32(
|
|
ambiguous.data(), ambiguous.size(), corrupt_decoy + 40),
|
|
0x4A03F7A2u);
|
|
EXPECT_FALSE(ota_bootloader_candidate_identity_from_image(
|
|
ambiguous.data(), ambiguous.size(), identity));
|
|
ASSERT_TRUE(store.begin((uint32_t)ambiguous.size()));
|
|
ASSERT_TRUE(store.write(0, ambiguous.data(), (uint32_t)ambiguous.size()));
|
|
EXPECT_FALSE(ota_bootloader_external_image_metadata(
|
|
store, 0, OTA_BL_PROFILE_SD_BOOT_UPDATE, identity, caps));
|
|
|
|
// The same malformed extension is harmless when its base CRC is invalid;
|
|
// only the canonical CRC-valid identity is selected and interpreted.
|
|
auto bad_crc_decoy = canonical;
|
|
memcpy(bad_crc_decoy.data() + corrupt_decoy,
|
|
bad_crc_decoy.data() + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET,
|
|
OTA_BOOT_MANIFEST_SIZE);
|
|
wr_u32le(bad_crc_decoy.data() + corrupt_decoy + 44,
|
|
OTA_BOOT_CONTINUITY_MAGIC0);
|
|
wr_u32le(bad_crc_decoy.data() + corrupt_decoy + 48, 0x21444142u);
|
|
wr_u32le(bad_crc_decoy.data() + corrupt_decoy + 40, 0xA5A5A5A5u);
|
|
wr_u32le(bad_crc_decoy.data() + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 40, 0u);
|
|
wr_u32le(bad_crc_decoy.data() + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 40,
|
|
ota_boot_image_crc32(
|
|
bad_crc_decoy.data(), bad_crc_decoy.size(),
|
|
OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 40));
|
|
ASSERT_NE(ota_boot_image_crc32(
|
|
bad_crc_decoy.data(), bad_crc_decoy.size(), corrupt_decoy + 40),
|
|
0xA5A5A5A5u);
|
|
EXPECT_TRUE(ota_bootloader_candidate_identity_from_image(
|
|
bad_crc_decoy.data(), bad_crc_decoy.size(), identity));
|
|
ASSERT_TRUE(store.begin((uint32_t)bad_crc_decoy.size()));
|
|
ASSERT_TRUE(store.write(0, bad_crc_decoy.data(),
|
|
(uint32_t)bad_crc_decoy.size()));
|
|
EXPECT_TRUE(ota_bootloader_external_image_metadata(
|
|
store, 0, OTA_BL_PROFILE_SD_BOOT_UPDATE, identity, caps));
|
|
|
|
// A sole CRC-valid identity that claims a corrupt extension is not a legacy
|
|
// image; installed diagnostics and candidate validation both fail closed.
|
|
auto sole_half = canonical;
|
|
wr_u32le(sole_half.data() + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 48,
|
|
0x21444142u);
|
|
wr_u32le(sole_half.data() + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 40, 0u);
|
|
wr_u32le(sole_half.data() + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 40,
|
|
ota_boot_image_crc32(
|
|
sole_half.data(), sole_half.size(),
|
|
OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 40));
|
|
EXPECT_FALSE(ota_bootloader_identity_from_image(
|
|
sole_half.data(), sole_half.size(), identity));
|
|
ASSERT_TRUE(store.begin((uint32_t)sole_half.size()));
|
|
ASSERT_TRUE(store.write(0, sole_half.data(), (uint32_t)sole_half.size()));
|
|
EXPECT_FALSE(ota_bootloader_external_image_metadata(
|
|
store, 0, OTA_BL_PROFILE_SD_BOOT_UPDATE, identity, caps));
|
|
|
|
ASSERT_TRUE(store.begin((uint32_t)relocated.size()));
|
|
ASSERT_TRUE(store.write(0, relocated.data(), (uint32_t)relocated.size()));
|
|
EXPECT_FALSE(ota_bootloader_external_image_metadata(
|
|
store, 0, OTA_BL_PROFILE_SD_BOOT_UPDATE, identity, caps));
|
|
}
|
|
|
|
TEST(OtaBootPackage, SdApprovalTokenBindsTheAuthenticatedImageHash) {
|
|
uint8_t hash[32];
|
|
for (uint8_t i = 0; i < sizeof(hash); i++) hash[i] = i;
|
|
uint8_t token[MOTA_SD_BOOT_TOKEN_LEN];
|
|
mota_sd_boot_token_encode(token, MOTA_NRF52_BOOT_CONTAINER_SIZE, hash);
|
|
EXPECT_EQ(0, memcmp(token, "MOTASDBL", 8));
|
|
EXPECT_EQ(mota_sd_boot_token_rd32(token + 8), 1u);
|
|
EXPECT_EQ(mota_sd_boot_token_rd32(token + 12), 64u);
|
|
EXPECT_EQ(mota_sd_boot_token_rd32(token + 16), MOTA_NRF52_BOOT_CONTAINER_SIZE);
|
|
EXPECT_EQ(mota_sd_boot_token_rd32(token + 20), ~MOTA_NRF52_BOOT_CONTAINER_SIZE);
|
|
EXPECT_EQ(0, memcmp(token + MOTA_SD_BOOT_TOKEN_IMAGE_HASH_OFFSET, hash, sizeof(hash)));
|
|
EXPECT_TRUE(mota_sd_boot_token_valid(
|
|
token, MOTA_NRF52_BOOT_CONTAINER_SIZE, hash));
|
|
|
|
token[MOTA_SD_BOOT_TOKEN_IMAGE_HASH_OFFSET] ^= 1u;
|
|
EXPECT_FALSE(mota_sd_boot_token_valid(
|
|
token, MOTA_NRF52_BOOT_CONTAINER_SIZE, hash));
|
|
token[MOTA_SD_BOOT_TOKEN_IMAGE_HASH_OFFSET] ^= 1u;
|
|
EXPECT_FALSE(mota_sd_boot_token_valid(
|
|
token, MOTA_NRF52_BOOT_CONTAINER_SIZE + 1u, hash));
|
|
}
|
|
|
|
TEST(OtaBootPackage, GenericIdentityUsesBoardAndNameForCollisionSafeTarget) {
|
|
const uint32_t shared_board_id = 0x239A0029u;
|
|
const uint8_t rak3401_name[16] = {'3','4','0','1','_','D','F','U',0,0,0,0,0,0,0,0};
|
|
const uint8_t rak4631_name[16] = {'4','6','3','1','_','D','F','U',0,0,0,0,0,0,0,0};
|
|
uint8_t hw3401[32], hw4631[32];
|
|
ASSERT_TRUE(ota_bootloader_hw_id(shared_board_id, rak3401_name, hw3401));
|
|
ASSERT_TRUE(ota_bootloader_hw_id(shared_board_id, rak4631_name, hw4631));
|
|
EXPECT_EQ(0, memcmp(hw3401, "NRF_BL_239A0029_3401_DFU", 24));
|
|
EXPECT_EQ(0, memcmp(hw4631, "NRF_BL_239A0029_4631_DFU", 24));
|
|
EXPECT_NE(0, memcmp(hw3401, hw4631, sizeof(hw3401)));
|
|
EXPECT_EQ(ota_bootloader_target_id(shared_board_id, rak3401_name), 0x23818A80u);
|
|
EXPECT_EQ(ota_bootloader_target_id(shared_board_id, rak4631_name), 0x2D0DF000u);
|
|
|
|
uint8_t invalid[16] = {'B','A','D',0,'T','A','I','L',0,0,0,0,0,0,0,0};
|
|
EXPECT_FALSE(ota_bootloader_device_name_valid(shared_board_id, invalid));
|
|
memset(invalid, 'A', sizeof(invalid));
|
|
EXPECT_FALSE(ota_bootloader_device_name_valid(shared_board_id, invalid));
|
|
EXPECT_FALSE(ota_bootloader_board_id_valid(0));
|
|
EXPECT_FALSE(ota_bootloader_board_id_valid(UINT32_MAX));
|
|
|
|
auto raw = boot_manifest_bytes();
|
|
wr_u32le(raw.data() + 3, ota_bootloader_target_id(shared_board_id, rak3401_name));
|
|
memcpy(raw.data() + 57, hw3401, sizeof(hw3401));
|
|
MotaManifest manifest;
|
|
ASSERT_TRUE(mota_parse_manifest(raw.data(), raw.size(), manifest));
|
|
OtaBootloaderIdentity installed;
|
|
installed.present = installed.crc_ok = true;
|
|
installed.image_start = OTA_BOOT_IMAGE_START;
|
|
installed.image_size = OTA_BOOT_IMAGE_SIZE;
|
|
installed.board_id = shared_board_id;
|
|
memcpy(installed.device_name, rak3401_name, sizeof(rak3401_name));
|
|
uint8_t mid[4] = {0x11,0x22,0x33,0x44};
|
|
uint8_t hash8[8]; memcpy(hash8, manifest.image_hash, sizeof(hash8));
|
|
EXPECT_EQ(ota_bootloader_confirmation_gate(manifest, installed, mid, mid, hash8),
|
|
OTA_BOOT_CONFIRM_OK);
|
|
wr_u32le(raw.data() + 3, shared_board_id); // raw USB ID is not the generic wire target
|
|
ASSERT_TRUE(mota_parse_manifest(raw.data(), raw.size(), manifest));
|
|
EXPECT_EQ(ota_bootloader_confirmation_gate(manifest, installed, mid, mid, hash8),
|
|
OTA_BOOT_CONFIRM_TARGET);
|
|
|
|
struct KnownIdentity { uint32_t board_id; const char* name; uint32_t target_id; };
|
|
const KnownIdentity known[] = {
|
|
{0x239A0071u, "TOWER_V2_OTA", 0x1150F50Eu},
|
|
{0x239A0071u, "T096_DFU", 0x42354C85u},
|
|
{0x239A0071u, "T1_DFU", 0xFC556FFCu},
|
|
{0x239A0071u, "T114_DFU", 0x0C3F2902u},
|
|
{0x239A0071u, "MESH_POCKET_OTA",0x059277F4u},
|
|
{0x239A00B3u, "KeepteenLT1_OTA",0xDB2E7B51u},
|
|
{0x239A0029u, "MX25_DFU", 0x026AA982u},
|
|
{0x239A00B3u, "PROM_DFU", 0xAF79E8CCu},
|
|
{0x28860057u, "T1KE_DFU", 0xE6F5F03Fu},
|
|
{0x239A00DAu, "TNM3_DFU", 0x0CA41DB2u},
|
|
{0x239A0029u, "3401_DFU", 0x23818A80u},
|
|
{0x239A0029u, "4631_DFU", 0x2D0DF000u},
|
|
{0x239A0029u, "RTAG_DFU", 0xC72E9C9Cu},
|
|
};
|
|
uint32_t targets[sizeof(known) / sizeof(known[0])] = {0};
|
|
for (size_t i = 0; i < sizeof(known) / sizeof(known[0]); i++) {
|
|
uint8_t name[16] = {0};
|
|
ASSERT_LT(strlen(known[i].name), sizeof(name));
|
|
memcpy(name, known[i].name, strlen(known[i].name));
|
|
targets[i] = ota_bootloader_target_id(known[i].board_id, name);
|
|
EXPECT_EQ(targets[i], known[i].target_id);
|
|
for (size_t j = 0; j < i; j++) EXPECT_NE(targets[i], targets[j]);
|
|
}
|
|
}
|
|
|
|
static std::vector<uint8_t> boot_manifest_bytes() {
|
|
std::vector<uint8_t> m(MOTA_MFL, 0);
|
|
m[0] = MOTA_BOOT_FORMAT_VER;
|
|
m[1] = MFLAG_FULL | MFLAG_SIGNED | MFLAG_BOOTLOADER;
|
|
m[2] = HASH_ALGO_SHA256;
|
|
wr_u32le(m.data() + 3, OTA_XIAO_BOARD_ID_BASE);
|
|
wr_u32le(m.data() + 7, 1);
|
|
wr_u32le(m.data() + 11, OTA_BOOT_IMAGE_SIZE);
|
|
wr_u32le(m.data() + 15, OTA_BOOT_IMAGE_SIZE);
|
|
m[19] = 10;
|
|
m[20] = 0x11; m[21] = 0x22; m[22] = 0x33; m[23] = 0x44;
|
|
for (uint8_t i = 0; i < 32; i++) m[24 + i] = (uint8_t)(0x80 + i);
|
|
m[56] = CODEC_FULL;
|
|
uint8_t hw[32]; EXPECT_TRUE(ota_xiao_bootloader_hw_id(OTA_XIAO_BOARD_ID_BASE, hw));
|
|
memcpy(m.data() + 57, hw, sizeof(hw));
|
|
memset(m.data() + MOTA_OFF_APPROVAL, 0xFF, 4);
|
|
return m;
|
|
}
|
|
|
|
TEST(OtaBootPackage, ParserSeparatesV2ApplicationsFromStrictV3Bootloader) {
|
|
MotaManifest parsed;
|
|
auto m = boot_manifest_bytes();
|
|
ASSERT_TRUE(mota_parse_manifest(m.data(), m.size(), parsed));
|
|
EXPECT_TRUE(parsed.is_bootloader());
|
|
EXPECT_EQ(parsed.block_count, 40u);
|
|
|
|
m[0] = MOTA_APP_FORMAT_VER;
|
|
EXPECT_FALSE(mota_parse_manifest(m.data(), m.size(), parsed));
|
|
m = boot_manifest_bytes(); m[1] &= ~MFLAG_BOOTLOADER;
|
|
EXPECT_FALSE(mota_parse_manifest(m.data(), m.size(), parsed));
|
|
m = boot_manifest_bytes(); m[19] = 9;
|
|
EXPECT_FALSE(mota_parse_manifest(m.data(), m.size(), parsed));
|
|
m = boot_manifest_bytes(); memset(m.data() + 7, 0, 4);
|
|
EXPECT_FALSE(mota_parse_manifest(m.data(), m.size(), parsed));
|
|
m = boot_manifest_bytes(); m[MOTA_OFF_BASE_HASH] = 1;
|
|
EXPECT_FALSE(mota_parse_manifest(m.data(), m.size(), parsed));
|
|
}
|
|
|
|
TEST(OtaBootPackage, EmbeddedIdentityVectorCapsAndExplicitConfirmationGate) {
|
|
std::vector<uint8_t> image(OTA_BOOT_IMAGE_SIZE, 0xFF);
|
|
wr_u32le(image.data(), OTA_NRF52840_RAM_END);
|
|
wr_u32le(image.data() + 4, OTA_BOOT_IMAGE_START + 0x101u);
|
|
ASSERT_TRUE(ota_bootloader_vector_sane(image.data()));
|
|
image[4] &= 0xFE;
|
|
EXPECT_FALSE(ota_bootloader_vector_sane(image.data()));
|
|
wr_u32le(image.data() + 4, OTA_BOOT_IMAGE_START + 0x101u);
|
|
|
|
const uint32_t moff = 0x100;
|
|
memset(image.data() + moff, 0, OTA_BOOT_MANIFEST_SIZE);
|
|
wr_u32le(image.data() + moff, OTA_BOOT_MANIFEST_MAGIC0);
|
|
wr_u32le(image.data() + moff + 4, OTA_BOOT_MANIFEST_MAGIC1);
|
|
image[moff + 8] = OTA_BOOT_MANIFEST_VERSION;
|
|
image[moff + 10] = OTA_BOOT_MANIFEST_SIZE;
|
|
wr_u32le(image.data() + moff + 12, OTA_BOOT_IMAGE_START);
|
|
wr_u32le(image.data() + moff + 16, OTA_BOOT_IMAGE_SIZE);
|
|
wr_u32le(image.data() + moff + 20, OTA_XIAO_BOARD_ID_BASE);
|
|
memcpy(image.data() + moff + 24, OTA_XIAO_BOOT_DEVICE_NAME,
|
|
OTA_BOOT_DEVICE_NAME_SIZE);
|
|
wr_u32le(image.data() + moff + 40,
|
|
ota_boot_image_crc32(image.data(), image.size(), moff + 40));
|
|
const std::vector<uint8_t> canonical_image = image;
|
|
|
|
OtaBootloaderIdentity identity;
|
|
ASSERT_TRUE(ota_bootloader_identity_from_image(image.data(), image.size(), identity));
|
|
EXPECT_TRUE(identity.crc_ok);
|
|
EXPECT_EQ(identity.board_id, OTA_XIAO_BOARD_ID_BASE);
|
|
|
|
// A structurally complete bad-CRC decoy before the real manifest must not
|
|
// shadow the later valid identity.
|
|
const uint32_t decoy = 0x20;
|
|
memcpy(image.data() + decoy, image.data() + moff, OTA_BOOT_MANIFEST_SIZE);
|
|
wr_u32le(image.data() + decoy + 40, 0xA5A5A5A5u);
|
|
wr_u32le(image.data() + moff + 40, 0);
|
|
wr_u32le(image.data() + moff + 40,
|
|
ota_boot_image_crc32(image.data(), image.size(), moff + 40));
|
|
ASSERT_NE(ota_boot_image_crc32(image.data(), image.size(), decoy + 40), 0xA5A5A5A5u);
|
|
ASSERT_TRUE(ota_bootloader_identity_from_image(image.data(), image.size(), identity));
|
|
EXPECT_EQ(identity.manifest_offset, moff);
|
|
|
|
// These coupled CRC values make both otherwise identical manifests valid
|
|
// in this deterministic fixture. Two privileged identities are ambiguous
|
|
// and must fail closed in the helper shared with the staged-QSPI scanner.
|
|
image = canonical_image;
|
|
const uint32_t second = 0x200;
|
|
memcpy(image.data() + second, image.data() + moff, OTA_BOOT_MANIFEST_SIZE);
|
|
wr_u32le(image.data() + moff + 40, 0x07033138u);
|
|
wr_u32le(image.data() + second + 40, 0x0998508Cu);
|
|
ASSERT_EQ(ota_boot_image_crc32(image.data(), image.size(), moff + 40), 0x07033138u);
|
|
ASSERT_EQ(ota_boot_image_crc32(image.data(), image.size(), second + 40), 0x0998508Cu);
|
|
EXPECT_FALSE(ota_bootloader_identity_from_image(image.data(), image.size(), identity));
|
|
|
|
image = canonical_image;
|
|
image[0x300] ^= 1;
|
|
EXPECT_FALSE(ota_bootloader_identity_from_image(image.data(), image.size(), identity));
|
|
|
|
uint8_t marker[16] = {'M','O','T','A','B','L','D','R', 3,0, 5,0,
|
|
(uint8_t)(OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE), 0,0,0};
|
|
OtaBootloaderCapsMarker caps;
|
|
EXPECT_TRUE(ota_bootloader_caps_marker_parse(marker, caps));
|
|
marker[15] = 1;
|
|
EXPECT_FALSE(ota_bootloader_caps_marker_parse(marker, caps));
|
|
marker[15] = 0;
|
|
marker[8] = 0; marker[9] = 1; // ABI 0x0100: valid little-endian u16
|
|
ASSERT_TRUE(ota_bootloader_caps_marker_parse(marker, caps));
|
|
EXPECT_EQ(caps.apply_abi, 0x0100u);
|
|
marker[8] = 0; marker[9] = 0;
|
|
EXPECT_FALSE(ota_bootloader_caps_marker_parse(marker, caps));
|
|
marker[8] = 0xFF; marker[9] = 0xFF;
|
|
EXPECT_FALSE(ota_bootloader_caps_marker_parse(marker, caps));
|
|
|
|
std::vector<uint8_t> internal_caps_image(64, 0xFF);
|
|
const uint8_t internal_marker[16] = {
|
|
'M','O','T','A','B','L','D','R', 3,0, 5,0,
|
|
(uint8_t)(OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_BOOT_UPDATE), 0,0,0};
|
|
memcpy(internal_caps_image.data() + 4, internal_marker, sizeof(internal_marker));
|
|
EXPECT_TRUE(ota_bootloader_caps_from_image(
|
|
internal_caps_image.data(), internal_caps_image.size(),
|
|
OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_BOOT_UPDATE,
|
|
caps));
|
|
EXPECT_FALSE(ota_bootloader_caps_from_image(
|
|
internal_caps_image.data(), internal_caps_image.size(),
|
|
OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE,
|
|
caps));
|
|
memcpy(internal_caps_image.data() + 24, internal_marker, sizeof(internal_marker));
|
|
internal_caps_image[24 + 12] =
|
|
OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE;
|
|
EXPECT_FALSE(ota_bootloader_caps_from_image(
|
|
internal_caps_image.data(), internal_caps_image.size(),
|
|
OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_BOOT_UPDATE,
|
|
caps));
|
|
|
|
std::vector<uint8_t> full_only_caps(32, 0xFF);
|
|
memcpy(full_only_caps.data() + 4, internal_marker, sizeof(internal_marker));
|
|
full_only_caps[4 + 10] = 1;
|
|
EXPECT_FALSE(ota_bootloader_caps_from_image(
|
|
full_only_caps.data(), full_only_caps.size(),
|
|
OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_BOOT_UPDATE,
|
|
caps));
|
|
|
|
OtaBlCaps selected;
|
|
selected.present = true;
|
|
selected.apply_abi = 3;
|
|
selected.storage_flags = OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE;
|
|
EXPECT_FALSE(ota_bl_caps_prefer(selected, 99, OTA_BL_STORAGE_QSPI, true));
|
|
EXPECT_TRUE(ota_bl_caps_prefer(selected, 4,
|
|
OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE, true));
|
|
|
|
auto mf = boot_manifest_bytes();
|
|
MotaManifest m;
|
|
ASSERT_TRUE(mota_parse_manifest(mf.data(), mf.size(), m));
|
|
OtaBootloaderIdentity installed;
|
|
installed.present = installed.crc_ok = true;
|
|
installed.image_start = OTA_BOOT_IMAGE_START;
|
|
installed.image_size = OTA_BOOT_IMAGE_SIZE;
|
|
installed.board_id = OTA_XIAO_BOARD_ID_BASE;
|
|
memcpy(installed.device_name, OTA_XIAO_BOOT_DEVICE_NAME,
|
|
OTA_BOOT_DEVICE_NAME_SIZE);
|
|
uint8_t mid[4] = {0x11,0x22,0x33,0x44};
|
|
uint8_t hash8[8]; memcpy(hash8, m.image_hash, sizeof(hash8));
|
|
EXPECT_EQ(ota_bootloader_confirmation_gate(m, installed, mid, mid, hash8), OTA_BOOT_CONFIRM_OK);
|
|
uint8_t bad_mid[4] = {0};
|
|
EXPECT_EQ(ota_bootloader_confirmation_gate(m, installed, mid, bad_mid, hash8),
|
|
OTA_BOOT_CONFIRM_MID);
|
|
hash8[0] ^= 1;
|
|
EXPECT_EQ(ota_bootloader_confirmation_gate(m, installed, mid, mid, hash8),
|
|
OTA_BOOT_CONFIRM_IMAGE_HASH);
|
|
}
|
|
|
|
TEST(OtaBootPackage, CapabilityScannerRejectsAnOtherwiseValidUnalignedMarker) {
|
|
const uint8_t qspi_profile =
|
|
OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_QSPI |
|
|
OTA_BL_STORAGE_BOOT_UPDATE;
|
|
uint8_t marker[16] = {'M','O','T','A','B','L','D','R', 3,0, 5,0,
|
|
qspi_profile, 0,0,0};
|
|
uint8_t image[64];
|
|
memset(image, 0xFF, sizeof(image));
|
|
memcpy(image + 1, marker, sizeof(marker));
|
|
EXPECT_FALSE(ota_bl_caps_scan_aligned(image, sizeof(image), true, qspi_profile).present);
|
|
|
|
memset(image, 0xFF, sizeof(image));
|
|
memcpy(image + 4, marker, sizeof(marker));
|
|
const OtaBlCaps caps = ota_bl_caps_scan_aligned(image, sizeof(image), true, qspi_profile);
|
|
ASSERT_TRUE(caps.present);
|
|
EXPECT_EQ(caps.apply_abi, 3u);
|
|
EXPECT_EQ(caps.storage_flags, qspi_profile);
|
|
|
|
marker[8] = 0; marker[9] = 1;
|
|
memset(image, 0xFF, sizeof(image));
|
|
memcpy(image + 4, marker, sizeof(marker));
|
|
const OtaBlCaps high_abi =
|
|
ota_bl_caps_scan_aligned(image, sizeof(image), true, qspi_profile);
|
|
ASSERT_TRUE(high_abi.present);
|
|
EXPECT_EQ(high_abi.apply_abi, 0x0100u);
|
|
marker[8] = 3; marker[9] = 0;
|
|
memset(image, 0xFF, sizeof(image));
|
|
memcpy(image + 4, marker, sizeof(marker));
|
|
|
|
// A malformed aligned magic decoy is ignored, but a second fully valid
|
|
// privileged marker makes the installed capability identity ambiguous.
|
|
memcpy(image + 24, marker, sizeof(marker));
|
|
EXPECT_FALSE(ota_bl_caps_scan_aligned(image, sizeof(image), true, qspi_profile).present);
|
|
memset(image + 24, 0xFF, sizeof(marker));
|
|
memcpy(image + 24, marker, sizeof(marker));
|
|
image[24 + 13] = 1;
|
|
EXPECT_TRUE(ota_bl_caps_scan_aligned(image, sizeof(image), true, qspi_profile).present);
|
|
|
|
// Any second structurally valid privileged marker is ambiguous, including
|
|
// another known storage profile or the bare BOOT_UPDATE capability.
|
|
memcpy(image + 24, marker, sizeof(marker));
|
|
image[24 + 12] = OTA_BL_STORAGE_STAGE_CEILING |
|
|
OTA_BL_STORAGE_BOOT_UPDATE;
|
|
EXPECT_FALSE(ota_bl_caps_scan_aligned(image, sizeof(image), true, qspi_profile).present);
|
|
image[24 + 12] = OTA_BL_STORAGE_BOOT_UPDATE;
|
|
EXPECT_FALSE(ota_bl_caps_scan_aligned(image, sizeof(image), true, qspi_profile).present);
|
|
|
|
memset(image, 0xFF, sizeof(image));
|
|
memcpy(image + 4, marker, sizeof(marker));
|
|
image[4 + 12] = OTA_BL_STORAGE_BOOT_UPDATE;
|
|
EXPECT_FALSE(ota_bl_caps_scan_aligned(image, sizeof(image), true, qspi_profile).present);
|
|
}
|
|
|
|
class FakeMotaSeederStream : public Stream {
|
|
public:
|
|
using Stream::write;
|
|
|
|
size_t write(const uint8_t* request, size_t len) override {
|
|
request_valid = len == 11 && request[0] == MOTA_SEEDER_REQ_MAGIC0
|
|
&& request[1] == MOTA_SEEDER_REQ_MAGIC1 && request[2] == MS_OP_READ;
|
|
if (!request_valid) return len;
|
|
|
|
uint8_t checksum = request[2];
|
|
for (size_t i = 3; i + 1 < len; i++) checksum ^= request[i];
|
|
request_valid = checksum == request[len - 1];
|
|
const uint32_t offset = rd_u32le(request + 4);
|
|
const uint16_t read_len = rd_u16le(request + 8);
|
|
offsets.push_back(offset);
|
|
lengths.push_back(read_len);
|
|
|
|
response.clear();
|
|
response_pos = 0;
|
|
response.push_back(MOTA_SEEDER_RSP_MAGIC0);
|
|
response.push_back(MOTA_SEEDER_RSP_MAGIC1);
|
|
response.push_back(MS_OP_READ);
|
|
response.push_back(MS_STATUS_OK);
|
|
uint8_t response_checksum = MOTA_SEEDER_RSP_MAGIC0 ^ MOTA_SEEDER_RSP_MAGIC1
|
|
^ MS_OP_READ ^ MS_STATUS_OK;
|
|
for (uint16_t i = 0; i < read_len; i++) {
|
|
const uint8_t value = (uint8_t)(offset + i);
|
|
response.push_back(value);
|
|
response_checksum ^= value;
|
|
}
|
|
response.push_back(response_checksum);
|
|
return len;
|
|
}
|
|
|
|
int read() override {
|
|
if (response_pos < response.size()) return response[response_pos++];
|
|
++g_mock_millis;
|
|
return -1;
|
|
}
|
|
|
|
void flush() override {
|
|
++flush_calls;
|
|
if (flush_discards_receive) {
|
|
response.clear();
|
|
response_pos = 0;
|
|
}
|
|
}
|
|
|
|
std::vector<uint32_t> offsets;
|
|
std::vector<uint16_t> lengths;
|
|
bool request_valid = true;
|
|
bool flush_discards_receive = false;
|
|
size_t flush_calls = 0;
|
|
|
|
private:
|
|
std::vector<uint8_t> response;
|
|
size_t response_pos = 0;
|
|
};
|
|
|
|
static_assert(!std::is_constructible<SerialMotaSource, Stream&>::value,
|
|
"mOTA source callers must choose a stream write policy");
|
|
static_assert(!std::is_constructible<SerialMotaSource, Stream&, uint32_t>::value,
|
|
"a timeout must not be mistaken for a stream write policy");
|
|
static_assert(!std::is_constructible<FolderMotaStore, Stream&>::value,
|
|
"folder store callers must choose a stream write policy");
|
|
static_assert(!std::is_constructible<FolderMotaStore, Stream&, uint32_t>::value,
|
|
"a timeout must not be mistaken for a stream write policy");
|
|
|
|
TEST(MotaSourceSerial, SplitsOneKilobyteReadsBelowCdcReceiveRing) {
|
|
resetArduinoMock();
|
|
FakeMotaSeederStream stream;
|
|
SerialMotaSource source(stream, MotaStreamWritePolicy::FlushTransmit);
|
|
std::array<uint8_t, 1024> data{};
|
|
|
|
ASSERT_TRUE(source.read(0, 0x1000, data.data(), data.size()));
|
|
EXPECT_TRUE(stream.request_valid);
|
|
EXPECT_EQ(stream.offsets,
|
|
(std::vector<uint32_t>{0x1000, 0x10C0, 0x1180, 0x1240, 0x1300, 0x13C0}));
|
|
EXPECT_EQ(stream.lengths,
|
|
(std::vector<uint16_t>{192, 192, 192, 192, 192, 64}));
|
|
EXPECT_EQ(stream.flush_calls, 6U);
|
|
for (size_t i = 0; i < data.size(); i++) {
|
|
EXPECT_EQ(data[i], (uint8_t)(0x1000 + i));
|
|
}
|
|
}
|
|
|
|
TEST(MotaSourceSerial, AcceptsEmptyReadAndRejectsInvalidRange) {
|
|
resetArduinoMock();
|
|
FakeMotaSeederStream stream;
|
|
SerialMotaSource source(stream, MotaStreamWritePolicy::FlushTransmit);
|
|
uint8_t byte = 0;
|
|
|
|
EXPECT_TRUE(source.read(0, 123, nullptr, 0));
|
|
EXPECT_FALSE(source.read(0, 123, nullptr, 1));
|
|
EXPECT_FALSE(source.read(0, UINT32_MAX, &byte, 2));
|
|
EXPECT_TRUE(stream.offsets.empty());
|
|
}
|
|
|
|
TEST(MotaSourceSerial, NetworkPolicyPreservesReplyWhenFlushWouldDiscardRx) {
|
|
resetArduinoMock();
|
|
FakeMotaSeederStream stream;
|
|
stream.flush_discards_receive = true;
|
|
SerialMotaSource source(stream, MotaStreamWritePolicy::NoFlush, 20);
|
|
std::array<uint8_t, 16> data{};
|
|
|
|
ASSERT_TRUE(source.read(0, 0x2000, data.data(), data.size()));
|
|
EXPECT_EQ(stream.flush_calls, 0U);
|
|
for (size_t i = 0; i < data.size(); i++) {
|
|
EXPECT_EQ(data[i], (uint8_t)(0x2000 + i));
|
|
}
|
|
}
|
|
|
|
TEST(MotaSourceSerial, WrongFlushPolicyDemonstratesNetworkReplyLoss) {
|
|
resetArduinoMock();
|
|
FakeMotaSeederStream stream;
|
|
stream.flush_discards_receive = true;
|
|
SerialMotaSource source(stream, MotaStreamWritePolicy::FlushTransmit, 20);
|
|
std::array<uint8_t, 4> data{};
|
|
|
|
EXPECT_FALSE(source.read(0, 0x3000, data.data(), data.size()));
|
|
EXPECT_EQ(stream.flush_calls, 1U);
|
|
}
|
|
|
|
class FakeFolderMotaSeederStream : public Stream {
|
|
public:
|
|
using Stream::write;
|
|
|
|
size_t write(const uint8_t* request, size_t len) override {
|
|
request_valid = len >= 4
|
|
&& request[0] == MOTA_SEEDER_REQ_MAGIC0
|
|
&& request[1] == MOTA_SEEDER_REQ_MAGIC1;
|
|
if (!request_valid) return len;
|
|
|
|
uint8_t checksum = request[2];
|
|
for (size_t i = 3; i + 1 < len; i++) checksum ^= request[i];
|
|
request_valid = checksum == request[len - 1];
|
|
if (!request_valid) return len;
|
|
|
|
operations.push_back(request[2]);
|
|
response = {
|
|
MOTA_SEEDER_RSP_MAGIC0,
|
|
MOTA_SEEDER_RSP_MAGIC1,
|
|
request[2],
|
|
MS_STATUS_OK,
|
|
(uint8_t)(MOTA_SEEDER_RSP_MAGIC0 ^ MOTA_SEEDER_RSP_MAGIC1
|
|
^ request[2] ^ MS_STATUS_OK),
|
|
};
|
|
response_pos = 0;
|
|
return len;
|
|
}
|
|
|
|
int read() override {
|
|
if (response_pos < response.size()) return response[response_pos++];
|
|
++g_mock_millis;
|
|
return -1;
|
|
}
|
|
|
|
void flush() override {
|
|
++flush_calls;
|
|
response.clear();
|
|
response_pos = 0;
|
|
}
|
|
|
|
std::vector<uint8_t> operations;
|
|
bool request_valid = true;
|
|
size_t flush_calls = 0;
|
|
|
|
private:
|
|
std::vector<uint8_t> response;
|
|
size_t response_pos = 0;
|
|
};
|
|
|
|
TEST(FolderMotaStore, NetworkPolicyPreservesFastBeginAndWriteReplies) {
|
|
resetArduinoMock();
|
|
FakeFolderMotaSeederStream stream;
|
|
FolderMotaStore store(stream, MotaStreamWritePolicy::NoFlush, 20);
|
|
const uint8_t mid[4] = {0x10, 0x20, 0x30, 0x40};
|
|
const uint8_t data[4] = {1, 2, 3, 4};
|
|
store.set_mid(mid);
|
|
|
|
ASSERT_TRUE(store.begin(64));
|
|
ASSERT_TRUE(store.write(0, data, sizeof(data)));
|
|
EXPECT_TRUE(stream.request_valid);
|
|
EXPECT_EQ(stream.operations,
|
|
(std::vector<uint8_t>{MS_OP_BEGIN, MS_OP_WRITE}));
|
|
EXPECT_EQ(stream.flush_calls, 0U);
|
|
}
|
|
|
|
// Build a flashed-image layout (body || fixed 56-byte EndF) the way the host packager / build hook do:
|
|
// marker(4) body_len(4) body_hash8(8) fw_version(4) target_id(4) hw_id(32). Identity is always present
|
|
// (zero/"" = unknown).
|
|
static std::vector<uint8_t> make_image_id(const std::vector<uint8_t>& body, uint32_t fw_version,
|
|
uint32_t target_id, const char* hw_id) {
|
|
std::vector<uint8_t> img = body;
|
|
img.insert(img.end(), ENDF_MAGIC, ENDF_MAGIC + 4);
|
|
uint32_t n = (uint32_t)body.size();
|
|
for (int i = 0; i < 4; i++) img.push_back((uint8_t)(n >> (8 * i)));
|
|
uint8_t h[8]; mh8(h, body.data(), body.size());
|
|
img.insert(img.end(), h, h + 8);
|
|
for (int i = 0; i < 4; i++) img.push_back((uint8_t)(fw_version >> (8 * i)));
|
|
for (int i = 0; i < 4; i++) img.push_back((uint8_t)(target_id >> (8 * i)));
|
|
uint8_t hw[32] = {0}; size_t k = hw_id ? strlen(hw_id) : 0; if (k > 32) k = 32; if (k) memcpy(hw, hw_id, k);
|
|
img.insert(img.end(), hw, hw + 32); // -> fixed 56-byte trailer
|
|
return img;
|
|
}
|
|
static std::vector<uint8_t> make_image(const std::vector<uint8_t>& body) {
|
|
return make_image_id(body, 0, 0, ""); // zero identity (still a full 56-byte trailer)
|
|
}
|
|
|
|
// --- cross-check the C++ parser/merkle against the Python reference vectors ----------------
|
|
|
|
TEST(OtaParse, ParsesReferenceContainer) {
|
|
MotaManifest m;
|
|
ASSERT_TRUE(mota_parse(MOTA_VEC, MOTA_VEC_LEN, m));
|
|
EXPECT_EQ(m.format_ver, MOTA_FORMAT_VER);
|
|
EXPECT_TRUE(m.is_full());
|
|
EXPECT_FALSE(m.is_signed());
|
|
EXPECT_EQ(m.target_id, EXP_TARGET_ID);
|
|
EXPECT_EQ(m.fw_version, EXP_FW_VERSION);
|
|
EXPECT_EQ(m.image_size, EXP_IMAGE_SIZE);
|
|
EXPECT_EQ(m.payload_size, EXP_PAYLOAD_SIZE);
|
|
EXPECT_EQ(m.block_count, EXP_BLOCK_COUNT);
|
|
EXPECT_EQ(m.block_size_log2, EXP_BLOCK_SIZE_LOG2);
|
|
EXPECT_EQ(m.codec_id, EXP_CODEC_ID);
|
|
EXPECT_EQ(0, memcmp(m.merkle_root, EXP_MERKLE_ROOT, 4));
|
|
EXPECT_EQ(0, memcmp(m.image_hash, EXP_IMAGE_HASH, 32));
|
|
ASSERT_NE(m.hw_id, nullptr);
|
|
EXPECT_EQ(0, memcmp(m.hw_id, EXP_HW_ID, 32)); // v2 hardware tag ("TESTHW" NUL-padded)
|
|
EXPECT_EQ(0, memcmp(m.approval, APPROVAL_NOT, 4)); // distributed = not approved
|
|
EXPECT_FALSE(m.is_approved());
|
|
}
|
|
|
|
TEST(OtaRescue, RequiresInvalidSelfExactBaseAndExactTarget) {
|
|
const uint8_t package_base[8] = {0x63, 0xd8, 0xdf, 0x63, 1, 2, 3, 4};
|
|
const uint8_t operator_base[8] = {0x63, 0xd8, 0xdf, 0x63, 1, 2, 3, 4};
|
|
const uint8_t wrong_base[8] = {0x63, 0xd8, 0xdf, 0x63, 1, 2, 3, 5};
|
|
const uint32_t target_id = 0x2FA509C1u;
|
|
|
|
EXPECT_EQ(ota_nrf52_rescue_gate(false, package_base, operator_base,
|
|
target_id, target_id), NRF52_RESCUE_OK);
|
|
EXPECT_EQ(ota_nrf52_rescue_gate(true, package_base, operator_base,
|
|
target_id, target_id), NRF52_RESCUE_SELF_VALID);
|
|
EXPECT_EQ(ota_nrf52_rescue_gate(false, nullptr, operator_base,
|
|
target_id, target_id), NRF52_RESCUE_BASE_MISSING);
|
|
EXPECT_EQ(ota_nrf52_rescue_gate(false, package_base, wrong_base,
|
|
target_id, target_id), NRF52_RESCUE_BASE_MISMATCH);
|
|
EXPECT_EQ(ota_nrf52_rescue_gate(false, package_base, operator_base,
|
|
target_id, target_id + 1), NRF52_RESCUE_TARGET_MISMATCH);
|
|
EXPECT_EQ(ota_nrf52_rescue_gate(false, package_base, operator_base,
|
|
target_id, 0), NRF52_RESCUE_TARGET_MISMATCH);
|
|
}
|
|
|
|
TEST(OtaParse, RejectsTampering) {
|
|
MotaManifest m;
|
|
// bad magic
|
|
std::vector<uint8_t> b(MOTA_VEC, MOTA_VEC + MOTA_VEC_LEN);
|
|
b[0] ^= 0xFF;
|
|
EXPECT_FALSE(mota_parse(b.data(), b.size(), m));
|
|
// bad trailer
|
|
b.assign(MOTA_VEC, MOTA_VEC + MOTA_VEC_LEN);
|
|
b[b.size() - 1] ^= 0xFF;
|
|
EXPECT_FALSE(mota_parse(b.data(), b.size(), m));
|
|
// wrong total-size field
|
|
b.assign(MOTA_VEC, MOTA_VEC + MOTA_VEC_LEN);
|
|
b[4] ^= 0x01;
|
|
EXPECT_FALSE(mota_parse(b.data(), b.size(), m));
|
|
}
|
|
|
|
// block_idx is a uint16 on the wire, so a manifest needing > 65535 blocks can't be addressed and must be
|
|
// rejected at parse (this also keeps block_count*4 from overflowing the leaves-length computation).
|
|
TEST(OtaParse, RejectsTooManyBlocks) {
|
|
auto manifest = [](uint32_t payload_size, uint8_t bsl) { // fixed-layout unsigned-full manifest
|
|
std::vector<uint8_t> m(MOTA_MFL, 0);
|
|
m[0] = MOTA_FORMAT_VER; m[1] = MFLAG_FULL; m[2] = 0x12;
|
|
m[15] = payload_size; m[16] = payload_size >> 8; m[17] = payload_size >> 16; m[18] = payload_size >> 24;
|
|
m[19] = bsl;
|
|
return m;
|
|
};
|
|
MotaManifest mm;
|
|
auto over = manifest(65536u * 1024u, 10); // 65536 blocks -> rejected
|
|
EXPECT_FALSE(mota_parse_manifest(over.data(), over.size(), mm));
|
|
auto ok = manifest(65535u * 1024u, 10); // 65535 blocks -> allowed
|
|
EXPECT_TRUE(mota_parse_manifest(ok.data(), ok.size(), mm));
|
|
EXPECT_EQ(mm.block_count, 65535u);
|
|
}
|
|
|
|
TEST(OtaMerkle, RootMatchesVectorAndLeaves) {
|
|
MotaManifest m;
|
|
ASSERT_TRUE(mota_parse(MOTA_VEC, MOTA_VEC_LEN, m));
|
|
// root recomputed from stored leaves[] == merkle_root field == Python's EXP_MERKLE_ROOT
|
|
uint8_t root[4];
|
|
merkle_root(root, m.leaves, m.block_count);
|
|
EXPECT_EQ(0, memcmp(root, EXP_MERKLE_ROOT, 4));
|
|
EXPECT_TRUE(mota_check_root(m));
|
|
// recompute each leaf from the payload block and compare to the stored leaf
|
|
uint32_t bs = m.block_size();
|
|
for (uint32_t i = 0; i < m.block_count; i++) {
|
|
uint32_t off = i * bs;
|
|
uint32_t len = (off + bs <= m.payload_size) ? bs : (m.payload_size - off);
|
|
uint8_t leaf[4];
|
|
merkle_leaf(leaf, m.payload + off, len);
|
|
EXPECT_EQ(0, memcmp(leaf, m.leaves + i * 4, 4)) << "leaf " << i;
|
|
}
|
|
EXPECT_TRUE(mota_check_payload(m));
|
|
}
|
|
|
|
TEST(OtaMerkle, PayloadCheckDetectsCorruptionNotVisibleInStoredLeaves) {
|
|
std::vector<uint8_t> b(MOTA_VEC, MOTA_VEC + MOTA_VEC_LEN);
|
|
MotaManifest m;
|
|
ASSERT_TRUE(mota_parse(b.data(), b.size(), m));
|
|
ASSERT_TRUE(mota_check_root(m));
|
|
ASSERT_TRUE(mota_check_payload(m));
|
|
|
|
size_t payload_off = (size_t)(m.payload - b.data());
|
|
b[payload_off + m.payload_size / 2] ^= 0x01;
|
|
ASSERT_TRUE(mota_parse(b.data(), b.size(), m));
|
|
EXPECT_TRUE(mota_check_root(m)); // leaves[] and its root are still self-consistent
|
|
EXPECT_FALSE(mota_check_payload(m)); // actual staged payload no longer matches those leaves
|
|
}
|
|
|
|
TEST(OtaMerkle, FullImageHashMatches) {
|
|
MotaManifest m;
|
|
ASSERT_TRUE(mota_parse(MOTA_VEC, MOTA_VEC_LEN, m));
|
|
EXPECT_TRUE(mota_check_image_hash_full(m));
|
|
}
|
|
|
|
TEST(OtaMerkle, ProofFromReferenceVerifies) {
|
|
MotaManifest m;
|
|
ASSERT_TRUE(mota_parse(MOTA_VEC, MOTA_VEC_LEN, m));
|
|
uint32_t bs = m.block_size();
|
|
uint32_t off = PROOF_INDEX * bs;
|
|
uint32_t len = (off + bs <= m.payload_size) ? bs : (m.payload_size - off);
|
|
|
|
EXPECT_TRUE(merkle_verify(m.payload + off, len, PROOF_INDEX,
|
|
PROOF_SIBLINGS, PROOF_NSIB, EXP_MERKLE_ROOT, m.block_count));
|
|
|
|
// tampered block -> fails
|
|
std::vector<uint8_t> blk(m.payload + off, m.payload + off + len);
|
|
blk[0] ^= 0xFF;
|
|
EXPECT_FALSE(merkle_verify(blk.data(), len, PROOF_INDEX,
|
|
PROOF_SIBLINGS, PROOF_NSIB, EXP_MERKLE_ROOT, m.block_count));
|
|
|
|
// wrong index with the same proof -> fails
|
|
EXPECT_FALSE(merkle_verify(m.payload + off, len, PROOF_INDEX + 1,
|
|
PROOF_SIBLINGS, PROOF_NSIB, EXP_MERKLE_ROOT, m.block_count));
|
|
}
|
|
|
|
// --- validate the O(log n) binary-counter root vs a plain level-by-level reference ----------
|
|
|
|
static void ref_root(uint8_t out[4], std::vector<std::array<uint8_t, 4>> level) {
|
|
while (level.size() > 1) {
|
|
std::vector<std::array<uint8_t, 4>> nxt;
|
|
for (size_t i = 0; i < level.size(); i += 2) {
|
|
if (i + 1 < level.size()) {
|
|
std::array<uint8_t, 4> p;
|
|
merkle_combine(p.data(), level[i].data(), level[i + 1].data());
|
|
nxt.push_back(p);
|
|
} else {
|
|
nxt.push_back(level[i]); // promote lone last node
|
|
}
|
|
}
|
|
level.swap(nxt);
|
|
}
|
|
std::memcpy(out, level[0].data(), 4);
|
|
}
|
|
|
|
TEST(OtaMerkle, BinaryCounterMatchesLevelByLevel) {
|
|
uint32_t state = 0x12345678;
|
|
auto rnd = [&]() { state = state * 1103515245u + 12345u; return (uint8_t)(state >> 16); };
|
|
// O(log n) root must equal the plain level-by-level root for every count
|
|
for (uint32_t count = 1; count <= 600; count++) {
|
|
std::vector<uint8_t> leaves(count * 4);
|
|
std::vector<std::array<uint8_t, 4>> ref(count);
|
|
for (uint32_t i = 0; i < count; i++)
|
|
for (int j = 0; j < 4; j++) { uint8_t v = rnd(); leaves[i * 4 + j] = v; ref[i][j] = v; }
|
|
uint8_t a[4], b[4];
|
|
merkle_root(a, leaves.data(), count);
|
|
ref_root(b, ref);
|
|
ASSERT_EQ(0, std::memcmp(a, b, 4)) << "root mismatch count=" << count;
|
|
}
|
|
}
|
|
|
|
TEST(OtaMerkle, StreamingAccumulatorMatchesContiguousRoot) {
|
|
uint8_t leaves[257 * 4];
|
|
for (size_t i = 0; i < sizeof(leaves); ++i) {
|
|
leaves[i] = (uint8_t)(i * 29U + 7U);
|
|
}
|
|
MerkleAccumulator accumulator;
|
|
for (uint32_t i = 0; i < 257; ++i) {
|
|
ASSERT_TRUE(accumulator.add(leaves + i * 4));
|
|
}
|
|
uint8_t streamed[4], contiguous[4];
|
|
ASSERT_TRUE(accumulator.finish(streamed));
|
|
merkle_root(contiguous, leaves, 257);
|
|
EXPECT_EQ(0, std::memcmp(streamed, contiguous, 4));
|
|
EXPECT_EQ(257U, accumulator.count());
|
|
|
|
accumulator.reset();
|
|
EXPECT_FALSE(accumulator.finish(streamed));
|
|
EXPECT_EQ(0U, accumulator.count());
|
|
}
|
|
|
|
// Verify every block's proof for several tricky counts, using proofs generated by the Python
|
|
// reference (the oracle) - covers deep promotion chains (100, 255, 256, ...).
|
|
TEST(OtaMerkle, ReferenceProofsAllIndices) {
|
|
for (int c = 0; c < N_PROOF_CASES; c++) {
|
|
const ProofCase& pc = PROOF_CASES[c];
|
|
uint8_t root[4];
|
|
merkle_root(root, pc.leaves, pc.count);
|
|
EXPECT_EQ(0, std::memcmp(root, pc.root, 4)) << "root mismatch count=" << pc.count;
|
|
for (uint32_t i = 0; i < pc.count; i++) {
|
|
EXPECT_TRUE(merkle_verify_from_leaf(pc.leaves + i * 4, i,
|
|
pc.pblob + pc.poff[i], pc.pnsib[i], pc.root, pc.count))
|
|
<< "count=" << pc.count << " idx=" << i;
|
|
}
|
|
// a wrong sibling for index 0 must fail
|
|
if (pc.pnsib[0] > 0) {
|
|
std::vector<uint8_t> bad(pc.pblob + pc.poff[0], pc.pblob + pc.poff[0] + pc.pnsib[0] * 4);
|
|
bad[0] ^= 0xFF;
|
|
EXPECT_FALSE(merkle_verify_from_leaf(pc.leaves, 0, bad.data(), pc.pnsib[0], pc.root, pc.count));
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- availability bitmap (derived from leaves[]) -------------------------------------------
|
|
|
|
TEST(OtaBitmap, AllPresentForCompleteContainer) {
|
|
MotaManifest m;
|
|
ASSERT_TRUE(mota_parse(MOTA_VEC, MOTA_VEC_LEN, m));
|
|
EXPECT_TRUE(all_present(m.leaves, m.block_count));
|
|
EXPECT_EQ(count_present(m.leaves, m.block_count), m.block_count);
|
|
|
|
// a leaf slot of all-FF (erased) means "missing"; bitmap round-trips
|
|
std::vector<uint8_t> leaves(m.leaves, m.leaves + m.block_count * 4);
|
|
std::memset(&leaves[4], 0xFF, 4); // mark block 1 missing
|
|
EXPECT_FALSE(leaf_present(leaves.data(), 1));
|
|
EXPECT_FALSE(all_present(leaves.data(), m.block_count));
|
|
EXPECT_EQ(count_present(leaves.data(), m.block_count), m.block_count - 1);
|
|
|
|
std::vector<uint8_t> bm(bitmap_bytes(m.block_count));
|
|
leaves_to_bitmap(leaves.data(), m.block_count, bm.data());
|
|
EXPECT_FALSE(bitmap_get(bm.data(), 1));
|
|
EXPECT_TRUE(bitmap_get(bm.data(), 0));
|
|
}
|
|
|
|
// --- EndF self-firmware scan (P2) -----------------------------------------------------------
|
|
|
|
TEST(OtaFirmwareInfo, FindsEndFInImage) {
|
|
std::vector<uint8_t> body(4321);
|
|
for (size_t i = 0; i < body.size(); i++) body[i] = (uint8_t)(i * 37 + 11);
|
|
std::vector<uint8_t> img = make_image(body);
|
|
|
|
// simulate a flash region: image, then erased 0xFF up to the partition end
|
|
std::vector<uint8_t> region = img;
|
|
region.resize(img.size() + 4096, 0xFF);
|
|
|
|
SelfFwInfo fi;
|
|
ASSERT_TRUE(find_self_firmware(region.data(), (uint32_t)region.size(), fi, /*verify_body=*/true));
|
|
EXPECT_EQ(fi.body_len, body.size());
|
|
EXPECT_EQ(fi.image_len, img.size());
|
|
EXPECT_EQ(fi.endf_offset, body.size());
|
|
uint8_t h[8]; mh8(h, body.data(), body.size());
|
|
EXPECT_EQ(0, std::memcmp(fi.body_hash, h, 8));
|
|
}
|
|
|
|
// The self-describing identity lives at fixed offsets in the 56-byte EndF and is always parsed; a
|
|
// zero-identity trailer reports zero/"" (unknown), still at the fixed 56-byte size.
|
|
TEST(OtaFirmwareInfo, ParsesIdentity) {
|
|
std::vector<uint8_t> body(2000);
|
|
for (size_t i = 0; i < body.size(); i++) body[i] = (uint8_t)(i * 13 + 5);
|
|
|
|
auto img = make_image_id(body, 0x01100000u, 0x04d413fdu, "RAK4631");
|
|
std::vector<uint8_t> region = img; region.resize(img.size() + 4096, 0xFF);
|
|
SelfFwInfo fi;
|
|
ASSERT_TRUE(find_self_firmware(region.data(), (uint32_t)region.size(), fi, /*verify_body=*/true));
|
|
EXPECT_EQ(fi.body_len, body.size());
|
|
EXPECT_EQ(fi.image_len, body.size() + 56); // fixed trailer length
|
|
EXPECT_EQ(fi.fw_version, 0x01100000u);
|
|
EXPECT_EQ(fi.target_id, 0x04d413fdu);
|
|
EXPECT_STREQ(fi.hw_id, "RAK4631");
|
|
|
|
auto img1 = make_image(body); // zero-identity trailer (still 56 bytes)
|
|
std::vector<uint8_t> r1 = img1; r1.resize(img1.size() + 64, 0xFF);
|
|
SelfFwInfo fi1;
|
|
ASSERT_TRUE(find_self_firmware(r1.data(), (uint32_t)r1.size(), fi1, true));
|
|
EXPECT_EQ(fi1.fw_version, 0u);
|
|
EXPECT_EQ(fi1.target_id, 0u);
|
|
EXPECT_STREQ(fi1.hw_id, "");
|
|
EXPECT_EQ(fi1.image_len, body.size() + 56);
|
|
}
|
|
|
|
TEST(OtaFirmwareInfo, IgnoresStagedMotaHigherInRegion) {
|
|
// The firmware's own EndF must win even when a staged .mota (which embeds its own EndF) sits
|
|
// above it in the same region - the body_len == offset check disambiguates.
|
|
std::vector<uint8_t> body(2000);
|
|
for (size_t i = 0; i < body.size(); i++) body[i] = (uint8_t)(i ^ 0x5A);
|
|
std::vector<uint8_t> img = make_image(body);
|
|
|
|
std::vector<uint8_t> region = img;
|
|
region.resize(8192, 0xFF); // gap
|
|
// drop the reference .mota (which contains an embedded EndF in its payload) higher up
|
|
region.insert(region.end(), MOTA_VEC, MOTA_VEC + MOTA_VEC_LEN);
|
|
|
|
SelfFwInfo fi;
|
|
ASSERT_TRUE(find_self_firmware(region.data(), (uint32_t)region.size(), fi, true));
|
|
EXPECT_EQ(fi.endf_offset, body.size()); // found OUR firmware, not the .mota's
|
|
EXPECT_EQ(fi.body_len, body.size());
|
|
}
|
|
|
|
TEST(OtaFirmwareInfo, NoMarkerReturnsFalse) {
|
|
std::vector<uint8_t> region(1000, 0xAB);
|
|
SelfFwInfo fi;
|
|
EXPECT_FALSE(find_self_firmware(region.data(), (uint32_t)region.size(), fi));
|
|
}
|
|
|
|
// --- signer allowlist (P3) ------------------------------------------------------------------
|
|
|
|
TEST(OtaAllowlist, AddContainsRemoveSerialize) {
|
|
SignerAllowlist a;
|
|
uint8_t k1[32], k2[32], k3[32];
|
|
memset(k1, 0x11, 32); memset(k2, 0x22, 32); memset(k3, 0x33, 32);
|
|
EXPECT_FALSE(a.contains(k1));
|
|
EXPECT_TRUE(a.add(k1));
|
|
EXPECT_TRUE(a.add(k2));
|
|
EXPECT_TRUE(a.add(k1)); // idempotent
|
|
EXPECT_EQ(a.count(), 2);
|
|
EXPECT_TRUE(a.contains(k1));
|
|
EXPECT_FALSE(a.contains(k3));
|
|
|
|
uint8_t buf[1 + MAX_OTA_SIGNERS * 32];
|
|
uint32_t n = a.serialize(buf, sizeof(buf));
|
|
EXPECT_EQ(n, 1u + 2 * 32);
|
|
SignerAllowlist b;
|
|
EXPECT_TRUE(b.deserialize(buf, n));
|
|
EXPECT_EQ(b.count(), 2);
|
|
EXPECT_TRUE(b.contains(k1) && b.contains(k2));
|
|
|
|
EXPECT_TRUE(a.remove(k1));
|
|
EXPECT_EQ(a.count(), 1);
|
|
EXPECT_FALSE(a.contains(k1));
|
|
EXPECT_TRUE(a.contains(k2));
|
|
}
|
|
|
|
// --- RAM store: out-of-order writes + availability via leaves[] --------------------------------
|
|
|
|
TEST(OtaStoreRamTest, RandomAccessAndErasedSentinel) {
|
|
OtaStoreRam<4096> s;
|
|
ASSERT_TRUE(s.begin(1000));
|
|
EXPECT_EQ(s.staged_size(), 1000u);
|
|
uint8_t blk[8] = {1,2,3,4,5,6,7,8};
|
|
EXPECT_TRUE(s.write(500, blk, 8)); // out-of-order offset
|
|
EXPECT_TRUE(s.write(0, blk, 8));
|
|
EXPECT_FALSE(s.write(998, blk, 8)); // out of range
|
|
uint8_t rd[8];
|
|
EXPECT_TRUE(s.read(500, rd, 8));
|
|
EXPECT_EQ(0, memcmp(rd, blk, 8));
|
|
// untouched region reads as erased 0xFF (so an unfilled leaf slot is "missing")
|
|
EXPECT_TRUE(s.read(100, rd, 8));
|
|
for (int i = 0; i < 8; i++) EXPECT_EQ(rd[i], 0xFF);
|
|
}
|
|
|
|
// --- merkle proof GENERATION (server side) matches the Python oracle ---------------------------
|
|
|
|
TEST(OtaMerkle, GenProofMatchesPythonAndVerifies) {
|
|
for (int c = 0; c < N_PROOF_CASES; c++) {
|
|
const ProofCase& pc = PROOF_CASES[c];
|
|
std::vector<uint8_t> scratch(pc.count * 4);
|
|
uint8_t out[32 * 4];
|
|
for (uint32_t i = 0; i < pc.count; i++) {
|
|
uint8_t n = merkle_gen_proof(pc.leaves, pc.count, i, scratch.data(), out);
|
|
ASSERT_EQ(n, pc.pnsib[i]) << "count=" << pc.count << " idx=" << i;
|
|
EXPECT_EQ(0, std::memcmp(out, pc.pblob + pc.poff[i], (size_t)n * 4))
|
|
<< "gen_proof != python count=" << pc.count << " idx=" << i;
|
|
EXPECT_TRUE(merkle_verify_from_leaf(pc.leaves + i * 4, i, out, n, pc.root, pc.count));
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- protocol codec round-trips ---------------------------------------------------------------
|
|
|
|
TEST(OtaProtocol, ClassifiesOnlyActiveTransferMessagesAsPrimary) {
|
|
EXPECT_FALSE(ota_is_transfer_message(OTA_ADV));
|
|
EXPECT_FALSE(ota_is_transfer_message(OTA_QUERY));
|
|
EXPECT_FALSE(ota_is_transfer_message(OTA_HAVE));
|
|
EXPECT_TRUE(ota_is_transfer_message(OTA_GET_MANIFEST));
|
|
EXPECT_TRUE(ota_is_transfer_message(OTA_MANIFEST));
|
|
EXPECT_TRUE(ota_is_transfer_message(OTA_REQ));
|
|
EXPECT_TRUE(ota_is_transfer_message(OTA_DATA));
|
|
EXPECT_TRUE(ota_is_transfer_message(OTA_REQ_PROOF));
|
|
EXPECT_TRUE(ota_is_transfer_message(OTA_PROOF));
|
|
EXPECT_TRUE(ota_is_transfer_message(OTA_GET_LEAVES));
|
|
EXPECT_TRUE(ota_is_transfer_message(OTA_LEAVES));
|
|
EXPECT_FALSE(ota_is_transfer_message(0xFF));
|
|
}
|
|
|
|
TEST(OtaProtocol, CodecRoundTrips) {
|
|
uint8_t buf[200];
|
|
|
|
// OTA_ADV is now a tiny per-node beacon: seeder_id + n_motas + set_digest
|
|
AdvMsg adv{{0x29,0x17,0xe4,0xf7}, 7, {0xde,0xad,0xbe,0xef}};
|
|
uint16_t n = encode_adv(buf, sizeof(buf), adv);
|
|
ASSERT_GT(n, 0); EXPECT_EQ(ota_msg_type(buf, n), OTA_ADV); EXPECT_EQ(n, 10);
|
|
AdvMsg a2; ASSERT_TRUE(decode_adv(buf, n, a2));
|
|
EXPECT_EQ(0, memcmp(a2.seeder_id, adv.seeder_id, 4));
|
|
EXPECT_EQ(a2.n_motas, 7);
|
|
EXPECT_EQ(0, memcmp(a2.set_digest, adv.set_digest, 4));
|
|
|
|
// OTA_QUERY: ask a source (by seeder_id) for the offering set_digest, optionally filtered to a target
|
|
QueryMsg qy{{0x29,0x17,0xe4,0xf7}, {0xd1,0xd2,0xd3,0xd4}, 0x11223344, 0xA5A50002};
|
|
n = encode_query(buf, sizeof(buf), qy);
|
|
ASSERT_GT(n, 0); EXPECT_EQ(ota_msg_type(buf, n), OTA_QUERY);
|
|
QueryMsg q2; ASSERT_TRUE(decode_query(buf, n, q2));
|
|
EXPECT_EQ(0, memcmp(q2.seeder_id, qy.seeder_id, 4));
|
|
EXPECT_EQ(0, memcmp(q2.set_digest, qy.set_digest, 4));
|
|
EXPECT_EQ(q2.filter_target, 0x11223344u);
|
|
EXPECT_EQ(q2.want_fragments, 0xA5A50002u);
|
|
QueryMsg qlegacy;
|
|
ASSERT_TRUE(decode_query(buf, n - 4, qlegacy)); // original QUERY ended after filter_target
|
|
EXPECT_EQ(qlegacy.filter_target, 0x11223344u);
|
|
EXPECT_EQ(qlegacy.want_fragments, 0u); // old sender means "all fragments"
|
|
|
|
// OTA_HAVE: a 2-row catalog (mid, target, fwver, codec, flags per row) tagged with the offering digest
|
|
uint8_t rows[2 * OTA_HAVE_ROW_BYTES];
|
|
for (int i = 0; i < 2 * OTA_HAVE_ROW_BYTES; i++) rows[i] = (uint8_t)(i + 1);
|
|
HaveMsg hv{{0x29,0x17,0xe4,0xf7}, {0xd1,0xd2,0xd3,0xd4}, 0, 1, 2, rows};
|
|
n = encode_have(buf, sizeof(buf), hv);
|
|
ASSERT_GT(n, 0); EXPECT_EQ(ota_msg_type(buf, n), OTA_HAVE);
|
|
HaveMsg h2; ASSERT_TRUE(decode_have(buf, n, h2));
|
|
EXPECT_EQ(0, memcmp(h2.seeder_id, hv.seeder_id, 4));
|
|
EXPECT_EQ(0, memcmp(h2.set_digest, hv.set_digest, 4));
|
|
EXPECT_EQ(h2.frag_total, 1); EXPECT_EQ(h2.n_rows, 2);
|
|
EXPECT_EQ(0, memcmp(h2.rows, rows, 2 * OTA_HAVE_ROW_BYTES));
|
|
|
|
GetManifestMsg gm{{1,2,3,4}, 0x0002}; // want only manifest fragment 1 (recovery)
|
|
n = encode_get_manifest(buf, sizeof(buf), gm);
|
|
GetManifestMsg g2; ASSERT_TRUE(decode_get_manifest(buf, n, g2));
|
|
EXPECT_EQ(0, memcmp(g2.manifest_id, gm.manifest_id, 4));
|
|
EXPECT_EQ(g2.want_mask, 0x0002);
|
|
|
|
uint8_t mbytes[40]; for (int i = 0; i < 40; i++) mbytes[i] = (uint8_t)(i + 1);
|
|
ManifestMsg mm{{9,8,7,6}, 0, 1, mbytes, 40};
|
|
n = encode_manifest(buf, sizeof(buf), mm);
|
|
ManifestMsg m2; ASSERT_TRUE(decode_manifest(buf, n, m2));
|
|
EXPECT_EQ(m2.frag_idx, 0); EXPECT_EQ(m2.frag_total, 1); EXPECT_EQ(m2.len, 40);
|
|
EXPECT_EQ(0, memcmp(m2.bytes, mbytes, 40));
|
|
|
|
ReqMsg rq{{4,3,2,1}, 7, 0x005f}; // block 7, want fragments {0,1,2,3,4,6} (a recovery mask)
|
|
n = encode_req(buf, sizeof(buf), rq);
|
|
ReqMsg r2; ASSERT_TRUE(decode_req(buf, n, r2));
|
|
EXPECT_EQ(r2.block_idx, 7); EXPECT_EQ(r2.want_mask, 0x005f);
|
|
|
|
// A request window is an append-only extension: its first row is byte-for-byte a legacy ReqMsg, while
|
|
// new decoders see every requested block. This is the rolling-upgrade fallback for adaptive flights.
|
|
ReqWindowMsg rw{};
|
|
memcpy(rw.manifest_id, rq.manifest_id, 4);
|
|
rw.n_items = 3;
|
|
rw.items[0] = {7, 0x005f}; rw.items[1] = {8, 0x007f}; rw.items[2] = {9, 0x0003};
|
|
n = encode_req_window(buf, sizeof(buf), rw);
|
|
ASSERT_EQ(n, 17u);
|
|
ReqMsg legacy;
|
|
ASSERT_TRUE(decode_req(buf, n, legacy)); // an old source serves row zero
|
|
EXPECT_EQ(legacy.block_idx, 7); EXPECT_EQ(legacy.want_mask, 0x005f);
|
|
ReqWindowMsg rw2{};
|
|
ASSERT_TRUE(decode_req_window(buf, n, rw2));
|
|
ASSERT_EQ(rw2.n_items, 3u);
|
|
EXPECT_EQ(rw2.items[1].block_idx, 8); EXPECT_EQ(rw2.items[2].want_mask, 0x0003);
|
|
EXPECT_FALSE(decode_req_window(buf, n - 1, rw2)); // reject a truncated final row
|
|
|
|
// GET_LEAVES: bulk-fetch the target leaves[] with a fragment want_mask (motatool warm-start)
|
|
GetLeavesMsg gl{{5,6,7,8}, 0x0007}; // want leaves fragments {0,1,2}
|
|
n = encode_get_leaves(buf, sizeof(buf), gl);
|
|
ASSERT_GT(n, 0); EXPECT_EQ(ota_msg_type(buf, n), OTA_GET_LEAVES);
|
|
GetLeavesMsg gl2; ASSERT_TRUE(decode_get_leaves(buf, n, gl2));
|
|
EXPECT_EQ(0, memcmp(gl2.manifest_id, gl.manifest_id, 4)); EXPECT_EQ(gl2.want_mask, 0x0007);
|
|
|
|
// LEAVES: one fragment of the leaves[] array
|
|
uint8_t lbytes[80]; for (int i = 0; i < 80; i++) lbytes[i] = (uint8_t)(200 - i);
|
|
LeavesMsg lm{{5,6,7,8}, 1, 4, lbytes, 80};
|
|
n = encode_leaves(buf, sizeof(buf), lm);
|
|
ASSERT_GT(n, 0); EXPECT_EQ(ota_msg_type(buf, n), OTA_LEAVES);
|
|
LeavesMsg lm2; ASSERT_TRUE(decode_leaves(buf, n, lm2));
|
|
EXPECT_EQ(lm2.frag_idx, 1); EXPECT_EQ(lm2.frag_total, 4); EXPECT_EQ(lm2.len, 80);
|
|
EXPECT_EQ(0, memcmp(lm2.bytes, lbytes, 80));
|
|
|
|
// DATA is one self-describing fragment of a block (frag_off places it; proof is fetched separately)
|
|
uint8_t data[100]; for (int i = 0; i < 100; i++) data[i] = (uint8_t)(i * 3);
|
|
DataMsg dm{{0,1,2,3}, 42, 0, data, 100}; // block 42, fragment at offset 0
|
|
n = encode_data(buf, sizeof(buf), dm);
|
|
DataMsg d2; ASSERT_TRUE(decode_data(buf, n, d2));
|
|
EXPECT_EQ(d2.block_idx, 42); EXPECT_EQ(d2.frag_off, 0);
|
|
EXPECT_EQ(d2.data_len, 100); EXPECT_EQ(0, memcmp(d2.data, data, 100));
|
|
|
|
// a later slice of the same block (non-zero frag_off)
|
|
DataMsg dm2{{0,1,2,3}, 42, 160, data, 50};
|
|
n = encode_data(buf, sizeof(buf), dm2);
|
|
DataMsg d3; ASSERT_TRUE(decode_data(buf, n, d3));
|
|
EXPECT_EQ(d3.block_idx, 42); EXPECT_EQ(d3.frag_off, 160); EXPECT_EQ(d3.data_len, 50);
|
|
|
|
// REQ_PROOF: request the merkle proof for one (reassembled) block
|
|
ReqProofMsg rp{{7,7,8,8}, 13};
|
|
n = encode_req_proof(buf, sizeof(buf), rp);
|
|
ASSERT_GT(n, 0); EXPECT_EQ(ota_msg_type(buf, n), OTA_REQ_PROOF);
|
|
ReqProofMsg rp2; ASSERT_TRUE(decode_req_proof(buf, n, rp2));
|
|
EXPECT_EQ(0, memcmp(rp2.manifest_id, rp.manifest_id, 4)); EXPECT_EQ(rp2.block_idx, 13);
|
|
|
|
// PROOF: ordered sibling digests for one block
|
|
uint8_t proof[12]; for (int i = 0; i < 12; i++) proof[i] = (uint8_t)(0xA0 + i);
|
|
ProofMsg pm{{7,7,8,8}, 13, 3, proof};
|
|
n = encode_proof(buf, sizeof(buf), pm);
|
|
ASSERT_GT(n, 0); EXPECT_EQ(ota_msg_type(buf, n), OTA_PROOF);
|
|
ProofMsg pm2; ASSERT_TRUE(decode_proof(buf, n, pm2));
|
|
EXPECT_EQ(0, memcmp(pm2.manifest_id, pm.manifest_id, 4));
|
|
EXPECT_EQ(pm2.block_idx, 13); EXPECT_EQ(pm2.n_proof, 3);
|
|
EXPECT_EQ(0, memcmp(pm2.proof, proof, 12));
|
|
}
|
|
|
|
// --- full transfer simulation between two OtaManagers (P4b) ------------------------------------
|
|
|
|
namespace {
|
|
struct SimMsg { OtaManager* dest; std::vector<uint8_t> bytes; };
|
|
static std::vector<SimMsg> g_q;
|
|
struct SendTo { OtaManager* dest; };
|
|
static bool sim_send(void* ctx, const uint8_t* msg, uint16_t len, bool /*flood*/) {
|
|
g_q.push_back({((SendTo*)ctx)->dest, std::vector<uint8_t>(msg, msg + len)});
|
|
return true;
|
|
}
|
|
struct CapturedMessages { std::vector<std::vector<uint8_t>> items; };
|
|
static bool capture_send(void* ctx, const uint8_t* msg, uint16_t len, bool /*flood*/) {
|
|
((CapturedMessages*)ctx)->items.emplace_back(msg, msg + len);
|
|
return true;
|
|
}
|
|
struct GatedCapture {
|
|
bool accept = false;
|
|
std::vector<std::vector<uint8_t>> items;
|
|
};
|
|
static bool gated_capture_send(void* ctx, const uint8_t* msg, uint16_t len, bool /*flood*/) {
|
|
GatedCapture* capture = (GatedCapture*)ctx;
|
|
if (!capture->accept) return false;
|
|
capture->items.emplace_back(msg, msg + len);
|
|
return true;
|
|
}
|
|
// Drive the bus to quiescence: deliver queued messages; when idle, advance the client's clock (monotonic
|
|
// across calls, so a jittered query scheduled in a prior pump still comes due) and call loop() (fires the
|
|
// scheduled catalog query / block re-requests). Two idle ticks in a row = quiescent.
|
|
static uint32_t g_clk = 0;
|
|
static void pump(OtaManager& client, OtaManager* server = nullptr, int guard_max = 200000) {
|
|
int idle = 0, guard = 0;
|
|
while (guard++ < guard_max) {
|
|
if (server) server->serviceEgress();
|
|
client.serviceEgress();
|
|
if (!g_q.empty()) {
|
|
SimMsg m = std::move(g_q.front()); g_q.erase(g_q.begin());
|
|
m.dest->on_message(m.bytes.data(), (uint16_t)m.bytes.size());
|
|
idle = 0;
|
|
} else {
|
|
g_clk += 5000;
|
|
client.set_clock(g_clk);
|
|
client.loop();
|
|
if (server) {
|
|
server->set_clock(g_clk);
|
|
server->loop();
|
|
server->serviceEgress();
|
|
}
|
|
client.serviceEgress();
|
|
if (!g_q.empty()) { idle = 0; continue; }
|
|
if (++idle >= 2) break;
|
|
}
|
|
}
|
|
}
|
|
|
|
static void finish_staged_verification(OtaManager& manager) {
|
|
int guard = 10000;
|
|
while (manager.fetchState() == OtaManager::VERIFYING_STAGED
|
|
&& guard-- > 0) {
|
|
manager.loop();
|
|
}
|
|
ASSERT_GT(guard, 0);
|
|
}
|
|
|
|
class FaultingResumeStore : public OtaStoreRam<4096> {
|
|
public:
|
|
void failReadAt(uint32_t offset) { _fail_offset = offset; }
|
|
|
|
bool read(uint32_t offset, uint8_t* buffer,
|
|
uint32_t length) const override {
|
|
if (offset == _fail_offset) return false;
|
|
return OtaStoreRam<4096>::read(offset, buffer, length);
|
|
}
|
|
|
|
private:
|
|
uint32_t _fail_offset = UINT32_MAX;
|
|
};
|
|
|
|
// A test MotaSource backing an external "folder" with one or more complete `.mota` images held in RAM -
|
|
// the simplest concrete transport (a real device uses serial/BLE/WiFi/FS, same interface). describe()
|
|
// parses each container for the catalog + region offsets; read() is a bounds-checked memcpy.
|
|
class RamMotaSource : public mesh::ota::MotaSource {
|
|
public:
|
|
void add(const uint8_t* buf, uint32_t len) { if (_n < 8) { _buf[_n] = buf; _len[_n] = len; _n++; } }
|
|
uint8_t count() override { return _n; }
|
|
bool describe(uint8_t idx, mesh::ota::MotaDesc& d) override {
|
|
if (idx >= _n) return false;
|
|
MotaManifest m;
|
|
if (!mota_parse(_buf[idx], _len[idx], m)) return false;
|
|
std::memcpy(d.mid, m.merkle_root, 4);
|
|
d.target_id = m.target_id; d.fw_version = m.fw_version;
|
|
d.codec_id = m.codec_id; d.flags = m.flags;
|
|
d.block_size_log2 = m.block_size_log2;
|
|
d.total_size = _len[idx];
|
|
d.leaves_off = (uint32_t)(m.leaves - _buf[idx]);
|
|
d.block_count = m.block_count;
|
|
d.payload_off = (uint32_t)(m.payload - _buf[idx]);
|
|
d.payload_size = m.payload_size;
|
|
return true;
|
|
}
|
|
bool read(uint8_t idx, uint32_t off, uint8_t* out, uint32_t len) override {
|
|
if (idx >= _n || (uint64_t)off + len > _len[idx]) return false;
|
|
std::memcpy(out, _buf[idx] + off, len);
|
|
return true;
|
|
}
|
|
private:
|
|
const uint8_t* _buf[8] = {nullptr}; uint32_t _len[8] = {0}; uint8_t _n = 0;
|
|
};
|
|
|
|
class SyntheticCatalogSource : public mesh::ota::MotaSource {
|
|
public:
|
|
explicit SyntheticCatalogSource(uint8_t count, uint8_t block_log2 = 10)
|
|
: _count(count), _block_log2(block_log2) {}
|
|
uint8_t count() override { return _count; }
|
|
bool describe(uint8_t idx, mesh::ota::MotaDesc& d) override {
|
|
if (idx >= _count) return false;
|
|
d = mesh::ota::MotaDesc{};
|
|
d.mid[0] = (uint8_t)(idx + 1); d.mid[1] = 0xA5; d.mid[2] = 0x5A; d.mid[3] = 0xC3;
|
|
d.target_id = SIM_TARGET_ID; d.fw_version = 0x01000000u + idx;
|
|
d.codec_id = CODEC_FULL; d.flags = MFLAG_FULL; d.block_size_log2 = _block_log2;
|
|
d.leaves_off = 8 + MOTA_MFL; d.block_count = 1;
|
|
d.payload_off = d.leaves_off + 4; d.payload_size = 1; d.total_size = d.payload_off + 1 + 5;
|
|
return true;
|
|
}
|
|
bool read(uint8_t, uint32_t, uint8_t*, uint32_t) override { return false; }
|
|
private:
|
|
uint8_t _count;
|
|
uint8_t _block_log2;
|
|
};
|
|
}
|
|
|
|
TEST(OtaServe, ClearPrimaryInvalidatesCallerOwnedView) {
|
|
OtaManager manager;
|
|
manager.begin(0, nullptr, nullptr);
|
|
|
|
ASSERT_TRUE(manager.serve(SIM_MOTA, SIM_MOTA_LEN));
|
|
RamMotaSource folder;
|
|
folder.add(SIM_MOTA_1K, SIM_MOTA_1K_LEN);
|
|
ASSERT_TRUE(manager.add_source(&folder));
|
|
ASSERT_EQ(manager.servedCount(), 2);
|
|
|
|
manager.clear_primary();
|
|
ASSERT_EQ(manager.servedCount(), 1);
|
|
EXPECT_FALSE(manager.servedEntry(0)->is_self);
|
|
|
|
// A caller may release the container and later install a fresh primary view
|
|
// without dropping or overwriting attached folder sources.
|
|
EXPECT_TRUE(manager.serve(SIM_MOTA, SIM_MOTA_LEN));
|
|
ASSERT_EQ(manager.servedCount(), 2);
|
|
EXPECT_TRUE(manager.servedEntry(0)->is_self);
|
|
EXPECT_FALSE(manager.servedEntry(1)->is_self);
|
|
|
|
// Detaching one source must preserve the primary view (and, on device, any other source such as SD).
|
|
EXPECT_TRUE(manager.remove_source(&folder));
|
|
ASSERT_EQ(manager.servedCount(), 1);
|
|
EXPECT_TRUE(manager.servedEntry(0)->is_self);
|
|
EXPECT_FALSE(manager.remove_source(&folder));
|
|
}
|
|
|
|
TEST(OtaTransfer, TwoManagersFullTransfer) {
|
|
g_q.clear();
|
|
OtaManager server, client;
|
|
OtaStoreRam<4096> store;
|
|
SendTo to_client{&client}, to_server{&server};
|
|
|
|
server.begin(/*server's own target irrelevant for serving*/ 0, sim_send, &to_client);
|
|
client.begin(SIM_TARGET_ID, sim_send, &to_server);
|
|
client.set_fetch_store(&store);
|
|
client.set_autofetch(OtaManager::AUTOFETCH_ANY); // tests exercise fetch-on-advert; policy default is OFF
|
|
|
|
ASSERT_TRUE(server.serve(SIM_MOTA, SIM_MOTA_LEN));
|
|
server.announce(); // -> client hears the beacon, queries, catalogs, then fetches
|
|
|
|
pump(client, &server); // beacon -> query -> have -> startFetch -> full transfer
|
|
|
|
EXPECT_EQ(client.fetchState(), OtaManager::COMPLETE);
|
|
EXPECT_EQ(client.blocksHave(), client.blocksTotal());
|
|
EXPECT_GT(client.blocksTotal(), 1u);
|
|
|
|
// the client's reassembled container must be byte-identical to the original .mota...
|
|
ASSERT_EQ(store.staged_size(), SIM_MOTA_LEN);
|
|
EXPECT_EQ(0, std::memcmp(store.data(), SIM_MOTA, SIM_MOTA_LEN));
|
|
|
|
// ...and independently re-verify it parses with a matching root + image_hash
|
|
MotaManifest m;
|
|
ASSERT_TRUE(mota_parse(store.data(), store.staged_size(), m));
|
|
EXPECT_TRUE(mota_check_root(m));
|
|
EXPECT_TRUE(mota_check_image_hash_full(m));
|
|
}
|
|
|
|
static void deliver_manifest_fragment(OtaManager& client, const uint8_t mid[4], uint8_t frag_idx,
|
|
const uint8_t* bytes, uint16_t len) {
|
|
uint8_t wire[MAX_PACKET_PAYLOAD];
|
|
ManifestMsg msg;
|
|
memcpy(msg.manifest_id, mid, 4);
|
|
msg.frag_idx = frag_idx;
|
|
msg.frag_total = (uint8_t)((MOTA_MFL + OTA_MF_FRAG - 1) / OTA_MF_FRAG);
|
|
msg.bytes = bytes;
|
|
msg.len = len;
|
|
uint16_t wire_len = encode_manifest(wire, sizeof(wire), msg);
|
|
ASSERT_GT(wire_len, 0);
|
|
client.on_message(wire, wire_len);
|
|
}
|
|
|
|
static void deliver_verified_block(OtaManager& client, const MotaManifest& manifest,
|
|
uint32_t block, bool reverse_fragments = false) {
|
|
ASSERT_LT(block, manifest.block_count);
|
|
const uint32_t block_size = manifest.block_size();
|
|
const uint32_t block_off = block * block_size;
|
|
const uint32_t block_len = block_off + block_size <= manifest.payload_size
|
|
? block_size : manifest.payload_size - block_off;
|
|
const uint32_t fragments = (block_len + OTA_FRAG_DATA - 1) / OTA_FRAG_DATA;
|
|
uint8_t wire[MAX_PACKET_PAYLOAD];
|
|
for (uint32_t position = 0; position < fragments; position++) {
|
|
const uint32_t fragment = reverse_fragments ? fragments - position - 1 : position;
|
|
const uint32_t offset = fragment * OTA_FRAG_DATA;
|
|
uint32_t length = block_len - offset;
|
|
if (length > OTA_FRAG_DATA) length = OTA_FRAG_DATA;
|
|
DataMsg data;
|
|
memcpy(data.manifest_id, manifest.merkle_root, 4);
|
|
data.block_idx = (uint16_t)block;
|
|
data.frag_off = (uint16_t)offset;
|
|
data.data = manifest.payload + block_off + offset;
|
|
data.data_len = (uint16_t)length;
|
|
uint16_t wire_len = encode_data(wire, sizeof(wire), data);
|
|
ASSERT_GT(wire_len, 0);
|
|
ASSERT_TRUE(client.on_message(wire, wire_len));
|
|
}
|
|
std::vector<uint8_t> scratch(manifest.block_count * 4);
|
|
uint8_t siblings[32 * 4];
|
|
uint8_t sibling_count = merkle_gen_proof(
|
|
manifest.leaves, manifest.block_count, block, scratch.data(), siblings);
|
|
ProofMsg proof;
|
|
memcpy(proof.manifest_id, manifest.merkle_root, 4);
|
|
proof.block_idx = (uint16_t)block;
|
|
proof.n_proof = sibling_count;
|
|
proof.proof = siblings;
|
|
uint16_t wire_len = encode_proof(wire, sizeof(wire), proof);
|
|
ASSERT_GT(wire_len, 0);
|
|
ASSERT_TRUE(client.on_message(wire, wire_len));
|
|
}
|
|
|
|
TEST(OtaTransfer, ServerPacesOneKilobyteBlockAndProactiveProofWithBackpressure) {
|
|
MotaManifest manifest;
|
|
ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest));
|
|
ASSERT_EQ(manifest.block_size(), 1024u);
|
|
|
|
OtaManager server;
|
|
GatedCapture sent;
|
|
server.begin(0, gated_capture_send, &sent);
|
|
ASSERT_TRUE(server.serve(SIM_MOTA_1K, SIM_MOTA_1K_LEN));
|
|
|
|
ReqMsg request;
|
|
memcpy(request.manifest_id, manifest.merkle_root, 4);
|
|
request.block_idx = 0;
|
|
request.want_mask = 0xFFFF;
|
|
uint8_t wire[MAX_PACKET_PAYLOAD];
|
|
uint16_t wire_len = encode_req(wire, sizeof(wire), request);
|
|
ASSERT_GT(wire_len, 0);
|
|
OtaManager relay;
|
|
relay.begin(0, capture_send, &sent);
|
|
EXPECT_FALSE(relay.on_message(wire, wire_len)); // an intermediate still forwards this request
|
|
EXPECT_TRUE(server.on_message(wire, wire_len));
|
|
EXPECT_TRUE(server.on_message(wire, wire_len)); // identical in-flight retry merges
|
|
EXPECT_EQ(server.pendingServeJobs(), 1u);
|
|
EXPECT_TRUE(sent.items.empty()); // receive handler never allocates a packet burst
|
|
|
|
server.serviceEgress(); // callback applies packet-queue backpressure
|
|
EXPECT_TRUE(sent.items.empty());
|
|
EXPECT_EQ(server.pendingServeJobs(), 1u);
|
|
|
|
sent.accept = true;
|
|
const uint32_t fragment_count = (1024 + OTA_FRAG_DATA - 1) / OTA_FRAG_DATA;
|
|
for (uint32_t i = 0; i < fragment_count; i++) {
|
|
server.serviceEgress();
|
|
ASSERT_EQ(sent.items.size(), i + 1);
|
|
DataMsg data;
|
|
ASSERT_TRUE(decode_data(sent.items.back().data(),
|
|
(uint16_t)sent.items.back().size(), data));
|
|
EXPECT_EQ(data.block_idx, 0);
|
|
EXPECT_EQ(data.frag_off, i * OTA_FRAG_DATA);
|
|
}
|
|
EXPECT_EQ(server.pendingServeJobs(), 1u); // proof is retained behind DATA
|
|
server.serviceEgress(); // reserve RX turnaround for legacy REQ_PROOF
|
|
ASSERT_EQ(sent.items.size(), fragment_count);
|
|
server.set_clock(OTA_MANIFEST_EGRESS_MIN_GAP_MS);
|
|
server.serviceEgress();
|
|
ASSERT_EQ(sent.items.size(), fragment_count + 1);
|
|
ProofMsg proof;
|
|
ASSERT_TRUE(decode_proof(sent.items.back().data(),
|
|
(uint16_t)sent.items.back().size(), proof));
|
|
EXPECT_EQ(proof.block_idx, 0);
|
|
EXPECT_EQ(server.pendingServeJobs(), 0u);
|
|
}
|
|
|
|
TEST(OtaTransfer, LegacyProofRequestBypassesProactiveProofTurnaround) {
|
|
MotaManifest manifest;
|
|
ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest));
|
|
|
|
OtaManager server;
|
|
CapturedMessages sent;
|
|
server.begin(0, capture_send, &sent);
|
|
server.set_link_timing(80, 2000); // 3 admitted packet intervals = 480 ms
|
|
ASSERT_TRUE(server.serve(SIM_MOTA_1K, SIM_MOTA_1K_LEN));
|
|
|
|
ReqMsg request;
|
|
memcpy(request.manifest_id, manifest.merkle_root, 4);
|
|
request.block_idx = 0;
|
|
request.want_mask = 0xFFFF;
|
|
uint8_t wire[MAX_PACKET_PAYLOAD];
|
|
uint16_t wire_len = encode_req(wire, sizeof(wire), request);
|
|
ASSERT_GT(wire_len, 0);
|
|
ASSERT_TRUE(server.on_message(wire, wire_len));
|
|
|
|
const uint32_t fragment_count =
|
|
(manifest.block_size() + OTA_FRAG_DATA - 1) / OTA_FRAG_DATA;
|
|
for (uint32_t i = 0; i < fragment_count; i++) server.serviceEgress();
|
|
ASSERT_EQ(sent.items.size(), fragment_count);
|
|
|
|
server.set_clock(479);
|
|
server.serviceEgress(); // no unsolicited proof before turnaround
|
|
ASSERT_EQ(sent.items.size(), fragment_count);
|
|
|
|
ReqProofMsg proof_request;
|
|
memcpy(proof_request.manifest_id, manifest.merkle_root, 4);
|
|
proof_request.block_idx = 0;
|
|
wire_len = encode_req_proof(wire, sizeof(wire), proof_request);
|
|
ASSERT_GT(wire_len, 0);
|
|
ASSERT_TRUE(server.on_message(wire, wire_len));
|
|
server.serviceEgress(); // explicit legacy request is served immediately
|
|
ASSERT_EQ(sent.items.size(), fragment_count + 1);
|
|
ProofMsg proof;
|
|
ASSERT_TRUE(decode_proof(sent.items.back().data(),
|
|
(uint16_t)sent.items.back().size(), proof));
|
|
EXPECT_EQ(proof.block_idx, 0);
|
|
}
|
|
|
|
TEST(OtaTransfer, ServerPacesManifestFragmentsAndConsumesResolvedRequest) {
|
|
MotaManifest manifest;
|
|
ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, manifest));
|
|
|
|
OtaManager server;
|
|
CapturedMessages sent;
|
|
server.begin(0, capture_send, &sent);
|
|
server.set_link_timing(80, 2000); // 160 ms at the active airtime/duty spacing
|
|
ASSERT_TRUE(server.serve(SIM_MOTA, SIM_MOTA_LEN));
|
|
|
|
GetManifestMsg request;
|
|
memcpy(request.manifest_id, manifest.merkle_root, 4);
|
|
request.want_mask = 0xFFFF;
|
|
uint8_t wire[MAX_PACKET_PAYLOAD];
|
|
uint16_t wire_len = encode_get_manifest(wire, sizeof(wire), request);
|
|
ASSERT_GT(wire_len, 0);
|
|
|
|
EXPECT_TRUE(server.on_message(wire, wire_len));
|
|
EXPECT_TRUE(sent.items.empty());
|
|
EXPECT_EQ(server.pendingManifestJobs(), 1u);
|
|
|
|
const uint32_t manifest_gap = 160;
|
|
server.set_clock(manifest_gap - 1);
|
|
server.serviceEgress();
|
|
EXPECT_TRUE(sent.items.empty());
|
|
|
|
server.set_clock(manifest_gap);
|
|
server.serviceEgress();
|
|
ASSERT_EQ(sent.items.size(), 1u);
|
|
ManifestMsg fragment;
|
|
ASSERT_TRUE(decode_manifest(sent.items[0].data(),
|
|
(uint16_t)sent.items[0].size(), fragment));
|
|
EXPECT_EQ(fragment.frag_idx, 0u);
|
|
EXPECT_EQ(fragment.frag_total, 2u);
|
|
|
|
server.set_clock(2 * manifest_gap - 1);
|
|
server.serviceEgress();
|
|
ASSERT_EQ(sent.items.size(), 1u);
|
|
|
|
server.set_clock(2 * manifest_gap);
|
|
server.serviceEgress();
|
|
ASSERT_EQ(sent.items.size(), 2u);
|
|
ASSERT_TRUE(decode_manifest(sent.items[1].data(),
|
|
(uint16_t)sent.items[1].size(), fragment));
|
|
EXPECT_EQ(fragment.frag_idx, 1u);
|
|
EXPECT_EQ(server.pendingManifestJobs(), 0u);
|
|
|
|
sent.items.clear();
|
|
request.manifest_id[0] ^= 0xFF;
|
|
wire_len = encode_get_manifest(wire, sizeof(wire), request);
|
|
ASSERT_GT(wire_len, 0);
|
|
EXPECT_FALSE(server.on_message(wire, wire_len));
|
|
EXPECT_TRUE(sent.items.empty());
|
|
}
|
|
|
|
TEST(OtaTransfer, ServerQueuesEveryBlockInOneRequestFlight) {
|
|
MotaManifest manifest;
|
|
ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest));
|
|
ASSERT_EQ(manifest.block_count, 3u);
|
|
|
|
OtaManager server;
|
|
CapturedMessages sent;
|
|
server.begin(0, capture_send, &sent);
|
|
ASSERT_TRUE(server.serve(SIM_MOTA_1K, SIM_MOTA_1K_LEN));
|
|
|
|
ReqWindowMsg request{};
|
|
memcpy(request.manifest_id, manifest.merkle_root, 4);
|
|
request.n_items = 3;
|
|
for (uint8_t i = 0; i < request.n_items; i++) {
|
|
request.items[i].block_idx = i;
|
|
request.items[i].want_mask = 0xFFFF;
|
|
}
|
|
uint8_t wire[MAX_PACKET_PAYLOAD];
|
|
uint16_t wire_len = encode_req_window(wire, sizeof(wire), request);
|
|
ASSERT_GT(wire_len, 0);
|
|
server.on_message(wire, wire_len);
|
|
EXPECT_EQ(server.pendingServeJobs(), 3u);
|
|
|
|
// The bounded sender remains paced and drains the jobs in request order, including each proactive proof
|
|
// after the legacy-receiver turnaround gap.
|
|
const uint32_t data_packets_per_full_block =
|
|
(manifest.block_size() + OTA_FRAG_DATA - 1) / OTA_FRAG_DATA;
|
|
for (uint32_t i = 0; i < data_packets_per_full_block; i++) server.serviceEgress();
|
|
server.set_clock(OTA_MANIFEST_EGRESS_MIN_GAP_MS);
|
|
server.serviceEgress();
|
|
const uint32_t packets_per_full_block = data_packets_per_full_block + 1;
|
|
ASSERT_EQ(sent.items.size(), packets_per_full_block);
|
|
ProofMsg proof;
|
|
ASSERT_TRUE(decode_proof(sent.items.back().data(),
|
|
(uint16_t)sent.items.back().size(), proof));
|
|
EXPECT_EQ(proof.block_idx, 0);
|
|
EXPECT_EQ(server.pendingServeJobs(), 2u);
|
|
}
|
|
|
|
TEST(OtaTransfer, MissingProactiveProofFallsBackAfterGrace) {
|
|
MotaManifest manifest;
|
|
ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest));
|
|
|
|
OtaManager client;
|
|
OtaStoreRam<4096> store;
|
|
CapturedMessages sent;
|
|
client.begin(SIM_TARGET_ID, capture_send, &sent);
|
|
client.set_fetch_store(&store);
|
|
client.pull(manifest.merkle_root, manifest.target_id);
|
|
sent.items.clear();
|
|
client.set_clock(100);
|
|
deliver_manifest_fragment(client, manifest.merkle_root, 0,
|
|
manifest.manifest_start, OTA_MF_FRAG);
|
|
deliver_manifest_fragment(client, manifest.merkle_root, 1,
|
|
manifest.manifest_start + OTA_MF_FRAG,
|
|
(uint16_t)(MOTA_MFL - OTA_MF_FRAG));
|
|
ASSERT_EQ(sent.items.size(), 1u); // first one-block flight opens immediately
|
|
|
|
const uint32_t block_len = manifest.block_size();
|
|
uint8_t wire[MAX_PACKET_PAYLOAD];
|
|
for (uint32_t offset = 0; offset < block_len; offset += OTA_FRAG_DATA) {
|
|
uint32_t length = block_len - offset;
|
|
if (length > OTA_FRAG_DATA) length = OTA_FRAG_DATA;
|
|
DataMsg data;
|
|
memcpy(data.manifest_id, manifest.merkle_root, 4);
|
|
data.block_idx = 0;
|
|
data.frag_off = (uint16_t)offset;
|
|
data.data = manifest.payload + offset;
|
|
data.data_len = (uint16_t)length;
|
|
uint16_t length_on_wire = encode_data(wire, sizeof(wire), data);
|
|
ASSERT_GT(length_on_wire, 0);
|
|
client.on_message(wire, length_on_wire);
|
|
}
|
|
EXPECT_EQ(sent.items.size(), 1u);
|
|
|
|
client.set_clock(100 + OTA_PROOF_GRACE_MS - 1);
|
|
client.serviceEgress();
|
|
EXPECT_EQ(sent.items.size(), 1u);
|
|
client.set_clock(100 + OTA_PROOF_GRACE_MS);
|
|
client.serviceEgress();
|
|
ASSERT_EQ(sent.items.size(), 2u);
|
|
ReqProofMsg fallback;
|
|
ASSERT_TRUE(decode_req_proof(sent.items.back().data(),
|
|
(uint16_t)sent.items.back().size(), fallback));
|
|
EXPECT_EQ(fallback.block_idx, 0);
|
|
}
|
|
|
|
TEST(OtaTransfer, ClientUsesQuietBatchedFlightsAndAcceptsOutOfOrderBlocks) {
|
|
MotaManifest manifest;
|
|
ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest));
|
|
ASSERT_EQ(manifest.block_count, 3u);
|
|
|
|
OtaManager client;
|
|
OtaStoreRam<4096> store;
|
|
CapturedMessages sent;
|
|
client.begin(SIM_TARGET_ID, capture_send, &sent);
|
|
client.set_fetch_store(&store);
|
|
client.pull(manifest.merkle_root, manifest.target_id);
|
|
sent.items.clear(); // discard the initial GET_MANIFEST
|
|
|
|
deliver_manifest_fragment(client, manifest.merkle_root, 0,
|
|
manifest.manifest_start, OTA_MF_FRAG);
|
|
deliver_manifest_fragment(client, manifest.merkle_root, 1,
|
|
manifest.manifest_start + OTA_MF_FRAG,
|
|
(uint16_t)(MOTA_MFL - OTA_MF_FRAG));
|
|
ASSERT_EQ(client.fetchState(), OtaManager::FETCHING);
|
|
ASSERT_EQ(sent.items.size(), 1u);
|
|
ReqWindowMsg first{};
|
|
ASSERT_TRUE(decode_req_window(sent.items[0].data(),
|
|
(uint16_t)sent.items[0].size(), first));
|
|
ASSERT_EQ(first.n_items, 1u);
|
|
EXPECT_EQ(first.items[0].block_idx, 0);
|
|
EXPECT_EQ(client.fetchPipelineWidth(), 1u);
|
|
|
|
// A clean one-block probe grows the next flight to two blocks, both named in ONE request packet.
|
|
deliver_verified_block(client, manifest, 0);
|
|
ASSERT_EQ(client.blocksHave(), 1u);
|
|
ASSERT_EQ(client.fetchPipelineWidth(), 2u);
|
|
ASSERT_EQ(sent.items.size(), 2u);
|
|
ReqWindowMsg second{};
|
|
ASSERT_TRUE(decode_req_window(sent.items[1].data(),
|
|
(uint16_t)sent.items[1].size(), second));
|
|
ASSERT_EQ(second.n_items, 2u);
|
|
EXPECT_EQ(second.items[0].block_idx, 1);
|
|
EXPECT_EQ(second.items[1].block_idx, 2);
|
|
|
|
// Complete block 2 first, with reversed fragments. Its slot verifies independently, but the client does
|
|
// not refill or transmit anything while block 1's response is still expected from the same flight.
|
|
deliver_verified_block(client, manifest, 2, true);
|
|
EXPECT_EQ(client.blocksHave(), 2u);
|
|
EXPECT_EQ(sent.items.size(), 2u);
|
|
|
|
deliver_verified_block(client, manifest, 1);
|
|
EXPECT_EQ(client.fetchState(), OtaManager::COMPLETE);
|
|
EXPECT_EQ(client.blocksHave(), 3u);
|
|
EXPECT_EQ(sent.items.size(), 2u); // no continuous-refill request was emitted
|
|
}
|
|
|
|
TEST(OtaTransfer, NewClientFallsBackWhenLegacySourceServesOnlyFirstWindowRow) {
|
|
MotaManifest manifest;
|
|
ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest));
|
|
ASSERT_EQ(manifest.block_count, 3u);
|
|
|
|
OtaManager client;
|
|
OtaStoreRam<4096> store;
|
|
CapturedMessages sent;
|
|
client.begin(SIM_TARGET_ID, capture_send, &sent);
|
|
client.set_fetch_store(&store);
|
|
client.set_clock(100);
|
|
client.pull(manifest.merkle_root, manifest.target_id);
|
|
sent.items.clear();
|
|
deliver_manifest_fragment(client, manifest.merkle_root, 0,
|
|
manifest.manifest_start, OTA_MF_FRAG);
|
|
deliver_manifest_fragment(client, manifest.merkle_root, 1,
|
|
manifest.manifest_start + OTA_MF_FRAG,
|
|
(uint16_t)(MOTA_MFL - OTA_MF_FRAG));
|
|
deliver_verified_block(client, manifest, 0);
|
|
ASSERT_EQ(sent.items.size(), 2u);
|
|
|
|
// Legacy decode sees and serves only row zero (block 1) from the new two-row request.
|
|
ReqMsg legacy{};
|
|
ASSERT_TRUE(decode_req(sent.items.back().data(),
|
|
(uint16_t)sent.items.back().size(), legacy));
|
|
ASSERT_EQ(legacy.block_idx, 1);
|
|
deliver_verified_block(client, manifest, legacy.block_idx);
|
|
ASSERT_EQ(client.blocksHave(), 2u);
|
|
ASSERT_EQ(sent.items.size(), 2u); // block 2 remains reserved; no immediate refill
|
|
|
|
const uint32_t timeout = client.fetchRetryTimeoutMs();
|
|
client.set_clock(100 + timeout);
|
|
client.loop(); // tail row was not served: recover it conventionally
|
|
ASSERT_EQ(sent.items.size(), 3u);
|
|
EXPECT_EQ(sent.items.back().size(), 9u); // exact legacy single-row OTA_REQ
|
|
ReqMsg fallback{};
|
|
ASSERT_TRUE(decode_req(sent.items.back().data(),
|
|
(uint16_t)sent.items.back().size(), fallback));
|
|
EXPECT_EQ(fallback.block_idx, 2);
|
|
|
|
deliver_verified_block(client, manifest, 2);
|
|
EXPECT_EQ(client.fetchState(), OtaManager::COMPLETE);
|
|
EXPECT_EQ(client.blocksHave(), 3u);
|
|
}
|
|
|
|
#if OTA_FETCH_PIPELINE >= 3
|
|
TEST(OtaTransfer, RequestFlightGrowsOnCleanFlightsAndHalvesAfterRecovery) {
|
|
MotaManifest manifest;
|
|
ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, manifest));
|
|
ASSERT_GE(manifest.block_count, 7u);
|
|
|
|
OtaManager client;
|
|
OtaStoreRam<4096> store;
|
|
CapturedMessages sent;
|
|
client.begin(SIM_TARGET_ID, capture_send, &sent);
|
|
client.set_fetch_store(&store);
|
|
client.set_clock(100);
|
|
client.pull(manifest.merkle_root, manifest.target_id);
|
|
ASSERT_EQ(client.fetchPipelineCapacity(), 4u);
|
|
sent.items.clear();
|
|
deliver_manifest_fragment(client, manifest.merkle_root, 0,
|
|
manifest.manifest_start, OTA_MF_FRAG);
|
|
deliver_manifest_fragment(client, manifest.merkle_root, 1,
|
|
manifest.manifest_start + OTA_MF_FRAG,
|
|
(uint16_t)(MOTA_MFL - OTA_MF_FRAG));
|
|
ASSERT_EQ(client.fetchPipelineWidth(), 1u);
|
|
ASSERT_EQ(sent.items.size(), 1u);
|
|
|
|
deliver_verified_block(client, manifest, 0); // clean width-1 flight -> width 2
|
|
ASSERT_EQ(client.fetchPipelineWidth(), 2u);
|
|
ASSERT_EQ(sent.items.size(), 2u);
|
|
ReqWindowMsg width2{};
|
|
ASSERT_TRUE(decode_req_window(sent.items.back().data(),
|
|
(uint16_t)sent.items.back().size(), width2));
|
|
ASSERT_EQ(width2.n_items, 2u);
|
|
|
|
deliver_verified_block(client, manifest, 1);
|
|
EXPECT_EQ(client.fetchPipelineWidth(), 2u); // still waiting for the same flight's block 2
|
|
EXPECT_EQ(sent.items.size(), 2u); // receiver remains silent
|
|
deliver_verified_block(client, manifest, 2); // clean width-2 flight -> width 3
|
|
EXPECT_EQ(client.fetchPipelineWidth(), 3u);
|
|
ASSERT_EQ(sent.items.size(), 3u);
|
|
ReqWindowMsg width3{};
|
|
ASSERT_TRUE(decode_req_window(sent.items.back().data(),
|
|
(uint16_t)sent.items.back().size(), width3));
|
|
ASSERT_EQ(width3.n_items, 3u);
|
|
EXPECT_EQ(width3.items[0].block_idx, 3);
|
|
|
|
// Ordinary one-second maintenance ticks do nothing until the flight's calculated service time expires.
|
|
const uint32_t timeout = client.fetchRetryTimeoutMs();
|
|
client.set_clock(100 + timeout - 1);
|
|
client.loop();
|
|
EXPECT_EQ(sent.items.size(), 3u);
|
|
EXPECT_EQ(client.fetchPipelineWidth(), 3u);
|
|
|
|
client.set_clock(100 + timeout);
|
|
client.loop(); // recover one slot with a legacy single-row REQ
|
|
ASSERT_EQ(sent.items.size(), 4u);
|
|
ReqMsg recovery{};
|
|
ASSERT_TRUE(decode_req(sent.items.back().data(),
|
|
(uint16_t)sent.items.back().size(), recovery));
|
|
EXPECT_EQ(recovery.block_idx, 3);
|
|
EXPECT_EQ(client.fetchPipelineWidth(), 3u); // resize only after this flight drains
|
|
|
|
deliver_verified_block(client, manifest, 3);
|
|
deliver_verified_block(client, manifest, 4);
|
|
deliver_verified_block(client, manifest, 5);
|
|
EXPECT_EQ(client.fetchPipelineWidth(), 2u);
|
|
}
|
|
#endif
|
|
|
|
TEST(OtaTransfer, FlightRetriesOnlyMissingFragmentsAfterItsDeadline) {
|
|
MotaManifest manifest;
|
|
ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest));
|
|
|
|
OtaManager client;
|
|
OtaStoreRam<4096> store;
|
|
CapturedMessages sent;
|
|
client.begin(SIM_TARGET_ID, capture_send, &sent);
|
|
client.set_fetch_store(&store);
|
|
client.set_clock(100);
|
|
client.pull(manifest.merkle_root, manifest.target_id);
|
|
sent.items.clear();
|
|
deliver_manifest_fragment(client, manifest.merkle_root, 0,
|
|
manifest.manifest_start, OTA_MF_FRAG);
|
|
deliver_manifest_fragment(client, manifest.merkle_root, 1,
|
|
manifest.manifest_start + OTA_MF_FRAG,
|
|
(uint16_t)(MOTA_MFL - OTA_MF_FRAG));
|
|
ASSERT_EQ(sent.items.size(), 1u); // conservative one-block probe flight
|
|
|
|
DataMsg first_fragment;
|
|
memcpy(first_fragment.manifest_id, manifest.merkle_root, 4);
|
|
first_fragment.block_idx = 0;
|
|
first_fragment.frag_off = 0;
|
|
first_fragment.data = manifest.payload;
|
|
first_fragment.data_len = OTA_FRAG_DATA;
|
|
uint8_t wire[MAX_PACKET_PAYLOAD];
|
|
uint16_t wire_len = encode_data(wire, sizeof(wire), first_fragment);
|
|
ASSERT_GT(wire_len, 0);
|
|
client.on_message(wire, wire_len);
|
|
|
|
const uint32_t timeout = client.fetchRetryTimeoutMs();
|
|
client.set_clock(100 + timeout - 1);
|
|
client.loop(); // no premature fixed-tick retry
|
|
EXPECT_EQ(sent.items.size(), 1u);
|
|
client.set_clock(100 + timeout);
|
|
client.loop(); // deadline: retry only block 0's holes
|
|
ASSERT_EQ(sent.items.size(), 2u);
|
|
ReqMsg retry0;
|
|
ASSERT_TRUE(decode_req(sent.items[1].data(), (uint16_t)sent.items[1].size(), retry0));
|
|
EXPECT_EQ(retry0.block_idx, 0);
|
|
EXPECT_EQ(retry0.want_mask, (uint16_t)(0x007F & ~0x0001));
|
|
|
|
client.set_clock(100 + timeout * 2 - 1);
|
|
client.loop(); // the recovery request restarted the deadline
|
|
EXPECT_EQ(sent.items.size(), 2u);
|
|
}
|
|
|
|
TEST(OtaTransfer, RetryTimingAdaptsToRadioPathAndAirtimeBudget) {
|
|
MotaManifest manifest;
|
|
ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest));
|
|
|
|
OtaManager client;
|
|
OtaStoreRam<4096> store;
|
|
CapturedMessages sent;
|
|
client.begin(SIM_TARGET_ID, capture_send, &sent);
|
|
client.set_fetch_store(&store);
|
|
client.set_clock(100);
|
|
client.set_max_hops(0);
|
|
client.set_link_timing(100, 2000); // direct response fits the conservative quiet floor
|
|
client.pull(manifest.merkle_root, manifest.target_id);
|
|
sent.items.clear();
|
|
deliver_manifest_fragment(client, manifest.merkle_root, 0,
|
|
manifest.manifest_start, OTA_MF_FRAG);
|
|
deliver_manifest_fragment(client, manifest.merkle_root, 1,
|
|
manifest.manifest_start + OTA_MF_FRAG,
|
|
(uint16_t)(MOTA_MFL - OTA_MF_FRAG));
|
|
const uint32_t fast_direct = client.fetchRetryTimeoutMs();
|
|
EXPECT_GE(fast_direct, (uint32_t)OTA_FETCH_RETRY_MIN_MS);
|
|
|
|
client.set_max_hops(2); // no observed reply path: budget source + 2 relays
|
|
const uint32_t fast_relayed = client.fetchRetryTimeoutMs();
|
|
EXPECT_GT(fast_relayed, fast_direct);
|
|
|
|
client.set_link_timing(200, 2000); // half the bandwidth doubles packet airtime
|
|
const uint32_t slower_radio = client.fetchRetryTimeoutMs();
|
|
EXPECT_GT(slower_radio, fast_relayed);
|
|
|
|
client.set_link_timing(200, 3000); // a 1/3-duty node needs more service time
|
|
EXPECT_GT(client.fetchRetryTimeoutMs(), slower_radio);
|
|
EXPECT_GT(client.proofGraceMs(), (uint32_t)OTA_PROOF_GRACE_MS);
|
|
}
|
|
|
|
TEST(OtaTransfer, RejectsShortNonFinalManifestFragment) {
|
|
g_q.clear();
|
|
MotaManifest manifest;
|
|
ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, manifest));
|
|
|
|
OtaManager client;
|
|
OtaStoreRam<4096> store;
|
|
SendTo to_none{&client};
|
|
client.begin(SIM_TARGET_ID, sim_send, &to_none);
|
|
client.set_fetch_store(&store);
|
|
client.pull(manifest.merkle_root, manifest.target_id);
|
|
g_q.clear();
|
|
|
|
const uint8_t* bytes = manifest.manifest_start;
|
|
const uint16_t final_len = (uint16_t)(MOTA_MFL - OTA_MF_FRAG);
|
|
deliver_manifest_fragment(client, manifest.merkle_root, 1, bytes + OTA_MF_FRAG, final_len);
|
|
deliver_manifest_fragment(client, manifest.merkle_root, 0, bytes, OTA_MF_FRAG - 1);
|
|
EXPECT_EQ(client.fetchState(), OtaManager::WANT_MANIFEST);
|
|
|
|
deliver_manifest_fragment(client, manifest.merkle_root, 0, bytes, OTA_MF_FRAG);
|
|
EXPECT_EQ(client.fetchState(), OtaManager::FETCHING);
|
|
}
|
|
|
|
TEST(OtaTransfer, RejectsManifestBlockExponentBeforeShift) {
|
|
g_q.clear();
|
|
MotaManifest manifest;
|
|
ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, manifest));
|
|
std::array<uint8_t, MOTA_MFL> bytes;
|
|
memcpy(bytes.data(), manifest.manifest_start, bytes.size());
|
|
bytes[19] = 32;
|
|
|
|
OtaManager client;
|
|
OtaStoreRam<4096> store;
|
|
SendTo to_none{&client};
|
|
client.begin(SIM_TARGET_ID, sim_send, &to_none);
|
|
client.set_fetch_store(&store);
|
|
client.pull(manifest.merkle_root, manifest.target_id);
|
|
g_q.clear();
|
|
|
|
deliver_manifest_fragment(client, manifest.merkle_root, 0, bytes.data(), OTA_MF_FRAG);
|
|
deliver_manifest_fragment(client, manifest.merkle_root, 1, bytes.data() + OTA_MF_FRAG,
|
|
(uint16_t)(MOTA_MFL - OTA_MF_FRAG));
|
|
EXPECT_EQ(client.fetchState(), OtaManager::FAILED);
|
|
}
|
|
|
|
TEST(OtaTransfer, RejectsUnsupportedManifestHashAlgorithm) {
|
|
g_q.clear();
|
|
MotaManifest manifest;
|
|
ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, manifest));
|
|
std::array<uint8_t, MOTA_MFL> bytes;
|
|
memcpy(bytes.data(), manifest.manifest_start, bytes.size());
|
|
bytes[2] = HASH_ALGO_SHA256 + 1;
|
|
|
|
OtaManager client;
|
|
OtaStoreRam<4096> store;
|
|
SendTo to_none{&client};
|
|
client.begin(SIM_TARGET_ID, sim_send, &to_none);
|
|
client.set_fetch_store(&store);
|
|
client.pull(manifest.merkle_root, manifest.target_id);
|
|
g_q.clear();
|
|
|
|
deliver_manifest_fragment(client, manifest.merkle_root, 0, bytes.data(), OTA_MF_FRAG);
|
|
deliver_manifest_fragment(client, manifest.merkle_root, 1, bytes.data() + OTA_MF_FRAG,
|
|
(uint16_t)(MOTA_MFL - OTA_MF_FRAG));
|
|
EXPECT_EQ(client.fetchState(), OtaManager::FAILED);
|
|
}
|
|
|
|
TEST(OtaTransfer, RejectsEnvelopeMidThatDoesNotMatchParsedManifestRootBeforeStaging) {
|
|
MotaManifest manifest;
|
|
ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, manifest));
|
|
std::array<uint8_t, MOTA_MFL> bytes;
|
|
memcpy(bytes.data(), manifest.manifest_start, bytes.size());
|
|
bytes[20] ^= 0x80; // parsed merkle_root; the peer keeps the requested MID in the wire envelope
|
|
|
|
OtaManager client;
|
|
OtaStoreRam<4096> store;
|
|
CapturedMessages sent;
|
|
client.begin(SIM_TARGET_ID, capture_send, &sent);
|
|
client.set_fetch_store(&store);
|
|
ASSERT_EQ(client.pull(manifest.merkle_root, manifest.target_id), OtaManager::PULL_STARTED);
|
|
|
|
deliver_manifest_fragment(client, manifest.merkle_root, 0, bytes.data(), OTA_MF_FRAG);
|
|
deliver_manifest_fragment(client, manifest.merkle_root, 1, bytes.data() + OTA_MF_FRAG,
|
|
(uint16_t)(MOTA_MFL - OTA_MF_FRAG));
|
|
EXPECT_EQ(client.fetchState(), OtaManager::FAILED);
|
|
EXPECT_EQ(client.fetchError(), OtaManager::FETCH_ERROR_MANIFEST);
|
|
EXPECT_EQ(store.staged_size(), 0u);
|
|
}
|
|
|
|
TEST(OtaTransfer, RejectsParsedManifestTargetThatDoesNotMatchRequestedCatalogTargetBeforeStaging) {
|
|
MotaManifest manifest;
|
|
ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, manifest));
|
|
std::array<uint8_t, MOTA_MFL> bytes;
|
|
memcpy(bytes.data(), manifest.manifest_start, bytes.size());
|
|
wr_u32le(bytes.data() + 3, manifest.target_id ^ 0x01000000u);
|
|
|
|
OtaManager client;
|
|
OtaStoreRam<4096> store;
|
|
CapturedMessages sent;
|
|
client.begin(SIM_TARGET_ID, capture_send, &sent);
|
|
client.set_fetch_store(&store);
|
|
ASSERT_EQ(client.pull(manifest.merkle_root, manifest.target_id), OtaManager::PULL_STARTED);
|
|
|
|
deliver_manifest_fragment(client, manifest.merkle_root, 0, bytes.data(), OTA_MF_FRAG);
|
|
deliver_manifest_fragment(client, manifest.merkle_root, 1, bytes.data() + OTA_MF_FRAG,
|
|
(uint16_t)(MOTA_MFL - OTA_MF_FRAG));
|
|
EXPECT_EQ(client.fetchState(), OtaManager::FAILED);
|
|
EXPECT_EQ(client.fetchError(), OtaManager::FETCH_ERROR_MANIFEST);
|
|
EXPECT_EQ(store.staged_size(), 0u);
|
|
}
|
|
|
|
// Same end-to-end transfer, but with 1 KB logical blocks: each block is delivered as several
|
|
// self-describing DATA fragments (frag_off), reassembled by the client, then its merkle PROOF is
|
|
// requested + verified separately before the block is committed. Exercises the multi-fragment path.
|
|
TEST(OtaTransfer, MultiFragmentBlocks) {
|
|
g_q.clear();
|
|
OtaManager server, client;
|
|
OtaStoreRam<4096> store;
|
|
SendTo to_client{&client}, to_server{&server};
|
|
|
|
server.begin(0, sim_send, &to_client);
|
|
client.begin(SIM_TARGET_ID, sim_send, &to_server);
|
|
client.set_fetch_store(&store);
|
|
client.set_autofetch(OtaManager::AUTOFETCH_ANY);
|
|
|
|
ASSERT_TRUE(server.serve(SIM_MOTA_1K, SIM_MOTA_1K_LEN));
|
|
server.announce();
|
|
|
|
pump(client, &server);
|
|
|
|
EXPECT_EQ(client.fetchState(), OtaManager::COMPLETE);
|
|
EXPECT_EQ(client.blocksTotal(), SIM_MOTA_1K_BLOCKS); // 1 KB blocks => fewer, larger blocks
|
|
EXPECT_EQ(client.blocksHave(), client.blocksTotal());
|
|
|
|
ASSERT_EQ(store.staged_size(), SIM_MOTA_1K_LEN);
|
|
EXPECT_EQ(0, std::memcmp(store.data(), SIM_MOTA_1K, SIM_MOTA_1K_LEN));
|
|
MotaManifest m;
|
|
ASSERT_TRUE(mota_parse(store.data(), store.staged_size(), m));
|
|
EXPECT_TRUE(mota_check_root(m));
|
|
EXPECT_TRUE(mota_check_image_hash_full(m));
|
|
}
|
|
|
|
// Multi-mota folder serve: a node serves its OWN fw (view0) PLUS an external folder (RamMotaSource) of
|
|
// other `.mota`. Peers discover BOTH via the tiny beacon -> query -> broadcast HAVE catalog, then fetch an
|
|
// external mota end-to-end. The relaying node never holds the folder image in RAM - it streams the
|
|
// manifest/leaves/blocks from the source on demand (loadSource + srcReadTramp + proof-gen from read
|
|
// leaves). The fetched bytes must equal the original `.mota` (proves the trustless relay is byte-exact).
|
|
TEST(OtaFolder, ServesSelfPlusFolderAndFetchesExternal) {
|
|
g_q.clear();
|
|
OtaManager server, client;
|
|
OtaStoreRam<4096> store;
|
|
SendTo to_client{&client}, to_server{&server};
|
|
|
|
server.begin(/*own target irrelevant for serving*/ 0, sim_send, &to_client);
|
|
uint8_t srv_id[4] = {0xAB, 0xCD, 0xEF, 0x01}; server.set_seeder_id(srv_id);
|
|
client.begin(SIM_TARGET_ID, sim_send, &to_server);
|
|
client.set_fetch_store(&store);
|
|
|
|
MotaManifest mSelf, mExt;
|
|
ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, mSelf)); // served as our own fw (view0)
|
|
ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, mExt)); // served from the external folder
|
|
|
|
ASSERT_TRUE(server.serve(SIM_MOTA, SIM_MOTA_LEN)); // entry 0 = self
|
|
static RamMotaSource folder;
|
|
folder.add(SIM_MOTA_1K, SIM_MOTA_1K_LEN); // an external image (different mid)
|
|
folder.add(SIM_MOTA, SIM_MOTA_LEN); // same as self -> must be DEDUPED
|
|
ASSERT_TRUE(server.add_source(&folder));
|
|
EXPECT_EQ(server.servedCount(), 2); // self + 1 distinct folder mota (dedup)
|
|
|
|
// discovery: beacon -> the client catalogs the source, queries it, and the broadcast HAVE fills the
|
|
// catalog with BOTH served mids.
|
|
server.announce();
|
|
pump(client, &server);
|
|
client.queryAll();
|
|
pump(client, &server);
|
|
EXPECT_EQ(client.catalogCount(), 2);
|
|
|
|
// fetch the EXTERNAL (folder) mota by mid -> served via the source, relayed block-by-block.
|
|
client.pull(mExt.merkle_root, mExt.target_id);
|
|
pump(client, &server);
|
|
|
|
EXPECT_EQ(client.fetchState(), OtaManager::COMPLETE);
|
|
EXPECT_EQ(client.blocksHave(), client.blocksTotal());
|
|
ASSERT_EQ(store.staged_size(), SIM_MOTA_1K_LEN);
|
|
EXPECT_EQ(0, std::memcmp(store.data(), SIM_MOTA_1K, SIM_MOTA_1K_LEN)); // byte-exact relay
|
|
MotaManifest got;
|
|
ASSERT_TRUE(mota_parse(store.data(), store.staged_size(), got));
|
|
EXPECT_TRUE(mota_check_root(got));
|
|
EXPECT_TRUE(mota_check_image_hash_full(got));
|
|
}
|
|
|
|
TEST(OtaCatalog, QueryCanRequestOnlyMissingHaveFragment) {
|
|
OtaManager server;
|
|
CapturedMessages sent;
|
|
uint8_t sid[4] = {0x10, 0x20, 0x30, 0x40};
|
|
server.begin(0, capture_send, &sent);
|
|
server.set_seeder_id(sid);
|
|
SyntheticCatalogSource source(12); // 10 rows/fragment => two HAVE fragments
|
|
ASSERT_TRUE(server.add_source(&source));
|
|
ASSERT_EQ(server.servedCount(), 12);
|
|
|
|
QueryMsg query{};
|
|
memcpy(query.seeder_id, sid, 4);
|
|
query.want_fragments = 1UL << 1; // recover only fragment 1
|
|
uint8_t wire[32];
|
|
uint16_t n = encode_query(wire, sizeof(wire), query);
|
|
ASSERT_GT(n, 0);
|
|
server.on_message(wire, n);
|
|
|
|
ASSERT_EQ(sent.items.size(), 1u);
|
|
HaveMsg have;
|
|
ASSERT_TRUE(decode_have(sent.items[0].data(), (uint16_t)sent.items[0].size(), have));
|
|
EXPECT_EQ(have.frag_idx, 1);
|
|
EXPECT_EQ(have.frag_total, 2);
|
|
EXPECT_EQ(have.n_rows, 2);
|
|
}
|
|
|
|
TEST(OtaCatalog, IncompleteCatalogRetriesOnlyMissingFragment) {
|
|
OtaManager client;
|
|
CapturedMessages sent;
|
|
client.begin(SIM_TARGET_ID, capture_send, &sent);
|
|
client.set_archive_interest(true);
|
|
uint8_t sid[4] = {0x44, 0x33, 0x22, 0x11};
|
|
uint8_t digest[4] = {0xDE, 0xAD, 0xBE, 0xEF};
|
|
AdvMsg adv{}; memcpy(adv.seeder_id, sid, 4); memcpy(adv.set_digest, digest, 4); adv.n_motas = 3;
|
|
uint8_t wire[MAX_PACKET_PAYLOAD];
|
|
uint16_t n = encode_adv(wire, sizeof(wire), adv);
|
|
client.set_clock(100);
|
|
client.on_message(wire, n);
|
|
client.set_clock(5000); // past every possible discovery jitter
|
|
client.loop();
|
|
ASSERT_EQ(sent.items.size(), 1u);
|
|
QueryMsg first;
|
|
ASSERT_TRUE(decode_query(sent.items[0].data(), (uint16_t)sent.items[0].size(), first));
|
|
EXPECT_EQ(first.want_fragments, 0u);
|
|
sent.items.clear();
|
|
|
|
auto deliver_have = [&](uint8_t frag) {
|
|
uint8_t row[OTA_HAVE_ROW_BYTES] = {0};
|
|
row[0] = (uint8_t)(frag + 1); row[1] = 0x77;
|
|
wr_u32le(row + 4, SIM_TARGET_ID); wr_u32le(row + 8, 0x01000000u + frag);
|
|
row[12] = CODEC_FULL; row[13] = MFLAG_FULL; row[14] = 1;
|
|
HaveMsg have{};
|
|
memcpy(have.seeder_id, sid, 4); memcpy(have.set_digest, digest, 4);
|
|
have.frag_idx = frag; have.frag_total = 3; have.n_rows = 1; have.rows = row;
|
|
uint16_t have_len = encode_have(wire, sizeof(wire), have);
|
|
ASSERT_GT(have_len, 0);
|
|
client.on_message(wire, have_len);
|
|
};
|
|
|
|
deliver_have(0);
|
|
deliver_have(2); // fragment 1 is lost
|
|
client.set_clock(5000 + OTA_CATALOG_RETRY_MS + 1);
|
|
client.loop();
|
|
ASSERT_EQ(sent.items.size(), 1u);
|
|
QueryMsg retry;
|
|
ASSERT_TRUE(decode_query(sent.items[0].data(), (uint16_t)sent.items[0].size(), retry));
|
|
EXPECT_EQ(retry.want_fragments, 1UL << 1);
|
|
sent.items.clear();
|
|
|
|
deliver_have(1);
|
|
EXPECT_EQ(client.catalogCount(), 3);
|
|
client.set_clock(5000 + OTA_CATALOG_RETRY_MS * 2 + 2);
|
|
client.loop();
|
|
EXPECT_TRUE(sent.items.empty()); // complete means no further catalog retries
|
|
}
|
|
|
|
TEST(OtaCatalog, RetainsProtocolMaximumRows) {
|
|
OtaManager client;
|
|
client.begin(SIM_TARGET_ID, nullptr, nullptr);
|
|
const uint8_t per = (uint8_t)((MAX_PACKET_PAYLOAD - 12) / OTA_HAVE_ROW_BYTES);
|
|
const uint8_t total = (uint8_t)((255 + per - 1) / per);
|
|
uint8_t sid[4] = {1, 2, 3, 4}, digest[4] = {5, 6, 7, 8};
|
|
uint16_t index = 0;
|
|
for (uint8_t frag = 0; frag < total; frag++) {
|
|
uint8_t rows[MAX_PACKET_PAYLOAD] = {0};
|
|
uint8_t count = 0;
|
|
while (count < per && index < 255) {
|
|
uint8_t* row = rows + (uint16_t)count * OTA_HAVE_ROW_BYTES;
|
|
row[0] = (uint8_t)index; row[1] = 0xC1; row[2] = 0xD2; row[3] = 0xE3;
|
|
wr_u32le(row + 4, SIM_TARGET_ID); wr_u32le(row + 8, 0x01000000u + index);
|
|
row[12] = CODEC_FULL; row[13] = MFLAG_FULL; row[14] = 1;
|
|
count++; index++;
|
|
}
|
|
HaveMsg have{};
|
|
memcpy(have.seeder_id, sid, 4); memcpy(have.set_digest, digest, 4);
|
|
have.frag_idx = frag; have.frag_total = total; have.n_rows = count; have.rows = rows;
|
|
uint8_t wire[MAX_PACKET_PAYLOAD];
|
|
uint16_t n = encode_have(wire, sizeof(wire), have);
|
|
ASSERT_GT(n, 0);
|
|
client.on_message(wire, n);
|
|
}
|
|
ASSERT_EQ(client.catalogCount(), 255);
|
|
ASSERT_NE(client.catalogRow(254), nullptr);
|
|
EXPECT_EQ(client.catalogRow(254)->mid[0], 254);
|
|
}
|
|
|
|
TEST(OtaCatalog, RejectsAdvertisedSourceWithOversizedBlocks) {
|
|
OtaManager server;
|
|
server.begin(0, nullptr, nullptr);
|
|
SyntheticCatalogSource source(1, 11); // 2048-byte blocks exceed OTA_MAX_BLOCK
|
|
ASSERT_TRUE(server.add_source(&source));
|
|
EXPECT_EQ(server.servedCount(), 0);
|
|
}
|
|
|
|
TEST(OtaFolder, ReportsEntriesOmittedByServeRegistryCapacity) {
|
|
OtaManager server;
|
|
server.begin(0, nullptr, nullptr);
|
|
ASSERT_TRUE(server.serve(SIM_MOTA, SIM_MOTA_LEN));
|
|
SyntheticCatalogSource source((uint8_t)(OTA_MAX_SERVE + 3));
|
|
ASSERT_TRUE(server.add_source(&source));
|
|
|
|
uint16_t offered = 0, advertised = 0;
|
|
ASSERT_TRUE(server.sourceStats(&source, offered, advertised));
|
|
EXPECT_EQ(offered, (uint16_t)(OTA_MAX_SERVE + 3));
|
|
EXPECT_EQ(advertised, (uint16_t)(OTA_MAX_SERVE - 1)); // primary image occupies slot zero
|
|
EXPECT_EQ(server.servedCount(), OTA_MAX_SERVE);
|
|
}
|
|
|
|
// Fetch-resume across a reboot: a client commits some blocks, "reboots" (a fresh OtaManager on the SAME
|
|
// persisted store), and resumeStaged() re-adopts the partial container and finishes the remaining blocks -
|
|
// without re-fetching the manifest or the blocks already present.
|
|
TEST(OtaTransfer, ResumeAfterReboot) {
|
|
g_q.clear();
|
|
OtaManager server, client;
|
|
OtaStoreRam<4096> store;
|
|
SendTo to_client{&client}, to_server{&server};
|
|
|
|
server.begin(0, sim_send, &to_client);
|
|
client.begin(SIM_TARGET_ID, sim_send, &to_server);
|
|
client.set_fetch_store(&store);
|
|
client.set_autofetch(OtaManager::AUTOFETCH_ANY);
|
|
|
|
ASSERT_TRUE(server.serve(SIM_MOTA_1K, SIM_MOTA_1K_LEN));
|
|
server.announce();
|
|
|
|
// drive only until the first block commits, then "crash"
|
|
int idle = 0, guard = 0;
|
|
while (guard++ < 100000) {
|
|
server.serviceEgress();
|
|
client.serviceEgress();
|
|
if (!g_q.empty()) {
|
|
SimMsg msg = std::move(g_q.front()); g_q.erase(g_q.begin());
|
|
msg.dest->on_message(msg.bytes.data(), (uint16_t)msg.bytes.size());
|
|
idle = 0;
|
|
} else {
|
|
g_clk += 5000;
|
|
client.set_clock(g_clk); client.loop(); client.serviceEgress();
|
|
server.set_clock(g_clk); server.loop(); server.serviceEgress();
|
|
if (!g_q.empty()) { idle = 0; } else if (++idle >= 2) break;
|
|
}
|
|
if (client.blocksHave() >= 1) break;
|
|
}
|
|
ASSERT_GE(client.blocksHave(), 1u);
|
|
ASSERT_LT(client.blocksHave(), client.blocksTotal()); // genuinely partial
|
|
uint32_t had = client.blocksHave();
|
|
g_q.clear(); // in-flight packets are lost in the "reboot"
|
|
|
|
// "reboot": a brand-new manager on the SAME store (its bytes survived) resumes the partial
|
|
OtaManager client2;
|
|
to_client.dest = &client2; // server now replies to the rebooted client
|
|
SendTo to_server2{&server};
|
|
client2.begin(SIM_TARGET_ID, sim_send, &to_server2);
|
|
client2.set_fetch_store(&store);
|
|
client2.set_autofetch(OtaManager::AUTOFETCH_ANY);
|
|
ASSERT_TRUE(client2.resumeStaged(nullptr)); // adopt whatever is staged
|
|
EXPECT_EQ(client2.fetchState(), OtaManager::VERIFYING_STAGED);
|
|
finish_staged_verification(client2);
|
|
EXPECT_EQ(client2.blocksHave(), had); // resumed exactly where we left off
|
|
EXPECT_EQ(client2.fetchState(), OtaManager::FETCHING);
|
|
EXPECT_EQ(client2.blocksTotal(), SIM_MOTA_1K_BLOCKS);
|
|
|
|
pump(client2, &server);
|
|
EXPECT_EQ(client2.fetchState(), OtaManager::COMPLETE);
|
|
ASSERT_EQ(store.staged_size(), SIM_MOTA_1K_LEN);
|
|
EXPECT_EQ(0, std::memcmp(store.data(), SIM_MOTA_1K, SIM_MOTA_1K_LEN)); // byte-identical to the original
|
|
}
|
|
|
|
TEST(OtaTransfer, BootResumeHonorsCurrentAutofetchTargetAndVersionPolicy) {
|
|
MotaManifest manifest;
|
|
ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, manifest));
|
|
ASSERT_TRUE(manifest.is_signed());
|
|
auto stage = [](OtaStoreRam<4096>& store, const uint8_t* bytes,
|
|
uint32_t length) {
|
|
return store.begin(length) && store.write(0, bytes, length);
|
|
};
|
|
|
|
OtaStoreRam<4096> off_store;
|
|
ASSERT_TRUE(stage(off_store, SIM_MOTA, SIM_MOTA_LEN));
|
|
OtaManager off;
|
|
off.begin(manifest.target_id, nullptr, nullptr);
|
|
off.set_fetch_store(&off_store);
|
|
EXPECT_FALSE(off.resumeStaged(nullptr));
|
|
|
|
std::vector<uint8_t> unsigned_mota(SIM_MOTA, SIM_MOTA + SIM_MOTA_LEN);
|
|
unsigned_mota[8u + 1u] &= (uint8_t)~MFLAG_SIGNED;
|
|
memset(unsigned_mota.data() + 8u + 97u, 0, 32u + 64u);
|
|
MotaManifest unsigned_manifest;
|
|
ASSERT_TRUE(mota_parse(unsigned_mota.data(),
|
|
(uint32_t)unsigned_mota.size(), unsigned_manifest));
|
|
ASSERT_FALSE(unsigned_manifest.is_signed());
|
|
OtaStoreRam<4096> unsigned_store;
|
|
ASSERT_TRUE(stage(unsigned_store, unsigned_mota.data(),
|
|
(uint32_t)unsigned_mota.size()));
|
|
OtaManager signed_only;
|
|
signed_only.begin(manifest.target_id, nullptr, nullptr);
|
|
signed_only.set_fetch_store(&unsigned_store);
|
|
signed_only.set_autofetch(OtaManager::AUTOFETCH_SIGNED);
|
|
signed_only.set_auto_version_floor(manifest.fw_version - 1u, true);
|
|
EXPECT_FALSE(signed_only.resumeStaged(nullptr));
|
|
|
|
OtaStoreRam<4096> replay_store;
|
|
ASSERT_TRUE(stage(replay_store, SIM_MOTA, SIM_MOTA_LEN));
|
|
OtaManager replay;
|
|
replay.begin(manifest.target_id, nullptr, nullptr);
|
|
replay.set_fetch_store(&replay_store);
|
|
replay.set_autofetch(OtaManager::AUTOFETCH_ANY);
|
|
replay.set_auto_version_floor(manifest.fw_version, true);
|
|
EXPECT_FALSE(replay.resumeStaged(nullptr));
|
|
|
|
OtaStoreRam<4096> wrong_target_store;
|
|
ASSERT_TRUE(stage(wrong_target_store, SIM_MOTA, SIM_MOTA_LEN));
|
|
OtaManager wrong_target;
|
|
wrong_target.begin(manifest.target_id ^ 1u, nullptr, nullptr);
|
|
wrong_target.set_fetch_store(&wrong_target_store);
|
|
wrong_target.set_autofetch(OtaManager::AUTOFETCH_ANY);
|
|
wrong_target.set_auto_version_floor(manifest.fw_version - 1u, true);
|
|
EXPECT_FALSE(wrong_target.resumeStaged(nullptr));
|
|
|
|
OtaStoreRam<4096> newer_store;
|
|
ASSERT_TRUE(stage(newer_store, SIM_MOTA, SIM_MOTA_LEN));
|
|
OtaManager newer;
|
|
newer.begin(manifest.target_id, nullptr, nullptr);
|
|
newer.set_fetch_store(&newer_store);
|
|
newer.set_autofetch(OtaManager::AUTOFETCH_SIGNED);
|
|
newer.set_auto_version_floor(manifest.fw_version - 1u, true);
|
|
EXPECT_TRUE(newer.resumeStaged(nullptr));
|
|
EXPECT_EQ(newer.fetchState(), OtaManager::VERIFYING_STAGED);
|
|
}
|
|
|
|
TEST(OtaTransfer, ExplicitResumePreservesTargetZeroWildcard) {
|
|
MotaManifest manifest;
|
|
ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, manifest));
|
|
auto stage = [](OtaStoreRam<4096>& store) {
|
|
return store.begin(SIM_MOTA_LEN) &&
|
|
store.write(0, SIM_MOTA, SIM_MOTA_LEN);
|
|
};
|
|
|
|
OtaStoreRam<4096> manual_store;
|
|
ASSERT_TRUE(stage(manual_store));
|
|
OtaManager manual;
|
|
manual.begin(manifest.target_id ^ 1u, nullptr, nullptr);
|
|
manual.set_fetch_store(&manual_store);
|
|
EXPECT_EQ(manual.pull(manifest.merkle_root, 0), OtaManager::PULL_RESUMED);
|
|
|
|
OtaStoreRam<4096> archive_store;
|
|
ASSERT_TRUE(stage(archive_store));
|
|
OtaManager archive;
|
|
archive.begin(manifest.target_id ^ 1u, nullptr, nullptr);
|
|
archive.set_fetch_store(&archive_store);
|
|
EXPECT_EQ(archive.pull_archive(manifest.merkle_root, 0),
|
|
OtaManager::PULL_RESUMED);
|
|
|
|
OtaStoreRam<4096> mismatch_store;
|
|
ASSERT_TRUE(stage(mismatch_store));
|
|
OtaManager mismatch;
|
|
mismatch.begin(manifest.target_id, nullptr, nullptr);
|
|
mismatch.set_fetch_store(&mismatch_store);
|
|
EXPECT_EQ(mismatch.pull(manifest.merkle_root, manifest.target_id ^ 1u),
|
|
OtaManager::PULL_STARTED);
|
|
EXPECT_EQ(mismatch.fetchState(), OtaManager::WANT_MANIFEST);
|
|
}
|
|
|
|
TEST(OtaTransfer, ExplicitDebugResumeUsesManualIntentNotAutofetchPolicy) {
|
|
MotaManifest manifest;
|
|
ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, manifest));
|
|
OtaStoreRam<4096> store;
|
|
ASSERT_TRUE(store.begin(SIM_MOTA_LEN));
|
|
ASSERT_TRUE(store.write(0, SIM_MOTA, SIM_MOTA_LEN));
|
|
|
|
// A deliberate debug/operator resume remains possible with autofetch disabled, an equal running
|
|
// version, and a cross-target wildcard. Those same properties are rejected by resumeStaged(nullptr).
|
|
OtaManager explicit_resume;
|
|
explicit_resume.begin(manifest.target_id ^ 1u, nullptr, nullptr);
|
|
explicit_resume.set_fetch_store(&store);
|
|
explicit_resume.set_autofetch(OtaManager::AUTOFETCH_OFF);
|
|
explicit_resume.set_auto_version_floor(manifest.fw_version, true);
|
|
ASSERT_TRUE(explicit_resume.resumeStagedExplicit(manifest.merkle_root, 0));
|
|
EXPECT_EQ(explicit_resume.fetchState(), OtaManager::VERIFYING_STAGED);
|
|
|
|
OtaStoreRam<4096> wrong_mid_store;
|
|
ASSERT_TRUE(wrong_mid_store.begin(SIM_MOTA_LEN));
|
|
ASSERT_TRUE(wrong_mid_store.write(0, SIM_MOTA, SIM_MOTA_LEN));
|
|
OtaManager wrong_mid;
|
|
wrong_mid.begin(manifest.target_id, nullptr, nullptr);
|
|
wrong_mid.set_fetch_store(&wrong_mid_store);
|
|
uint8_t other_mid[4];
|
|
memcpy(other_mid, manifest.merkle_root, sizeof(other_mid));
|
|
other_mid[0] ^= 1u;
|
|
EXPECT_FALSE(wrong_mid.resumeStagedExplicit(other_mid, 0));
|
|
EXPECT_EQ(wrong_mid.fetchState(), OtaManager::IDLE);
|
|
}
|
|
|
|
TEST(OtaTransfer, ResumeRehashesPayloadBeforeTrustingPresentLeaf) {
|
|
g_q.clear();
|
|
OtaManager server, client;
|
|
OtaStoreRam<4096> store;
|
|
SendTo to_client{&client}, to_server{&server};
|
|
server.begin(0, sim_send, &to_client);
|
|
client.begin(SIM_TARGET_ID, sim_send, &to_server);
|
|
client.set_fetch_store(&store);
|
|
client.set_autofetch(OtaManager::AUTOFETCH_ANY);
|
|
ASSERT_TRUE(server.serve(SIM_MOTA, SIM_MOTA_LEN));
|
|
server.announce();
|
|
pump(client, &server);
|
|
ASSERT_EQ(client.fetchState(), OtaManager::COMPLETE);
|
|
|
|
MotaManifest staged;
|
|
ASSERT_TRUE(mota_parse(store.data(), store.staged_size(), staged));
|
|
const uint32_t payload_offset =
|
|
(uint32_t)(staged.payload - store.data());
|
|
const uint32_t leaves_offset =
|
|
(uint32_t)(staged.leaves - store.data());
|
|
uint8_t damaged = (uint8_t)(staged.payload[0] ^ 0x5A);
|
|
ASSERT_TRUE(store.write(payload_offset, &damaged, 1));
|
|
|
|
OtaManager resumed;
|
|
resumed.begin(SIM_TARGET_ID, nullptr, nullptr);
|
|
resumed.set_fetch_store(&store);
|
|
resumed.set_autofetch(OtaManager::AUTOFETCH_ANY);
|
|
ASSERT_TRUE(resumed.resumeStaged(nullptr));
|
|
finish_staged_verification(resumed);
|
|
EXPECT_EQ(resumed.fetchState(), OtaManager::FETCHING);
|
|
EXPECT_EQ(resumed.blocksHave() + 1, resumed.blocksTotal());
|
|
uint8_t marker[4] = {};
|
|
ASSERT_TRUE(store.read(leaves_offset, marker, sizeof(marker)));
|
|
const uint8_t missing[4] = {0xFF, 0xFF, 0xFF, 0xFF};
|
|
EXPECT_EQ(0, std::memcmp(marker, missing, sizeof(marker)));
|
|
}
|
|
|
|
TEST(OtaTransfer, ResumeReadFailureCanNeverBecomeComplete) {
|
|
g_q.clear();
|
|
OtaManager server, client;
|
|
FaultingResumeStore store;
|
|
SendTo to_client{&client}, to_server{&server};
|
|
server.begin(0, sim_send, &to_client);
|
|
client.begin(SIM_TARGET_ID, sim_send, &to_server);
|
|
client.set_fetch_store(&store);
|
|
client.set_autofetch(OtaManager::AUTOFETCH_ANY);
|
|
ASSERT_TRUE(server.serve(SIM_MOTA, SIM_MOTA_LEN));
|
|
server.announce();
|
|
pump(client, &server);
|
|
ASSERT_EQ(client.fetchState(), OtaManager::COMPLETE);
|
|
|
|
MotaManifest staged;
|
|
ASSERT_TRUE(mota_parse(store.data(), store.staged_size(), staged));
|
|
store.failReadAt((uint32_t)(staged.leaves - store.data()));
|
|
|
|
OtaManager resumed;
|
|
resumed.begin(SIM_TARGET_ID, nullptr, nullptr);
|
|
resumed.set_fetch_store(&store);
|
|
resumed.set_autofetch(OtaManager::AUTOFETCH_ANY);
|
|
ASSERT_TRUE(resumed.resumeStaged(nullptr));
|
|
finish_staged_verification(resumed);
|
|
EXPECT_EQ(resumed.fetchState(), OtaManager::FAILED);
|
|
}
|
|
|
|
TEST(OtaTransfer, ClientRejectsWrongTarget) {
|
|
g_q.clear();
|
|
OtaManager server, client;
|
|
OtaStoreRam<4096> store;
|
|
SendTo to_client{&client}, to_server{&server};
|
|
server.begin(0, sim_send, &to_client);
|
|
client.begin(SIM_TARGET_ID ^ 0x1u, sim_send, &to_server); // different target -> not interested
|
|
client.set_fetch_store(&store);
|
|
client.set_autofetch(OtaManager::AUTOFETCH_ANY); // tests exercise fetch-on-advert; policy default is OFF
|
|
ASSERT_TRUE(server.serve(SIM_MOTA, SIM_MOTA_LEN));
|
|
server.announce();
|
|
pump(client, &server); // catalogs the row but wantRow rejects it (wrong target) -> never fetches
|
|
EXPECT_EQ(client.fetchState(), OtaManager::IDLE); // never started
|
|
}
|
|
|
|
TEST(OtaTransfer, ManualCrossTargetFetch) {
|
|
// A node whose own target differs from the served firmware normally won't fetch (role-switch case:
|
|
// e.g. companion wanting repeater firmware). An explicit want() override lets it fetch deliberately.
|
|
g_q.clear();
|
|
OtaManager server, client;
|
|
OtaStoreRam<4096> store;
|
|
SendTo to_client{&client}, to_server{&server};
|
|
server.begin(0, sim_send, &to_client);
|
|
client.begin(SIM_TARGET_ID ^ 0xABCDu, sim_send, &to_server); // DIFFERENT own target
|
|
client.set_fetch_store(&store);
|
|
client.set_autofetch(OtaManager::AUTOFETCH_ANY); // tests exercise fetch-on-advert; policy default is OFF
|
|
ASSERT_TRUE(server.serve(SIM_MOTA, SIM_MOTA_LEN));
|
|
|
|
// without the override: catalogs the row but won't fetch (wrong target)
|
|
server.announce();
|
|
pump(client, &server);
|
|
EXPECT_EQ(client.fetchState(), OtaManager::IDLE);
|
|
|
|
// with want(): deliberately fetch the different-target firmware to completion
|
|
client.want(SIM_TARGET_ID);
|
|
server.announce();
|
|
pump(client, &server);
|
|
EXPECT_EQ(client.fetchState(), OtaManager::COMPLETE);
|
|
ASSERT_EQ(store.staged_size(), SIM_MOTA_LEN);
|
|
EXPECT_EQ(0, std::memcmp(store.data(), SIM_MOTA, SIM_MOTA_LEN));
|
|
}
|
|
|
|
// Encode a 1-row OTA_HAVE catalog (the discovery reply a peer acts on).
|
|
static uint16_t make_have1(uint8_t* buf, uint16_t cap, const uint8_t mid[4],
|
|
uint32_t target, uint32_t fwver, uint8_t codec, uint8_t flags) {
|
|
uint8_t row[OTA_HAVE_ROW_BYTES];
|
|
memcpy(row, mid, 4);
|
|
row[4]=target; row[5]=target>>8; row[6]=target>>16; row[7]=target>>24;
|
|
row[8]=fwver; row[9]=fwver>>8; row[10]=fwver>>16; row[11]=fwver>>24;
|
|
row[12]=codec; row[13]=flags;
|
|
row[14]=0; row[15]=0; // have_count (unused in this 1-row discovery test)
|
|
HaveMsg hv{{0xAA,0xBB,0xCC,0xDD}, {0,0,0,0}, 0, 1, 1, row};
|
|
return encode_have(buf, cap, hv);
|
|
}
|
|
|
|
TEST(OtaTransfer, BootloaderPackageRequiresCapableExplicitMidPull) {
|
|
const auto manifest = boot_manifest_bytes();
|
|
const uint8_t mid[4] = {0x11, 0x22, 0x33, 0x44};
|
|
const uint8_t boot_flags = MFLAG_FULL | MFLAG_SIGNED | MFLAG_BOOTLOADER;
|
|
uint8_t wire[64];
|
|
|
|
// Even a capable XIAO must never autofetch a bootloader package. The target is intentionally
|
|
// its exact installed-bootloader board ID, so this proves the manual-MID gate rather than a
|
|
// coincidental target mismatch.
|
|
OtaManager capable;
|
|
OtaStoreRam<45000> capable_store;
|
|
capable.begin(OTA_XIAO_BOARD_ID_BASE, nullptr, nullptr);
|
|
capable.set_fetch_store(&capable_store);
|
|
capable.set_accept_full(false); // internal nRF52 app FULL remains forbidden
|
|
capable.set_accept_bootloader(true);
|
|
capable.set_autofetch(OtaManager::AUTOFETCH_ANY);
|
|
capable.on_message(
|
|
wire, make_have1(wire, sizeof(wire), mid, OTA_XIAO_BOARD_ID_BASE, 1,
|
|
CODEC_FULL, boot_flags));
|
|
EXPECT_EQ(capable.fetchState(), OtaManager::IDLE);
|
|
EXPECT_EQ(capable_store.staged_size(), 0u);
|
|
|
|
// Enabling the privileged bootloader path must not broaden the ordinary
|
|
// single-slot application codec policy or turn autofetch on for FULL apps.
|
|
const uint8_t app_mid[4] = {0x21, 0x22, 0x23, 0x24};
|
|
capable.on_message(
|
|
wire, make_have1(wire, sizeof(wire), app_mid, OTA_XIAO_BOARD_ID_BASE, 2,
|
|
CODEC_FULL, MFLAG_FULL));
|
|
EXPECT_EQ(capable.fetchState(), OtaManager::IDLE);
|
|
EXPECT_EQ(capable_store.staged_size(), 0u);
|
|
|
|
// An exact explicit pull admits the strict v3 manifest and opens the store for the 40 KiB payload.
|
|
EXPECT_EQ(capable.pull(mid, OTA_XIAO_BOARD_ID_BASE), OtaManager::PULL_STARTED);
|
|
deliver_manifest_fragment(capable, mid, 0, manifest.data(), OTA_MF_FRAG);
|
|
deliver_manifest_fragment(capable, mid, 1, manifest.data() + OTA_MF_FRAG,
|
|
(uint16_t)(MOTA_MFL - OTA_MF_FRAG));
|
|
EXPECT_EQ(capable.fetchState(), OtaManager::FETCHING);
|
|
EXPECT_TRUE(capable.fetched_is_bootloader());
|
|
EXPECT_GT(capable_store.staged_size(), OTA_BOOT_IMAGE_SIZE);
|
|
|
|
// The same explicit pull is rejected before storage begin when this firmware lacks the privileged
|
|
// capability. This is the manager-layer guard; every apply backend has an independent rejection too.
|
|
OtaManager incapable;
|
|
OtaStoreRam<45000> incapable_store;
|
|
incapable.begin(OTA_XIAO_BOARD_ID_BASE, nullptr, nullptr);
|
|
incapable.set_fetch_store(&incapable_store);
|
|
incapable.set_accept_full(true);
|
|
EXPECT_EQ(incapable.pull(mid, OTA_XIAO_BOARD_ID_BASE), OtaManager::PULL_STARTED);
|
|
deliver_manifest_fragment(incapable, mid, 0, manifest.data(), OTA_MF_FRAG);
|
|
deliver_manifest_fragment(incapable, mid, 1, manifest.data() + OTA_MF_FRAG,
|
|
(uint16_t)(MOTA_MFL - OTA_MF_FRAG));
|
|
EXPECT_EQ(incapable.fetchState(), OtaManager::FAILED);
|
|
EXPECT_EQ(incapable.fetchError(), OtaManager::FETCH_ERROR_CODEC);
|
|
EXPECT_EQ(incapable_store.staged_size(), 0u);
|
|
}
|
|
|
|
// A node must not fetch firmware it can't apply: a catalog row whose codec the platform can't decode is
|
|
// not fetched. Full-image acceptance is platform-selectable (nRF52 single-slot disables it).
|
|
TEST(OtaTransfer, RejectsIncompatibleCodec) {
|
|
g_q.clear();
|
|
OtaManager client; OtaStoreRam<4096> store;
|
|
SendTo to_server{&client}; // dest unused (we only check client state)
|
|
client.begin(SIM_TARGET_ID, sim_send, &to_server);
|
|
client.set_fetch_store(&store);
|
|
client.set_autofetch(OtaManager::AUTOFETCH_ANY);
|
|
client.set_apply_codec(CODEC_DETOOLS_INPLACE);
|
|
client.set_accept_full(false); // nRF52-style: in-place delta only
|
|
uint8_t b[64];
|
|
|
|
// a SEQUENTIAL delta for our target -> incompatible -> not fetched (stays IDLE)
|
|
uint8_t midA[4] = {1,2,3,4};
|
|
client.on_message(b, make_have1(b, sizeof(b), midA, SIM_TARGET_ID, 0x01000000, CODEC_DETOOLS_SEQUENTIAL, 0));
|
|
EXPECT_EQ(client.fetchState(), OtaManager::IDLE);
|
|
|
|
// a FULL image cannot be installed in an nRF52 single slot -> do not spend hours fetching it
|
|
uint8_t midFull[4] = {2,3,4,5};
|
|
client.on_message(b, make_have1(b, sizeof(b), midFull, SIM_TARGET_ID, 0x01000000, CODEC_FULL, MFLAG_FULL));
|
|
EXPECT_EQ(client.fetchState(), OtaManager::IDLE);
|
|
|
|
// an IN-PLACE delta for our target -> compatible -> begins fetching (requests the manifest)
|
|
uint8_t midB[4] = {5,6,7,8};
|
|
client.on_message(b, make_have1(b, sizeof(b), midB, SIM_TARGET_ID, 0x01000000, CODEC_DETOOLS_INPLACE, 0));
|
|
EXPECT_EQ(client.fetchState(), OtaManager::WANT_MANIFEST);
|
|
g_q.clear();
|
|
}
|
|
|
|
TEST(OtaTransfer, ManualPullReportsIncompatibleManifestInsteadOfGoingIdle) {
|
|
g_q.clear();
|
|
OtaManager server, client;
|
|
OtaStoreRam<4096> store;
|
|
SendTo to_client{&client}, to_server{&server};
|
|
server.begin(0, sim_send, &to_client);
|
|
client.begin(SIM_TARGET_ID, sim_send, &to_server);
|
|
client.set_fetch_store(&store);
|
|
client.set_apply_codec(CODEC_DETOOLS_INPLACE);
|
|
client.set_accept_full(false);
|
|
ASSERT_TRUE(server.serve(SIM_MOTA, SIM_MOTA_LEN));
|
|
MotaManifest manifest;
|
|
ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, manifest));
|
|
|
|
EXPECT_EQ(client.pull(manifest.merkle_root, manifest.target_id), OtaManager::PULL_STARTED);
|
|
pump(client, &server);
|
|
|
|
EXPECT_EQ(client.fetchState(), OtaManager::FAILED);
|
|
EXPECT_EQ(client.fetchError(), OtaManager::FETCH_ERROR_CODEC);
|
|
EXPECT_EQ(client.wanted(), 0u); // terminal rejection no longer leaves discovery armed
|
|
EXPECT_EQ(store.staged_size(), 0u); // compatibility is rejected before the store is begun
|
|
}
|
|
|
|
TEST(OtaTransfer, AutomaticAdmissionRejectsReplayAndRechecksLyingHaveVersion) {
|
|
MotaManifest manifest;
|
|
ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, manifest));
|
|
OtaManager client;
|
|
OtaStoreRam<4096> store;
|
|
CapturedMessages sent;
|
|
client.begin(SIM_TARGET_ID, capture_send, &sent);
|
|
client.set_fetch_store(&store);
|
|
client.set_autofetch(OtaManager::AUTOFETCH_ANY);
|
|
client.set_auto_version_floor(manifest.fw_version, true);
|
|
|
|
uint8_t wire[64];
|
|
client.on_message(wire, make_have1(wire, sizeof(wire), manifest.merkle_root,
|
|
manifest.target_id, manifest.fw_version,
|
|
manifest.codec_id, manifest.flags));
|
|
EXPECT_EQ(client.fetchState(), OtaManager::IDLE); // equal-version replay rejected at catalog admission
|
|
|
|
client.on_message(wire, make_have1(wire, sizeof(wire), manifest.merkle_root,
|
|
manifest.target_id, manifest.fw_version + 1u,
|
|
manifest.codec_id, manifest.flags));
|
|
ASSERT_EQ(client.fetchState(), OtaManager::WANT_MANIFEST);
|
|
deliver_manifest_fragment(client, manifest.merkle_root, 0,
|
|
manifest.manifest_start, OTA_MF_FRAG);
|
|
deliver_manifest_fragment(client, manifest.merkle_root, 1,
|
|
manifest.manifest_start + OTA_MF_FRAG,
|
|
(uint16_t)(MOTA_MFL - OTA_MF_FRAG));
|
|
EXPECT_EQ(client.fetchState(), OtaManager::FAILED); // parsed version defeats the lying HAVE row
|
|
EXPECT_EQ(client.fetchError(), OtaManager::FETCH_ERROR_VERSION);
|
|
EXPECT_EQ(store.staged_size(), 0u);
|
|
}
|
|
|
|
TEST(OtaTransfer, SignedOnlyAutofetchRechecksTheParsedManifestFlag) {
|
|
MotaManifest manifest;
|
|
ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, manifest));
|
|
ASSERT_TRUE(manifest.is_signed());
|
|
std::array<uint8_t, MOTA_MFL> unsigned_bytes;
|
|
memcpy(unsigned_bytes.data(), manifest.manifest_start, unsigned_bytes.size());
|
|
unsigned_bytes[1] &= (uint8_t)~MFLAG_SIGNED;
|
|
OtaManager client;
|
|
OtaStoreRam<4096> store;
|
|
CapturedMessages sent;
|
|
client.begin(SIM_TARGET_ID, capture_send, &sent);
|
|
client.set_fetch_store(&store);
|
|
client.set_autofetch(OtaManager::AUTOFETCH_SIGNED);
|
|
client.set_auto_version_floor(manifest.fw_version - 1u, true);
|
|
|
|
uint8_t wire[64];
|
|
client.on_message(wire, make_have1(wire, sizeof(wire), manifest.merkle_root,
|
|
manifest.target_id, manifest.fw_version,
|
|
manifest.codec_id,
|
|
(uint8_t)(manifest.flags | MFLAG_SIGNED)));
|
|
ASSERT_EQ(client.fetchState(), OtaManager::WANT_MANIFEST);
|
|
deliver_manifest_fragment(client, manifest.merkle_root, 0,
|
|
unsigned_bytes.data(), OTA_MF_FRAG);
|
|
deliver_manifest_fragment(client, manifest.merkle_root, 1,
|
|
unsigned_bytes.data() + OTA_MF_FRAG,
|
|
(uint16_t)(MOTA_MFL - OTA_MF_FRAG));
|
|
EXPECT_EQ(client.fetchState(), OtaManager::FAILED);
|
|
EXPECT_EQ(client.fetchError(), OtaManager::FETCH_ERROR_MANIFEST);
|
|
EXPECT_EQ(store.staged_size(), 0u);
|
|
}
|
|
|
|
TEST(OtaTransfer, ExplicitManualPullMayOverrideAutomaticVersionFloor) {
|
|
MotaManifest manifest;
|
|
ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, manifest));
|
|
OtaManager client;
|
|
OtaStoreRam<4096> store;
|
|
CapturedMessages sent;
|
|
client.begin(SIM_TARGET_ID, capture_send, &sent);
|
|
client.set_fetch_store(&store);
|
|
client.set_auto_version_floor(manifest.fw_version, true);
|
|
|
|
ASSERT_EQ(client.pull(manifest.merkle_root, manifest.target_id), OtaManager::PULL_STARTED);
|
|
deliver_manifest_fragment(client, manifest.merkle_root, 0,
|
|
manifest.manifest_start, OTA_MF_FRAG);
|
|
deliver_manifest_fragment(client, manifest.merkle_root, 1,
|
|
manifest.manifest_start + OTA_MF_FRAG,
|
|
(uint16_t)(MOTA_MFL - OTA_MF_FRAG));
|
|
EXPECT_EQ(client.fetchState(), OtaManager::FETCHING);
|
|
EXPECT_GT(store.staged_size(), 0u);
|
|
}
|
|
|
|
TEST(OtaTransfer, PullAdmissionReportsNoStoreAndBusyWithoutReplacingIntent) {
|
|
g_q.clear();
|
|
OtaManager client;
|
|
SendTo to_none{&client};
|
|
client.begin(SIM_TARGET_ID, sim_send, &to_none);
|
|
uint8_t first[4] = {1, 2, 3, 4}, second[4] = {5, 6, 7, 8};
|
|
EXPECT_EQ(client.pull(first, SIM_TARGET_ID), OtaManager::PULL_NO_STORE);
|
|
EXPECT_EQ(client.fetchState(), OtaManager::IDLE);
|
|
EXPECT_EQ(client.wanted(), 0u);
|
|
|
|
OtaStoreRam<4096> store;
|
|
client.set_fetch_store(&store);
|
|
EXPECT_EQ(client.pull(first, SIM_TARGET_ID), OtaManager::PULL_STARTED);
|
|
EXPECT_EQ(client.pull(second, SIM_TARGET_ID ^ 0x55AAu), OtaManager::PULL_BUSY);
|
|
EXPECT_EQ(client.fetchState(), OtaManager::WANT_MANIFEST);
|
|
EXPECT_EQ(client.wanted(), SIM_TARGET_ID);
|
|
EXPECT_EQ(0, memcmp(client.fetchManifestId(), first, sizeof(first)));
|
|
g_q.clear();
|
|
}
|
|
|
|
// An archive capture is not an install. It must retain cross-target and otherwise unsupported containers
|
|
// byte-for-byte so this node can relay them to hardware that does understand their codec.
|
|
TEST(OtaTransfer, ArchivePullAcceptsCrossTargetUnsupportedCodec) {
|
|
g_q.clear();
|
|
OtaManager server, client;
|
|
OtaStoreRam<4096> store;
|
|
SendTo to_client{&client}, to_server{&server};
|
|
server.begin(0, sim_send, &to_client);
|
|
client.begin(SIM_TARGET_ID ^ 0x55AAu, sim_send, &to_server);
|
|
client.set_fetch_store(&store);
|
|
client.set_apply_codec(CODEC_DETOOLS_INPLACE);
|
|
client.set_accept_full(false); // install path cannot accept SIM_MOTA (full image)
|
|
ASSERT_TRUE(server.serve(SIM_MOTA, SIM_MOTA_LEN));
|
|
|
|
MotaManifest m;
|
|
ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, m));
|
|
client.pull_archive(m.merkle_root, m.target_id); // capture ignores local target + install codec
|
|
pump(client, &server);
|
|
|
|
EXPECT_EQ(client.fetchState(), OtaManager::COMPLETE);
|
|
ASSERT_EQ(store.staged_size(), SIM_MOTA_LEN);
|
|
EXPECT_EQ(0, std::memcmp(store.data(), SIM_MOTA, SIM_MOTA_LEN));
|
|
}
|
|
|
|
// Encode a 1-row OTA_HAVE from a specific seeder, carrying have_count (Phase-2 awareness).
|
|
static uint16_t make_have_row(uint8_t* buf, uint16_t cap, const uint8_t mid[4], uint32_t target,
|
|
uint32_t fwver, uint8_t codec, uint8_t flags,
|
|
const uint8_t seeder[4], uint16_t have_count,
|
|
const uint8_t digest[4] = nullptr) {
|
|
uint8_t row[OTA_HAVE_ROW_BYTES];
|
|
memcpy(row, mid, 4);
|
|
row[4]=target; row[5]=target>>8; row[6]=target>>16; row[7]=target>>24;
|
|
row[8]=fwver; row[9]=fwver>>8; row[10]=fwver>>16; row[11]=fwver>>24;
|
|
row[12]=codec; row[13]=flags;
|
|
row[14]=(uint8_t)(have_count & 0xFF); row[15]=(uint8_t)(have_count >> 8);
|
|
HaveMsg hv; memcpy(hv.seeder_id, seeder, 4);
|
|
if (digest) memcpy(hv.set_digest, digest, 4); else memset(hv.set_digest, 0, 4);
|
|
hv.frag_idx=0; hv.frag_total=1; hv.n_rows=1; hv.rows=row;
|
|
return encode_have(buf, cap, hv);
|
|
}
|
|
|
|
// Catalog accounting: "N nodes have it" must count DISTINCT seeders (a repeated HAVE from one node must
|
|
// not inflate it), and have_max tracks the best progress any source reported.
|
|
TEST(OtaCatalog, DistinctSeederCountAndHaveCount) {
|
|
OtaManager m; SendTo none{&m}; m.begin(SIM_TARGET_ID, sim_send, &none);
|
|
uint8_t b[64]; uint8_t mid[4]={9,9,9,9};
|
|
uint8_t s1[4]={1,0,0,0}, s2[4]={2,0,0,0};
|
|
m.on_message(b, make_have_row(b, sizeof b, mid, SIM_TARGET_ID, 0x01020300, CODEC_FULL, 0, s1, 5));
|
|
m.on_message(b, make_have_row(b, sizeof b, mid, SIM_TARGET_ID, 0x01020300, CODEC_FULL, 0, s1, 7)); // same seeder
|
|
ASSERT_EQ(m.catalogCount(), 1);
|
|
EXPECT_EQ(m.catalogRow(0)->n_seeders, 1); // counted once despite two HAVEs
|
|
EXPECT_EQ(m.catalogRow(0)->have_max, 7u); // max progress seen
|
|
m.on_message(b, make_have_row(b, sizeof b, mid, SIM_TARGET_ID, 0x01020300, CODEC_FULL, 0, s2, 3)); // new seeder
|
|
EXPECT_EQ(m.catalogRow(0)->n_seeders, 2);
|
|
EXPECT_EQ(m.catalogRow(0)->have_max, 7u); // still the max, not overwritten by the lower one
|
|
g_q.clear();
|
|
}
|
|
|
|
TEST(OtaCatalog, DigestChangePurgesOnlyThatSeedersRowsAndProgress) {
|
|
OtaManager m; SendTo none{&m}; m.begin(SIM_TARGET_ID, sim_send, &none);
|
|
m.set_archive_interest(true);
|
|
uint8_t wire[64], mid[4] = {9, 8, 7, 6};
|
|
uint8_t s1[4] = {1, 0, 0, 0}, s2[4] = {2, 0, 0, 0};
|
|
uint8_t d1[4] = {0x11, 0, 0, 0}, d2[4] = {0x22, 0, 0, 0}, changed[4] = {0x33, 0, 0, 0};
|
|
|
|
auto advertise = [&](const uint8_t sid[4], const uint8_t digest[4], uint8_t count = 1) {
|
|
AdvMsg adv{}; memcpy(adv.seeder_id, sid, 4); memcpy(adv.set_digest, digest, 4); adv.n_motas = count;
|
|
uint16_t n = encode_adv(wire, sizeof(wire), adv);
|
|
ASSERT_GT(n, 0);
|
|
m.on_message(wire, n);
|
|
};
|
|
advertise(s1, d1);
|
|
advertise(s2, d2);
|
|
m.set_clock(100);
|
|
m.on_message(wire, make_have_row(wire, sizeof(wire), mid, SIM_TARGET_ID, 0x01020300,
|
|
CODEC_FULL, MFLAG_FULL, s1, 7, d1));
|
|
m.set_clock(200);
|
|
m.on_message(wire, make_have_row(wire, sizeof(wire), mid, SIM_TARGET_ID, 0x01020300,
|
|
CODEC_FULL, MFLAG_FULL, s2, 3, d2));
|
|
ASSERT_EQ(m.catalogCount(), 1);
|
|
ASSERT_EQ(m.catalogRow(0)->n_seeders, 2);
|
|
ASSERT_EQ(m.catalogRow(0)->have_max, 7u);
|
|
|
|
advertise(s1, changed);
|
|
ASSERT_EQ(m.catalogCount(), 1);
|
|
EXPECT_EQ(m.catalogRow(0)->n_seeders, 1);
|
|
EXPECT_EQ(m.catalogRow(0)->have_max, 3u);
|
|
EXPECT_EQ(m.catalogRow(0)->last_ms, 200u);
|
|
|
|
m.set_clock(300);
|
|
m.on_message(wire, make_have_row(wire, sizeof(wire), mid, SIM_TARGET_ID, 0x01020300,
|
|
CODEC_FULL, MFLAG_FULL, s1, 9, d1)); // delayed row from the old digest
|
|
ASSERT_EQ(m.catalogCount(), 1);
|
|
EXPECT_EQ(m.catalogRow(0)->n_seeders, 1);
|
|
EXPECT_EQ(m.catalogRow(0)->have_max, 3u);
|
|
|
|
advertise(s2, changed, 0); // an explicit empty advert withdraws the source
|
|
EXPECT_EQ(m.catalogCount(), 0); // nobody still advertises the old set
|
|
g_q.clear();
|
|
}
|
|
|
|
// An unanswered GET_MANIFEST must not pin the fetch slot forever: after OTA_MANIFEST_MAX_RETRY ticks with
|
|
// no manifest, the session gives up (FAILED) so a new pull can take the slot. (Bounded primary operation.)
|
|
TEST(OtaTransfer, ManifestGiveUpAfterRetries) {
|
|
g_q.clear();
|
|
OtaManager client; OtaStoreRam<4096> store; SendTo to_none{&client};
|
|
client.begin(SIM_TARGET_ID, sim_send, &to_none);
|
|
client.set_fetch_store(&store);
|
|
uint8_t mid[4]={7,7,7,7};
|
|
client.pull(mid, SIM_TARGET_ID); // no server -> stuck WANT_MANIFEST
|
|
EXPECT_EQ(client.fetchState(), OtaManager::WANT_MANIFEST);
|
|
for (int i = 0; i < OTA_MANIFEST_MAX_RETRY + 2; i++) { g_clk += 5000; client.set_clock(g_clk); client.loop(); g_q.clear(); }
|
|
EXPECT_EQ(client.fetchState(), OtaManager::FAILED);
|
|
EXPECT_EQ(client.fetchError(), OtaManager::FETCH_ERROR_MANIFEST_TIMEOUT);
|
|
EXPECT_EQ(client.wanted(), 0u);
|
|
}
|
|
|
|
// A receiver never becomes a source, either while fetching or after completion.
|
|
TEST(OtaTransfer, ReceiverDoesNotReSeed) {
|
|
g_q.clear();
|
|
OtaManager server, client; OtaStoreRam<4096> store;
|
|
SendTo to_client{&client}, to_server{&server};
|
|
server.begin(0, sim_send, &to_client);
|
|
client.begin(SIM_TARGET_ID, sim_send, &to_server);
|
|
client.set_fetch_store(&store);
|
|
client.set_autofetch(OtaManager::AUTOFETCH_ANY);
|
|
ASSERT_TRUE(server.serve(SIM_MOTA, SIM_MOTA_LEN));
|
|
server.announce();
|
|
pump(client, &server);
|
|
ASSERT_EQ(client.fetchState(), OtaManager::COMPLETE);
|
|
EXPECT_EQ(client.servedCount(), 0);
|
|
client.reset_session();
|
|
EXPECT_EQ(client.servedCount(), 0);
|
|
}
|
|
|
|
// --- detools delta decode (vendored detools C decoder, CRLE-only build) ----------------------
|
|
// Mirrors the device apply path (src/helpers/ota/OtaApply.cpp): base read via from_read/from_seek,
|
|
// patch streamed via patch_read, output written via to_write. Proves the on-device delta apply uses
|
|
// detools 0.53.0's own decoder and reproduces the exact target the host packager targeted.
|
|
namespace {
|
|
struct DTMem {
|
|
const uint8_t* base; long base_len; long base_pos;
|
|
const uint8_t* patch; long patch_len; long patch_pos;
|
|
std::vector<uint8_t> out;
|
|
};
|
|
int dt_from_read(void* a, uint8_t* b, size_t n) {
|
|
DTMem* c = (DTMem*)a;
|
|
if (c->base_pos < 0 || c->base_pos + (long)n > c->base_len) return -DETOOLS_IO_FAILED;
|
|
std::memcpy(b, c->base + c->base_pos, n); c->base_pos += (long)n; return DETOOLS_OK;
|
|
}
|
|
int dt_from_seek(void* a, int off) {
|
|
DTMem* c = (DTMem*)a; c->base_pos += off;
|
|
if (c->base_pos < 0 || c->base_pos > c->base_len) return -DETOOLS_IO_FAILED;
|
|
return DETOOLS_OK;
|
|
}
|
|
int dt_patch_read(void* a, uint8_t* b, size_t n) {
|
|
DTMem* c = (DTMem*)a;
|
|
if (c->patch_pos + (long)n > c->patch_len) return -DETOOLS_IO_FAILED;
|
|
std::memcpy(b, c->patch + c->patch_pos, n); c->patch_pos += (long)n; return DETOOLS_OK;
|
|
}
|
|
int dt_to_write(void* a, const uint8_t* b, size_t n) {
|
|
DTMem* c = (DTMem*)a; c->out.insert(c->out.end(), b, b + n); return DETOOLS_OK;
|
|
}
|
|
|
|
// In-place apply over a flat memory region (models the nRF52 app workspace / the bootloader's flash).
|
|
struct DTInPlace {
|
|
std::vector<uint8_t> mem; // [0,memory_size): base in, target out
|
|
const uint8_t* patch; long plen, ppos; int step;
|
|
};
|
|
int ip_mem_read(void* a, void* dst, uintptr_t src, size_t n) {
|
|
DTInPlace* c = (DTInPlace*)a; if (src + n > c->mem.size()) return -DETOOLS_IO_FAILED;
|
|
std::memcpy(dst, c->mem.data() + src, n); return DETOOLS_OK;
|
|
}
|
|
int ip_mem_write(void* a, uintptr_t dst, void* src, size_t n) {
|
|
DTInPlace* c = (DTInPlace*)a; if (dst + n > c->mem.size()) return -DETOOLS_IO_FAILED;
|
|
std::memcpy(c->mem.data() + dst, src, n); return DETOOLS_OK;
|
|
}
|
|
int ip_mem_erase(void* a, uintptr_t addr, size_t n) {
|
|
DTInPlace* c = (DTInPlace*)a; if (addr + n > c->mem.size()) return -DETOOLS_IO_FAILED;
|
|
std::memset(c->mem.data() + addr, 0xFF, n); return DETOOLS_OK;
|
|
}
|
|
int ip_step_set(void* a, int s) { ((DTInPlace*)a)->step = s; return DETOOLS_OK; }
|
|
int ip_step_get(void* a, int* s) { *s = ((DTInPlace*)a)->step; return DETOOLS_OK; }
|
|
int ip_patch_read(void* a, uint8_t* b, size_t n) {
|
|
DTInPlace* c = (DTInPlace*)a; if (c->ppos + (long)n > c->plen) return -DETOOLS_IO_FAILED;
|
|
std::memcpy(b, c->patch + c->ppos, n); c->ppos += (long)n; return DETOOLS_OK;
|
|
}
|
|
} // namespace
|
|
|
|
TEST(Detools, SequentialCrlePatchReproducesTarget) {
|
|
DTMem c{DT_BASE, (long)DT_BASE_LEN, 0, DT_PATCH, (long)DT_PATCH_LEN, 0, {}};
|
|
int r = detools_apply_patch_callbacks(dt_from_read, dt_from_seek, dt_patch_read,
|
|
(size_t)DT_PATCH_LEN, dt_to_write, &c);
|
|
ASSERT_EQ(r, (int)DT_TARGET_LEN); // returns to-size on success
|
|
ASSERT_EQ(c.out.size(), (size_t)DT_TARGET_LEN);
|
|
EXPECT_EQ(0, std::memcmp(c.out.data(), DT_TARGET, DT_TARGET_LEN));
|
|
}
|
|
|
|
TEST(Detools, WrongBaseDoesNotReproduceTarget) {
|
|
// a base that differs from the one the patch was built against must NOT yield the target
|
|
std::vector<uint8_t> bad(DT_BASE, DT_BASE + DT_BASE_LEN);
|
|
for (size_t i = 0; i < bad.size(); i += 7) bad[i] ^= 0xFF;
|
|
DTMem c{bad.data(), (long)bad.size(), 0, DT_PATCH, (long)DT_PATCH_LEN, 0, {}};
|
|
int r = detools_apply_patch_callbacks(dt_from_read, dt_from_seek, dt_patch_read,
|
|
(size_t)DT_PATCH_LEN, dt_to_write, &c);
|
|
bool reproduced = (r == (int)DT_TARGET_LEN && c.out.size() == (size_t)DT_TARGET_LEN &&
|
|
std::memcmp(c.out.data(), DT_TARGET, DT_TARGET_LEN) == 0);
|
|
EXPECT_FALSE(reproduced); // wrong base -> wrong/short output (the device then fails image_hash)
|
|
}
|
|
|
|
TEST(Detools, TruncatedPatchFails) {
|
|
DTMem c{DT_BASE, (long)DT_BASE_LEN, 0, DT_PATCH, (long)(DT_PATCH_LEN / 2), 0, {}};
|
|
int r = detools_apply_patch_callbacks(dt_from_read, dt_from_seek, dt_patch_read,
|
|
(size_t)(DT_PATCH_LEN / 2), dt_to_write, &c);
|
|
EXPECT_TRUE(r < 0 || c.out.size() != (size_t)DT_TARGET_LEN);
|
|
}
|
|
|
|
// nRF52 path: the bootloader applies an in-place patch over the single app slot. Model the app
|
|
// region as a DT_IP_MEM buffer holding the base; after apply, region[0:to_size] must equal the target.
|
|
TEST(Detools, InPlaceCrlePatchReproducesTarget) {
|
|
DTInPlace c; c.mem.assign(DT_IP_MEM, 0xFF);
|
|
std::memcpy(c.mem.data(), DT_IP_BASE, DT_IP_BASE_LEN); // base loaded at offset 0
|
|
c.patch = DT_IP_PATCH; c.plen = DT_IP_PATCH_LEN; c.ppos = 0; c.step = 0;
|
|
int r = detools_apply_patch_in_place_callbacks(ip_mem_read, ip_mem_write, ip_mem_erase,
|
|
ip_step_set, ip_step_get, ip_patch_read,
|
|
(size_t)DT_IP_PATCH_LEN, &c);
|
|
ASSERT_EQ(r, (int)DT_IP_TARGET_LEN); // returns to-size on success
|
|
EXPECT_EQ(0, std::memcmp(c.mem.data(), DT_IP_TARGET, DT_IP_TARGET_LEN));
|
|
}
|
|
|
|
// --- leaf-diff warm-start core (motatool folder-capture): the device fetches the target leaves[], recomputes
|
|
// the root to authenticate them, then keeps every seed block whose leaf matches and refetches only the rest.
|
|
// This exercises that logic (leaf authentication + per-block diff) with no store/fetch machinery. ----------
|
|
TEST(OtaWarmStart, LeafDiffAuthenticatesAndFindsDifferingBlocks) {
|
|
const uint32_t BS = 16, BC = 5;
|
|
std::vector<uint8_t> target(BS * BC), seed(BS * BC);
|
|
for (uint32_t i = 0; i < target.size(); i++) target[i] = seed[i] = (uint8_t)(i * 7 + 3);
|
|
seed[1 * BS + 5] ^= 0xFF; // blocks 1 and 3 differ in the seed (a non-deterministic-rebuild style diff)
|
|
seed[3 * BS + 0] ^= 0x01;
|
|
|
|
// target leaves + root (what the device receives over OTA_LEAVES + the manifest merkle_root)
|
|
uint8_t tleaves[BC * 4], troot[4];
|
|
for (uint32_t i = 0; i < BC; i++) merkle_leaf(tleaves + i * 4, target.data() + i * BS, BS);
|
|
merkle_root(troot, tleaves, BC);
|
|
|
|
// authenticate the fetched leaves: recomputing the root from them must equal the manifest root
|
|
uint8_t chk[4]; merkle_root(chk, tleaves, BC);
|
|
EXPECT_EQ(0, memcmp(chk, troot, 4));
|
|
|
|
// diff: a seed block is kept iff its leaf equals the (authenticated) target leaf
|
|
int nmiss = 0; bool miss[BC] = {false};
|
|
for (uint32_t i = 0; i < BC; i++) {
|
|
uint8_t sl[4]; merkle_leaf(sl, seed.data() + i * BS, BS);
|
|
if (memcmp(sl, tleaves + i * 4, 4) != 0) { miss[i] = true; nmiss++; }
|
|
}
|
|
EXPECT_EQ(nmiss, 2);
|
|
EXPECT_TRUE(miss[1]); EXPECT_TRUE(miss[3]);
|
|
EXPECT_FALSE(miss[0]); EXPECT_FALSE(miss[2]); EXPECT_FALSE(miss[4]);
|
|
}
|
|
|
|
int main(int argc, char** argv) {
|
|
::testing::InitGoogleTest(&argc, argv);
|
|
return RUN_ALL_TESTS();
|
|
}
|