diff --git a/docs/cli_commands.md b/docs/cli_commands.md index bb7fab9c6..8847a6323 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -4744,8 +4744,12 @@ preview, and vendor/build suffixes. If that string is absent or ambiguous, it uses the existing whole-image CRC and unique BLMF/BLM2 validation to read the embedded OTAFIX version (for example, `> OTAFIX2.4.6`). Some current release images, including MeshTower V2 SD 2.4.6, omit the UF2 text entirely. As a last -fallback, the Adafruit core's startup-captured version is explicitly labelled -`(base)`; it cannot identify an OTAFIX/fork revision. +OTAFIX-specific fallback, it accepts one unambiguous standalone version token +such as `v0.11.0-OTAFIX2.4.9` from only the first 40 KiB of the identified +bootloader region. A corrupt canonical bootloader manifest cannot be bypassed +by that printable token. The Adafruit core's startup-captured version remains +the final fallback and is explicitly labelled `(base)`; it cannot identify an +OTAFIX/fork revision. `unknown` remains possible for a bootloader exposing none of these version sources, invalid geometry/vectors, or unresolved ambiguity. There is no universal diff --git a/src/helpers/Nrf52BootloaderVersion.h b/src/helpers/Nrf52BootloaderVersion.h index 3e39a3498..29a708c4f 100644 --- a/src/helpers/Nrf52BootloaderVersion.h +++ b/src/helpers/Nrf52BootloaderVersion.h @@ -68,6 +68,37 @@ inline size_t nrf52BootVersionTokenSize(const uint8_t* token, size_t available) return 0; // unterminated or too long } +inline bool nrf52BootDecimalComponent(const uint8_t* token, size_t length, size_t& pos) { + const size_t begin = pos; + while (pos < length && token[pos] >= '0' && token[pos] <= '9') ++pos; + return pos != begin; +} + +// Recent OTAFIX images embed the INFO_UF2 version value without the older +// "UF2 Bootloader " label. Match only that explicit version shape, not an +// arbitrary dotted number or a SoftDevice/application string. +inline bool nrf52BootStandaloneOtafixToken(const uint8_t* token, size_t length) { + if (!token || length == 0u || token[0] != 'v') return false; + size_t pos = 1u; + for (unsigned component = 0; component < 3u; ++component) { + if (!nrf52BootDecimalComponent(token, length, pos)) return false; + if (component != 2u && (pos >= length || token[pos++] != '.')) return false; + } + static const char marker[] = "-OTAFIX"; + const size_t marker_size = sizeof(marker) - 1u; + if (length - pos < marker_size || memcmp(token + pos, marker, marker_size) != 0) + return false; + pos += marker_size; + if (!nrf52BootDecimalComponent(token, length, pos) || + pos >= length || token[pos++] != '.' || + !nrf52BootDecimalComponent(token, length, pos)) return false; + if (pos < length && token[pos] == '.') { + ++pos; + if (!nrf52BootDecimalComponent(token, length, pos)) return false; + } + return pos == length || token[pos] == '-' || token[pos] == '+' || token[pos] == '_'; +} + inline bool nrf52BootloaderVersion(const uint8_t* image, size_t size, uint32_t start, uint32_t cached_base_version, char* out, size_t capacity) { @@ -122,6 +153,53 @@ inline bool nrf52BootloaderVersion(const uint8_t* image, size_t size, } if (conflicting) return false; + // Only as a fallback, inspect at most the first 40 KiB of the already + // identified bootloader region. A unique standalone OTAFIX version is useful + // on boards whose installed image lacks a reader-recognized BLMF identity. + // If a canonical identity is present but corrupt, do not let its printable + // version string make that image look healthy. + bool allow_standalone = true; + if (size == ota::OTA_BOOT_IMAGE_SIZE) { + const size_t offset = ota::OTA_BOOT_CANDIDATE_MANIFEST_OFFSET; + const bool magic0 = ota::ota_boot_rd32(image + offset) == ota::OTA_BOOT_MANIFEST_MAGIC0; + const bool magic1 = ota::ota_boot_rd32(image + offset + 4u) == ota::OTA_BOOT_MANIFEST_MAGIC1; + if (magic0 || magic1) { + ota::OtaBootloaderIdentity continuity; + allow_standalone = + magic0 && magic1 && + ota::ota_boot_rd16(image + offset + 8u) == ota::OTA_BOOT_MANIFEST_VERSION && + ota::ota_boot_rd16(image + offset + 10u) == ota::OTA_BOOT_MANIFEST_SIZE && + ota::ota_boot_rd32(image + offset + 12u) == ota::OTA_BOOT_IMAGE_START && + ota::ota_boot_rd32(image + offset + 16u) == ota::OTA_BOOT_IMAGE_SIZE && + ota::ota_bootloader_board_id_valid(ota::ota_boot_rd32(image + offset + 20u)) && + ota::ota_boot_image_crc32(image, size, offset + 40u) == + ota::ota_boot_rd32(image + offset + 40u) && + ota::ota_bootloader_continuity_parse(image + offset, size - offset, + continuity) && + continuity.continuity_present; + } + } + const size_t scan_size = size < ota::OTA_BOOT_IMAGE_SIZE ? size : ota::OTA_BOOT_IMAGE_SIZE; + const uint8_t* standalone = nullptr; + size_t standalone_size = 0; + for (size_t offset = 0; allow_standalone && offset < scan_size; ++offset) { + if (image[offset] != 'v' || + (offset != 0u && nrf52BootVersionChar(image[offset - 1u]))) continue; + const uint8_t* token = image + offset; + const size_t length = nrf52BootVersionTokenSize(token, scan_size - offset); + if (length == 0u || !nrf52BootStandaloneOtafixToken(token, length)) continue; + if (standalone && + (length != standalone_size || memcmp(standalone, token, length) != 0)) return false; + standalone = token; + standalone_size = length; + } + if (standalone) { + if (standalone_size >= capacity) return false; + memcpy(out, standalone, standalone_size); + out[standalone_size] = 0; + return true; + } + // Adafruit's core captures TIMER2->CC[0] at startup, before the timer can // be reused. This is only a base version (no OTAFIX/fork suffix). Never read // the live timer here or reinterpret Nordic settings with a different layout. diff --git a/test/fixtures/nrf52_bootloader_version/test_version.cpp b/test/fixtures/nrf52_bootloader_version/test_version.cpp index f22c91b0d..a90739927 100644 --- a/test/fixtures/nrf52_bootloader_version/test_version.cpp +++ b/test/fixtures/nrf52_bootloader_version/test_version.cpp @@ -26,6 +26,11 @@ static void text(std::vector& bytes, size_t offset, const std::string& assert(offset + line.size() <= bytes.size()); memcpy(bytes.data() + offset, line.data(), line.size()); } +static void standalone(std::vector& bytes, size_t offset, const std::string& version) { + const std::string line = version + " s140 6.1.1\r\n"; + assert(offset + line.size() <= bytes.size()); + memcpy(bytes.data() + offset, line.data(), line.size()); +} static void refreshManifestCrc(std::vector& bytes) { const size_t crc_offset = OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 40; put32(bytes, crc_offset, ota_boot_image_crc32(bytes.data(), bytes.size(), crc_offset)); @@ -99,7 +104,55 @@ int main(int argc, char** argv) { auto bytes = image(); text(bytes, offset, version); expect(bytes, version); } } - auto bytes = image(); text(bytes, 0x100, "0.11.0"); text(bytes, 0x8000, "0.11.0"); + auto bytes = image(); + // OTAFIX 2.4.9 and later board images use a standalone INFO_UF2 value. + // Search only the first 40 KiB of the identified bootloader region. + for (const char* version : {"v0.11.0-OTAFIX2.4.9", "v0.11.0-OTAFIX2.4.10", + "v0.9.2-OTAFIX2.2-BP1.3", "v0.11.0-OTAFIX2.4.9-preview.1"}) { + for (size_t offset : {0x100u, 0x8000u, 0x98fbu, 0x9c9du}) { + auto bytes = image(); standalone(bytes, offset, version); expect(bytes, version); + } + } + bytes = image(); standalone(bytes, 0x100, "v0.11.0-OTAFIX2.4.9"); + standalone(bytes, 0x8000, "v0.11.0-OTAFIX2.4.9"); + expect(bytes, "v0.11.0-OTAFIX2.4.9"); // identical copies are harmless + standalone(bytes, 0x8000, "v0.11.0-OTAFIX2.4.10"); expect(bytes, nullptr); + bytes = image(); standalone(bytes, 0x100, "v0.11.0-OTAFIX2.4.9"); + char small[8]; + assert(!nrf52BootloaderVersion(bytes.data(), bytes.size(), 0xf4000, 0, + small, sizeof(small)) && small[0] == 0); + for (const char* bad : {"v0.11-OTAFIX2.4.9", "v0.11.0-OTAFIX2.x.9", + "v0.11.0-OTAFIX2.4.9junk", "v0.11.0-OTAFIX2.4.", + "v0.11.0-OTAFIX", "v0.11.0-notOTAFIX2.4.9"}) { + bytes = image(); standalone(bytes, 0x100, bad); expect(bytes, nullptr); + } + bytes = image(); standalone(bytes, 0x100, "v0.11.0-OTAFIX2.4.9"); + put32(bytes, 4, 0x27001); expect(bytes, nullptr); // reject application vectors + bytes = image(); standalone(bytes, 0x100, "v0.11.0-OTAFIX2.4.9"); + manifest(bytes, 0x020406ff); expect(bytes, "OTAFIX2.4.6"); // validated metadata wins + bytes[0x300] ^= 1; expect(bytes, nullptr); // never mask a corrupt manifest CRC + bytes = image(); standalone(bytes, 0x100, "v0.11.0-OTAFIX2.4.9"); + manifest(bytes, 0x020409ff); + put32(bytes, OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 20, OTA_XIAO_BOARD_ID_BASE); + memset(bytes.data() + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 24, 0, 16); + memcpy(bytes.data() + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 24, "SCAP_DFU", 8); + refreshManifestCrc(bytes); + OtaBootloaderIdentity unexpected_board; + assert(!ota_bootloader_identity_from_image(bytes.data(), bytes.size(), unexpected_board)); + expect(bytes, "v0.11.0-OTAFIX2.4.9"); // diagnostic fallback for an unexpected board name + bytes = image(); standalone(bytes, 0x100, "v0.11.0-OTAFIX2.4.9"); + manifest(bytes, 0x020409ff); + put16(bytes, OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 8, 0); + refreshManifestCrc(bytes); expect(bytes, nullptr); // malformed BLMF is not a fallback + bytes = image(); standalone(bytes, 0x100, "v0.11.0-OTAFIX2.4.9"); + manifest(bytes, 0x020409ff); + put16(bytes, OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 52, 0); + refreshManifestCrc(bytes); expect(bytes, nullptr); // malformed BLM2 is not a fallback + bytes = image(0xe000, 0xf0000); + standalone(bytes, 0xb000, "v0.11.0-OTAFIX2.4.9"); expect(bytes, nullptr, 0xf0000); + standalone(bytes, 0x9800, "v0.11.0-OTAFIX2.4.9"); + expect(bytes, "v0.11.0-OTAFIX2.4.9", 0xf0000); + bytes = image(); text(bytes, 0x100, "0.11.0"); text(bytes, 0x8000, "0.11.0"); expect(bytes, "0.11.0"); // identical copies are harmless text(bytes, 0x8000, "0.12.0"); expect(bytes, nullptr, 0xf4000, 0xb00); bytes = image(); memcpy(bytes.data() + 0x100, "UF2 Bootloader ", 15); diff --git a/test/test_nrf52_bootloader_version.py b/test/test_nrf52_bootloader_version.py index a673a2f0d..440f14858 100644 --- a/test/test_nrf52_bootloader_version.py +++ b/test/test_nrf52_bootloader_version.py @@ -1,4 +1,4 @@ -"""Compile the real version reader; optionally verify the pinned 2.4.6 release.""" +"""Compile the real version reader; optionally verify pinned OTAFIX releases.""" import hashlib import os from pathlib import Path @@ -78,6 +78,32 @@ class BootloaderVersionTest(unittest.TestCase): self.assertEqual(result.stdout.strip(), "OTAFIX2.4.6") print(f"Verified version reader against {len(names)} signed OTAFIX 2.4.6 release images") + release_249 = os.environ.get("MESHCORE_BOOTLOADER_MOTA_249_ZIP") + if release_249: + self.assertEqual(hashlib.sha256(Path(release_249).read_bytes()).hexdigest(), + "6fd7d799873ba6016d37679d9ef3610e62105c2477b49a71933eec12756e625c", + "unexpected 2.4.9 release archive") + with zipfile.ZipFile(release_249) as archive: + names = [name for name in archive.namelist() if name.endswith(".mota")] + self.assertEqual(len(names), 27) + for name in names: + with self.subTest(release_image=name): + # The pinned bundle holds one exact 40 KiB full image per + # board. Isolate the native reader without making this + # test depend on package capability-policy changes. + container = archive.read(name) + self.assertEqual(len(container), 41330) + payload = container[-5 - 40960:-5] + result = subprocess.run([str(exe), "--hex"], input=payload.hex(), + capture_output=True, text=True, timeout=20) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("OTAFIX2.4.9", result.stdout) + if "sensecap_solar_p1" in name: + self.assertEqual(result.stdout.strip(), "v0.11.0-OTAFIX2.4.9") + if "wiscore_rak4631_board_bootloader" in name: + self.assertEqual(result.stdout.strip(), "OTAFIX2.4.9") + print(f"Verified version reader against {len(names)} OTAFIX 2.4.9 board images") + if __name__ == "__main__": unittest.main()