Files
HaloKeymind/test/test_ota/test_ota_core.cpp
T
mikecarper 5b392b8aa6 Fix OTA resume and radio state recovery
Preserve resumed nRF52 flash pages and keep filtered OTA catalogs incomplete until full pages arrive. Bound stuck radio preparation, report companion setting failures, retain safe rollback, and codify conservative advert penalties with regression tests.
2026-09-15 12:17:54 -07:00

5268 lines
226 KiB
C++

#include <gtest/gtest.h>
#include <algorithm>
#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/OtaDeflate.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(0xBD), 0xBD); // retained-source authorization/handoff failure
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(0xBE), 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(OtaCapability, LiveMaximumTracksTheApplicationReassemblyLimit) {
EXPECT_EQ(ota_max_block_capability(), 2048u);
EXPECT_EQ(ota_max_block_capability(), OTA_MAX_BLOCK);
EXPECT_LE(ota_max_block_capability(), OTA_DATA_V2_MAX_ENCODED);
}
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 gat562_name[16] = {'G','A','T','5','6','2','_','D','F','U',0,0,0,0,0,0};
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 hw_gat562[32], hw3401[32], hw4631[32];
ASSERT_TRUE(ota_bootloader_hw_id(shared_board_id, gat562_name, hw_gat562));
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(hw_gat562, "NRF_BL_239A0029_GAT562_DFU", 26));
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(hw_gat562, hw4631, sizeof(hw_gat562)));
EXPECT_NE(0, memcmp(hw3401, hw4631, sizeof(hw3401)));
EXPECT_EQ(ota_bootloader_target_id(shared_board_id, gat562_name), 0xD50D2D44u);
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[] = {
{0x239A0029u, "GAT562_DFU", 0xD50D2D44u},
{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_size(), 1024u);
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(); m[19] = 11;
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);
}
TEST(OtaHybridCapability, RequiresOneExactAlignedMOTARAMARecord) {
uint8_t marker[16] = {0};
memcpy(marker, MOTA_RAM_CAP_MAGIC, sizeof(MOTA_RAM_CAP_MAGIC));
mota_hybrid_wr16(marker + 8u, MOTA_RAM_CAP_ABI);
mota_hybrid_wr16(marker + 10u, MOTA_HYBRID_AUTH_LEN);
mota_hybrid_wr32(marker + 12u, MOTA_NRF52_HYBRID_RAM_SIZE);
OtaRamCaps parsed;
ASSERT_TRUE(ota_ram_caps_marker_parse(marker, parsed));
EXPECT_TRUE(ota_bootloader_supports_hybrid(parsed));
EXPECT_EQ(parsed.abi, MOTA_RAM_CAP_ABI);
EXPECT_EQ(parsed.record_len, MOTA_HYBRID_AUTH_LEN);
EXPECT_EQ(parsed.arena_size, MOTA_NRF52_HYBRID_RAM_SIZE);
uint8_t image[64];
memset(image, 0xFF, sizeof(image));
memcpy(image + 1u, marker, sizeof(marker));
EXPECT_FALSE(ota_ram_caps_scan_aligned(image, sizeof(image)).present);
memset(image, 0xFF, sizeof(image));
memcpy(image + 4u, marker, sizeof(marker));
EXPECT_TRUE(ota_bootloader_supports_hybrid(
ota_ram_caps_scan_aligned(image, sizeof(image))));
// Two valid capabilities are ambiguous; a malformed decoy is ignored.
memcpy(image + 24u, marker, sizeof(marker));
EXPECT_FALSE(ota_ram_caps_scan_aligned(image, sizeof(image)).present);
image[24u + 12u] ^= 1u;
EXPECT_TRUE(ota_ram_caps_scan_aligned(image, sizeof(image)).present);
uint8_t bad[sizeof(marker)];
memcpy(bad, marker, sizeof(bad));
bad[8] = 2u;
EXPECT_FALSE(ota_ram_caps_marker_parse(bad, parsed));
memcpy(bad, marker, sizeof(bad));
bad[10] = (uint8_t)(MOTA_HYBRID_AUTH_LEN - 1u);
EXPECT_FALSE(ota_ram_caps_marker_parse(bad, parsed));
memcpy(bad, marker, sizeof(bad));
bad[12] ^= 1u;
EXPECT_FALSE(ota_ram_caps_marker_parse(bad, parsed));
}
TEST(OtaHybridHandoff, EncodesExactGeometryAndRejectsCorruption) {
const uint32_t total = 17u * MOTA_NRF52_FLASH_PAGE;
uint32_t start = 0, flash = 0, ram = 0;
ASSERT_TRUE(mota_nrf52_hybrid_stage_plan(
total, MOTA_NRF52_APP_BASE_S140_V6,
MOTA_NRF52_APP_BASE_S140_V6 + 512u * 1024u,
MOTA_NRF52_STAGE_CEILING_EXPANDED, start, flash, ram));
ASSERT_EQ(flash, MOTA_NRF52_FLASH_PAGE);
ASSERT_EQ(ram, MOTA_NRF52_HYBRID_RAM_SIZE);
uint8_t hash[32];
for (uint8_t i = 0; i < sizeof(hash); ++i) hash[i] = (uint8_t)(i * 7u);
uint8_t record[MOTA_HYBRID_AUTH_LEN];
ASSERT_TRUE(mota_hybrid_auth_encode(
record, total, start, flash, ram, hash));
EXPECT_TRUE(mota_hybrid_auth_valid(record));
EXPECT_EQ(memcmp(record, MOTA_HYBRID_AUTH_MAGIC, 8u), 0);
EXPECT_EQ(mota_hybrid_rd16(record + 8u), MOTA_HYBRID_AUTH_VERSION);
EXPECT_EQ(mota_hybrid_rd16(record + 10u), MOTA_HYBRID_AUTH_LEN);
EXPECT_EQ(record[12], MOTA_HYBRID_AUTH_PURPOSE_APP);
EXPECT_EQ(record[13], MOTA_HYBRID_AUTH_FORMAT_APP);
EXPECT_EQ(mota_hybrid_rd32(record + 16u), total);
EXPECT_EQ(mota_hybrid_rd32(record + 20u), start);
EXPECT_EQ(mota_hybrid_rd32(record + 24u), flash);
EXPECT_EQ(mota_hybrid_rd32(record + 28u), ram);
EXPECT_EQ(memcmp(record + 32u, hash, sizeof(hash)), 0);
const uint32_t crc = mota_hybrid_rd32(record + 64u);
EXPECT_EQ(crc, mota_hybrid_crc32(record, 64u));
EXPECT_EQ(mota_hybrid_rd32(record + 68u), ~crc);
record[32] ^= 1u;
EXPECT_FALSE(mota_hybrid_auth_valid(record));
record[32] ^= 1u;
record[68] ^= 1u;
EXPECT_FALSE(mota_hybrid_auth_valid(record));
// The descriptor accepts only the frozen minimum flash charge and ED000
// ceiling. Legacy-D4000 or overcharged splits cannot be authorized.
EXPECT_FALSE(mota_hybrid_auth_encode(
record, total, start - MOTA_NRF52_FLASH_PAGE,
flash + MOTA_NRF52_FLASH_PAGE,
ram - MOTA_NRF52_FLASH_PAGE, hash));
EXPECT_FALSE(mota_hybrid_auth_encode(
record, total,
MOTA_NRF52_STAGE_CEILING_LEGACY - MOTA_NRF52_FLASH_PAGE,
MOTA_NRF52_FLASH_PAGE, ram, hash));
}
TEST(OtaBootPackage, LegacyAndCurrentBootloadersHaveSeparateCapabilityViews) {
const uint8_t internal_profile = OTA_BL_PROFILE_INTERNAL_BOOT_UPDATE;
const uint8_t legacy_marker[16] = {
'M','O','T','A','B','L','D','R', 2,0, 4,0, 0,0,0,0};
const uint8_t current_marker[16] = {
'M','O','T','A','B','L','D','R', 3,0, 5,0,
internal_profile, 0,0,0};
uint8_t image[64];
// The released OTAFIX Preview 5 marker is naturally halfword-aligned at
// address 2 mod 4. It can still install ABI-2, codec-2 application deltas,
// but cannot replace itself and must retain the legacy staging ceiling.
memset(image, 0xFF, sizeof(image));
memcpy(image + 2, legacy_marker, sizeof(legacy_marker));
OtaBlCaps app = ota_bl_app_caps_scan(image, sizeof(image));
OtaBlCaps update = ota_bl_update_caps_scan_aligned(
image, sizeof(image), internal_profile);
ASSERT_TRUE(app.present);
EXPECT_EQ(app.apply_abi, 2u);
EXPECT_EQ(app.codec_mask, 1u << 2);
EXPECT_FALSE(update.present);
EXPECT_FALSE(ota_bootloader_supports_expanded_stage(app));
// Do not turn the compatibility exception into a bytewise magic scan.
memset(image, 0xFF, sizeof(image));
memcpy(image + 1, legacy_marker, sizeof(legacy_marker));
EXPECT_FALSE(ota_bl_app_caps_scan(image, sizeof(image)).present);
// The current marker enables both views and advertises the expanded-stage
// handoff independently of the privileged self-update decision.
memset(image, 0xFF, sizeof(image));
memcpy(image + 4, current_marker, sizeof(current_marker));
app = ota_bl_app_caps_scan(image, sizeof(image));
update = ota_bl_update_caps_scan_aligned(image, sizeof(image), internal_profile);
ASSERT_TRUE(app.present);
ASSERT_TRUE(update.present);
EXPECT_EQ(update.storage_flags, internal_profile);
EXPECT_TRUE(ota_bootloader_supports_expanded_stage(app));
// Ambiguous privileged markers fail closed only for self-update. Ordinary
// application OTA remains available through the unprivileged view.
memcpy(image + 24, current_marker, sizeof(current_marker));
app = ota_bl_app_caps_scan(image, sizeof(image));
update = ota_bl_update_caps_scan_aligned(image, sizeof(image), internal_profile);
EXPECT_TRUE(app.present);
EXPECT_FALSE(update.present);
// A valid marker for another storage profile is visible diagnostically but
// cannot authorize an internal-flash bootloader replacement.
memset(image, 0xFF, sizeof(image));
memcpy(image + 4, current_marker, sizeof(current_marker));
image[4 + 12] = OTA_BL_PROFILE_QSPI_BOOT_UPDATE;
app = ota_bl_app_caps_scan(image, sizeof(image));
update = ota_bl_update_caps_scan_aligned(image, sizeof(image), internal_profile);
EXPECT_TRUE(app.present);
EXPECT_FALSE(update.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;
};
class FakeDeflateMotaSeederStream : public Stream {
public:
using Stream::write;
FakeDeflateMotaSeederStream() {
encoded.resize(421);
for (size_t i = 0; i < encoded.size(); i++) encoded[i] = (uint8_t)(i * 17u + 3u);
}
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_DEFLATE_BLOCK;
uint8_t checksum = request_valid ? request[2] : 0;
for (size_t i = 3; request_valid && i + 1 < len; i++) checksum ^= request[i];
request_valid = request_valid && checksum == request[len - 1];
if (!request_valid) return len;
const uint16_t block = rd_u16le(request + 4);
const uint16_t offset = rd_u16le(request + 6);
const uint16_t read_len = rd_u16le(request + 8);
blocks.push_back(block);
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_DEFLATE_BLOCK);
const bool valid_range = offset <= encoded.size() && read_len <= encoded.size() - offset;
const uint8_t status = reject || !valid_range ? MS_STATUS_ERR : MS_STATUS_OK;
response.push_back(status);
if (status == MS_STATUS_OK) {
response.push_back((uint8_t)(encoded.size() & 0xFF));
response.push_back((uint8_t)(encoded.size() >> 8));
response.insert(response.end(), encoded.begin() + offset,
encoded.begin() + offset + read_len);
}
uint8_t response_checksum = 0;
for (uint8_t byte : response) response_checksum ^= byte;
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; }
std::vector<uint8_t> encoded;
std::vector<uint16_t> blocks;
std::vector<uint16_t> offsets;
std::vector<uint16_t> lengths;
bool request_valid = true;
bool reject = 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);
}
TEST(MotaSourceSerial, ReadsIndependentDeflateBlockInBoundedChunks) {
resetArduinoMock();
FakeDeflateMotaSeederStream stream;
SerialMotaSource source(stream, MotaStreamWritePolicy::FlushTransmit);
std::array<uint8_t, 1024> data{};
uint16_t encoded_len = 0;
ASSERT_TRUE(source.read_deflated_block(2, 37, data.data(), data.size(), &encoded_len));
EXPECT_TRUE(stream.request_valid);
EXPECT_EQ(encoded_len, stream.encoded.size());
EXPECT_EQ(stream.blocks, (std::vector<uint16_t>{37, 37, 37, 37}));
EXPECT_EQ(stream.offsets, (std::vector<uint16_t>{0, 0, 190, 380}));
EXPECT_EQ(stream.lengths, (std::vector<uint16_t>{0, 190, 190, 41}));
EXPECT_EQ(stream.flush_calls, 4u);
EXPECT_EQ(0, memcmp(data.data(), stream.encoded.data(), encoded_len));
}
TEST(MotaSourceSerial, DeflateExtensionFailureLeavesRawFallbackAvailable) {
resetArduinoMock();
FakeDeflateMotaSeederStream stream;
stream.reject = true; // deployed host does not implement operation 0x09
SerialMotaSource source(stream, MotaStreamWritePolicy::FlushTransmit);
std::array<uint8_t, 1024> data{};
uint16_t encoded_len = 999;
EXPECT_FALSE(source.read_deflated_block(0, 0, data.data(), data.size(), &encoded_len));
EXPECT_EQ(encoded_len, 0u);
EXPECT_EQ(stream.offsets, (std::vector<uint16_t>{0}));
EXPECT_EQ(stream.lengths, (std::vector<uint16_t>{0}));
}
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(OtaParse, BlockCountCeilingDoesNotOverflowAtUint32Maximum) {
std::vector<uint8_t> manifest(MOTA_MFL, 0);
manifest[0] = MOTA_FORMAT_VER;
manifest[1] = MFLAG_FULL;
manifest[2] = HASH_ALGO_SHA256;
manifest[15] = 0xFF;
manifest[16] = 0xFF;
manifest[17] = 0xFF;
manifest[18] = 0xFF;
manifest[19] = 24; // largest parser-valid block: ceil(UINT32_MAX / 2^24) = 256
MotaManifest parsed;
ASSERT_TRUE(mota_parse_manifest(manifest.data(), manifest.size(), parsed));
EXPECT_EQ(parsed.block_count, 256u);
}
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);
}
TEST(OtaStoreRamTest, ClearKeepsResumeFixtureButDiscardConsumesHeader) {
OtaStoreRam<4096> s;
constexpr uint32_t total = 8u + MOTA_MFL + 5u;
uint8_t header[8] = {'m', 'O', 'T', 'A',
static_cast<uint8_t>(total),
static_cast<uint8_t>(total >> 8),
static_cast<uint8_t>(total >> 16),
static_cast<uint8_t>(total >> 24)};
ASSERT_TRUE(s.begin(total));
ASSERT_TRUE(s.write(0, header, sizeof(header)));
// clear() remains the cheap RAM-session reset used by reboot/resume tests.
s.clear();
EXPECT_TRUE(s.reopen());
EXPECT_EQ(s.staged_size(), total);
// User-facing cancellation uses discard(), which must prevent a fresh
// reopen even for the native in-process persistence model.
EXPECT_TRUE(s.discard());
EXPECT_EQ(s.staged_size(), 0u);
EXPECT_FALSE(s.reopen());
}
TEST(OtaStoreRamTest, SeederSizedStoreDiscardsWithoutAssumingAHeaderFits) {
// OTA_SEEDER_ONLY intentionally uses OtaStoreRam<1>: it needs a valid
// manager destination object but never stages firmware locally.
OtaStoreRam<1> s;
EXPECT_TRUE(s.discard());
EXPECT_EQ(s.staged_size(), 0u);
EXPECT_FALSE(s.reopen());
}
namespace {
class SessionOnlyOtaStore : public OtaStore {
public:
bool begin(uint32_t) override { return false; }
bool write(uint32_t, const uint8_t*, uint32_t) override { return false; }
bool read(uint32_t, uint8_t*, uint32_t) const override { return false; }
uint32_t capacity() const override { return 0; }
uint32_t staged_size() const override { return 0; }
void clear() override { cleared = true; }
bool cleared = false;
};
} // namespace
TEST(OtaStoreDiscardTest, ConservativeDefaultCannotClaimDurableInvalidation) {
SessionOnlyOtaStore store;
EXPECT_FALSE(store.discard());
EXPECT_TRUE(store.cleared);
}
TEST(OtaDeflate, AcceptsStoredFixedDynamicAndMultiBlockRawStreams) {
static const uint8_t stored[] = {
0x01,0x40,0x00,0xBF,0xFF,0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x08,0x09,
0x0A,0x0B,0x0C,0x0D,0x0E,0x0F,0x10,0x11,0x12,0x13,0x14,0x15,0x16,0x17,0x18,
0x19,0x1A,0x1B,0x1C,0x1D,0x1E,0x1F,0x20,0x21,0x22,0x23,0x24,0x25,0x26,0x27,
0x28,0x29,0x2A,0x2B,0x2C,0x2D,0x2E,0x2F,0x30,0x31,0x32,0x33,0x34,0x35,0x36,
0x37,0x38,0x39,0x3A,0x3B,0x3C,0x3D,0x3E,0x3F,
};
static const uint8_t fixed[] = {
0xF3,0x4D,0x2D,0xCE,0x70,0xCE,0x2F,0x4A,0x55,0x28,0x29,0x4A,0xCC,0x2B,0x2E,0xC8,
0x2F,0x2A,0x51,0x48,0xCB,0xAC,0x48,0x4D,0x51,0xF0,0x28,0x4D,0x4B,0xCB,0x4D,0xCC,
0x53,0x28,0x49,0x2D,0x2E,0xD1,0x53,0xF0,0x1D,0x55,0x46,0x1B,0x65,0x00,
};
static const uint8_t dynamic[] = {
0xED,0xCF,0xD9,0x0D,0x83,0x30,0x14,0x44,0xD1,0x0B,0xC1,0x2C,0xC1,0x2C,0xC1,0x2C,
0xB1,0x81,0xF4,0x5F,0x66,0x9E,0x26,0x05,0xA4,0x01,0x7F,0x5F,0x1D,0x69,0x86,0xA2,
0x7C,0x54,0xAE,0xA6,0xED,0x9E,0xBD,0x1F,0x98,0xE6,0xD7,0x12,0x56,0xF6,0xE3,0x1D,
0xD3,0xC9,0xFD,0xC1,0x32,0xAE,0x6E,0x2C,0xE3,0x87,0xD1,0x32,0x61,0xDD,0x2C,0x93,
0xCE,0xCB,0x32,0xD2,0x0D,0xD2,0x23,0xD2,0x1B,0xD2,0x17,0xD2,0x15,0xD2,0x3D,0xD2,
0x0B,0xD2,0x11,0xE9,0x02,0xE9,0x16,0xE9,0x09,0xE9,0x1D,0xE9,0x1B,0x69,0x87,0xB4,
0x47,0x3A,0x20,0x9D,0x90,0x2E,0x91,0xEE,0x90,0x9E,0x91,0x3E,0x88,0xBF,0x69,0xF9,
0x58,0x3E,0x96,0x8F,0xFD,0x3B,0xF6,0x05,
};
static const uint8_t multi_block[] = {
0x4A,0xCB,0x2C,0x2A,0x2E,0xD1,0x4D,0xCA,0xC9,0x4F,0xCE,0xD6,0x4D,0x1B,0x66,0x6C,
0x00,0x00,0x00,0x00,0xFF,0xFF,0x2B,0x4E,0x4D,0xCE,0xCF,0x4B,0x81,0x0A,0x8C,0x72,
0xF0,0x72,0x00,
};
EXPECT_EQ((stored[0] >> 1) & 3u, 0u);
EXPECT_EQ((fixed[0] >> 1) & 3u, 1u);
EXPECT_EQ((dynamic[0] >> 1) & 3u, 2u);
std::array<uint8_t, 1024> output{};
uint16_t output_len = 0;
ASSERT_TRUE(ota_transport_inflate(nullptr, stored, sizeof(stored), output.data(), 64, &output_len));
ASSERT_EQ(output_len, 64u);
for (uint16_t i = 0; i < output_len; i++) EXPECT_EQ(output[i], (uint8_t)i);
static const char fixed_phrase[] = "MeshCore transport fixed Huffman test. ";
ASSERT_TRUE(ota_transport_inflate(nullptr, fixed, sizeof(fixed), output.data(), 384, &output_len));
ASSERT_EQ(output_len, 384u);
for (uint16_t i = 0; i < output_len; i++)
EXPECT_EQ(output[i], (uint8_t)fixed_phrase[i % (sizeof(fixed_phrase) - 1)]);
ASSERT_TRUE(ota_transport_inflate(nullptr, dynamic, sizeof(dynamic), output.data(), 1024, &output_len));
ASSERT_EQ(output_len, 1024u);
for (uint16_t i = 0; i < output_len; i++)
EXPECT_EQ(output[i], (uint8_t)((i % 7u) ? i % 31u : 0u));
ASSERT_TRUE(ota_transport_inflate(nullptr, multi_block, sizeof(multi_block), output.data(), 520,
&output_len));
ASSERT_EQ(output_len, 520u);
static const char first_phrase[] = "first-block-";
static const char second_phrase[] = "second-block-";
for (uint16_t i = 0; i < 220; i++)
EXPECT_EQ(output[i], (uint8_t)first_phrase[i % (sizeof(first_phrase) - 1)]);
for (uint16_t i = 220; i < output_len; i++)
EXPECT_EQ(output[i], (uint8_t)second_phrase[(i - 220) % (sizeof(second_phrase) - 1)]);
}
TEST(OtaDeflate, RejectsTruncationBoundsWrongLengthTrailingBytesAndMalformedTrees) {
static const uint8_t dynamic[] = {
0xED,0xCF,0xD9,0x0D,0x83,0x30,0x14,0x44,0xD1,0x0B,0xC1,0x2C,0xC1,0x2C,0xC1,0x2C,
0xB1,0x81,0xF4,0x5F,0x66,0x9E,0x26,0x05,0xA4,0x01,0x7F,0x5F,0x1D,0x69,0x86,0xA2,
0x7C,0x54,0xAE,0xA6,0xED,0x9E,0xBD,0x1F,0x98,0xE6,0xD7,0x12,0x56,0xF6,0xE3,0x1D,
0xD3,0xC9,0xFD,0xC1,0x32,0xAE,0x6E,0x2C,0xE3,0x87,0xD1,0x32,0x61,0xDD,0x2C,0x93,
0xCE,0xCB,0x32,0xD2,0x0D,0xD2,0x23,0xD2,0x1B,0xD2,0x17,0xD2,0x15,0xD2,0x3D,0xD2,
0x0B,0xD2,0x11,0xE9,0x02,0xE9,0x16,0xE9,0x09,0xE9,0x1D,0xE9,0x1B,0x69,0x87,0xB4,
0x47,0x3A,0x20,0x9D,0x90,0x2E,0x91,0xEE,0x90,0x9E,0x91,0x3E,0x88,0xBF,0x69,0xF9,
0x58,0x3E,0x96,0x8F,0xFD,0x3B,0xF6,0x05,
};
static const uint8_t invalid_distance[] = {0x63,0x00,0x3E,0x00};
static const uint8_t invalid_tree[] = {
0x0D,0xCE,0x81,0x00,0x00,0x00,0x00,0x80,0xA0,0xFD,0xA9,0xBB,0x1F,0xA0,0x01,
};
std::array<uint8_t, 1025> output{};
uint16_t output_len = 999;
EXPECT_FALSE(ota_transport_inflate(nullptr, dynamic, sizeof(dynamic) - 1,
output.data(), 1024, &output_len));
EXPECT_EQ(output_len, 0u);
EXPECT_FALSE(ota_transport_inflate(nullptr, dynamic, sizeof(dynamic),
output.data(), 1023, &output_len));
EXPECT_FALSE(ota_transport_inflate(nullptr, dynamic, sizeof(dynamic),
output.data(), 1025, &output_len));
std::array<uint8_t, sizeof(dynamic) + 1> trailing{};
memcpy(trailing.data(), dynamic, sizeof(dynamic));
trailing.back() = 0x00;
EXPECT_FALSE(ota_transport_inflate(nullptr, trailing.data(), trailing.size(),
output.data(), 1024, &output_len));
EXPECT_FALSE(ota_transport_inflate(nullptr, invalid_distance, sizeof(invalid_distance),
output.data(), 4, &output_len));
EXPECT_FALSE(ota_transport_inflate(nullptr, invalid_tree, sizeof(invalid_tree),
output.data(), 4, &output_len));
}
// --- 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));
}
TEST(OtaProtocol, V2ProfilesPackLegacyAndExtendedLengthBoundaries) {
const uint16_t request = ota_req_make_v2(0x007Fu, true);
EXPECT_TRUE(ota_req_is_v2(request));
EXPECT_EQ(ota_req_v2_fragments(request), 0x007Fu);
EXPECT_NE(request & OTA_REQ_V2_ALLOW_DEFLATE, 0u);
EXPECT_FALSE(ota_req_v2_extended_length(request));
// Extended v2 needs at most twelve 171-byte fragments. Keeping fragment bit 12 clear leaves the
// deployed legacy "send all" value 0xFFFF unambiguous.
const uint16_t extended_request = ota_req_make_v2(0x0FFFu, true, true);
EXPECT_TRUE(ota_req_is_v2(extended_request));
EXPECT_TRUE(ota_req_v2_extended_length(extended_request));
EXPECT_EQ(ota_req_v2_fragments(extended_request), 0x0FFFu);
EXPECT_EQ(extended_request, 0xEFFFu);
EXPECT_FALSE(ota_req_is_v2(0xFFFFu)); // deployed legacy full-block request
EXPECT_FALSE(ota_req_is_v2(0x007Fu));
uint16_t descriptor = 0;
ASSERT_TRUE(ota_data_v2_pack(5, 1024, true, descriptor));
uint8_t fragment = 0;
uint16_t encoded_len = 0;
bool deflated = false;
ASSERT_TRUE(ota_data_v2_unpack(descriptor, fragment, encoded_len, deflated));
EXPECT_EQ(fragment, 5u);
EXPECT_EQ(encoded_len, 1024u);
EXPECT_TRUE(deflated);
EXPECT_FALSE(ota_data_v2_unpack((uint16_t)(descriptor & ~OTA_DATA_V2_MARK),
fragment, encoded_len, deflated));
EXPECT_FALSE(ota_data_v2_pack(16, 1024, false, descriptor));
EXPECT_FALSE(ota_data_v2_pack(0, 0, false, descriptor));
EXPECT_FALSE(ota_data_v2_pack(0, 1025, false, descriptor));
ASSERT_TRUE(ota_data_v2_pack_extended(15, 2048, descriptor));
EXPECT_EQ(descriptor, 0xFFFFu);
ASSERT_TRUE(ota_data_v2_unpack_extended(descriptor, 2048,
fragment, encoded_len, deflated));
EXPECT_EQ(fragment, 15u);
EXPECT_EQ(encoded_len, 2048u);
EXPECT_FALSE(deflated);
EXPECT_FALSE(ota_data_v2_unpack_extended(descriptor, 2047,
fragment, encoded_len, deflated));
ASSERT_TRUE(ota_data_v2_pack_extended(0, 1024, descriptor));
ASSERT_TRUE(ota_data_v2_unpack_extended(descriptor, 2048,
fragment, encoded_len, deflated));
EXPECT_EQ(fragment, 0u);
EXPECT_EQ(encoded_len, 1024u);
EXPECT_TRUE(deflated);
ASSERT_TRUE(ota_data_v2_pack_extended(0, 193, descriptor));
ASSERT_TRUE(ota_data_v2_unpack_extended(descriptor, 193,
fragment, encoded_len, deflated));
EXPECT_EQ(encoded_len, 193u);
EXPECT_FALSE(deflated); // raw short tail in a 2 KiB manifest
EXPECT_FALSE(ota_data_v2_pack_extended(16, 2048, descriptor));
EXPECT_FALSE(ota_data_v2_pack_extended(0, 0, descriptor));
EXPECT_FALSE(ota_data_v2_pack_extended(0, 2049, descriptor));
EXPECT_FALSE(ota_data_v2_unpack_extended(0, 2048,
fragment, encoded_len, deflated));
EXPECT_FALSE(ota_data_v2_unpack_extended(OTA_DATA_V2_MARK, 0,
fragment, encoded_len, deflated));
EXPECT_FALSE(ota_data_v2_unpack_extended(OTA_DATA_V2_MARK, 2049,
fragment, encoded_len, deflated));
std::array<uint8_t, OTA_DATA_V2_STREAM_ID_BYTES + OTA_FRAG_DATA_V2> payload{};
ASSERT_TRUE(ota_data_v2_pack(0, 1024, false, descriptor));
DataMsg message{{1, 2, 3, 4}, 9, descriptor, payload.data(),
(uint16_t)payload.size()};
uint8_t wire[MAX_PACKET_PAYLOAD];
const uint16_t wire_len = encode_data(wire, sizeof(wire), message);
EXPECT_EQ(wire_len, MAX_PACKET_PAYLOAD);
DataMsg decoded{};
ASSERT_TRUE(decode_data(wire, wire_len, decoded));
EXPECT_EQ(decoded.data_len,
(uint16_t)(OTA_DATA_V2_STREAM_ID_BYTES + OTA_FRAG_DATA_V2));
}
// --- 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 ExtendedV2Trace {
const MotaManifest* manifest = nullptr;
uint32_t extended_requests = 0;
uint32_t legacy_requests = 0;
bool full_raw = false;
bool tail_raw = false;
bool full_deflated = false;
bool tail_deflated = false;
};
struct TracedSendTo { OtaManager* dest; ExtendedV2Trace* trace; };
static bool traced_sim_send(void* ctx, const uint8_t* msg, uint16_t len, bool /*flood*/) {
TracedSendTo* route = (TracedSendTo*)ctx;
ExtendedV2Trace* trace = route->trace;
if (trace && trace->manifest && ota_msg_type(msg, len) == OTA_REQ) {
ReqWindowMsg request{};
if (decode_req_window(msg, len, request)) {
for (uint8_t i = 0; i < request.n_items; i++) {
if (ota_req_is_v2(request.items[i].want_mask) &&
ota_req_v2_extended_length(request.items[i].want_mask)) {
trace->extended_requests++;
} else if (!ota_req_is_v2(request.items[i].want_mask)) {
trace->legacy_requests++;
}
}
}
} else if (trace && trace->manifest && ota_msg_type(msg, len) == OTA_DATA) {
DataMsg data{};
if (decode_data(msg, len, data) && (data.frag_off & OTA_DATA_V2_MARK) != 0 &&
data.block_idx < trace->manifest->block_count) {
const uint32_t block_offset = (uint32_t)data.block_idx * trace->manifest->block_size();
const uint16_t raw_len = (uint16_t)std::min<uint32_t>(
trace->manifest->block_size(), trace->manifest->payload_size - block_offset);
uint8_t fragment = 0;
uint16_t encoded_len = 0;
bool deflated = false;
if (ota_data_v2_unpack_extended(data.frag_off, raw_len,
fragment, encoded_len, deflated)) {
const bool full = raw_len == trace->manifest->block_size();
if (deflated) {
if (full) trace->full_deflated = true;
else trace->tail_deflated = true;
} else {
if (full) trace->full_raw = true;
else trace->tail_raw = true;
}
}
}
}
g_q.push_back({route->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;
}
// Tiny reversible stand-in for the separately tested RFC1951 implementation. These callbacks isolate the
// OtaManager negotiation/reassembly path: each payload block is represented by its authenticated block index,
// then expanded from the immutable test manifest before the normal Merkle proof is checked.
struct TestWireCodec {
const MotaManifest* manifest = nullptr;
uint32_t calls = 0;
};
static bool test_wire_encode(void* context, const uint8_t* source, uint16_t source_len,
uint8_t* destination, uint16_t capacity, uint16_t* encoded_len) {
TestWireCodec* codec = (TestWireCodec*)context;
if (!codec || !codec->manifest || !source || !destination || !encoded_len || capacity < 2) return false;
const MotaManifest& manifest = *codec->manifest;
const uint32_t block_size = manifest.block_size();
for (uint32_t block = 0; block < manifest.block_count && block <= 0xFFu; block++) {
const uint32_t offset = block * block_size;
const uint32_t length = std::min<uint32_t>(block_size, manifest.payload_size - offset);
if (length == source_len && memcmp(source, manifest.payload + offset, length) == 0) {
destination[0] = 0xD1;
destination[1] = (uint8_t)block;
*encoded_len = 2;
codec->calls++;
return true;
}
}
return false;
}
static bool test_wire_decode(void* context, const uint8_t* source, uint16_t source_len,
uint8_t* destination, uint16_t capacity, uint16_t* decoded_len) {
TestWireCodec* codec = (TestWireCodec*)context;
if (!codec || !codec->manifest || !source || !destination || !decoded_len || source_len != 2 ||
source[0] != 0xD1 || source[1] >= codec->manifest->block_count) return false;
const MotaManifest& manifest = *codec->manifest;
const uint32_t offset = (uint32_t)source[1] * manifest.block_size();
const uint32_t length = std::min<uint32_t>(manifest.block_size(), manifest.payload_size - offset);
if (length != capacity) return false;
memcpy(destination, manifest.payload + offset, length);
*decoded_len = (uint16_t)length;
codec->calls++;
return true;
}
struct VectorDeflateCodec {
const MotaManifest* manifest = nullptr;
uint32_t calls = 0;
};
static bool vector_2k_wire_encode(void* context, const uint8_t* source, uint16_t source_len,
uint8_t* destination, uint16_t capacity,
uint16_t* encoded_len) {
VectorDeflateCodec* codec = (VectorDeflateCodec*)context;
if (!codec || !codec->manifest || !source || !destination || !encoded_len ||
codec->manifest->block_count != SIM_MOTA_2K_BLOCKS) return false;
for (uint32_t block = 0; block < codec->manifest->block_count; block++) {
const uint32_t offset = block * codec->manifest->block_size();
const uint16_t length = (uint16_t)std::min<uint32_t>(
codec->manifest->block_size(), codec->manifest->payload_size - offset);
if (length != source_len || memcmp(source, codec->manifest->payload + offset, length) != 0) continue;
const uint16_t representation_len = SIM_MOTA_2K_DEFLATED_LENGTHS[block];
if (representation_len > capacity) return false;
memcpy(destination,
SIM_MOTA_2K_DEFLATED + SIM_MOTA_2K_DEFLATED_OFFSETS[block],
representation_len);
*encoded_len = representation_len;
codec->calls++;
return true;
}
return false;
}
struct RejectWireDecode {
uint32_t calls = 0;
};
static bool reject_wire_decode(void* context, const uint8_t*, uint16_t,
uint8_t*, uint16_t, uint16_t* decoded_len) {
RejectWireDecode* decoder = (RejectWireDecode*)context;
if (decoder) decoder->calls++;
if (decoded_len) *decoded_len = 0;
return false;
}
struct SyntheticRepresentationDecode {
const uint8_t* representation = nullptr;
uint16_t representation_len = 0;
const uint8_t* decoded = nullptr;
uint16_t decoded_len = 0;
uint32_t calls = 0;
};
static bool synthetic_representation_decode(void* context, const uint8_t* source,
uint16_t source_len, uint8_t* destination,
uint16_t capacity, uint16_t* decoded_len) {
SyntheticRepresentationDecode* decoder = (SyntheticRepresentationDecode*)context;
if (!decoder || !source || !destination || !decoded_len ||
source_len != decoder->representation_len || capacity != decoder->decoded_len ||
memcmp(source, decoder->representation, source_len) != 0) return false;
memcpy(destination, decoder->decoded, capacity);
*decoded_len = capacity;
decoder->calls++;
return true;
}
static bool test_representation_decode(void* context, const uint8_t* source, uint16_t source_len,
uint8_t* destination, uint16_t capacity,
uint16_t* decoded_len) {
const MotaManifest* manifest = (const MotaManifest*)context;
if (!manifest || !source || !destination || !decoded_len || source_len != 300 ||
(source[0] != 0xA1 && source[0] != 0xB2) || capacity != manifest->block_size()) return false;
memcpy(destination, manifest->payload, capacity);
*decoded_len = capacity;
return true;
}
TEST(OtaMetrics, CountsOnlyPacketsAcceptedByTheRadioAdapter) {
OtaManager manager;
GatedCapture sent;
manager.begin(0, gated_capture_send, &sent);
EXPECT_EQ(manager.packetsSent(), 0u);
manager.announce();
EXPECT_EQ(manager.packetsSent(), 0u);
EXPECT_TRUE(sent.items.empty());
sent.accept = true;
manager.announce();
manager.announce();
EXPECT_EQ(manager.packetsSent(), 2u);
EXPECT_EQ(sent.items.size(), 2u);
}
// 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++; } }
void setSourceCaps(uint8_t caps) { _source_caps = caps; }
uint32_t deflateCalls() const { return _deflate_calls; }
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.source_caps = _source_caps;
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;
}
bool read_deflated_block(uint8_t, uint16_t, uint8_t*, uint16_t, uint16_t* len) override {
_deflate_calls++;
if (len) *len = 0;
return false;
}
private:
const uint8_t* _buf[8] = {nullptr}; uint32_t _len[8] = {0}; uint8_t _n = 0;
uint8_t _source_caps = 0;
uint32_t _deflate_calls = 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;
};
class FaultingMotaSource : public RamMotaSource {
public:
uint32_t fail_offset = UINT32_MAX;
bool partial_read = false;
bool read(uint8_t idx, uint32_t off, uint8_t* out, uint32_t len) override {
if (idx == 1 && off == fail_offset) {
if (partial_read && len > 1) RamMotaSource::read(idx, off, out, len / 2);
return false;
}
return RamMotaSource::read(idx, off, out, len);
}
};
static bool request_manifest(OtaManager& manager, const uint8_t* mid) {
GetManifestMsg request{};
memcpy(request.manifest_id, mid, 4);
request.want_mask = 0xFFFF;
uint8_t wire[MAX_PACKET_PAYLOAD];
const uint16_t length = encode_get_manifest(wire, sizeof(wire), request);
return length && manager.on_message(wire, length);
}
static void expect_queued_manifest(OtaManager& manager, CapturedMessages& sent,
const MotaManifest& expected) {
for (uint32_t tick = 1; tick <= 16 && manager.pendingManifestJobs(); ++tick) {
manager.set_clock(tick * 10000u);
manager.serviceEgress();
}
ASSERT_EQ(manager.pendingManifestJobs(), 0u);
std::vector<uint8_t> bytes;
for (const auto& wire : sent.items) {
ManifestMsg fragment{};
ASSERT_TRUE(decode_manifest(wire.data(), (uint16_t)wire.size(), fragment));
EXPECT_EQ(memcmp(fragment.manifest_id, expected.merkle_root, 4), 0);
EXPECT_EQ(fragment.frag_idx * OTA_MF_FRAG, bytes.size());
bytes.insert(bytes.end(), fragment.bytes, fragment.bytes + fragment.len);
}
ASSERT_EQ(bytes.size(), MOTA_MFL);
EXPECT_EQ(memcmp(bytes.data(), expected.manifest_start, MOTA_MFL), 0);
}
static void expect_served_block(OtaManager& manager, CapturedMessages& sent,
const MotaManifest& expected) {
sent.items.clear();
ReqMsg request{};
memcpy(request.manifest_id, expected.merkle_root, 4);
request.want_mask = 0xFFFF;
uint8_t wire[MAX_PACKET_PAYLOAD];
const uint16_t length = encode_req(wire, sizeof(wire), request);
ASSERT_TRUE(manager.on_message(wire, length));
for (uint32_t tick = 1; tick <= 32 && manager.pendingServeJobs(); ++tick) {
manager.set_clock(200000u + tick * 10000u);
manager.serviceEgress();
}
ASSERT_EQ(manager.pendingServeJobs(), 0u);
std::vector<uint8_t> bytes;
bool verified = false;
for (const auto& packet : sent.items) {
DataMsg data{};
ProofMsg proof{};
if (decode_data(packet.data(), (uint16_t)packet.size(), data)) {
EXPECT_EQ(memcmp(data.manifest_id, expected.merkle_root, 4), 0);
ASSERT_EQ(data.frag_off, bytes.size());
bytes.insert(bytes.end(), data.data, data.data + data.data_len);
} else {
ASSERT_TRUE(decode_proof(packet.data(), (uint16_t)packet.size(), proof));
EXPECT_EQ(memcmp(proof.manifest_id, expected.merkle_root, 4), 0);
verified = merkle_verify(bytes.data(), (uint32_t)bytes.size(), 0,
proof.proof, proof.n_proof, expected.merkle_root, expected.block_count);
}
}
ASSERT_EQ(bytes.size(), std::min(expected.block_size(), expected.payload_size));
EXPECT_EQ(memcmp(bytes.data(), expected.payload, bytes.size()), 0);
EXPECT_TRUE(verified);
}
}
TEST(OtaServe, RejectedPrimaryReplacementPreservesQueuedManifest) {
MotaManifest original;
ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, original));
for (bool streaming : {false, true}) {
SCOPED_TRACE(streaming ? "serve_self" : "serve");
OtaManager manager;
CapturedMessages sent;
manager.begin(0, capture_send, &sent);
ASSERT_TRUE(manager.serve(SIM_MOTA, SIM_MOTA_LEN));
ASSERT_TRUE(request_manifest(manager, original.merkle_root));
// Parsing gets far enough to overwrite pointers/geometry before rejecting
// this replacement. It must never mutate the live, advertised image.
std::vector<uint8_t> bad(SIM_MOTA_1K, SIM_MOTA_1K + SIM_MOTA_1K_LEN);
bad[8 + 19] = 25;
uint8_t scratch[OTA_PROOFGEN_SCRATCH];
if (streaming) {
EXPECT_FALSE(manager.serve_self(bad.data() + 8, MOTA_MFL,
original.leaves, original.block_count, scratch, sizeof(scratch),
[](void*, uint32_t, uint8_t*, uint32_t) { return false; }, nullptr));
} else {
EXPECT_FALSE(manager.serve(bad.data(), (uint32_t)bad.size()));
}
expect_queued_manifest(manager, sent, original);
expect_served_block(manager, sent, original);
}
}
TEST(OtaServe, InvalidPrimaryBackingDoesNotReplaceLiveImage) {
MotaManifest original;
ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, original));
uint8_t scratch[OTA_PROOFGEN_SCRATCH];
const auto read = [](void*, uint32_t, uint8_t*, uint32_t) { return false; };
for (unsigned failure = 0; failure < 9; ++failure) {
SCOPED_TRACE(failure);
OtaManager manager;
CapturedMessages sent;
manager.begin(0, capture_send, &sent);
ASSERT_TRUE(manager.serve(SIM_MOTA, SIM_MOTA_LEN));
if (failure < 2) {
EXPECT_FALSE(manager.serve(failure == 0 ? nullptr : SIM_MOTA,
failure == 0 ? SIM_MOTA_LEN : 0));
} else {
EXPECT_FALSE(manager.serve_self(
failure == 2 ? nullptr : original.manifest_start,
failure == 3 ? MOTA_MFL - 1 : MOTA_MFL,
failure == 4 ? nullptr : original.leaves,
failure == 5 ? original.block_count + 1 : original.block_count,
failure == 6 ? nullptr : scratch,
failure == 7 ? 0 : sizeof(scratch),
failure == 8 ? nullptr : +read, nullptr));
}
ASSERT_TRUE(request_manifest(manager, original.merkle_root));
expect_queued_manifest(manager, sent, original);
expect_served_block(manager, sent, original);
}
}
TEST(OtaServe, FailedSourceSwitchReloadsPreviousImageBeforeQueuedReply) {
MotaManifest original;
ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, original));
MotaManifest other;
ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, other));
for (uint32_t failure : {8u, 8u + MOTA_MFL}) {
for (bool partial : {false, true}) {
SCOPED_TRACE(failure);
SCOPED_TRACE(partial);
OtaManager manager;
CapturedMessages sent;
FaultingMotaSource folder;
folder.add(SIM_MOTA, SIM_MOTA_LEN);
folder.add(SIM_MOTA_1K, SIM_MOTA_1K_LEN);
manager.begin(0, capture_send, &sent);
ASSERT_TRUE(manager.add_source(&folder));
ASSERT_TRUE(request_manifest(manager, original.merkle_root));
folder.fail_offset = failure;
folder.partial_read = partial;
EXPECT_FALSE(request_manifest(manager, other.merkle_root));
expect_queued_manifest(manager, sent, original);
expect_served_block(manager, sent, original);
// A transient source failure must also recover when the new image is
// requested again, with the original source still registered.
sent.items.clear();
manager.set_clock(0);
folder.fail_offset = UINT32_MAX;
ASSERT_TRUE(request_manifest(manager, other.merkle_root));
expect_queued_manifest(manager, sent, other);
expect_served_block(manager, sent, other);
}
}
}
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(OtaServe, FetchSessionResetPreservesIndependentPrimaryView) {
OtaManager manager;
manager.begin(0, nullptr, nullptr);
ASSERT_TRUE(manager.serve(SIM_MOTA, SIM_MOTA_LEN));
ASSERT_EQ(manager.servedCount(), 1);
ASSERT_TRUE(manager.servedEntry(0)->is_self);
// `ota cancel` resets only the receive side. A served image (and its
// caller-owned backing buffer) must remain registered until the serving
// side explicitly calls clear_primary().
manager.reset_session();
ASSERT_EQ(manager.servedCount(), 1);
EXPECT_TRUE(manager.servedEntry(0)->is_self);
}
TEST(OtaTransfer, TwoManagersFullTransfer) {
g_q.clear();
OtaManager server, client;
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));
}
TEST(OtaTransfer, LegacyOneKilobyteV2CodecStillStagesOriginalContainer) {
g_q.clear();
MotaManifest manifest;
ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest));
ASSERT_EQ(manifest.block_size(), 1024u);
OtaManager server, client;
OtaStoreRam<4096> store;
SendTo to_client{&client}, to_server{&server};
TestWireCodec encoder;
TestWireCodec decoder;
encoder.manifest = &manifest;
decoder.manifest = &manifest;
server.begin(0, sim_send, &to_client);
server.set_transport_deflate_encoder(test_wire_encode, &encoder);
client.begin(SIM_TARGET_ID, sim_send, &to_server);
client.set_transport_deflate_decoder(test_wire_decode, &decoder);
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_GT(encoder.calls, 0u);
EXPECT_EQ(decoder.calls, manifest.block_count);
ASSERT_EQ(store.staged_size(), SIM_MOTA_1K_LEN);
EXPECT_EQ(0, memcmp(store.data(), SIM_MOTA_1K, SIM_MOTA_1K_LEN));
}
TEST(OtaTransfer, TwoKilobyteExtendedV2TransfersFullAndShortRawBlocks) {
g_q.clear();
MotaManifest manifest;
ASSERT_TRUE(mota_parse(SIM_MOTA_2K, SIM_MOTA_2K_LEN, manifest));
ASSERT_EQ(manifest.block_size(), 2048u);
ASSERT_EQ(manifest.block_count, SIM_MOTA_2K_BLOCKS);
ASSERT_EQ(manifest.block_count, 2u);
ASSERT_GT(manifest.payload_size, manifest.block_size());
ASSERT_LT(manifest.payload_size - manifest.block_size(), manifest.block_size());
OtaManager server, client;
OtaStoreRam<4096> store;
ExtendedV2Trace trace{};
trace.manifest = &manifest;
TracedSendTo to_client{&client, &trace}, to_server{&server, &trace};
server.begin(0, traced_sim_send, &to_client);
client.begin(SIM_TARGET_ID, traced_sim_send, &to_server);
client.set_fetch_store(&store);
client.set_autofetch(OtaManager::AUTOFETCH_ANY);
ASSERT_TRUE(server.serve(SIM_MOTA_2K, SIM_MOTA_2K_LEN));
server.announce();
pump(client, &server);
EXPECT_EQ(client.fetchState(), OtaManager::COMPLETE);
EXPECT_GT(trace.extended_requests, 0u);
EXPECT_EQ(trace.legacy_requests, 0u);
EXPECT_TRUE(trace.full_raw);
EXPECT_TRUE(trace.tail_raw);
EXPECT_FALSE(trace.full_deflated);
EXPECT_FALSE(trace.tail_deflated);
ASSERT_EQ(store.staged_size(), SIM_MOTA_2K_LEN);
EXPECT_EQ(0, memcmp(store.data(), SIM_MOTA_2K, SIM_MOTA_2K_LEN));
}
TEST(OtaTransfer, TwoKilobyteExtendedV2InflatesFullAndShortBlocksBeforeProof) {
g_q.clear();
MotaManifest manifest;
ASSERT_TRUE(mota_parse(SIM_MOTA_2K, SIM_MOTA_2K_LEN, manifest));
ASSERT_EQ(manifest.block_size(), 2048u);
ASSERT_EQ(manifest.block_count, SIM_MOTA_2K_BLOCKS);
OtaManager server, client;
OtaStoreRam<4096> store;
ExtendedV2Trace trace{};
trace.manifest = &manifest;
TracedSendTo to_client{&client, &trace}, to_server{&server, &trace};
VectorDeflateCodec encoder{};
encoder.manifest = &manifest;
server.begin(0, traced_sim_send, &to_client);
server.set_transport_deflate_encoder(vector_2k_wire_encode, &encoder);
client.begin(SIM_TARGET_ID, traced_sim_send, &to_server);
client.set_transport_deflate_decoder(ota_transport_inflate);
client.set_fetch_store(&store);
client.set_autofetch(OtaManager::AUTOFETCH_ANY);
ASSERT_TRUE(server.serve(SIM_MOTA_2K, SIM_MOTA_2K_LEN));
server.announce();
pump(client, &server);
EXPECT_EQ(client.fetchState(), OtaManager::COMPLETE);
EXPECT_EQ(encoder.calls, manifest.block_count);
EXPECT_GT(trace.extended_requests, 0u);
EXPECT_EQ(trace.legacy_requests, 0u);
EXPECT_TRUE(trace.full_deflated);
EXPECT_TRUE(trace.tail_deflated);
EXPECT_FALSE(trace.full_raw);
EXPECT_FALSE(trace.tail_raw);
ASSERT_EQ(store.staged_size(), SIM_MOTA_2K_LEN);
EXPECT_EQ(0, memcmp(store.data(), SIM_MOTA_2K, SIM_MOTA_2K_LEN));
}
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));
}
static void deliver_verified_v2_raw_block(OtaManager& client, const MotaManifest& manifest,
uint32_t block) {
ASSERT_LT(block, manifest.block_count);
const uint32_t block_size = manifest.block_size();
const uint32_t block_off = block * block_size;
const uint16_t block_len = (uint16_t)(block_off + block_size <= manifest.payload_size
? block_size : manifest.payload_size - block_off);
const uint32_t fragments = (block_len + OTA_FRAG_DATA_V2 - 1) / OTA_FRAG_DATA_V2;
uint8_t stream_id[OTA_DATA_V2_STREAM_ID_BYTES];
mh4(stream_id, manifest.payload + block_off, block_len);
uint8_t wire[MAX_PACKET_PAYLOAD];
for (uint32_t fragment = 0; fragment < fragments; fragment++) {
const uint32_t offset = fragment * OTA_FRAG_DATA_V2;
uint16_t length = (uint16_t)(block_len - offset);
if (length > OTA_FRAG_DATA_V2) length = OTA_FRAG_DATA_V2;
uint8_t body[OTA_DATA_V2_STREAM_ID_BYTES + OTA_FRAG_DATA_V2];
memcpy(body, stream_id, sizeof(stream_id));
memcpy(body + sizeof(stream_id), manifest.payload + block_off + offset, length);
DataMsg data{};
memcpy(data.manifest_id, manifest.merkle_root, 4);
data.block_idx = (uint16_t)block;
if (block_size > OTA_DATA_V2_LEGACY_MAX_ENCODED) {
ASSERT_TRUE(ota_data_v2_pack_extended((uint8_t)fragment, block_len, data.frag_off));
} else {
ASSERT_TRUE(ota_data_v2_pack((uint8_t)fragment, block_len, false, data.frag_off));
}
data.data = body;
data.data_len = (uint16_t)(sizeof(stream_id) + length);
const 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];
const 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;
const uint16_t wire_len = encode_proof(wire, sizeof(wire), proof);
ASSERT_GT(wire_len, 0);
ASSERT_TRUE(client.on_message(wire, wire_len));
}
static void deliver_v2_representation(OtaManager& client, const MotaManifest& manifest,
uint32_t block, const uint8_t* representation,
uint16_t representation_len) {
ASSERT_LT(block, manifest.block_count);
ASSERT_NE(representation, nullptr);
const uint32_t block_offset = block * manifest.block_size();
const uint16_t raw_len = (uint16_t)std::min<uint32_t>(
manifest.block_size(), manifest.payload_size - block_offset);
ASSERT_GT(representation_len, 0u);
ASSERT_LT(representation_len, raw_len);
uint8_t stream_id[OTA_DATA_V2_STREAM_ID_BYTES];
mh4(stream_id, representation, representation_len);
const uint32_t fragments =
(representation_len + OTA_FRAG_DATA_V2 - 1) / OTA_FRAG_DATA_V2;
uint8_t wire[MAX_PACKET_PAYLOAD];
for (uint32_t fragment = 0; fragment < fragments; fragment++) {
const uint32_t offset = fragment * OTA_FRAG_DATA_V2;
const uint16_t length = (uint16_t)std::min<uint32_t>(
OTA_FRAG_DATA_V2, representation_len - offset);
uint8_t body[OTA_DATA_V2_STREAM_ID_BYTES + OTA_FRAG_DATA_V2];
memcpy(body, stream_id, sizeof(stream_id));
memcpy(body + sizeof(stream_id), representation + offset, length);
DataMsg data{};
memcpy(data.manifest_id, manifest.merkle_root, 4);
data.block_idx = (uint16_t)block;
if (manifest.block_size() > OTA_DATA_V2_LEGACY_MAX_ENCODED) {
ASSERT_TRUE(ota_data_v2_pack_extended(
(uint8_t)fragment, representation_len, data.frag_off));
} else {
ASSERT_TRUE(ota_data_v2_pack(
(uint8_t)fragment, representation_len, true, data.frag_off));
}
data.data = body;
data.data_len = (uint16_t)(sizeof(stream_id) + length);
const uint16_t wire_len = encode_data(wire, sizeof(wire), data);
ASSERT_GT(wire_len, 0);
ASSERT_TRUE(client.on_message(wire, wire_len));
}
}
static void deliver_block_proof(OtaManager& client, const MotaManifest& manifest,
uint32_t block) {
ASSERT_LT(block, manifest.block_count);
std::vector<uint8_t> scratch(manifest.block_count * 4);
uint8_t siblings[32 * 4];
const 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;
uint8_t wire[MAX_PACKET_PAYLOAD];
const 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, LiteralLegacyFullMaskServesTwoKilobyteBlockInThirteenFragments) {
MotaManifest manifest;
ASSERT_TRUE(mota_parse(SIM_MOTA_2K, SIM_MOTA_2K_LEN, manifest));
ASSERT_EQ(manifest.block_size(), 2048u);
OtaManager server;
CapturedMessages sent;
server.begin(0, capture_send, &sent);
ASSERT_TRUE(server.serve(SIM_MOTA_2K, SIM_MOTA_2K_LEN));
ReqMsg request{};
memcpy(request.manifest_id, manifest.merkle_root, 4);
request.block_idx = 0;
request.want_mask = 0xFFFFu; // deployed legacy "all fragments"
EXPECT_FALSE(ota_req_is_v2(request.want_mask));
uint8_t wire[MAX_PACKET_PAYLOAD];
const uint16_t request_len = encode_req(wire, sizeof(wire), request);
ASSERT_GT(request_len, 0);
ASSERT_TRUE(server.on_message(wire, request_len));
const uint32_t fragment_count =
(manifest.block_size() + OTA_FRAG_DATA - 1) / OTA_FRAG_DATA;
ASSERT_EQ(fragment_count, 13u);
for (uint32_t fragment = 0; fragment < fragment_count; fragment++) {
server.serviceEgress();
ASSERT_EQ(sent.items.size(), fragment + 1);
DataMsg data{};
ASSERT_TRUE(decode_data(sent.items.back().data(),
(uint16_t)sent.items.back().size(), data));
EXPECT_EQ(data.block_idx, 0u);
EXPECT_EQ(data.frag_off, fragment * OTA_FRAG_DATA);
const uint16_t expected_len = (uint16_t)std::min<uint32_t>(
OTA_FRAG_DATA, manifest.block_size() - fragment * OTA_FRAG_DATA);
EXPECT_EQ(data.data_len, expected_len);
EXPECT_EQ(0, memcmp(data.data,
manifest.payload + fragment * OTA_FRAG_DATA,
expected_len));
}
EXPECT_EQ(sent.items.size(), 13u); // no v2 12-fragment interpretation
EXPECT_EQ(server.pendingServeJobs(), 1u);
server.serviceEgress(); // establish proof turnaround deadline
ASSERT_EQ(sent.items.size(), 13u);
server.set_clock(OTA_MANIFEST_EGRESS_MIN_GAP_MS);
server.serviceEgress();
ASSERT_EQ(sent.items.size(), 14u);
ProofMsg proof{};
ASSERT_TRUE(decode_proof(sent.items.back().data(),
(uint16_t)sent.items.back().size(), proof));
EXPECT_EQ(proof.block_idx, 0u);
EXPECT_EQ(server.pendingServeJobs(), 0u);
}
TEST(OtaTransfer, NegotiatedV2Carries171RawBytesAndRepeatsRepresentationId) {
MotaManifest manifest;
ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest));
ASSERT_EQ(manifest.block_size(), 1024u);
OtaManager server;
CapturedMessages sent;
server.begin(0, 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 = ota_req_make_v2(0x007Fu, true); // no encoder installed: source must fall back to raw
uint8_t wire[MAX_PACKET_PAYLOAD];
const uint16_t request_len = encode_req(wire, sizeof(wire), request);
ASSERT_GT(request_len, 0);
ASSERT_TRUE(server.on_message(wire, request_len));
const uint32_t fragment_count =
(manifest.block_size() + OTA_FRAG_DATA_V2 - 1) / OTA_FRAG_DATA_V2;
ASSERT_EQ(fragment_count, 6u);
std::array<uint8_t, 1024> reassembled{};
uint8_t expected_stream_id[4];
mh4(expected_stream_id, manifest.payload, manifest.block_size());
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));
uint8_t fragment = 0;
uint16_t encoded_len = 0;
bool deflated = true;
ASSERT_TRUE(ota_data_v2_unpack(data.frag_off, fragment, encoded_len, deflated));
EXPECT_EQ(fragment, i);
EXPECT_EQ(encoded_len, manifest.block_size());
EXPECT_FALSE(deflated);
ASSERT_GE(data.data_len, OTA_DATA_V2_STREAM_ID_BYTES);
EXPECT_EQ(0, memcmp(data.data, expected_stream_id, sizeof(expected_stream_id)));
const uint16_t slice_len =
(uint16_t)(data.data_len - OTA_DATA_V2_STREAM_ID_BYTES);
const uint32_t offset = i * OTA_FRAG_DATA_V2;
EXPECT_EQ(slice_len, (uint16_t)std::min<uint32_t>(OTA_FRAG_DATA_V2,
manifest.block_size() - offset));
if (i + 1 < fragment_count) EXPECT_EQ(sent.items.back().size(), MAX_PACKET_PAYLOAD);
memcpy(reassembled.data() + offset, data.data + OTA_DATA_V2_STREAM_ID_BYTES, slice_len);
}
EXPECT_EQ(0, memcmp(reassembled.data(), manifest.payload, manifest.block_size()));
}
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, NewClientRequestsV2ThenRetriesLegacyWhenNoV2DataArrives) {
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);
ReqMsg first{};
ASSERT_TRUE(decode_req(sent.items[0].data(), (uint16_t)sent.items[0].size(), first));
EXPECT_TRUE(ota_req_is_v2(first.want_mask));
EXPECT_EQ(ota_req_v2_fragments(first.want_mask), 0x007Fu); // remains a complete old-source request
EXPECT_EQ(first.want_mask & OTA_REQ_V2_ALLOW_DEFLATE, 0u); // no decoder was linked/configured
const uint32_t timeout = client.fetchRetryTimeoutMs();
client.set_clock(100 + timeout);
client.loop();
ASSERT_EQ(sent.items.size(), 2u);
ReqMsg fallback{};
ASSERT_TRUE(decode_req(sent.items[1].data(), (uint16_t)sent.items[1].size(), fallback));
EXPECT_FALSE(ota_req_is_v2(fallback.want_mask));
EXPECT_EQ(fallback.want_mask, 0x007Fu);
}
TEST(OtaTransfer, RejectedFirstFlightDoesNotDisableV2BeforeAnyRequestWasSent) {
MotaManifest manifest;
ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest));
OtaManager client;
OtaStoreRam<4096> store;
GatedCapture sent;
client.begin(SIM_TARGET_ID, gated_capture_send, &sent);
client.set_fetch_store(&store);
client.set_clock(100);
client.pull(manifest.merkle_root, manifest.target_id);
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);
for (uint32_t i = 1; i <= 10; ++i) {
client.set_clock(100 + i * client.fetchRetryTimeoutMs());
client.loop();
}
EXPECT_TRUE(sent.items.empty());
sent.accept = true;
client.loop();
ASSERT_EQ(sent.items.size(), 1u);
ReqMsg first{};
ASSERT_TRUE(decode_req(sent.items[0].data(), (uint16_t)sent.items[0].size(), first));
EXPECT_TRUE(ota_req_is_v2(first.want_mask));
}
TEST(OtaTransfer, RejectedSparseRetryKeepsReceivedV2Fragments) {
MotaManifest manifest;
ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest));
OtaManager client;
OtaStoreRam<4096> store;
GatedCapture sent;
sent.accept = true;
client.begin(SIM_TARGET_ID, gated_capture_send, &sent);
client.set_fetch_store(&store);
client.set_clock(100);
client.pull(manifest.merkle_root, manifest.target_id);
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));
uint8_t body[OTA_DATA_V2_STREAM_ID_BYTES + OTA_FRAG_DATA_V2];
mh4(body, manifest.payload, manifest.block_size());
memcpy(body + OTA_DATA_V2_STREAM_ID_BYTES, manifest.payload, OTA_FRAG_DATA_V2);
DataMsg partial{};
memcpy(partial.manifest_id, manifest.merkle_root, 4);
ASSERT_TRUE(ota_data_v2_pack(0, (uint16_t)manifest.block_size(), false, partial.frag_off));
partial.data = body;
partial.data_len = sizeof body;
uint8_t wire[MAX_PACKET_PAYLOAD];
ASSERT_TRUE(client.on_message(wire, encode_data(wire, sizeof wire, partial)));
sent.items.clear();
sent.accept = false;
uint32_t now = 100;
for (unsigned i = 0; i < 10; ++i) {
now += client.fetchRetryTimeoutMs();
client.set_clock(now);
client.loop();
}
EXPECT_TRUE(sent.items.empty());
sent.accept = true;
client.loop();
ASSERT_EQ(sent.items.size(), 1u);
ReqMsg retry{};
ASSERT_TRUE(decode_req(sent.items[0].data(), (uint16_t)sent.items[0].size(), retry));
EXPECT_TRUE(ota_req_is_v2(retry.want_mask));
EXPECT_EQ(ota_req_v2_fragments(retry.want_mask) & 1u, 0u); // keep fragment zero
}
TEST(OtaTransfer, TwoKilobyteClientAcceptsCanonicalLegacyFallbackThroughBitTwelve) {
MotaManifest manifest;
ASSERT_TRUE(mota_parse(SIM_MOTA_2K, SIM_MOTA_2K_LEN, manifest));
ASSERT_EQ(manifest.block_size(), 2048u);
ASSERT_EQ(manifest.block_count, 2u);
OtaManager client;
OtaStoreRam<4096> store;
CapturedMessages sent;
client.begin(SIM_TARGET_ID, capture_send, &sent);
client.set_fetch_store(&store);
client.set_clock(100);
ASSERT_EQ(client.pull(manifest.merkle_root, manifest.target_id), OtaManager::PULL_STARTED);
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);
ReqMsg extended{};
ASSERT_TRUE(decode_req(sent.items[0].data(),
(uint16_t)sent.items[0].size(), extended));
EXPECT_TRUE(ota_req_is_v2(extended.want_mask));
EXPECT_TRUE(ota_req_v2_extended_length(extended.want_mask));
EXPECT_EQ(ota_req_v2_fragments(extended.want_mask), 0x0FFFu);
// The first canonical legacy slice selects the fallback geometry. Completing the 2 KiB block then
// necessarily receives fragment bit 12 (offset 1920) before its proof can authenticate and commit it.
ASSERT_EQ((manifest.block_size() + OTA_FRAG_DATA - 1) / OTA_FRAG_DATA, 13u);
deliver_verified_block(client, manifest, 0);
ASSERT_EQ(client.blocksHave(), 1u);
ASSERT_EQ(sent.items.size(), 2u);
ReqMsg next{};
ASSERT_TRUE(decode_req(sent.items.back().data(),
(uint16_t)sent.items.back().size(), next));
EXPECT_EQ(next.block_idx, 1u);
EXPECT_FALSE(ota_req_is_v2(next.want_mask));
EXPECT_EQ(next.want_mask, 0x0003u); // short tail remains legacy after downgrade
deliver_verified_block(client, manifest, 1);
EXPECT_EQ(client.fetchState(), OtaManager::COMPLETE);
EXPECT_EQ(client.blocksHave(), client.blocksTotal());
ASSERT_EQ(store.staged_size(), SIM_MOTA_2K_LEN);
EXPECT_EQ(0, memcmp(store.data(), SIM_MOTA_2K, SIM_MOTA_2K_LEN));
}
TEST(OtaTransfer, MalformedLegacyDataCannotForceV2SessionDowngrade) {
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();
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));
uint8_t wire[MAX_PACKET_PAYLOAD];
DataMsg malformed;
memcpy(malformed.manifest_id, manifest.merkle_root, 4);
malformed.block_idx = 0;
malformed.frag_off = 1; // not canonical 160-byte legacy alignment
malformed.data = manifest.payload;
malformed.data_len = OTA_FRAG_DATA;
uint16_t wire_len = encode_data(wire, sizeof(wire), malformed);
ASSERT_GT(wire_len, 0);
EXPECT_FALSE(client.on_message(wire, wire_len));
uint8_t body[OTA_DATA_V2_STREAM_ID_BYTES + OTA_FRAG_DATA_V2];
mh4(body, manifest.payload, manifest.block_size());
memcpy(body + OTA_DATA_V2_STREAM_ID_BYTES, manifest.payload, OTA_FRAG_DATA_V2);
DataMsg valid;
memcpy(valid.manifest_id, manifest.merkle_root, 4);
valid.block_idx = 0;
ASSERT_TRUE(ota_data_v2_pack(0, (uint16_t)manifest.block_size(), false, valid.frag_off));
valid.data = body;
valid.data_len = sizeof(body);
wire_len = encode_data(wire, sizeof(wire), valid);
ASSERT_EQ(wire_len, MAX_PACKET_PAYLOAD);
EXPECT_TRUE(client.on_message(wire, wire_len)); // malformed legacy packet left v2 negotiation intact
}
TEST(OtaTransfer, PartialV2SeederCanDisappearAndLegacySeederTakesOver) {
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_transport_deflate_decoder(ota_transport_inflate);
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));
uint8_t body[OTA_DATA_V2_STREAM_ID_BYTES + OTA_FRAG_DATA_V2];
mh4(body, manifest.payload, manifest.block_size());
memcpy(body + OTA_DATA_V2_STREAM_ID_BYTES, manifest.payload, OTA_FRAG_DATA_V2);
DataMsg partial{};
memcpy(partial.manifest_id, manifest.merkle_root, 4);
partial.block_idx = 0;
ASSERT_TRUE(ota_data_v2_pack(0, (uint16_t)manifest.block_size(), false, partial.frag_off));
partial.data = body;
partial.data_len = sizeof(body);
uint8_t wire[MAX_PACKET_PAYLOAD];
uint16_t wire_len = encode_data(wire, sizeof(wire), partial);
ASSERT_EQ(wire_len, MAX_PACKET_PAYLOAD);
ASSERT_TRUE(client.on_message(wire, wire_len));
sent.items.clear();
uint32_t now = 100;
uint32_t timeout = client.fetchRetryTimeoutMs();
now += timeout;
client.set_clock(now);
client.loop(); // one sparse v2 retry
ASSERT_FALSE(sent.items.empty());
ReqMsg retry{};
ASSERT_TRUE(decode_req(sent.items.back().data(), (uint16_t)sent.items.back().size(), retry));
EXPECT_TRUE(ota_req_is_v2(retry.want_mask));
sent.items.clear();
timeout = client.fetchRetryTimeoutMs();
now += timeout;
client.set_clock(now);
client.loop(); // bounded whole-session legacy fallback
ASSERT_FALSE(sent.items.empty());
ReqMsg fallback{};
ASSERT_TRUE(decode_req(sent.items.back().data(), (uint16_t)sent.items.back().size(), fallback));
EXPECT_FALSE(ota_req_is_v2(fallback.want_mask));
EXPECT_EQ(fallback.want_mask, 0x007Fu);
deliver_verified_block(client, manifest, 0); // an old seeder can now make forward progress
EXPECT_EQ(client.blocksHave(), 1u);
}
TEST(OtaTransfer, FailedTwoKilobyteInflateRetriesRawWithoutLeavingExtendedV2) {
MotaManifest manifest;
ASSERT_TRUE(mota_parse(SIM_MOTA_2K, SIM_MOTA_2K_LEN, manifest));
ASSERT_EQ(manifest.block_size(), 2048u);
ASSERT_EQ(manifest.block_count, 2u);
OtaManager client;
OtaStoreRam<4096> store;
CapturedMessages sent;
RejectWireDecode decoder{};
client.begin(SIM_TARGET_ID, capture_send, &sent);
client.set_transport_deflate_decoder(reject_wire_decode, &decoder);
client.set_fetch_store(&store);
client.set_clock(100);
ASSERT_EQ(client.pull(manifest.merkle_root, manifest.target_id), OtaManager::PULL_STARTED);
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);
ReqMsg first{};
ASSERT_TRUE(decode_req(sent.items[0].data(),
(uint16_t)sent.items[0].size(), first));
EXPECT_TRUE(ota_req_is_v2(first.want_mask));
EXPECT_TRUE(ota_req_v2_extended_length(first.want_mask));
EXPECT_NE(first.want_mask & OTA_REQ_V2_ALLOW_DEFLATE, 0u);
sent.items.clear();
deliver_v2_representation(
client, manifest, 0,
SIM_MOTA_2K_DEFLATED + SIM_MOTA_2K_DEFLATED_OFFSETS[0],
SIM_MOTA_2K_DEFLATED_LENGTHS[0]);
EXPECT_EQ(decoder.calls, 1u);
EXPECT_EQ(client.blocksHave(), 0u);
EXPECT_TRUE(sent.items.empty()); // retry is paced by the normal deadline
const uint32_t timeout = client.fetchRetryTimeoutMs();
client.set_clock(100 + timeout);
client.loop();
ASSERT_EQ(sent.items.size(), 1u);
ReqMsg retry{};
ASSERT_TRUE(decode_req(sent.items[0].data(),
(uint16_t)sent.items[0].size(), retry));
EXPECT_EQ(retry.block_idx, 0u);
EXPECT_TRUE(ota_req_is_v2(retry.want_mask));
EXPECT_TRUE(ota_req_v2_extended_length(retry.want_mask));
EXPECT_EQ(retry.want_mask & OTA_REQ_V2_ALLOW_DEFLATE, 0u);
EXPECT_EQ(ota_req_v2_fragments(retry.want_mask), 0x0FFFu);
sent.items.clear();
deliver_verified_v2_raw_block(client, manifest, 0);
ASSERT_EQ(client.blocksHave(), 1u);
EXPECT_EQ(decoder.calls, 1u); // raw v2 bypasses the failed decoder
ASSERT_EQ(sent.items.size(), 1u);
ReqMsg next{};
ASSERT_TRUE(decode_req(sent.items[0].data(),
(uint16_t)sent.items[0].size(), next));
EXPECT_EQ(next.block_idx, 1u);
EXPECT_TRUE(ota_req_is_v2(next.want_mask));
EXPECT_TRUE(ota_req_v2_extended_length(next.want_mask));
EXPECT_EQ(next.want_mask & OTA_REQ_V2_ALLOW_DEFLATE, 0u);
deliver_verified_v2_raw_block(client, manifest, 1);
EXPECT_EQ(client.fetchState(), OtaManager::COMPLETE);
EXPECT_EQ(client.blocksHave(), client.blocksTotal());
EXPECT_EQ(decoder.calls, 1u);
ASSERT_EQ(store.staged_size(), SIM_MOTA_2K_LEN);
EXPECT_EQ(0, memcmp(store.data(), SIM_MOTA_2K, SIM_MOTA_2K_LEN));
}
TEST(OtaTransfer, ConfirmedV2SeederCanDisappearBetweenBlocksAndLegacySeederTakesOver) {
MotaManifest manifest;
ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest));
ASSERT_GE(manifest.block_count, 2u);
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_v2_raw_block(client, manifest, 0);
ASSERT_EQ(client.blocksHave(), 1u);
sent.items.clear(); // v2 source vanishes before the next flight answers
uint32_t now = 100;
uint32_t timeout = client.fetchRetryTimeoutMs();
now += timeout;
client.set_clock(now);
client.loop(); // one sparse v2 retry
ASSERT_EQ(sent.items.size(), 1u);
ReqWindowMsg retry{};
ASSERT_TRUE(decode_req_window(sent.items.back().data(),
(uint16_t)sent.items.back().size(), retry));
ASSERT_EQ(retry.n_items, 1u);
EXPECT_TRUE(ota_req_is_v2(retry.items[0].want_mask));
sent.items.clear();
timeout = client.fetchRetryTimeoutMs();
now += timeout;
client.set_clock(now);
client.loop(); // whole empty flight falls back to legacy
ASSERT_EQ(sent.items.size(), 1u);
ReqWindowMsg fallback{};
ASSERT_TRUE(decode_req_window(sent.items.back().data(),
(uint16_t)sent.items.back().size(), fallback));
ASSERT_GE(fallback.n_items, 1u);
for (uint8_t i = 0; i < fallback.n_items; i++) {
EXPECT_FALSE(ota_req_is_v2(fallback.items[i].want_mask));
}
deliver_verified_block(client, manifest, 1); // deployed source resumes the same session
EXPECT_EQ(client.blocksHave(), 2u);
}
TEST(OtaTransfer, V2NeverMixesDifferentCompressedRepresentationsAcrossSeeders) {
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_transport_deflate_decoder(test_representation_decode, &manifest);
client.set_fetch_store(&store);
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);
ReqMsg request{};
ASSERT_TRUE(decode_req(sent.items[0].data(), (uint16_t)sent.items[0].size(), request));
EXPECT_NE(request.want_mask & OTA_REQ_V2_ALLOW_DEFLATE, 0u);
std::array<uint8_t, 300> representation_a{};
std::array<uint8_t, 300> representation_b{};
representation_a.fill(0xA1);
representation_b.fill(0xB2);
uint8_t id_a[4], id_b[4];
mh4(id_a, representation_a.data(), representation_a.size());
mh4(id_b, representation_b.data(), representation_b.size());
ASSERT_NE(0, memcmp(id_a, id_b, sizeof(id_a)));
auto deliver_fragment = [&](const std::array<uint8_t, 300>& representation,
const uint8_t id[4], uint8_t fragment) {
const uint32_t offset = (uint32_t)fragment * OTA_FRAG_DATA_V2;
const uint16_t length = (uint16_t)std::min<uint32_t>(OTA_FRAG_DATA_V2,
representation.size() - offset);
std::array<uint8_t, OTA_DATA_V2_STREAM_ID_BYTES + OTA_FRAG_DATA_V2> body{};
memcpy(body.data(), id, OTA_DATA_V2_STREAM_ID_BYTES);
memcpy(body.data() + OTA_DATA_V2_STREAM_ID_BYTES, representation.data() + offset, length);
uint16_t descriptor = 0;
if (!ota_data_v2_pack(fragment, (uint16_t)representation.size(), true, descriptor)) return false;
DataMsg data;
memcpy(data.manifest_id, manifest.merkle_root, 4);
data.block_idx = 0;
data.frag_off = descriptor;
data.data = body.data();
data.data_len = (uint16_t)(OTA_DATA_V2_STREAM_ID_BYTES + length);
uint8_t wire[MAX_PACKET_PAYLOAD];
const uint16_t wire_len = encode_data(wire, sizeof(wire), data);
return wire_len != 0 && client.on_message(wire, wire_len);
};
ASSERT_TRUE(deliver_fragment(representation_a, id_a, 0));
EXPECT_FALSE(deliver_fragment(representation_b, id_b, 1)); // valid stream, wrong representation id
ASSERT_TRUE(deliver_fragment(representation_a, id_a, 1));
std::vector<uint8_t> scratch(manifest.block_count * 4);
uint8_t siblings[32 * 4];
const uint8_t sibling_count = merkle_gen_proof(
manifest.leaves, manifest.block_count, 0, scratch.data(), siblings);
ProofMsg proof;
memcpy(proof.manifest_id, manifest.merkle_root, 4);
proof.block_idx = 0;
proof.n_proof = sibling_count;
proof.proof = siblings;
uint8_t proof_wire[MAX_PACKET_PAYLOAD];
const uint16_t proof_len = encode_proof(proof_wire, sizeof(proof_wire), proof);
ASSERT_GT(proof_len, 0);
ASSERT_TRUE(client.on_message(proof_wire, proof_len));
EXPECT_EQ(client.blocksHave(), 1u);
const uint32_t payload_offset = (uint32_t)(manifest.payload - SIM_MOTA_1K);
EXPECT_EQ(0, memcmp(store.data() + payload_offset, manifest.payload, manifest.block_size()));
}
TEST(OtaTransfer, ExtendedCompressedRepresentationReassemblesThroughFragmentEight) {
MotaManifest manifest;
ASSERT_TRUE(mota_parse(SIM_MOTA_2K, SIM_MOTA_2K_LEN, manifest));
ASSERT_EQ(manifest.block_size(), 2048u);
std::vector<uint8_t> representation(1500);
for (uint32_t i = 0; i < representation.size(); i++) {
representation[i] = (uint8_t)(i * 29u + 7u);
}
ASSERT_GT(representation.size(), 1024u);
ASSERT_LT(representation.size(), manifest.block_size());
ASSERT_EQ((representation.size() + OTA_FRAG_DATA_V2 - 1) / OTA_FRAG_DATA_V2, 9u);
SyntheticRepresentationDecode decoder{};
decoder.representation = representation.data();
decoder.representation_len = (uint16_t)representation.size();
decoder.decoded = manifest.payload;
decoder.decoded_len = (uint16_t)manifest.block_size();
OtaManager client;
OtaStoreRam<4096> store;
CapturedMessages sent;
client.begin(SIM_TARGET_ID, capture_send, &sent);
client.set_transport_deflate_decoder(synthetic_representation_decode, &decoder);
client.set_fetch_store(&store);
ASSERT_EQ(client.pull(manifest.merkle_root, manifest.target_id), OtaManager::PULL_STARTED);
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);
ReqMsg request{};
ASSERT_TRUE(decode_req(sent.items[0].data(),
(uint16_t)sent.items[0].size(), request));
EXPECT_TRUE(ota_req_v2_extended_length(request.want_mask));
EXPECT_NE(request.want_mask & OTA_REQ_V2_ALLOW_DEFLATE, 0u);
sent.items.clear();
deliver_v2_representation(client, manifest, 0,
representation.data(), (uint16_t)representation.size());
EXPECT_EQ(decoder.calls, 1u); // fragment 8 completed reassembly + decode
EXPECT_EQ(client.blocksHave(), 0u); // proof still gates the decoded bytes
EXPECT_TRUE(sent.items.empty());
deliver_block_proof(client, manifest, 0);
EXPECT_EQ(client.blocksHave(), 1u);
const uint32_t payload_offset = (uint32_t)(manifest.payload - SIM_MOTA_2K);
EXPECT_EQ(0, memcmp(store.data() + payload_offset,
manifest.payload, manifest.block_size()));
}
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(OtaFolder, OldHostCapabilityZeroSkipsUnsupportedDeflateOperation) {
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_transport_deflate_decoder(ota_transport_inflate);
client.set_fetch_store(&store);
RamMotaSource old_host;
old_host.add(SIM_MOTA_1K, SIM_MOTA_1K_LEN);
old_host.setSourceCaps(0); // deployed descriptor's reserved byte
ASSERT_TRUE(server.add_source(&old_host));
MotaManifest manifest;
ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest));
server.announce();
pump(client, &server);
client.pull(manifest.merkle_root, manifest.target_id);
pump(client, &server);
EXPECT_EQ(client.fetchState(), OtaManager::COMPLETE);
EXPECT_EQ(old_host.deflateCalls(), 0u); // no per-block timeout on unknown op 0x09
ASSERT_EQ(store.staged_size(), SIM_MOTA_1K_LEN);
EXPECT_EQ(0, memcmp(store.data(), SIM_MOTA_1K, SIM_MOTA_1K_LEN));
}
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 = 23;
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) {
const uint8_t count = frag == 2 ? 3 : 10;
uint8_t rows[10 * OTA_HAVE_ROW_BYTES] = {0};
for (uint8_t index = 0; index < count; ++index) {
uint8_t* row = rows + index * OTA_HAVE_ROW_BYTES;
row[0] = (uint8_t)(frag * 10 + index + 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 = count; have.rows = rows;
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(), 23);
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, 12); // 4096-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);
}
TEST(OtaTransfer, MetadataRetryLimitsDoNotCountRejectedQueueAdmission) {
MotaManifest manifest;
ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, manifest));
for (bool leaves : {false, true}) {
OtaManager client;
OtaStoreRam<4096> store;
GatedCapture sent;
client.begin(SIM_TARGET_ID, gated_capture_send, &sent);
client.set_fetch_store(&store);
ASSERT_EQ(client.pull(manifest.merkle_root, manifest.target_id, leaves), OtaManager::PULL_STARTED);
if (leaves) {
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 auto waiting = leaves ? OtaManager::WANT_LEAVES : OtaManager::WANT_MANIFEST;
ASSERT_EQ(client.fetchState(), waiting);
for (unsigned i = 0; i < 100; ++i) { client.set_clock(i * 1000); client.loop(); }
ASSERT_EQ(client.fetchState(), waiting);
EXPECT_TRUE(sent.items.empty());
sent.accept = true;
client.loop();
ASSERT_EQ(sent.items.size(), 1u);
EXPECT_EQ(ota_msg_type(sent.items[0].data(), sent.items[0].size()), leaves ? OTA_GET_LEAVES : OTA_GET_MANIFEST);
for (unsigned i = 0; i < 21; ++i) client.loop(); // actual unanswered sends remain bounded
EXPECT_EQ(client.fetchState(), OtaManager::FAILED);
}
}
TEST(OtaTransfer, CatalogRetriesRemainAvailableAfterQueueBackpressure) {
for (bool manual : {false, true}) {
OtaManager client;
GatedCapture sent;
client.begin(SIM_TARGET_ID, gated_capture_send, &sent);
client.set_archive_interest(true);
AdvMsg adv{};
adv.seeder_id[0] = 1; adv.n_motas = 1;
uint8_t wire[MAX_PACKET_PAYLOAD];
client.on_message(wire, encode_adv(wire, sizeof wire, adv));
if (manual) client.queryAll();
for (unsigned i = 1; i <= 10; ++i) { client.set_clock(i * OTA_CATALOG_RETRY_MS); client.loop(); }
EXPECT_TRUE(sent.items.empty());
sent.accept = true;
client.loop();
ASSERT_EQ(sent.items.size(), 1u);
sent.accept = false;
for (unsigned i = 11; i <= 20; ++i) { client.set_clock(i * OTA_CATALOG_RETRY_MS); client.loop(); }
EXPECT_EQ(sent.items.size(), 1u);
sent.accept = true;
client.loop();
ASSERT_EQ(sent.items.size(), 2u);
EXPECT_EQ(ota_msg_type(sent.items.back().data(), sent.items.back().size()), OTA_QUERY);
}
}
// 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]);
}
TEST(OtaSpeed, BoundsScalingAndLongIntervals) {
EXPECT_TRUE(validSpeed(0.05f));
EXPECT_TRUE(validSpeed(3.0f));
EXPECT_FALSE(validSpeed(0.049f));
EXPECT_FALSE(validSpeed(3.001f));
EXPECT_FALSE(validSpeed(NAN));
EXPECT_FALSE(validSpeed(INFINITY));
for (uint32_t delay : {0u, 1u, 100u, 1000u, 86400000u}) {
EXPECT_EQ(scaleDelay(delay, 1.0f), delay);
}
EXPECT_EQ(scaleDelay(1000, 0.05f), 20000u);
EXPECT_EQ(scaleDelay(1000, 0.5f), 2000u);
EXPECT_EQ(scaleDelay(1000, 3.0f), 334u);
EXPECT_EQ(scaleDelay(UINT32_MAX, 0.05f), (uint32_t)INT32_MAX);
EXPECT_EQ(relayDelay(2000000, 0.05f), 0x00ffffffU);
EXPECT_EQ(relayDelay(100, 1.0f), 100u);
EXPECT_EQ(packetQuietTime(1000, 0.05f), 19000u);
EXPECT_EQ(packetQuietTime(1000, 1.0f), 0u);
EXPECT_EQ(packetQuietTime(1000, 3.0f), 0u);
LongTimer timer;
uint32_t now = UINT32_MAX - 100;
timer.arm(now, 7u * 86400000u, 0.05f);
for (unsigned day = 1; day < 140; ++day) {
now += 86400000u;
EXPECT_FALSE(timer.ready(now)) << day;
}
now += 86400000u;
EXPECT_TRUE(timer.ready(now));
timer.arm(now, 1000, 0.5f);
timer.rescale(now + 1000, 0.5f, 1.0f);
EXPECT_FALSE(timer.ready(now + 1499));
EXPECT_TRUE(timer.ready(now + 1500));
}
TEST(OtaSpeed, ManifestPacingAndRetryAllowancesFollowFactor) {
MotaManifest manifest;
ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, manifest));
for (float speed : {0.05f, 0.5f, 1.0f, 3.0f}) {
OtaManager server;
CapturedMessages sent;
server.begin(0, capture_send, &sent);
ASSERT_TRUE(server.set_speed(speed));
EXPECT_FALSE(server.set_speed(NAN));
EXPECT_FLOAT_EQ(server.speed(), speed);
server.set_link_timing(80, 2000);
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];
const uint16_t len = encode_get_manifest(wire, sizeof wire, request);
server.set_clock(UINT32_MAX - 40); // egress crosses millis rollover
ASSERT_TRUE(server.on_message(wire, len));
const uint32_t gap = speed == 0.05f ? 3200 : speed == 0.5f ? 320 : speed == 1.0f ? 160 : 54;
server.set_clock(UINT32_MAX - 40 + gap - 1);
server.serviceEgress();
EXPECT_TRUE(sent.items.empty());
server.set_clock(UINT32_MAX - 40 + gap);
server.serviceEgress();
ASSERT_EQ(sent.items.size(), 1u);
server.set_clock(UINT32_MAX - 40 + 2 * gap);
server.serviceEgress();
ASSERT_EQ(sent.items.size(), 2u);
EXPECT_EQ(server.pendingManifestJobs(), 0u);
const uint32_t timeout = server.fetchRetryTimeoutMs();
ASSERT_TRUE(server.set_speed(1.0f));
const uint32_t normal = server.fetchRetryTimeoutMs();
EXPECT_GE(timeout, normal);
if (speed < 1.0f) EXPECT_GT(timeout, normal);
}
}
TEST(OtaSpeed, LiveChangeRescalesQueuedMetadataWithoutDroppingIt) {
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);
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];
server.set_clock(UINT32_MAX - 159); // deadline is exactly zero
ASSERT_TRUE(server.on_message(wire, encode_get_manifest(wire, sizeof wire, request)));
server.set_clock(UINT32_MAX - 79); // 80 ms remain at 1x
ASSERT_TRUE(server.set_speed(0.5f));
server.set_clock(79); // 159 ms later
server.serviceEgress();
EXPECT_TRUE(sent.items.empty());
server.set_clock(80);
server.serviceEgress();
ASSERT_EQ(sent.items.size(), 1u);
server.set_clock(160);
ASSERT_TRUE(server.set_speed(3.0f)); // 240 ms of 0.5x waiting become 40 ms
server.set_clock(199);
server.serviceEgress();
EXPECT_EQ(sent.items.size(), 1u);
server.set_clock(200);
server.serviceEgress();
EXPECT_EQ(sent.items.size(), 2u);
}
TEST(OtaSpeed, WholeTransferRecoversLostDataAtEverySpeed) {
struct Link {
OtaManager* destination;
std::vector<std::vector<uint8_t>> queue;
uint32_t next = 0;
bool drop_data = false;
static bool send(void* context, const uint8_t* bytes, uint16_t len, bool) {
auto& link = *static_cast<Link*>(context);
if (link.queue.size() >= 2) return false;
link.queue.emplace_back(bytes, bytes + len);
return true;
}
void deliver(uint32_t now, float speed) {
if (queue.empty() || (int32_t)(now - next) < 0) return;
auto bytes = std::move(queue.front()); queue.erase(queue.begin());
next = now + 100 + packetQuietTime(100, speed);
if (drop_data && ota_msg_type(bytes.data(), bytes.size()) == OTA_DATA) drop_data = false;
else destination->on_message(bytes.data(), bytes.size());
}
};
for (float speed : {0.05f, 0.5f, 1.0f, 3.0f}) {
SCOPED_TRACE(speed);
OtaManager server, client;
OtaStoreRam<4096> store;
Link to_client{&client, {}, 0, true}, to_server{&server};
server.begin(0, Link::send, &to_client);
client.begin(SIM_TARGET_ID, Link::send, &to_server);
client.set_fetch_store(&store);
client.set_autofetch(OtaManager::AUTOFETCH_ANY);
for (auto* manager : {&server, &client}) {
ASSERT_TRUE(manager->set_speed(speed));
manager->set_link_timing(100, 2000);
manager->set_max_hops(0);
}
ASSERT_TRUE(server.serve(SIM_MOTA_1K, SIM_MOTA_1K_LEN));
server.announce();
uint32_t next_loop = client.retryDelay(1000);
for (uint32_t now = 0; now < 600000 && client.fetchState() != OtaManager::COMPLETE; now += 10) {
server.set_clock(now); client.set_clock(now);
server.serviceEgress(); client.serviceEgress();
to_client.deliver(now, speed); to_server.deliver(now, speed);
if (now >= next_loop) {
server.loop(); client.loop();
next_loop = now + client.retryDelay(1000);
}
}
EXPECT_FALSE(to_client.drop_data);
ASSERT_EQ(client.fetchState(), OtaManager::COMPLETE);
ASSERT_EQ(store.staged_size(), SIM_MOTA_1K_LEN);
EXPECT_EQ(memcmp(store.data(), SIM_MOTA_1K, SIM_MOTA_1K_LEN), 0);
}
}
TEST(OtaSpeed, DiscoveryAndCatalogRecoveryRespectSlowerTiming) {
OtaManager client;
CapturedMessages sent;
client.begin(SIM_TARGET_ID, capture_send, &sent);
client.set_archive_interest(true);
ASSERT_TRUE(client.set_speed(0.05f));
AdvMsg adv{};
adv.seeder_id[0] = 1; // deterministic jitter of 1 ms before scaling
adv.n_motas = 1;
uint8_t wire[MAX_PACKET_PAYLOAD];
client.set_clock(100);
client.on_message(wire, encode_adv(wire, sizeof wire, adv));
client.set_clock(6119);
client.loop();
EXPECT_TRUE(sent.items.empty());
client.set_clock(6120);
client.loop();
ASSERT_EQ(sent.items.size(), 1u);
client.set_clock(6120 + 299999);
client.loop();
EXPECT_EQ(sent.items.size(), 1u);
client.set_clock(6120 + 300000);
client.loop();
EXPECT_EQ(sent.items.size(), 2u);
}
TEST(OtaSpeed, CatalogRetryStaysArmedAtMillisRollover) {
for (float speed : {0.05f, 1.0f, 3.0f}) {
OtaManager client;
CapturedMessages sent;
client.begin(SIM_TARGET_ID, capture_send, &sent);
ASSERT_TRUE(client.set_speed(speed));
AdvMsg adv{};
adv.seeder_id[0] = 1; adv.n_motas = 1;
uint8_t wire[MAX_PACKET_PAYLOAD];
client.on_message(wire, encode_adv(wire, sizeof wire, adv));
client.set_clock(0u - client.retryDelay(OTA_CATALOG_RETRY_MS));
client.queryAll(); // retry deadline would be zero, the old disarmed sentinel
ASSERT_EQ(sent.items.size(), 1u);
client.set_clock(0); client.loop();
EXPECT_EQ(sent.items.size(), 1u);
client.set_clock(1); client.loop();
EXPECT_EQ(sent.items.size(), 2u);
}
}
TEST(OtaSpeed, SlowRadioSettingDoesNotDelayLocalResumeVerification) {
OtaStoreRam<4096> store;
ASSERT_TRUE(store.begin(SIM_MOTA_LEN));
ASSERT_TRUE(store.write(0, SIM_MOTA, SIM_MOTA_LEN));
OtaManager manager;
manager.begin(SIM_TARGET_ID, nullptr, nullptr);
manager.set_fetch_store(&store);
manager.set_autofetch(OtaManager::AUTOFETCH_ANY);
ASSERT_TRUE(manager.set_speed(0.05f));
EXPECT_EQ(manager.loopIntervalMs(1000), 20000u);
ASSERT_TRUE(manager.resumeStaged(nullptr));
ASSERT_EQ(manager.fetchState(), OtaManager::VERIFYING_STAGED);
EXPECT_EQ(manager.loopIntervalMs(1000), 1000u);
manager.loop();
EXPECT_EQ(manager.fetchState(), OtaManager::COMPLETE);
}
TEST(OtaSpeed, ProactiveProofPreservesDrainTimeAndSurvivesRollover) {
MotaManifest manifest;
ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest));
const unsigned fragments = (manifest.block_size() + OTA_FRAG_DATA - 1) / OTA_FRAG_DATA;
for (uint32_t start : {0u, UINT32_MAX - 599}) {
OtaManager server;
CapturedMessages sent;
server.begin(0, capture_send, &sent);
server.set_link_timing(100, 2000); // three packet-service intervals = 600 ms
ASSERT_TRUE(server.serve(SIM_MOTA_1K, SIM_MOTA_1K_LEN));
ReqMsg request{};
memcpy(request.manifest_id, manifest.merkle_root, 4);
request.want_mask = 0xffff;
uint8_t wire[MAX_PACKET_PAYLOAD];
server.set_clock(start);
ASSERT_TRUE(server.on_message(wire, encode_req(wire, sizeof wire, request)));
for (unsigned i = 0; i < fragments; ++i) server.serviceEgress();
ASSERT_EQ(sent.items.size(), fragments);
server.set_clock(start + 300);
ASSERT_TRUE(server.set_speed(3)); // cannot shorten a physical drain already pending
server.set_clock(start + 599); server.serviceEgress();
EXPECT_EQ(sent.items.size(), fragments);
server.set_clock(start + 601); server.serviceEgress();
ASSERT_EQ(sent.items.size(), fragments + 1);
EXPECT_EQ(ota_msg_type(sent.items.back().data(), sent.items.back().size()), OTA_PROOF);
// Also preserve the drain when the response job starts at 3x.
sent.items.clear();
server.set_clock(1000);
ASSERT_TRUE(server.on_message(wire, encode_req(wire, sizeof wire, request)));
for (unsigned i = 0; i < fragments; ++i) server.serviceEgress();
server.set_clock(1599); server.serviceEgress();
EXPECT_EQ(sent.items.size(), fragments);
server.set_clock(1600); server.serviceEgress();
EXPECT_EQ(sent.items.size(), fragments + 1);
}
}
TEST(OtaSpeed, MissingProofFallbackStaysArmedAtMillisRollover) {
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);
ASSERT_TRUE(client.set_speed(0.05f));
client.pull(manifest.merkle_root, manifest.target_id);
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);
sent.items.clear();
client.set_clock(0u - client.retryDelay(OTA_PROOF_GRACE_MS));
uint8_t wire[MAX_PACKET_PAYLOAD];
for (uint32_t offset = 0; offset < manifest.block_size(); offset += OTA_FRAG_DATA) {
DataMsg data{};
memcpy(data.manifest_id, manifest.merkle_root, 4);
data.frag_off = offset;
data.data = manifest.payload + offset;
data.data_len = std::min<uint32_t>(OTA_FRAG_DATA, manifest.block_size() - offset);
ASSERT_TRUE(client.on_message(wire, encode_data(wire, sizeof wire, data)));
}
client.set_clock(0); client.serviceEgress();
EXPECT_TRUE(sent.items.empty());
client.set_clock(1); client.serviceEgress();
ASSERT_EQ(sent.items.size(), 1u);
EXPECT_EQ(ota_msg_type(sent.items.back().data(), sent.items.back().size()), OTA_REQ_PROOF);
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}