259 lines
13 KiB
Python
259 lines
13 KiB
Python
"""Exercise embedded-bootloader build identity and packaged-image gates without PIO."""
|
|
|
|
import argparse
|
|
import hashlib
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from types import SimpleNamespace
|
|
import unittest
|
|
from unittest import mock
|
|
import zipfile
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT / "scripts"))
|
|
sys.path.insert(0, str(ROOT / "tools/mota"))
|
|
import motalib as ml
|
|
import qualify_nrf52_embedded_bootloader as qualifier
|
|
|
|
SPEC = importlib.util.spec_from_file_location(
|
|
"embedded_variant_recipe", ROOT / "scripts/firmware_build_recipe.py")
|
|
recipe = importlib.util.module_from_spec(SPEC)
|
|
SPEC.loader.exec_module(recipe)
|
|
|
|
TARGET = "RAK_4631_repeater"
|
|
VERSION = "v1.18.0.1-halo-keymind-cascade-dev-12345678"
|
|
|
|
|
|
class BuildIdentityTests(unittest.TestCase):
|
|
def bash(self, script, *arguments):
|
|
environment = os.environ.copy()
|
|
environment.pop("MESHCORE_NRF52_EMBED_BOOTLOADER", None)
|
|
environment.pop("MESHCORE_NRF52_LEGACY_BOOTLOADER", None)
|
|
return subprocess.run(
|
|
["bash", "-c", 'source "$1/build_legacy.sh"\n'
|
|
'pio() { echo FORBIDDEN_PLATFORMIO >&2; return 99; }\n' + script,
|
|
"embedded-variant", str(ROOT), *arguments],
|
|
cwd=ROOT, env=environment, text=True, capture_output=True, timeout=10)
|
|
|
|
def test_option_accepts_only_explicit_pinned_release_and_retains_target(self):
|
|
result = self.bash('''
|
|
shift
|
|
parse_cli_options "$@" || exit $?
|
|
printf '%s\n' "$MESHCORE_NRF52_EMBED_BOOTLOADER" "${PARSED_COMMAND_ARGS[@]}"
|
|
''', "build-firmware", TARGET, "--with-bootloader", "2.4.12")
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
self.assertEqual(result.stdout.splitlines(), ["2.4.12", "build-firmware", TARGET])
|
|
for arguments in (("--with-bootloader",),
|
|
("--with-bootloader", "2.4.11"),
|
|
("--with-bootloader", "latest")):
|
|
with self.subTest(arguments=arguments):
|
|
result = self.bash('shift\nparse_cli_options "$@"\n', *arguments)
|
|
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
self.assertIn("pinned release 2.4.12", result.stdout + result.stderr)
|
|
|
|
def filename(self, *, embedded=False, ota=True, infix="", full=False):
|
|
return self.bash('''
|
|
PIO_ENV_PLATFORM_BY_NAME[RAK_4631_repeater]=NRF52_PLATFORM
|
|
PIO_ENV_OTA_BY_NAME[RAK_4631_repeater]=$3
|
|
BUILD_PROFILE_FOR_TARGET=auto
|
|
FIRMWARE_FILENAME_INFIX=$4
|
|
ESP32_FULL_BUILD=$5
|
|
if [ "$2" = yes ]; then export MESHCORE_NRF52_EMBED_BOOTLOADER=2.4.12; fi
|
|
embedded_bootloader_artifact_target RAK_4631_repeater || exit $?
|
|
get_firmware_filename RAK_4631_repeater "$6"
|
|
''', "yes" if embedded else "no", "1" if ota else "0", infix,
|
|
"1" if full else "0", VERSION)
|
|
|
|
def test_ordinary_names_and_ota_classification_survive_publication_suffix(self):
|
|
for embedded in (False, True):
|
|
for ota in (False, True):
|
|
with self.subTest(embedded=embedded, ota=ota):
|
|
result = self.filename(embedded=embedded, ota=ota)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
artifact = TARGET + ("-with-bootloader-2.4.12" if embedded else "")
|
|
filename = artifact + ("-ota" if ota else "") + "-" + VERSION
|
|
self.assertEqual(result.stdout.splitlines(), [artifact, filename])
|
|
|
|
def test_full_ota_profile_keeps_both_publication_markers(self):
|
|
for embedded in (False, True):
|
|
for infix, full in (("full-ota", False), ("full", True),
|
|
("full-logging", True), ("full-usb-wifi", True)):
|
|
with self.subTest(embedded=embedded, infix=infix, full=full):
|
|
result = self.filename(embedded=embedded, infix=infix, full=full)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
artifact = TARGET + ("-with-bootloader-2.4.12" if embedded else "")
|
|
expected_infix = infix if infix.endswith("-ota") else infix + "-ota"
|
|
self.assertEqual(result.stdout.splitlines(),
|
|
[artifact, artifact + "-" + expected_infix + "-" + VERSION])
|
|
|
|
def test_invalid_inherited_release_cannot_create_publishable_name(self):
|
|
result = self.bash('''
|
|
export MESHCORE_NRF52_EMBED_BOOTLOADER=2.4.11
|
|
embedded_bootloader_artifact_target RAK_4631_repeater
|
|
''')
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertEqual(result.stdout, "")
|
|
|
|
|
|
class PackagedApplicationTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temporary = tempfile.TemporaryDirectory(prefix="mesh-embedded-package-")
|
|
self.addCleanup(self.temporary.cleanup)
|
|
self.directory = Path(self.temporary.name)
|
|
self.resource = self.directory / "bootloader-migration-resource"
|
|
self.resource.mkdir()
|
|
self.manifest_path = self.directory / "firmware.capabilities.json"
|
|
self.manifest = {"platform": "NRF52_PLATFORM", "verified": True,
|
|
"target": TARGET,
|
|
"artifact_target": TARGET + "-with-bootloader-2.4.12",
|
|
"capabilities": ["ota.update.lora", "bootloader.embedded"],
|
|
"source_commit": "a" * 40}
|
|
self.manifest_path.write_text(json.dumps(self.manifest))
|
|
self.candidate = (struct.pack("<II", 0x20030000, 0xF4101)
|
|
+ bytes(range(256)) * (qualifier.BOOT_SIZE // 256))[0:qualifier.BOOT_SIZE]
|
|
self.identity = SimpleNamespace(app_base=0x26000)
|
|
self.profile = {"id": "wiscore_rak4631_auto", "storage": "adaptive",
|
|
"files": {"zip": {"sha256": "b" * 64}}}
|
|
self.metadata = {"profile": self.profile["id"], "release": "2.4.12",
|
|
"manifest_sha256": qualifier.MANIFEST_SHA256,
|
|
"asset_sha256": "b" * 64,
|
|
"candidate_sha256": hashlib.sha256(self.candidate).hexdigest(),
|
|
"legacy_sha256": None}
|
|
self.write_resource()
|
|
self.write_application(self.application())
|
|
self.patches = {}
|
|
for name, value in (("load_manifest", {"profiles": [self.profile]}),
|
|
("select_profile", (self.profile, {"role": "repeater"})),
|
|
("download_zip", b"verified published ZIP"),
|
|
("validate_candidate_zip", (self.candidate, self.identity))):
|
|
patcher = mock.patch.object(qualifier, name, return_value=value)
|
|
self.patches[name] = patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
|
|
def write_resource(self, candidate=None):
|
|
(self.resource / "BootloaderMigrationResource.json").write_text(json.dumps(self.metadata))
|
|
(self.resource / "BootloaderMigrationResource.bin").write_bytes(
|
|
self.candidate if candidate is None else candidate)
|
|
|
|
def application(self, *, prefix=b"\x02" * 32, candidate=None, duplicate=False,
|
|
marker=True, target=TARGET, layout=True, app_base=0x26000,
|
|
padding=0):
|
|
candidate = self.candidate if candidate is None else candidate
|
|
body = prefix + candidate + (candidate if duplicate else b"")
|
|
if marker:
|
|
body += b"\0BL embedded 2.4.12\0"
|
|
body += bytes(padding)
|
|
if layout:
|
|
body = ml.ensure_nrf52_layout(body, ml.Nrf52Layout(
|
|
app_base, 0xED000, 0xED000, ml.NRF52_LAYOUT_FLAG_HYBRID_RAM))
|
|
return ml.ensure_endf(body, ml.FwIdent(
|
|
0x01120001, ml.target_id_for_env(target), "RAK_4631"))[0]
|
|
|
|
def write_application(self, application):
|
|
with zipfile.ZipFile(self.directory / "firmware.zip", "w") as package:
|
|
package.writestr("manifest.json", json.dumps({"manifest": {"application": {
|
|
"bin_file": "firmware.bin", "dat_file": "firmware.dat"}}}))
|
|
package.writestr("firmware.bin", application)
|
|
package.writestr("firmware.dat", b"qualified separately")
|
|
|
|
def assert_refused_without_changing_manifest(self):
|
|
original = self.manifest_path.read_bytes()
|
|
with self.assertRaises((ValueError, KeyError)):
|
|
qualifier.qualify(self.directory, self.manifest_path)
|
|
self.assertEqual(original, self.manifest_path.read_bytes())
|
|
|
|
def test_claim_is_bound_to_actual_application_and_preserves_existing_qualification(self):
|
|
result = qualifier.qualify(self.directory, self.manifest_path)
|
|
manifest = json.loads(self.manifest_path.read_text())
|
|
self.assertEqual(manifest.pop("embedded_bootloader"), result)
|
|
self.assertEqual(manifest, self.manifest)
|
|
self.assertEqual(result["app_offset"], 32)
|
|
self.assertEqual(result["image_bytes"], 40960)
|
|
self.assertEqual(result["release"], "2.4.12")
|
|
self.assertEqual(result["profile"], self.profile["id"])
|
|
self.assertEqual(result["candidate_sha256"], hashlib.sha256(self.candidate).hexdigest())
|
|
self.assertEqual(result["activation"], "ota bootloader update")
|
|
self.assertEqual(result["automatic_delay_seconds"], 70)
|
|
self.patches["select_profile"].assert_called_once_with(
|
|
{"profiles": [self.profile]}, TARGET)
|
|
self.patches["validate_candidate_zip"].assert_called_once_with(
|
|
b"verified published ZIP", self.profile)
|
|
|
|
def test_resource_and_release_metadata_tampering_are_refused(self):
|
|
for field, value in (("profile", "another_board"), ("release", "2.4.11"),
|
|
("manifest_sha256", "0" * 64),
|
|
("asset_sha256", "0" * 64),
|
|
("candidate_sha256", "0" * 64),
|
|
("legacy_sha256", "0" * 64)):
|
|
with self.subTest(field=field):
|
|
original = self.metadata[field]
|
|
self.metadata[field] = value
|
|
self.write_resource()
|
|
self.assert_refused_without_changing_manifest()
|
|
self.metadata[field] = original
|
|
self.write_resource(self.candidate[:-1] + bytes([self.candidate[-1] ^ 1]))
|
|
self.assert_refused_without_changing_manifest()
|
|
|
|
def test_invalid_pinned_candidate_never_attaches_claim(self):
|
|
self.patches["validate_candidate_zip"].side_effect = ValueError("candidate identity invalid")
|
|
self.assert_refused_without_changing_manifest()
|
|
|
|
def test_corrupt_application_and_validly_restamped_wrong_images_are_refused(self):
|
|
corrupted = bytearray(self.application())
|
|
corrupted[40] ^= 1 # Leave EndF unchanged, as with corruption after packaging.
|
|
variants = {"body corruption": bytes(corrupted),
|
|
"other target": self.application(target="RAK_4631_room_server"),
|
|
"unaligned candidate": self.application(prefix=b"\x02" * 31),
|
|
"duplicate candidate": self.application(duplicate=True),
|
|
"missing candidate": self.application(candidate=b""),
|
|
"activation absent": self.application(marker=False),
|
|
"layout absent": self.application(layout=False),
|
|
"other SoftDevice base": self.application(app_base=0x27000),
|
|
"exceeds application region": self.application(padding=0xC7000)}
|
|
for name, application in variants.items():
|
|
with self.subTest(variant=name):
|
|
self.write_application(application)
|
|
self.assert_refused_without_changing_manifest()
|
|
|
|
def test_unqualified_or_other_platform_artifacts_cannot_gain_embedded_claim(self):
|
|
for changes in ({"verified": False}, {"platform": "ESP32_PLATFORM"}):
|
|
with self.subTest(changes=changes):
|
|
self.manifest_path.write_text(json.dumps({**self.manifest, **changes}))
|
|
self.assert_refused_without_changing_manifest()
|
|
|
|
|
|
class RecipeBindingTests(unittest.TestCase):
|
|
def test_external_legacy_input_bytes_are_bound_even_when_path_does_not_change(self):
|
|
with tempfile.TemporaryDirectory(prefix="mesh-embedded-recipe-") as temporary:
|
|
directory = Path(temporary)
|
|
legacy = directory / "legacy.hex"
|
|
legacy.write_bytes(b"exact first legacy image")
|
|
args = argparse.Namespace(
|
|
root=directory, output=directory / "out", source_commit="a" * 40,
|
|
target=TARGET, artifact_target=TARGET + "-with-bootloader-2.4.12",
|
|
pio_env=TARGET, platform="NRF52_PLATFORM", embedded_version=VERSION,
|
|
profile="auto", sensor_profile="", ota_policy="1")
|
|
options = [["env:" + TARGET, [["board", "wiscore_rak4631"]]]]
|
|
with mock.patch.object(recipe, "source_digest", return_value="c" * 64), \
|
|
mock.patch.dict(os.environ, {
|
|
"MESHCORE_NRF52_EMBED_BOOTLOADER": "2.4.12",
|
|
"MESHCORE_NRF52_LEGACY_BOOTLOADER": str(legacy)}, clear=True):
|
|
first = recipe.recipe_digest(args, options)
|
|
self.assertEqual(first, recipe.recipe_digest(args, options))
|
|
legacy.write_bytes(b"same path, different legacy image")
|
|
self.assertNotEqual(first, recipe.recipe_digest(args, options))
|
|
legacy.unlink()
|
|
with self.assertRaises(OSError):
|
|
recipe.recipe_digest(args, options)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|