From 98af9161c63bbe752528e9efbce80913a78fe80b Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 29 Sep 2026 14:37:27 -0700 Subject: [PATCH] Support stock nRF52 bootloader version formats and audit images --- docs/bootloader_version_stock_audit.md | 173 ++++++++++++ docs/cli_commands.md | 10 +- src/helpers/Nrf52BootloaderVersion.h | 6 +- .../stock_artifacts.py | 79 ++++++ .../stock_images.json | 266 ++++++++++++++++++ .../nrf52_bootloader_version/test_version.cpp | 32 ++- test/test_nrf52_bootloader_version.py | 82 +++++- 7 files changed, 640 insertions(+), 8 deletions(-) create mode 100644 docs/bootloader_version_stock_audit.md create mode 100644 test/fixtures/nrf52_bootloader_version/stock_artifacts.py create mode 100644 test/fixtures/nrf52_bootloader_version/stock_images.json diff --git a/docs/bootloader_version_stock_audit.md b/docs/bootloader_version_stock_audit.md new file mode 100644 index 00000000..0b3b8d96 --- /dev/null +++ b/docs/bootloader_version_stock_audit.md @@ -0,0 +1,173 @@ +# Stock nRF52 bootloader version audit + +Checked **2026-09-29**, against the 40 nRF52 variant families resolving to 26 +board configurations in this checkout. This concerns `get bootloader.ver`, not +bootloader installation or OTA eligibility. No devices were flashed, and no +features, storage sizes, or partitions were changed. + +## Result + +The real firmware reader successfully reads **26 distinct manufacturer-published +bootloader images** from 32 artifact records, including older RAK revisions, +current and historical LilyGO images, and vendor HEX, UF2 and DFU ZIP packages. +Repeated copies and identical normalized bootloader images are counted once. +These are publicly supplied stock/recovery images, **not proof that every +manufacturing batch shipped identical bytes**. No untouched live unit was read +in this audit. + +One format gap was fixed: Seeed's T1000-E **LoRaWAN development-board** bootloader +declares `UF2 Bootloader 1.00`. The reader now accepts that two-component version +and returns `1.00` unchanged. It does not invent `1.00.0`. That development image +is separate from Seeed's Meshtastic/MeshCore recovery image, which reports +`0.9.1-5-g488711a` and already worked. + +All other tested images already expose a readable UF2 bootloader record. +Generic bare dotted-number searches remain deliberately unsupported: TinyUSB, +nrfx, and SoftDevice can embed their own versions inside the same bootloader. +The bounded standalone fallback still requires the explicit OTAFIX version +shape. Ambiguous records, invalid vectors, and corrupt canonical OTAFIX metadata +retain the existing rejection rules. + +## Published images actually tested + +The exact source URLs, download hashes, normalized-image hashes, extraction +layout and expected strings are pinned in +[`stock_images.json`](../test/fixtures/nrf52_bootloader_version/stock_images.json). + +| Manufacturer image family | Version captured from the image | +| --- | --- | +| RAK4631/RAK4630 Arduino bootloader, historical through current | `0.3.2-109-gd6b28e6`, `0.4.1-13-g5e6690e-dirty`, `0.4.2`, `0.4.3`, `0.4.4` | +| Heltec HT-n5262, SDK and T114 vendor recovery HEX | `0.9.0-2-g836c8dc-dirty` | +| LilyGO T-Echo | `0.6.1-2-g1224915` | +| LilyGO T-Echo Lite, Card, T-Impulse Plus (including historical builds) | `0.9.2-dirty` | +| Seeed XIAO nRF52840 and Sense | `0.6.1` | +| Seeed XIAO nRF52840 Plus and Sense Plus | `0.9.2-29-g6a9a6a3` | +| Seeed Wio Tracker 1110 SDK/conversion bootloader | `0.6.2-26-g949425a-dirty` | +| Seeed Wio Tracker L1 vendor recovery ZIP | `0.7.0-22-g277a0c8` | +| Seeed T1000-E Meshtastic/MeshCore recovery ZIP | `0.9.1-5-g488711a` | +| Seeed T1000-E LoRaWAN development-board HEX | `1.00` (newly supported) | +| Seeed MeshTracker X1 recovery ZIP | `0.10.0-18-gb93789f` | +| MTools Tec MeshTiny vendor bootloader UF2 | `0.4.5` | +| BQ/Unit Engineering Nano G2 Ultra vendor HEX ZIP | `0.6.4-dirty` | +| Muzi Works R1 Neo release HEX | `0.9.2-31-g990aa7f-dirty` | + +**Use the embedded version, not the filename.** For example, Seeed's XIAO file +named `...bootloader-0.6.2...hex` reports `0.6.1`, and its L1 ZIP named +`...bootloader-0.10.0...zip` reports `0.7.0-22-g277a0c8`. These are actual tested +bytes, not renamed or inferred release numbers. + +All these normalized images use the Adafruit nRF52840 `F4000..FE000` bootloader +window. HEX/UF2 address records identify the bytes. The boot-only Seeed DFU +packages omit MBR/UICR address words; their bootloader binary is extracted using +the manifest's `sd_size`/`bl_size` and the +[Adafruit nRF52840 linker layout](https://github.com/adafruit/Adafruit_nRF52_Bootloader/blob/master/linker/nrf52840.ld). +The runtime reader independently selects the installed MBR/UICR boot address; +this audit does not hardcode that layout into the firmware getter. + +## Coverage of every variant family + +- **Image verified:** a published bootloader for that named product/module was + tested. Factory batch identification still requires reading the actual unit. +- **Representative only:** a related module/SDK bootloader was tested; its exact + factory image was not obtained. Shared build configuration alone does not + establish identical factory bootloaders. +- **Unverified:** no accessible factory bootloader image was obtained. This is + not a demonstrated reader failure. + +| Variant family | Status | Evidence or remaining gap | +| --- | --- | --- | +| `gat562_30s_mesh_kit` | Unverified | GAT documentation/firmware repository checked; no product-specific stock bootloader found. | +| `gat562_mesh_evb_pro` | Unverified | Same GAT gap; RAK board configuration is not factory-image proof. | +| `gat562_mesh_tracker_pro` | Unverified | Same GAT gap; custom vendor/OTAFIX builds also exist. | +| `gat562_mesh_watch13` | Unverified | Same GAT gap. | +| `heltec_mesh_solar` | Representative only | HT-n5262 SDK image passes; Solar downloads found application images, not a board-specific stock bootloader. | +| `heltec_t096` | Representative only | HT-n5262 SDK image passes; T096 factory revision not established. | +| `heltec_t1` | Representative only | SDK image passes; vendor F&T archive contains application-only UF2s. | +| `heltec_t114` | Image verified | Both SDK HEX and T114-specific vendor recovery HEX pass. | +| `heltec_tower_v2` | Representative only | SDK image passes; vendor matching-firmware archive contains applications, not the stock bootloader. | +| `ikoka_handheld_nrf` | Representative only | XIAO board configuration; XIAO module images pass, exact installed factory image not obtained. | +| `ikoka_nano_nrf` | Representative only | Same XIAO representative coverage. | +| `ikoka_stick_nrf` | Representative only | Same XIAO representative coverage. | +| `keepteen_lt1` | Unverified | Manufacturer product/download search did not yield a factory bootloader binary. | +| `lilygo_t_impulse_plus` | Image verified | Both historical and newer vendor bootloader HEX images pass. | +| `lilygo_techo` | Image verified | Vendor T-Echo bootloader passes. | +| `lilygo_techo_card` | Image verified | Three distinct vendor historical/current bootloader images pass. NFC UICR repair application excluded. | +| `lilygo_techo_lite` | Image verified | Vendor bootloader HEX passes. | +| `mesh_pocket` | Representative only | HT-n5262 SDK image passes; exact Pocket factory revision not established. | +| `meshtiny` | Image verified | Direct manufacturer bootloader UF2 passes; newer ADV/factory revisions require their actual image. | +| `meshtracker_x1` | Image verified | Seeed product-specific recovery ZIP passes. | +| `minewsemi_me25ls01` | Unverified | Manufacturer module page lists firmware as none; no stock bootloader binary found. OEM/dev-kit installations can differ. | +| `muzi_base` | Unverified | Manufacturer publishes bootloader source, but its release assets found were for R1 Neo, not BASE. | +| `muziworks_r1_neo` | Image verified | Manufacturer R1 Neo release HEX passes; do not substitute a generic RAK version. | +| `nano_g2_ultra` | Image verified | Manufacturer's preinstalled-bootloader recovery HEX ZIP passes. | +| `promicro` | Unverified | Nologo documents a Nice!Nano UF2 drive; its linked download requires Baidu access. Generic clones need separate factory images. | +| `rak3401` | Representative only | RAK4631 Arduino images pass; RAK3401-specific untouched image not obtained. | +| `rak4631` | Image verified | Five distinct published stock Arduino bootloader revisions pass. RAK4631-R/RUI3 is a different bootloader family. | +| `rak_wismesh_tag` | Representative only | RAK core images pass; exact Tag factory image not obtained. | +| `sensecap_solar` | Representative only | Seeed documentation identifies XIAO DFU; XIAO images pass, exact Solar factory image not obtained. | +| `t1000-e` | Image verified | Both vendor recovery ZIP and separate LoRaWAN development-board HEX pass. | +| `thinknode_m1` | Unverified | Manufacturer repository checked; no factory bootloader binary found. | +| `thinknode_m3` | Unverified | Published factory UF2 is application-only (`26000..52BFF`), not a bootloader image. | +| `thinknode_m4` | Unverified | Published factory UF2 is application-only (`26000..521FF`). | +| `thinknode_m6` | Unverified | Published factory UF2 is application-only (`26000..46CFF`). | +| `thinknode_m8` | Unverified | No accessible product-specific factory bootloader binary found. | +| `wio-tracker-l1` | Image verified | Seeed's L1 recovery ZIP passes. | +| `wio-tracker-l1-1w` | Representative only | L1 recovery image passes; exact 1W/ADV factory revision not established. | +| `wio-tracker-l1-eink` | Representative only | L1 recovery image passes; exact e-ink factory revision not established. | +| `wio_wm1110` | Representative only | Published Wio Tracker 1110 and XIAO SDK images pass; the variant's XIAO config does not prove the shipped module's image. | +| `xiao_nrf52` | Image verified | XIAO, Sense, Plus and Sense Plus vendor images pass. | + +Representative/unverified products use the same shared reader and should report +a version **if their installed bootloader exposes one of the recognized records**. +There is no evidence yet for claiming an exact version or success on every +untouched unit. ESP32, RP2040 and STM32 are outside this nRF52 flash reader; +their getters were not changed and this report does not claim stock-version +capture on those architectures. + +## Reproduce the artifact tests + +Normal tests are offline and include all observed version strings, malformed and +conflicting versions, dependency-version false positives, region/vector bounds, +and the existing OTAFIX metadata/CRC/fallback cases: + +```sh +python3 test/test_nrf52_bootloader_version.py -v +``` + +To replay the real images, download each `url` in `stock_images.json` into a +cache directory using its `file` name, then run: + +```sh +MESHCORE_STOCK_BOOTLOADER_CACHE=/absolute/path/to/cache \ + python3 test/test_nrf52_bootloader_version.py -v +``` + +This checks the exact download SHA-256, extracts only the bootloader window, +checks the normalized-image SHA-256, and invokes the actual C++ reader with an +exact expected version. It never downloads automatically or flashes a device. +The ordinary regression cases cover every captured format even without the +optional cached artifacts. RAK4631 and T1000-E repeater firmware builds passed. + +## What is needed to close the remaining gaps + +For each untouched unit, collect its DFU drive's **`INFO_UF2.TXT`** if present. +To prove the application's flash reader can capture that exact revision, also +obtain a read-only bootloader dump plus the MBR word at `0xFF8`, UICR `NRFFW[0]`, +and flash geometry (or a full vendor HEX including those address records). +Keep the bootloader unchanged while collecting this evidence. Only the identified +bootloader region is needed; private application keys/configuration are not. +If no version bytes or startup register value exist, report `unknown`; a regex +cannot recover a version that was never embedded. + +## Manufacturer sources + +- [RAK stock bootloader catalog](https://github.com/RAKWireless/WisBlock/tree/master/bootloader/RAK4630) +- [Heltec SDK](https://github.com/HelTecAutomation/Heltec_nRF52), [vendor downloads](https://resource.heltec.cn/download) +- LilyGO [T-Echo](https://github.com/Xinyuan-LilyGO/T-Echo), [T-Echo Lite](https://github.com/Xinyuan-LilyGO/T-Echo-Lite), [T-Echo Card](https://github.com/Xinyuan-LilyGO/T-Echo-Card), [T-Impulse Plus](https://github.com/Xinyuan-LilyGO/T-Impulse-Plus) +- [Seeed Arduino bootloader catalog](https://github.com/Seeed-Studio/Adafruit_nRF52_Arduino/tree/master/bootloader), [T1000-E recovery](https://wiki.seeedstudio.com/sensecap_t1000_e/), [T1000-E development board](https://github.com/Seeed-Studio/Seeed-Tracker-T1000-E-for-LoRaWAN-dev-board), [MeshTracker X1 recovery](https://wiki.seeedstudio.com/sensecap_meshtracker_x1_meshcore/), [Wio L1 bootloader guidance](https://wiki.seeedstudio.com/get_started_with_other_mesh_firmware/) +- [MeshTiny manufacturer bootloader download](https://shop.mtoolstec.com/bootloader-update-on-meshtiny.html) +- [Nano G2 Ultra manufacturer bootloader documentation](https://wiki.bqvoy.com/en/meshtastic/nano-g2-ultra) +- [Muzi bootloader source/releases](https://github.com/muzi-works/Adafruit_nRF52_Bootloader), [BASE source guidance](https://muzi.works/pages/base-system-downloads) +- [GAT manufacturer documentation](https://github.com/gat-iot/GAT562-family), [Elecrow manufacturer repositories](https://github.com/Elecrow-RD), [Keepteen](https://www.keepteen.com/) +- [Nologo ProMicro documentation](https://www.nologo.tech/product/otherboard/NRF52840.html) +- [Minewsemi ME25LS01 module specifications](https://en.minewsemi.com/lora-module/lr1110-nrf52840-me25LS01) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 8847a632..c656364d 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -4740,9 +4740,10 @@ selected by the MBR/UICR boot address and bounded by the chip's flash geometry; it does not search application firmware or staged update files. The reader preserves a complete Adafruit/UF2 version string, including OTAFIX, -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 +preview, and vendor/build suffixes. Manufacturer two-component versions such as +Seeed's `1.00` are preserved without inventing a patch number. 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 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 @@ -4758,6 +4759,9 @@ displaying a version neither grants OTA capability nor bypasses install checks. The fix is in MeshCore application firmware; installing another bootloader alone does not update an older application's version reader. +See the [stock bootloader audit](bootloader_version_stock_audit.md) for tested +manufacturer images and the boards still requiring untouched factory dumps. + --- #### View power management support diff --git a/src/helpers/Nrf52BootloaderVersion.h b/src/helpers/Nrf52BootloaderVersion.h index 29a708c4..33c2d001 100644 --- a/src/helpers/Nrf52BootloaderVersion.h +++ b/src/helpers/Nrf52BootloaderVersion.h @@ -50,13 +50,17 @@ inline bool nrf52BootVersionChar(uint8_t c) { // Return the complete token, never a truncated (and possibly different) version. // An embedded marker literal with no actual version is not an INFO_UF2 record. +// Some Seeed stock bootloaders declare two components (e.g. "1.00"). Preserve +// those bytes; adding a synthetic patch number would misreport their version. inline size_t nrf52BootVersionTokenSize(const uint8_t* token, size_t available) { size_t pos = available && token[0] == 'v' ? 1u : 0u; for (unsigned component = 0; component < 3u; ++component) { const size_t begin = pos; while (pos < available && token[pos] >= '0' && token[pos] <= '9') ++pos; if (pos == begin) return 0; - if (component != 2u && (pos >= available || token[pos++] != '.')) return 0; + if (component == 2u) break; + if (component == 1u && (pos >= available || token[pos] != '.')) break; + if (pos >= available || token[pos++] != '.') return 0; } size_t n = 0; for (; n < available && n <= 127u; ++n) { diff --git a/test/fixtures/nrf52_bootloader_version/stock_artifacts.py b/test/fixtures/nrf52_bootloader_version/stock_artifacts.py new file mode 100644 index 00000000..b504de6e --- /dev/null +++ b/test/fixtures/nrf52_bootloader_version/stock_artifacts.py @@ -0,0 +1,79 @@ +"""Read cached manufacturer artifacts for the optional, hash-pinned audit. + +No network access or device writes. Normalize only the declared bootloader +window; application/SoftDevice bytes in a combined image are never searched. +""" +import io +import json +import struct +import zipfile + + +def bootloader_image(raw, record): + start = int(record["start"], 0) + image = bytearray(b"\xff" * record["size"]) + end = start + len(image) + written = {} + + def put(address, data): + for pos, value in enumerate(data, address): + if start <= pos < end: + if pos in written and written[pos] != value: + raise ValueError("conflicting flash bytes") + written[pos] = value + image[pos - start] = value + + if record.get("member"): + with zipfile.ZipFile(io.BytesIO(raw)) as archive: + raw = archive.read(record["member"]) + kind = record["format"] + if kind == "hex": + base = 0 + eof = False + for line in raw.decode("ascii").splitlines(): + if not line: + continue + if eof or not line.startswith(":"): + raise ValueError("invalid Intel HEX record") + data = bytes.fromhex(line[1:]) + if len(data) < 5 or len(data) != data[0] + 5 or sum(data) & 0xff: + raise ValueError("Intel HEX length/checksum") + address = int.from_bytes(data[1:3], "big") + payload, record_type = data[4:-1], data[3] + if record_type == 0: + put(base + address, payload) + elif record_type in (2, 4): + if len(payload) != 2 or address != 0: + raise ValueError("invalid Intel HEX extended address") + base = int.from_bytes(payload, "big") << (4 if record_type == 2 else 16) + elif record_type == 1: + if payload or address != 0: + raise ValueError("invalid Intel HEX EOF") + eof = True + elif record_type not in (3, 5): + raise ValueError("unknown Intel HEX record type") + if not eof: + raise ValueError("missing Intel HEX EOF") + elif kind == "uf2": + if not raw or len(raw) % 512: + raise ValueError("invalid UF2 length") + for offset in range(0, len(raw), 512): + block = raw[offset:offset + 512] + magic0, magic1, flags, address, length = struct.unpack_from(" 476: + raise ValueError("invalid UF2 block") + if not flags & 1: # NOT_MAIN_FLASH blocks aren't executable flash + put(address, block[32:32 + length]) + elif kind == "dfu": + with zipfile.ZipFile(io.BytesIO(raw)) as archive: + manifest = json.loads(archive.read("manifest.json"))["manifest"] + part = manifest["softdevice_bootloader"] + data = archive.read(part["bin_file"]) + sd_size, bl_size = part["sd_size"], part["bl_size"] + if sd_size + bl_size != len(data) or bl_size > len(image): + raise ValueError("DFU bootloader length mismatch") + put(start, data[sd_size:]) + else: + raise ValueError("unknown artifact format: " + kind) + return bytes(image) diff --git a/test/fixtures/nrf52_bootloader_version/stock_images.json b/test/fixtures/nrf52_bootloader_version/stock_images.json new file mode 100644 index 00000000..72c2e642 --- /dev/null +++ b/test/fixtures/nrf52_bootloader_version/stock_images.json @@ -0,0 +1,266 @@ +{ + "audit_date": "2026-09-29", + "artifacts": [ + { + "file": "88e0b86aa24eefc6c6c85ba2bbaf9908271100be.hex", + "url": "https://raw.githubusercontent.com/Xinyuan-LilyGO/T-Impulse-Plus/main/bootloader/%28%E4%BF%AE%E6%94%B9%E5%90%AF%E5%8A%A8%E6%97%B6%E9%97%B4%E4%B8%BA700ms%29t_impulse_plus_nrf52840_bootloader-0.9.2-dirty_s140_6.1.1_202508251059.hex", + "sha256": "b370eea5b238fecf4dd162a190a35e18129a3b23adbf27450ab1147c5d3b5572", + "format": "hex", + "start": "0xf4000", + "size": 40960, + "image_sha256": "3bd7d638548bfc08d20898521aa0c2647ecb226d07296ad4c6b63bb7914aa6aa", + "version": "0.9.2-dirty" + }, + { + "file": "a60a6b39854ea6bc69e75acad47524181ab53b28.hex", + "url": "https://raw.githubusercontent.com/Xinyuan-LilyGO/T-Impulse-Plus/main/bootloader/deprecated/t_impulse_plus_nrf52840_bootloader-0.9.2-dirty_s140_6.1.1_202507211712.hex", + "sha256": "71fc6319904641d4d60300abaca57d7c187fb215b8f02db954fb1807bd67e99a", + "format": "hex", + "start": "0xf4000", + "size": 40960, + "image_sha256": "d047b1f9f23a7aae48118b1da777f0f0053c044667cc4c42f2d8c01331b2e5b6", + "version": "0.9.2-dirty" + }, + { + "file": "5605ef4a7f53eb32e428b6fdfd83a251e2291012.hex", + "url": "https://raw.githubusercontent.com/Seeed-Studio/Seeed-Tracker-T1000-E-for-LoRaWAN-dev-board/main/firmware/t1000_e_bootloader_1th_ota_uf2.hex", + "sha256": "fdca62eace7dee87265a0f6c567a4a8ab6889bf1f8ff6dc7cef4c1b368309cd3", + "format": "hex", + "start": "0xf4000", + "size": 40960, + "image_sha256": "dd1599f1885a5bab04e46e81e95197574a877bf427e420b39f7353f47f93a8e2", + "version": "1.00" + }, + { + "file": "f2e0a8e78eeaf9357eac6d85d8a05ba0eac485f8.hex", + "url": "https://raw.githubusercontent.com/HelTecAutomation/Heltec_nRF52/main/bootloader/HT-n5262/HT-n5262_bootloader-0.9.0_s140_6.1.1.hex", + "sha256": "b0eb140afac57633208ace8a98908e8c90149a38c1b0b62cf65e0ec48acea1b7", + "format": "hex", + "start": "0xf4000", + "size": 40960, + "image_sha256": "8618c2d91d855afab573d4e9e7efcdc61d80242faa7b9e74f403a81e744abe16", + "version": "0.9.0-2-g836c8dc-dirty" + }, + { + "file": "1aa29101857a9b3973fe51028ea7c7a2df97f497.hex", + "url": "https://raw.githubusercontent.com/Xinyuan-LilyGO/T-Echo-Lite/main/bootloader/t-echo_lite_nrf52840_bootloader-0.9.2-dirty_s140_6.1.1_202411271344.hex", + "sha256": "278a059c0288d919a166373da6241f36a53b33518c915f380878daee7b0a09e8", + "format": "hex", + "start": "0xf4000", + "size": 40960, + "image_sha256": "911bd9a317c3b69c8db1406dae2857410d8e608b256a9c06b5d351d90da63995", + "version": "0.9.2-dirty" + }, + { + "file": "153c400ac21ab646231871b6f15a3be25316178a.hex", + "url": "https://raw.githubusercontent.com/RAKWireless/WisBlock/master/bootloader/RAK4630/Latest/WisCore_RAK4631_Bootloader/wiscore_rak4631_board_bootloader-0.4.3.hex", + "sha256": "e537340f695523b80c36c3cba6b74ae9b25c2d5047cf195eea19535b7171c356", + "format": "hex", + "start": "0xf4000", + "size": 40960, + "image_sha256": "6cbfa569a05cbd232dabe4a654abe9c04b7a03ab457615028d28f2d1329e58d2", + "version": "0.4.3" + }, + { + "file": "c59a5156a0ec065fa9e56faf1da124297080014c.hex", + "url": "https://raw.githubusercontent.com/RAKWireless/WisBlock/master/bootloader/RAK4630/Latest/wiscore_rak4631_board_bootloader-0.4.4.hex", + "sha256": "29635d90eccd181ce206bfa10aa0ca92b9cff61735a6205321efe241e0f1298a", + "format": "hex", + "start": "0xf4000", + "size": 40960, + "image_sha256": "34d59ed3c584b973ced76917cc9c7a7bc979f0b71865055729dc9a8326dc326b", + "version": "0.4.4" + }, + { + "file": "9fe7f40ac0344b8db514ea6770f8bf65ad8b08da.hex", + "url": "https://raw.githubusercontent.com/RAKWireless/WisBlock/master/bootloader/RAK4630/Old/V0.3.2/RAK4630_bootloader.hex", + "sha256": "5c44ea2eb151c20c2cb5a63ca3ff4c4d10ae11b29f97812fb7a21167de7acb44", + "format": "hex", + "start": "0xf4000", + "size": 40960, + "image_sha256": "f345e1b2b85c88c32b3b1ad95d3330fd1d9bcf2ee0df25289c3e2f0d5fb3228d", + "version": "0.3.2-109-gd6b28e6" + }, + { + "file": "f33019e13e85f424f850a418a0c6628a5f7c864f.hex", + "url": "https://raw.githubusercontent.com/RAKWireless/WisBlock/master/bootloader/RAK4630/Old/V0.4.1/RAK4630_bootloader-0.4.1.hex", + "sha256": "3f317b2d05c8c65a98a5de306c7055078426ed5398dabb99ed1d2af93c270579", + "format": "hex", + "start": "0xf4000", + "size": 40960, + "image_sha256": "15003850514750f2606c9edb91ed81bf636d75a47d0961f3a8644847be2a9ef7", + "version": "0.4.1-13-g5e6690e-dirty" + }, + { + "file": "c7c4a3c3468da4cc3680ca38caf6c5212e953c3f.hex", + "url": "https://raw.githubusercontent.com/RAKWireless/WisBlock/master/bootloader/RAK4630/Old/V0.4.2/WisCore_RAK4631_Board_Bootloader.hex", + "sha256": "44013dd02aa5335cbeb85a6b838408dc34b267e8d732965ab4c62730fa81b9a1", + "format": "hex", + "start": "0xf4000", + "size": 40960, + "image_sha256": "c77b8a4de1c33bedcb96d28cfa68d5a1c510e7cd886cfc62e6d07fbf2126ccea", + "version": "0.4.2" + }, + { + "file": "f7dab5b353598db337a0e83416eab51895706a6a.hex", + "url": "https://raw.githubusercontent.com/Xinyuan-LilyGO/T-Echo/main/bootloader/lilygo_techo_bootloader-0.6.1_s140_6.1.1.hex", + "sha256": "0f19e2a83ab76ead26e729f9c6ee5e5db6b8c6e45e6ed47b01d648d531f2418b", + "format": "hex", + "start": "0xf4000", + "size": 40960, + "image_sha256": "f5f3a732241590d49a355b78d12aee4952115d773796f8e40e86ff1cf0fbcc50", + "version": "0.6.1-2-g1224915" + }, + { + "file": "25900da2245a7a727e22e6c3e6f727e1e83c0508.hex", + "url": "https://raw.githubusercontent.com/Seeed-Studio/Adafruit_nRF52_Arduino/master/bootloader/Seeed_Wio_Tracker_1110/Seeed_Wio_Tracker_1110_bootloader-0.6.2_s140_6.1.1.hex", + "sha256": "ed8c28b406a40e1d8a79e9ffe4d93a7f1aa388dba0459c912f91ad6d67fef0c3", + "format": "hex", + "start": "0xf4000", + "size": 40960, + "image_sha256": "a1f8e7d932801e6f50818182cfd3536e610df8ff83bc7582ac8a6fdd4fd24121", + "version": "0.6.2-26-g949425a-dirty" + }, + { + "file": "b63bd32af33a6f1392483e3d7915016f8d871410.hex", + "url": "https://raw.githubusercontent.com/Seeed-Studio/Adafruit_nRF52_Arduino/master/bootloader/Seeed_XIAO_nRF52840/Seeed_XIAO_nRF52840_bootloader-0.6.2_s140_7.3.0.hex", + "sha256": "c79c8cf75ebb7abfa53b02fd3584aa9a5aeb8dc1b073f674f4fa162225630c6c", + "format": "hex", + "start": "0xf4000", + "size": 40960, + "image_sha256": "77941feb9ea9d99acaeb1f815a905e23f618381390d40f38374a894505e6798f", + "version": "0.6.1" + }, + { + "file": "fced8e3e3c0cacc89db9a2ba5c00ceb97345ab9b.hex", + "url": "https://raw.githubusercontent.com/Seeed-Studio/Adafruit_nRF52_Arduino/master/bootloader/Seeed_XIAO_nRF52840_Plus/Seeed_XIAO_nRF52840_Plus_bootloader-0.6.2_s140_7.3.0.hex", + "sha256": "6bd76f49e1dfa839d0084b9af5563115df2ee438f1980327dd4364792475048b", + "format": "hex", + "start": "0xf4000", + "size": 40960, + "image_sha256": "caf05e859193001ef745d21ae71b218f2f23a0ed995a4adc69debcdb31e35ba8", + "version": "0.9.2-29-g6a9a6a3" + }, + { + "file": "6341509efdc55c5fc04b1423ca81afdd9e4673d8.hex", + "url": "https://raw.githubusercontent.com/Seeed-Studio/Adafruit_nRF52_Arduino/master/bootloader/Seeed_XIAO_nRF52840_Sense/Seeed_XIAO_nRF52840_Sense_bootloader-0.6.2_s140_7.3.0.hex", + "sha256": "ac654c6cab225a933278c8be09b92b41bc4c044d064bd8c9c3314c1c4a0cc8c8", + "format": "hex", + "start": "0xf4000", + "size": 40960, + "image_sha256": "894f2e6df7663fcc5c398e210db7f2e7f21d3da53036e0a11576909c849f78fb", + "version": "0.6.1" + }, + { + "file": "3c2e9de75c21b7f50ef7a99710c33df51cf7d337.hex", + "url": "https://raw.githubusercontent.com/Seeed-Studio/Adafruit_nRF52_Arduino/master/bootloader/Seeed_XIAO_nRF52840_Sense_Plus/Seeed_XIAO_nRF52840_Sense_Plus_bootloader-0.6.2_s140_7.3.0.hex", + "sha256": "b401776e6586f6065c46187d190dfa4acf9f7897a7d7c5f12a34662dff405e64", + "format": "hex", + "start": "0xf4000", + "size": 40960, + "image_sha256": "b19bd74a743a8311b12a07ae5cc17f28966d6e61b66f93f2afb2ceb52c5240d2", + "version": "0.9.2-29-g6a9a6a3" + }, + { + "file": "9f030583154e432000b1cae8a18a411cac9db146.hex", + "url": "https://raw.githubusercontent.com/Xinyuan-LilyGO/T-Echo-Card/main/bootloader/T-Echo-Card%20bootloader/deprecated/%28%E5%88%A0%E9%99%A4%E5%90%AF%E5%8A%A8%E6%97%B6%E9%97%B4%29t_echo_card_nrf52840_bootloader-0.9.2-dirty_s140_6.1.1.hex", + "sha256": "f54ce5e646f35257491a250712aefc3b1402e4526af5b5ed356bd395b918c364", + "format": "hex", + "start": "0xf4000", + "size": 40960, + "image_sha256": "5f9ccec2c1cb7cf85d4271f0a3c472a64d40458bf1e05779daf3d49b81e1fe98", + "version": "0.9.2-dirty" + }, + { + "file": "291209d1108f1f61e5096edd64f633920556ef7f.hex", + "url": "https://raw.githubusercontent.com/Xinyuan-LilyGO/T-Echo-Card/main/bootloader/T-Echo-Card%20bootloader/deprecated/t_echo_card_nrf52840_bootloader-0.9.2-dirty_s140_6.1.1_202510090919.hex", + "sha256": "6a7a64dc2b9337dfb76f6eeb2de3eaeeab7b953d46754afe5297ec0b1f78785f", + "format": "hex", + "start": "0xf4000", + "size": 40960, + "image_sha256": "17a6c1b72aaed448aa94a09aee36dbfb75343c559a2e84df849e476eee01d430", + "version": "0.9.2-dirty" + }, + { + "file": "8c1a827f68e388cc067ba58542123db70e339f2a.hex", + "url": "https://raw.githubusercontent.com/Xinyuan-LilyGO/T-Echo-Card/main/bootloader/T-Echo-Card%20bootloader/t_echo_card_nrf52840_bootloader-0.9.2-dirty_s140_6.1.1_202608131146.hex", + "sha256": "c6b8025cb855d15fb06cbaba24d460a12bfc87b22df10667fb88d9ff13c5941e", + "format": "hex", + "start": "0xf4000", + "size": 40960, + "image_sha256": "efd403e7d211534a019467e9c39781bf49d4252fbb4850e8bb21fdc7895dab12", + "version": "0.9.2-dirty" + }, + { + "file": "meshtiny.uf2", + "url": "https://dl.mtoolstec.com/meshtiny/bootloader.uf2", + "sha256": "7bdbb6eedaca1cf62d4479bb23b612964e838f6c06a6e67113ec9edde0244aee", + "format": "uf2", + "start": "0xf4000", + "size": 40960, + "image_sha256": "8c666e29d64d4875b7f076574454e9ac99fc2e5eb25af06500ad05ee245d6440", + "version": "0.4.5" + }, + { + "file": "nano-g2.zip", + "url": "https://wiki.bqvoy.com/meshtastic/nano_g2_ultra/nano_g2_nrf52840_bootloader-0.6.4_s140_6.1.1.hex.zip", + "sha256": "8f10154689f8e922062ace1fe2177513ca846c166b9a3eaa48e76387dc459f5d", + "format": "hex", + "start": "0xf4000", + "size": 40960, + "image_sha256": "c0607db64762d2b3203fcb13f3abed9adde1ec0f6ce2f5f204291f940511c5a5", + "version": "0.6.4-dirty", + "member": "nano_g2_nrf52840_bootloader-0.6.4_s140_6.1.1.hex" + }, + { + "file": "wio-l1.zip", + "url": "https://files.seeedstudio.com/wiki/SenseCAP/Meshtastic/adafruit/wio_tracker_l1_bootloader-0.10.0_s140_7.3.0.zip", + "sha256": "6d8fab06f8fd146653b93196ebb86203e336d74bb7f06d1dabc89f31d23783d1", + "format": "dfu", + "start": "0xf4000", + "size": 40960, + "image_sha256": "4e9c4b9a9d610ccb7c7e9f16757eb51bedeeb2b30b438d5c1ad07a852f2a24b4", + "version": "0.7.0-22-g277a0c8" + }, + { + "file": "t1000-e.zip", + "url": "https://files.seeedstudio.com/wiki/SenseCAP/lorahub/t1000_e_bootloader-0.9.1-5-g488711a_s140_7.3.0.zip", + "sha256": "87c4ba0721be16cca9b784ccb70ea41103100767978431ba47c692d2644bdccc", + "format": "dfu", + "start": "0xf4000", + "size": 40960, + "image_sha256": "9f286a5c8c634a65e4f6d5b63c35eeb1466f900de9abb58ae109e67a82092562", + "version": "0.9.1-5-g488711a" + }, + { + "file": "meshtracker-x1.zip", + "url": "https://files.seeedstudio.com/wiki/SenseCAP/Meshcore/MeshTrackerX1/Bootloader.zip", + "sha256": "69c246a8f29bc2ce7d1448581076be97a7232e8b548279ee6e1ec3a0d77f9e0c", + "format": "dfu", + "start": "0xf4000", + "size": 40960, + "image_sha256": "082eb4cd2cd3db971b9bf4b659c220473cdf13b9400de6fac84b0b1fd62dc005", + "version": "0.10.0-18-gb93789f" + }, + { + "file": "r1-neo.hex", + "url": "https://github.com/muzi-works/Adafruit_nRF52_Bootloader/releases/download/0.9.2/r1_neo_board_bootloader-0.9.2-31-g990aa7f-dirty_s140_6.1.1.hex", + "sha256": "54f77f1e2c6cf5675aec9730cff27087f3f838d7f7075dba5a266e6e0ac355ff", + "format": "hex", + "start": "0xf4000", + "size": 40960, + "image_sha256": "8bfe28094a824f08bf703bf3ccc929e0b21b0b37f94ccee3fbd89413b4eedfeb", + "version": "0.9.2-31-g990aa7f-dirty" + }, + { + "file": "t114-vendor.hex", + "url": "https://resource.heltec.cn/download/Mesh_Node_T114/firmware/HT-n5262-bootloader-20240709.hex", + "sha256": "e950057bebc4beddafdf30955b2d473c4cc26ed008858cf114c613ba0c6c8f88", + "format": "hex", + "start": "0xf4000", + "size": 40960, + "image_sha256": "906fbbe787e18b789a695ae8bec6a5c31320f6948a656344c30bde0eaca1afbd", + "version": "0.9.0-2-g836c8dc-dirty" + } + ] +} diff --git a/test/fixtures/nrf52_bootloader_version/test_version.cpp b/test/fixtures/nrf52_bootloader_version/test_version.cpp index a9073992..a10fa031 100644 --- a/test/fixtures/nrf52_bootloader_version/test_version.cpp +++ b/test/fixtures/nrf52_bootloader_version/test_version.cpp @@ -77,7 +77,8 @@ int main(int argc, char** argv) { bytes.assign(std::istreambuf_iterator(file), {}); } char output[128]; - if (!nrf52BootloaderVersion(bytes.data(), bytes.size(), 0xf4000, 0, output, sizeof(output))) return 3; + const uint32_t start = argc > 2 ? (uint32_t)std::stoul(argv[2], nullptr, 0) : 0xf4000; + if (!nrf52BootloaderVersion(bytes.data(), bytes.size(), start, 0, output, sizeof(output))) return 3; std::cout << output << '\n'; return 0; } @@ -98,13 +99,40 @@ int main(int argc, char** argv) { // long release/preview/fork/build versions that the old 32-byte buffer cut. const char* versions[] = {"0.6.4", "0.9.2", "0.11.0-OTAFIX2.4.2", "0.11.0-OTAFIX2.4.6", "0.11.0-OTAFIX2.4.6-preview.12", "0.11.0-OTAFIX2.4.3-dirty-test-version-0x02040401", - "v0.9.2-Seeed", "0.8.2+build.abcdef", "0.6.1_RAK4631"}; + "v0.9.2-Seeed", "0.8.2+build.abcdef", "0.6.1_RAK4631", + // Embedded values from manufacturer images, not filename versions. + "0.3.2-109-gd6b28e6", "0.4.1-13-g5e6690e-dirty", "0.4.2", "0.4.3", "0.4.4", "0.4.5", + "0.9.0-2-g836c8dc-dirty", "0.6.2-26-g949425a-dirty", "0.6.1", + "0.9.2-29-g6a9a6a3", "0.6.1-2-g1224915", "0.9.2-dirty", "0.6.4-dirty", "1.00", + "0.7.0-22-g277a0c8", "0.9.1-5-g488711a", "0.10.0-18-gb93789f", "0.9.2-31-g990aa7f-dirty"}; for (const char* version : versions) { for (size_t offset : {0x100u, 0x101u, 0x102u, 0x103u, 0x6ff0u, 0x7000u, 0x9e00u}) { auto bytes = image(); text(bytes, offset, version); expect(bytes, version); } } auto bytes = image(); + for (const char* bad : {"1", "1.", "1..00", "1.00.", "1.00..0", "v.1.00"}) { + bytes = image(); text(bytes, 0x100, bad); expect(bytes, nullptr); + } + bytes = image(); + const std::string stock_boundary = "UF2 Bootloader 1.00\n"; + memcpy(bytes.data() + bytes.size() - stock_boundary.size(), + stock_boundary.data(), stock_boundary.size()); + expect(bytes, "1.00"); // two-component record ending at the region boundary + bytes.back() = '0'; expect(bytes, nullptr); // still require an in-bounds terminator + bytes = image(); text(bytes, 0x100, "1.00"); + char stock_tiny[4]; + assert(!nrf52BootloaderVersion(bytes.data(), bytes.size(), 0xf4000, 0, + stock_tiny, sizeof(stock_tiny)) && stock_tiny[0] == 0); + bytes = image(); text(bytes, 0x100, "1.00"); text(bytes, 0x8000, "1.01"); + expect(bytes, nullptr); // conflicting two-component records remain unknown + bytes = image(); standalone(bytes, 0x100, "1.00"); expect(bytes, nullptr); + bytes = image(); standalone(bytes, 0x100, "v1.00"); expect(bytes, nullptr); + // A generic regex must not pick a dependency version as the bootloader. + for (const char* library : {"TinyUSB 0.12.0", "nrfx 2.0.0", "SoftDevice 7.3.0", + "v0.9.2", "0.6.4", "1.00"}) { + bytes = image(); standalone(bytes, 0x100, library); expect(bytes, nullptr); + } // 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", diff --git a/test/test_nrf52_bootloader_version.py b/test/test_nrf52_bootloader_version.py index 440f1485..302d05c0 100644 --- a/test/test_nrf52_bootloader_version.py +++ b/test/test_nrf52_bootloader_version.py @@ -1,8 +1,12 @@ -"""Compile the real version reader; optionally verify pinned OTAFIX releases.""" +"""Compile the real reader; optionally verify pinned stock/OTAFIX artifacts.""" import hashlib +import importlib.util +import io +import json import os from pathlib import Path import shutil +import struct import subprocess import sys import tempfile @@ -10,9 +14,66 @@ import unittest import zipfile ROOT = Path(__file__).resolve().parents[1] +FIXTURES = ROOT / "test/fixtures/nrf52_bootloader_version" +SPEC = importlib.util.spec_from_file_location("nrf52_stock_artifacts", FIXTURES / "stock_artifacts.py") +STOCK = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(STOCK) class BootloaderVersionTest(unittest.TestCase): + def test_stock_artifact_manifest(self): + records = json.loads((FIXTURES / "stock_images.json").read_text())["artifacts"] + self.assertEqual(len(records), 26) + self.assertEqual(len({record["image_sha256"] for record in records}), len(records)) + for record in records: + with self.subTest(source=record["url"]): + self.assertRegex(record["sha256"], r"^[0-9a-f]{64}$") + self.assertRegex(record["image_sha256"], r"^[0-9a-f]{64}$") + self.assertRegex(record["version"], r"^\d+\.\d+(?:\.\d+)?(?:[-_+].+)?$") + self.assertIn(record["format"], ("hex", "uf2", "dfu")) + self.assertEqual(record["start"], "0xf4000") + self.assertEqual(record["size"], 40960) + self.assertEqual(Path(record["file"]).name, record["file"]) + + def test_stock_artifact_decoders(self): + record = {"start": "0xf4000", "size": 40960, "format": "hex"} + def ihex(address, kind, payload): + data = bytes((len(payload), address >> 8, address & 255, kind)) + payload + return ":" + (data + bytes((-sum(data) & 255,))).hex() + "\n" + raw = (ihex(0, 4, b"\x00\x0f") + ihex(0x4000, 0, b"abcd") + + ihex(0, 1, b"")).encode() + self.assertEqual(STOCK.bootloader_image(raw, record)[:8], b"abcd\xff\xff\xff\xff") + with self.assertRaises(ValueError): + STOCK.bootloader_image(raw.replace(b"61626364", b"61626365"), record) + with self.assertRaises(ValueError): + STOCK.bootloader_image(raw.split(b":00000001")[0], record) + conflict = (ihex(0, 4, b"\x00\x0f") + ihex(0x4000, 0, b"a") + + ihex(0x4000, 0, b"b") + ihex(0, 1, b"")).encode() + with self.assertRaises(ValueError): + STOCK.bootloader_image(conflict, record) + record["format"] = "uf2" + block = bytearray(512) + struct.pack_into("