Verify RP2040 capabilities against matching UF2 application

This commit is contained in:
mikecarper committed 2026-10-18 20:40:06 -07:00
1 parent 9c78ccde57
commit 5df3e4ae53
6 files changed
+253 -2

No files matched your search

+1
View File
@@ -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: |
+3
View File
@@ -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
View File
@@ -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
+7 -1
View File
@@ -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:
+49
View File
@@ -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()