mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-23 19:12:23 +00:00
Verify RP2040 capabilities against matching UF2 application
This commit is contained in:
1 parent
9c78ccde57
commit
5df3e4ae53
6 files changed
+253
-2
No files matched your search
@@ -825,6 +825,7 @@ jobs:
|
||||
python3 -B test/test_t114_release_profiles.py -v
|
||||
python3 -B test/test_optional_display_profiles.py -v
|
||||
python3 -B test/test_consolidated_profiles.py -v
|
||||
python3 -B test/test_rp2040_application_capabilities.py -v
|
||||
|
||||
- name: Verify Full logging compiler flags and packaged capabilities
|
||||
run: |
|
||||
|
||||
@@ -4665,6 +4665,9 @@ write_build_capability_manifest() {
|
||||
--partitions "${build_output_dir}/partitions.bin")
|
||||
elif [ "$env_platform" = "NRF52_PLATFORM" ]; then
|
||||
checker_args+=(--dfu-package "${build_output_dir}/firmware.zip")
|
||||
elif [ "$env_platform" = "RP2040_PLATFORM" ]; then
|
||||
checker_args+=(--firmware-bin "${build_output_dir}/firmware.bin"
|
||||
--uf2-package "${build_output_dir}/firmware.uf2")
|
||||
fi
|
||||
|
||||
for item in "${BUILD_CAPABILITIES[@]}"; do
|
||||
|
||||
+1
-1
@@ -131,7 +131,7 @@ extra_javascript:
|
||||
- _javascript/hosted_doc_links.js?v=20261007-1
|
||||
- _javascript/notification_builder.js?v=20261006-channel9
|
||||
- https://unpkg.com/leaflet@1.9.4/dist/leaflet.js
|
||||
- _javascript/firmware_picker.js?v=20261016-onboard-qspi-2
|
||||
- _javascript/firmware_picker.js?v=20261018-variant-consolidation-1
|
||||
- _javascript/telemetry_decoder.js?v=20261007-hosted-form
|
||||
- _javascript/management_decoder.js
|
||||
- _javascript/filter_tool.js?v=20261002-hashbytes-2plus
|
||||
|
||||
@@ -11,6 +11,7 @@ import sys
|
||||
import struct
|
||||
import zipfile
|
||||
import zlib
|
||||
from rp2040_application import read_application as read_rp2040_application
|
||||
from package_meshtext_license import (CAPABILITY as MESHTEXT_CAPABILITY,
|
||||
meshtext_evidence, notice_bytes as meshtext_notice_bytes,
|
||||
notice_record as meshtext_notice_record)
|
||||
@@ -81,6 +82,7 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--firmware-bin", type=Path)
|
||||
parser.add_argument("--partitions", type=Path)
|
||||
parser.add_argument("--dfu-package", type=Path)
|
||||
parser.add_argument("--uf2-package", type=Path)
|
||||
parser.add_argument(
|
||||
"--expect",
|
||||
action="append",
|
||||
@@ -96,7 +98,7 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument(
|
||||
"--expect-application", action="append", default=[],
|
||||
metavar="CAPABILITY=TEXT",
|
||||
help="Require TEXT in the packaged firmware.bin or DFU application, not ELF metadata.",
|
||||
help="Require TEXT in the packaged BIN, DFU application, or verified RP2040 UF2 application, not ELF metadata.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
@@ -124,6 +126,10 @@ def linked_gzip_assets(image):
|
||||
|
||||
|
||||
def read_application(args):
|
||||
if args.platform == "RP2040_PLATFORM" or args.uf2_package is not None:
|
||||
if args.platform != "RP2040_PLATFORM" or args.uf2_package is None or args.firmware_bin is None:
|
||||
raise ValueError("RP2040 application checks require both --uf2-package and --firmware-bin")
|
||||
return read_rp2040_application(args.uf2_package, args.firmware_bin)
|
||||
if args.firmware_bin is not None:
|
||||
return args.firmware_bin.read_bytes()
|
||||
if args.dfu_package is not None:
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Verify that an RP2040 UF2 carries the exact application offered as BIN."""
|
||||
from pathlib import Path
|
||||
import struct
|
||||
|
||||
BLOCK_BYTES = 512
|
||||
PAYLOAD_BYTES = 256
|
||||
FLASH_BASE = 0x10000000
|
||||
FLASH_END = 0x11000000 # RP2040's maximum 16 MiB XIP flash window.
|
||||
FAMILY = 0xE48BFF56
|
||||
|
||||
|
||||
def read_application(uf2_path: Path, bin_path: Path) -> bytes:
|
||||
"""Return real flash bytes, excluding UF2 headers and alignment padding.
|
||||
|
||||
Both public formats must agree. ELF strings, unused sector bytes, and
|
||||
bytes outside the BIN's application extent cannot supply capability proof.
|
||||
The board's ordinary linker/size checks retain its stricter flash limit.
|
||||
"""
|
||||
size = uf2_path.stat().st_size
|
||||
if not size or size % BLOCK_BYTES or size > (FLASH_END - FLASH_BASE) * 2:
|
||||
raise ValueError("RP2040 UF2 has an invalid sector extent")
|
||||
application_size = bin_path.stat().st_size
|
||||
if not 0 < application_size <= FLASH_END - FLASH_BASE:
|
||||
raise ValueError("RP2040 BIN is empty or exceeds the XIP flash window")
|
||||
data = uf2_path.read_bytes()
|
||||
if len(data) != size:
|
||||
raise ValueError("RP2040 UF2 changed during application qualification")
|
||||
count = size // BLOCK_BYTES
|
||||
payload = bytearray()
|
||||
for index in range(count):
|
||||
offset = index * BLOCK_BYTES
|
||||
magic0, magic1, flags, address, length, number, total, family = struct.unpack_from("<8I", data, offset)
|
||||
trailer = struct.unpack_from("<I", data, offset + 508)[0]
|
||||
if (magic0, magic1, trailer) != (0x0A324655, 0x9E5D5157, 0x0AB16F30):
|
||||
raise ValueError("RP2040 UF2 has an invalid sector header/trailer")
|
||||
if flags != 0x2000 or family != FAMILY:
|
||||
raise ValueError("RP2040 UF2 must contain only main-flash blocks with the RP2040 family")
|
||||
if length != PAYLOAD_BYTES or number != index or total != count:
|
||||
raise ValueError("RP2040 UF2 has inconsistent payload lengths or block numbers")
|
||||
if address != FLASH_BASE + index * PAYLOAD_BYTES or address + length > FLASH_END:
|
||||
raise ValueError("RP2040 UF2 has gaps, overlaps, or addresses outside its XIP flash window")
|
||||
payload.extend(data[offset + 32:offset + 32 + length])
|
||||
application = bin_path.read_bytes()
|
||||
tail = payload[application_size:]
|
||||
if len(payload) < application_size or len(tail) >= PAYLOAD_BYTES or payload[:application_size] != application:
|
||||
raise ValueError("RP2040 UF2 application does not match the packaged BIN")
|
||||
if tail and set(tail) not in ({0}, {255}):
|
||||
raise ValueError("RP2040 UF2 has nonuniform application-alignment padding")
|
||||
return bytes(payload[:application_size])
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Actual RP2040 UF2/BIN application evidence, without PlatformIO or hardware."""
|
||||
from pathlib import Path
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'scripts'))
|
||||
from rp2040_application import FLASH_BASE, FLASH_END, read_application
|
||||
|
||||
CAPABILITY = 'companion.picow.combined'
|
||||
MARKER = b'meshcore.capability.companion.picow.combined.v1'
|
||||
|
||||
|
||||
def uf2(application, padding=255):
|
||||
count = (len(application) + 255) // 256
|
||||
payload = application + bytes([padding]) * (count * 256 - len(application))
|
||||
blocks = []
|
||||
for index in range(count):
|
||||
block = bytearray(512)
|
||||
struct.pack_into('<8I', block, 0, 0x0A324655, 0x9E5D5157, 0x2000,
|
||||
FLASH_BASE + index * 256, 256, index, count, 0xE48BFF56)
|
||||
block[32:288] = payload[index * 256:(index + 1) * 256]
|
||||
struct.pack_into('<I', block, 508, 0x0AB16F30)
|
||||
blocks.append(bytes(block))
|
||||
return b''.join(blocks)
|
||||
|
||||
|
||||
class RP2040ApplicationCapabilityTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temp = tempfile.TemporaryDirectory(prefix='rp2040-application-')
|
||||
self.addCleanup(temp.cleanup)
|
||||
self.path = Path(temp.name)
|
||||
self.bin = self.path / 'firmware.bin'
|
||||
self.uf2 = self.path / 'firmware.uf2'
|
||||
self.elf = self.path / 'firmware.elf'
|
||||
self.output = self.path / 'firmware.capabilities.json'
|
||||
self.app = b'program-prefix\0' + MARKER + b'\0' + b'code' * 145
|
||||
self.bin.write_bytes(self.app)
|
||||
self.uf2.write_bytes(uf2(self.app))
|
||||
self.elf.write_bytes(b'ELF metadata without combined capability')
|
||||
|
||||
def checker(self, platform='RP2040_PLATFORM', include_bin=True, include_uf2=True, extra=()):
|
||||
command = [sys.executable, '-B', str(ROOT / 'scripts/check_firmware_capabilities.py'),
|
||||
'--image', str(self.elf), '--output', str(self.output), '--target', 'PicoW_companion_radio',
|
||||
'--platform', platform, '--build-profile', 'auto', '--expect-application',
|
||||
CAPABILITY + '=' + MARKER.decode(), *extra]
|
||||
if include_bin:
|
||||
command.extend(['--firmware-bin', str(self.bin)])
|
||||
if include_uf2:
|
||||
command.extend(['--uf2-package', str(self.uf2)])
|
||||
result = subprocess.run(command, text=True, capture_output=True, check=False)
|
||||
return result, json.loads(self.output.read_text())
|
||||
|
||||
def test_actual_uploadable_payload_and_both_supported_padding_conventions(self):
|
||||
for padding in (0, 255):
|
||||
self.uf2.write_bytes(uf2(self.app, padding))
|
||||
self.assertEqual(read_application(self.uf2, self.bin), self.app)
|
||||
result, manifest = self.checker()
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertTrue(manifest['verified'])
|
||||
self.assertIn(dict(capability=CAPABILITY, evidence=MARKER.decode(), present=True,
|
||||
source='packaged application'), manifest['verification'])
|
||||
self.assertEqual(manifest['ota_update_methods'], [])
|
||||
self.assertFalse(manifest['ota_update_verified'])
|
||||
|
||||
def test_elf_metadata_alone_cannot_supply_application_proof(self):
|
||||
self.elf.write_bytes(b'ELF metadata\0' + MARKER)
|
||||
self.bin.write_bytes(b'program without combined transports')
|
||||
self.uf2.write_bytes(uf2(self.bin.read_bytes()))
|
||||
result, manifest = self.checker()
|
||||
self.assertEqual(result.returncode, 1, result.stderr)
|
||||
self.assertFalse(manifest['verified'])
|
||||
|
||||
def test_larger_flash_hardware_is_not_limited_to_the_pico_two_megabyte_size(self):
|
||||
application = self.app + b'\0' * (2 * 1024 * 1024 + 17 - len(self.app))
|
||||
self.bin.write_bytes(application)
|
||||
self.uf2.write_bytes(uf2(application))
|
||||
self.assertEqual(read_application(self.uf2, self.bin), application)
|
||||
|
||||
def test_unused_uf2_sector_bytes_are_never_scanned_for_capabilities(self):
|
||||
self.bin.write_bytes(b'program without combined transports')
|
||||
wire = bytearray(uf2(self.bin.read_bytes()))
|
||||
wire[320:320+len(MARKER)] = MARKER
|
||||
self.uf2.write_bytes(wire)
|
||||
self.elf.write_bytes(MARKER)
|
||||
result, manifest = self.checker()
|
||||
self.assertEqual(result.returncode, 1, result.stderr)
|
||||
self.assertFalse(manifest['verified'])
|
||||
|
||||
def test_uf2_alignment_tail_cannot_supply_application_proof(self):
|
||||
self.bin.write_bytes(b'program')
|
||||
wire = bytearray(uf2(self.bin.read_bytes()))
|
||||
wire[32+len(b'program'):32+len(b'program')+len(MARKER)] = MARKER
|
||||
self.uf2.write_bytes(wire)
|
||||
result, manifest = self.checker()
|
||||
self.assertEqual(result.returncode, 2, result.stderr)
|
||||
self.assertFalse(manifest['verified'])
|
||||
self.assertIn('padding', result.stderr)
|
||||
|
||||
def test_raw_bin_and_uf2_disagreement_fails_before_marker_admission(self):
|
||||
wire = bytearray(uf2(self.app))
|
||||
wire[32] ^= 1
|
||||
self.uf2.write_bytes(wire)
|
||||
result, manifest = self.checker()
|
||||
self.assertEqual(result.returncode, 2, result.stderr)
|
||||
self.assertFalse(manifest['verified'])
|
||||
self.assertIn('does not match', result.stderr)
|
||||
|
||||
def test_invalid_family_headers_layout_and_sector_metadata_are_rejected(self):
|
||||
original = uf2(self.app)
|
||||
changes = (
|
||||
(0, 0), (4, 0), (508, 0), (8, 0), (8, 0x2001), (8, 0x3000),
|
||||
(28, 0xADA52840), (16, 252), (20, 1), (24, 0),
|
||||
(12, FLASH_BASE-256), (12, FLASH_END),
|
||||
(512+12, FLASH_BASE), (512+12, FLASH_BASE+512), (512+20, 0), (512+24, 1),
|
||||
)
|
||||
for offset, value in changes:
|
||||
wire = bytearray(original)
|
||||
struct.pack_into('<I', wire, offset, value)
|
||||
self.uf2.write_bytes(wire)
|
||||
with self.subTest(offset=offset, value=value), self.assertRaises(ValueError):
|
||||
read_application(self.uf2, self.bin)
|
||||
for wire in (b'', original[:-1], original+b'\0'):
|
||||
self.uf2.write_bytes(wire)
|
||||
with self.subTest(size=len(wire)), self.assertRaises(ValueError):
|
||||
read_application(self.uf2, self.bin)
|
||||
|
||||
def test_empty_oversized_or_unnecessarily_extended_application_is_rejected(self):
|
||||
for size in (0, FLASH_END-FLASH_BASE+1):
|
||||
with self.bin.open('wb') as stream:
|
||||
stream.truncate(size)
|
||||
with self.subTest(size=size), self.assertRaises(ValueError):
|
||||
read_application(self.uf2, self.bin)
|
||||
self.bin.write_bytes(self.app[:256])
|
||||
self.uf2.write_bytes(uf2(self.app[:256] + b'\xff'*256))
|
||||
with self.assertRaisesRegex(ValueError, 'does not match'):
|
||||
read_application(self.uf2, self.bin)
|
||||
|
||||
def test_rp2040_application_proof_requires_both_exact_public_formats(self):
|
||||
for include_bin, include_uf2, platform in ((True, False, 'RP2040_PLATFORM'),
|
||||
(False, True, 'RP2040_PLATFORM'),
|
||||
(True, True, 'ESP32_PLATFORM')):
|
||||
result, manifest = self.checker(platform, include_bin, include_uf2)
|
||||
with self.subTest(platform=platform, bin=include_bin, uf2=include_uf2):
|
||||
self.assertEqual(result.returncode, 2, result.stderr)
|
||||
self.assertFalse(manifest['verified'])
|
||||
self.assertIn('require both', result.stderr)
|
||||
|
||||
def test_combined_transports_never_qualify_as_wireless_self_update(self):
|
||||
result, manifest = self.checker(extra=('--require-ota',))
|
||||
self.assertEqual(result.returncode, 1, result.stderr)
|
||||
self.assertFalse(manifest['ota_update_verified'])
|
||||
self.assertEqual(manifest['ota_update_methods'], [])
|
||||
self.assertIn('wireless self-update', result.stderr)
|
||||
|
||||
@unittest.skipIf(os.name == 'nt', 'builder is POSIX shell')
|
||||
def test_builder_passes_both_real_artifacts_to_the_production_checker(self):
|
||||
directory = self.path / 'build/PicoW_companion_radio'
|
||||
directory.mkdir(parents=True)
|
||||
for source in (self.bin, self.uf2, self.elf):
|
||||
(directory / source.name).write_bytes(source.read_bytes())
|
||||
output = self.path / 'out'
|
||||
output.mkdir()
|
||||
program = r'''
|
||||
set -euo pipefail
|
||||
source build.sh
|
||||
pio() { echo 'PlatformIO must not run' >&2; return 99; }
|
||||
PIO_BUILD_DIR_OVERRIDE=@BUILD@
|
||||
OUTPUT_DIR=@OUTPUT@
|
||||
BUILD_PROFILE_FOR_TARGET=auto
|
||||
BUILD_RECIPE_SHA256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
BUILD_CAPABILITIES=() BUILD_REDUCTIONS=() BUILD_EXPECTATIONS=()
|
||||
BUILD_APPLICATION_EXPECTATIONS=('companion.picow.combined=meshcore.capability.companion.picow.combined.v1')
|
||||
write_build_capability_manifest PicoW_companion_radio RP2040_PLATFORM PicoW_companion_radio fixture
|
||||
'''
|
||||
program = program.replace('@BUILD@', shlex.quote(str(self.path / 'build'))).replace('@OUTPUT@', shlex.quote(str(output)))
|
||||
result = subprocess.run(['bash', '-c', program], cwd=ROOT, capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
manifest = json.loads((output / 'fixture.capabilities.json').read_text())
|
||||
self.assertTrue(manifest['verified'])
|
||||
self.assertIn(CAPABILITY, manifest['capabilities'])
|
||||
self.assertEqual(manifest['ota_update_methods'], [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user