mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-20 08:02:39 +00:00
188 lines
11 KiB
Python
188 lines
11 KiB
Python
"""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
|
|
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("<IIIIIIII", block, 0, 0x0a324655, 0x9e5d5157,
|
|
0x2000, 0xf4000, 4, 0, 1, 0xada52840)
|
|
block[32:36] = b"abcd"
|
|
struct.pack_into("<I", block, 508, 0x0ab16f30)
|
|
self.assertEqual(STOCK.bootloader_image(block, record)[:4], b"abcd")
|
|
struct.pack_into("<I", block, 8, 0x2001)
|
|
self.assertEqual(STOCK.bootloader_image(block, record), b"\xff" * 40960)
|
|
struct.pack_into("<I", block, 8, 0x2000)
|
|
struct.pack_into("<I", block, 12, 0x26000) # application-only UF2
|
|
self.assertEqual(STOCK.bootloader_image(block, record), b"\xff" * 40960)
|
|
block[-1] ^= 1
|
|
with self.assertRaises(ValueError):
|
|
STOCK.bootloader_image(block, record)
|
|
record["format"] = "dfu"
|
|
archive_bytes = io.BytesIO()
|
|
with zipfile.ZipFile(archive_bytes, "w") as archive:
|
|
archive.writestr("manifest.json", json.dumps({"manifest": {"softdevice_bootloader": {
|
|
"sd_size": 2, "bl_size": 4, "bin_file": "sd_bl.bin"}}}))
|
|
archive.writestr("sd_bl.bin", b"SDabcd")
|
|
self.assertEqual(STOCK.bootloader_image(archive_bytes.getvalue(), record)[:4], b"abcd")
|
|
|
|
def test_firmware_uses_shared_reader(self):
|
|
board = (ROOT / "src/helpers/NRF52Board.cpp").read_text()
|
|
getter = board.split("bool NRF52Board::getBootloaderVersion(", 1)[1].split("\n}", 1)[0]
|
|
self.assertIn("mesh::nrf52BootloaderRegion(", getter)
|
|
self.assertIn("NRF_FICR->CODEPAGESIZE, NRF_FICR->CODESIZE", getter)
|
|
self.assertIn("mbr_start, NRF_UICR->NRFFW[0]", getter)
|
|
self.assertIn("mesh::nrf52BootloaderVersion(", getter)
|
|
self.assertIn("bootloaderVersion, out, max_len", getter)
|
|
self.assertNotIn("0x000FB000", getter)
|
|
common = (ROOT / "src/helpers/CommonCLI.cpp").read_text()
|
|
cli = common.split('configKeyEquals(config, "bootloader.ver")', 1)[1].split("} else {", 1)[0]
|
|
self.assertIn("char ver[128]", cli)
|
|
self.assertIn("_board->getBootloaderVersion(ver, sizeof(ver))", cli)
|
|
|
|
def test_native_version_reader(self):
|
|
compiler = shutil.which(os.environ.get("CXX", "g++"))
|
|
self.assertIsNotNone(compiler, "g++ (or CXX) is required for this native test")
|
|
with tempfile.TemporaryDirectory(prefix="meshcore-boot-version-") as temp:
|
|
exe = Path(temp) / ("test.exe" if os.name == "nt" else "test")
|
|
command = [compiler, "-std=c++17", "-O2", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter",
|
|
"-I" + str(ROOT / "src"), "-isystem", str(ROOT / "test/mocks"),
|
|
str(ROOT / "test/fixtures/nrf52_bootloader_version/test_version.cpp"),
|
|
"-o", str(exe)]
|
|
built = subprocess.run(command, capture_output=True, text=True, timeout=60)
|
|
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
|
|
result = subprocess.run([str(exe)], capture_output=True, text=True, timeout=20)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
self.assertIn("native cases passed", result.stdout)
|
|
# Optional manufacturer downloads: exact hashes, normalized layout,
|
|
# and exact reported versions, not merely a dotted-number regex.
|
|
stock_cache = os.environ.get("MESHCORE_STOCK_BOOTLOADER_CACHE")
|
|
if stock_cache:
|
|
records = json.loads((FIXTURES / "stock_images.json").read_text())["artifacts"]
|
|
for record in records:
|
|
with self.subTest(stock_image=record["url"]):
|
|
raw = (Path(stock_cache) / record["file"]).read_bytes()
|
|
self.assertEqual(hashlib.sha256(raw).hexdigest(), record["sha256"])
|
|
payload = STOCK.bootloader_image(raw, record)
|
|
self.assertEqual(hashlib.sha256(payload).hexdigest(), record["image_sha256"])
|
|
result = subprocess.run([str(exe), "--hex", record["start"]],
|
|
input=payload.hex(), capture_output=True,
|
|
text=True, timeout=20)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
self.assertEqual(result.stdout.strip(), record["version"])
|
|
print(f"Verified version reader against {len(records)} distinct manufacturer bootloader images")
|
|
# Optional local/release artifacts; never downloaded or flashed by
|
|
# this test. Each must be a raw bootloader image starting at F4000.
|
|
for path in os.environ.get("MESHCORE_BOOTLOADER_IMAGES", "").split(os.pathsep):
|
|
if path:
|
|
with self.subTest(image=path):
|
|
result = subprocess.run([str(exe), path], check=True, capture_output=True,
|
|
text=True, timeout=20)
|
|
self.assertRegex(result.stdout, r"\d+\.\d+(?:\.\d+)?")
|
|
release = os.environ.get("MESHCORE_BOOTLOADER_MOTA_ZIP")
|
|
if release:
|
|
self.assertEqual(hashlib.sha256(Path(release).read_bytes()).hexdigest(),
|
|
"04fe6d1b11b0c3b926289d6384dae5cb878462da"
|
|
"a7fbbe9afb70f42d869f8003", "unexpected 2.4.6 release archive")
|
|
sys.path.insert(0, str(ROOT / "tools/mota"))
|
|
import motalib
|
|
with zipfile.ZipFile(release) as archive:
|
|
names = [name for name in archive.namelist() if name.endswith(".mota")]
|
|
self.assertEqual(len(names), 17)
|
|
sd_name = "mota/update-heltec_mesh_tower_v2_sdcard_bootloader-0.11.0-OTAFIX2.4.6.mota"
|
|
self.assertIn(sd_name, names)
|
|
for name in names:
|
|
with self.subTest(release_image=name):
|
|
parsed = motalib.parse_container(archive.read(name))
|
|
self.assertEqual(motalib.verify(parsed), [])
|
|
if name == sd_name:
|
|
self.assertNotIn(b"UF2 Bootloader ", parsed.payload)
|
|
identity = motalib.parse_bootloader_identity(parsed.payload)
|
|
self.assertEqual(identity.crc32, 0x5DACDB3D)
|
|
self.assertEqual(identity.boot_version, 0x020406FF)
|
|
result = subprocess.run([str(exe), "--hex"], input=parsed.payload.hex(),
|
|
capture_output=True, text=True, timeout=20)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
self.assertIn("OTAFIX2.4.6", result.stdout)
|
|
if name == sd_name:
|
|
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()
|