From 2d150eec7691397d60b50edcdcb658ae056a3989 Mon Sep 17 00:00:00 2001 From: Ahnaf Shahriar Date: Sat, 12 Sep 2026 15:11:28 -0700 Subject: [PATCH] Add esp_image: build bootable ESP32 C3/C6/P4 image ELFs, fetching Espressif's pinned mask ROM The ESP32 platforms boot the real mask ROM, so `sim --elf` needs one ELF carrying the ROM plus the whole flash image. That packing lived in three near-identical scripts in mcu-lib, which external users do not have, and each asked the user to find the ROM themselves. simantic.esp_image (and `simantic esp-image` / `simantic esp-rom`): - One layout table for C3 (with its DROM alias), C6 and P4. - ROMs download once from Espressif's own repositories, pinned by commit and SHA-256, cached in ~/.simantic/esp-rom: C3/C6 raw dumps from espressif/qemu pc-bios, the P4 ROM ELF from esp-rom-elfs 20241011. - The P4 ROM window is rebuilt from the ROM ELF in pure Python: code sections, the .data initialisers from the ROM copy table, and the interface table at the top of the window. No objcopy needed. - Flash images merge from OFFSET:PATH parts with 0xFF fill, or pass a merged image. - Refuses the known-bad C3 dump with a zeroed PHY jump table, debug ELFs where a raw dump is needed, and an objcopy-only P4 extract. Verified byte for byte: C3 rebuild of the CrossPoint featble payload, the mcu-lib-esp32p4 hello image, the old mcu-lib scripts for all three chips on identical inputs, and the P4 section layout against riscv32-esp-elf-objcopy. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 27 +++ src/simantic/_cli.py | 69 ++++++- src/simantic/esp_image.py | 406 ++++++++++++++++++++++++++++++++++++++ tests/test_esp_image.py | 274 +++++++++++++++++++++++++ 4 files changed, 773 insertions(+), 3 deletions(-) create mode 100644 src/simantic/esp_image.py create mode 100644 tests/test_esp_image.py diff --git a/README.md b/README.md index 34db9be..3cab61b 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,33 @@ or CAN and radio injection, raises `simantic.NotSupported` and names the gap instead of quietly doing nothing. You can follow what each engine covers in [simantic-core#183](https://github.com/simantic-dev/simantic-core/issues/183). +## ESP32 images + +The ESP32-C3, C6 and P4 boot the way silicon does: Espressif's mask ROM runs +first, then your bootloader, then your app. `sim --elf` therefore takes one ELF +that carries the ROM and your whole flash image. Build it from the files your +ESP-IDF or PlatformIO build already produced: + +```bash +simantic esp-image --chip esp32c3 \ + --part 0x0:bootloader.bin --part 0x8000:partitions.bin --part 0x10000:firmware.bin \ + --flash-size 16MB -o image.elf +sim --elf image.elf ... +``` + +Already have a merged image from `esptool.py merge_bin`? Pass `--flash merged.bin` +instead of the parts. The same thing from Python is +`simantic.esp_image.build_image("esp32c3", flash, out="image.elf")`. + +The mask ROM is Espressif's and is not bundled. On first use it is downloaded +from Espressif's own repositories, pinned by commit and SHA-256, and cached in +`~/.simantic/esp-rom`: the raw C3 and C6 dumps from +[espressif/qemu](https://github.com/espressif/qemu/tree/master/pc-bios), and +the P4 ROM from the [esp-rom-elfs](https://github.com/espressif/esp-rom-elfs) +release ESP-IDF installs. Offline, or with your own copy, pass `--rom`. +`simantic esp-rom --chip esp32c3` downloads it ahead of time and prints where it +went. + ## Testing with pytest Take the `sim` fixture and write ordinary tests: diff --git a/src/simantic/_cli.py b/src/simantic/_cli.py index 4866b64..d7a8768 100644 --- a/src/simantic/_cli.py +++ b/src/simantic/_cli.py @@ -1,4 +1,5 @@ -"""The `simantic` command: authenticate, install binaries, report status. +"""The `simantic` command: authenticate, install binaries, report status, +and build bootable ESP32 images. Thin by design. It exists so `pip install simantic` is followed by two obvious commands rather than a documentation hunt, not to become a third @@ -11,7 +12,7 @@ import getpass import sys -from . import auth, install, telemetry +from . import auth, esp_image, install, telemetry from ._locate import BinaryNotFound, locate from .mcu import BINARY as SIM_BINARY from .mcu import ENV_VAR as SIM_ENV @@ -81,6 +82,40 @@ def _status(args) -> int: return 0 +def _parse_part(text: str) -> tuple[int, bytes]: + offset, sep, path = text.partition(":") + if not sep or not path: + raise esp_image.EspImageError(f"--part expects OFFSET:PATH, e.g. 0x10000:firmware.bin (got {text!r})") + try: + return int(offset, 0), open(path, "rb").read() + except ValueError: + raise esp_image.EspImageError(f"--part offset {offset!r} is not a number") from None + except OSError as exc: + raise esp_image.EspImageError(f"--part {path}: {exc.strerror}") from None + + +def _esp_image(args) -> int: + size = esp_image.parse_size(args.flash_size) if args.flash_size else None + if args.flash: + flash = esp_image._read(args.flash, "flash image") + if size is not None: + flash = esp_image.merge_flash([(0, flash)], size=size) + else: + flash = esp_image.merge_flash([_parse_part(p) for p in args.part], size=size) + rom = args.rom + if rom is None: + lay = esp_image.layout(args.chip) + print(f"using Espressif's {lay.chip} mask ROM from {lay.source.url} (cached after the first download)") + elf = esp_image.build_image(args.chip, flash, out=args.out, rom=rom) + print(f"wrote {args.out}: {len(elf)} bytes, flash {len(flash):#x} bytes; run it with `sim --elf {args.out}`") + return 0 + + +def _esp_rom(args) -> int: + print(esp_image.fetch_rom(args.chip, force=args.force)) + return 0 + + def main(argv: list[str] | None = None) -> int: # prog is left to argparse so usage reflects however it was invoked: # `simantic`, the short `smtc`, or `python -m simantic`. @@ -117,6 +152,34 @@ def main(argv: list[str] | None = None) -> int: func=_status ) + p_img = sub.add_parser( + "esp-image", + help="build a bootable ESP32 image ELF (mask ROM + flash) for `sim --elf`", + ) + p_img.add_argument("--chip", required=True, help=f"one of {', '.join(esp_image.chips())}") + src = p_img.add_mutually_exclusive_group(required=True) + src.add_argument("--flash", help="merged flash image (bootloader, partition table and app at their offsets)") + src.add_argument( + "--part", + action="append", + metavar="OFFSET:PATH", + help="a flash part at its offset, repeatable, e.g. 0x0:bootloader.bin 0x8000:partitions.bin " + "0x10000:firmware.bin (gaps are 0xFF)", + ) + p_img.add_argument("--flash-size", help="pad the flash image to this size, e.g. 16MB") + p_img.add_argument( + "--rom", + help="use this mask ROM instead of downloading Espressif's pinned copy " + "(raw dump for C3/C6; esp32p4_rev0_rom.elf for P4)", + ) + p_img.add_argument("-o", "--out", required=True, help="output ELF path") + p_img.set_defaults(func=_esp_image) + + p_rom = sub.add_parser("esp-rom", help="download Espressif's pinned mask ROM for a chip and print its path") + p_rom.add_argument("--chip", required=True, help=f"one of {', '.join(esp_image.chips())}") + p_rom.add_argument("--force", action="store_true", help="download again even if cached") + p_rom.set_defaults(func=_esp_rom) + args = parser.parse_args(argv) telemetry.record(f"cli.{args.command}") try: @@ -125,7 +188,7 @@ def main(argv: list[str] | None = None) -> int: # waiting on a simulation, and the spool is due at most hourly. telemetry.flush() return result - except (auth.AuthError, install.InstallError) as exc: + except (auth.AuthError, install.InstallError, esp_image.EspImageError) as exc: print(f"error: {exc}", file=sys.stderr) return 1 except KeyboardInterrupt: diff --git a/src/simantic/esp_image.py b/src/simantic/esp_image.py new file mode 100644 index 0000000..0da057b --- /dev/null +++ b/src/simantic/esp_image.py @@ -0,0 +1,406 @@ +"""Bootable image ELFs for the ESP32 ROM-boot platforms (C3, C6, P4). + +The ESP32 platforms boot the real Espressif mask ROM, which reads the flash +image over SPI, runs the 2nd-stage bootloader and jumps to the app. The single +ELF the simulator loads therefore carries the mask ROM at the reset vector and +the whole flash image at a backing address the SPI flash device reads by +reference. This module packs those into one ELF: + + from simantic import esp_image + esp_image.build_image("esp32c3", "flash.bin", out="image.elf") + +The mask ROM is Espressif's, and it is not shipped in this package. When no +ROM is given it is downloaded once from Espressif's own public repositories, +pinned by commit and SHA-256, and cached under ``~/.simantic/esp-rom``: + +* C3 and C6: the raw dumps QEMU for Espressif ships in ``pc-bios/``. +* P4: ``esp32p4_rev0_rom.elf`` from the ``esp-rom-elfs`` release ESP-IDF + installs. The ROM debug ELF does not hold the mask ROM verbatim, so the + bytes are rebuilt from its sections (see ``_p4_rom_from_elf``). + +Why not the ``esp-rom-elfs`` ELF for C3/C6 too: its ROM ``.data`` initialisers +sit relocated for the debugger, so a flatten reads zeros where the ROM's +``_init`` expects its function tables. A raw dump is required for those two. +""" + +from __future__ import annotations + +import hashlib +import io +import os +import re +import struct +import tarfile +import urllib.error +import urllib.request +from dataclasses import dataclass +from pathlib import Path + +from .install import simantic_home + +EM_RISCV = 243 +PF_R, PF_W, PF_X = 4, 2, 1 + +_QEMU = "https://raw.githubusercontent.com/espressif/qemu/febae182e132e4055529be423a818225ebddaa3a/pc-bios/" +_ROM_ELFS = "https://github.com/espressif/esp-rom-elfs/releases/download/20241011/esp-rom-elfs-20241011.tar.gz" + + +class EspImageError(RuntimeError): + """The image could not be built, or the ROM could not be fetched or trusted.""" + + +@dataclass(frozen=True) +class RomSource: + url: str + sha256: str + #: File inside a .tar.gz download, and its own checksum. + member: str | None = None + member_sha256: str | None = None + + +@dataclass(frozen=True) +class Layout: + chip: str + reset: int + rom_size: int + flash_addr: int + source: RomSource + #: (vaddr, start, end): a read-only alias of rom[start:end], C3 only. + drom: tuple[int, int, int] | None = None + + +LAYOUTS: dict[str, Layout] = { + "esp32c3": Layout( + chip="esp32c3", + reset=0x40000000, + rom_size=0x60000, + flash_addr=0x30000000, + drom=(0x3FF00000, 0x40000, 0x60000), + source=RomSource( + _QEMU + "esp32c3-rom.bin", + "0de1e65020e803bea0d7443dca149d61895e01fca3bb9c82d073234eebd73f99", + ), + ), + "esp32c6": Layout( + chip="esp32c6", + reset=0x40000000, + rom_size=0x50000, + flash_addr=0x30000000, + source=RomSource( + _QEMU + "esp32c6-rom.bin", + "91db14c2419391308b108bac0b15fe3442a9c411cdd7f3fb088767c9fbc4b713", + ), + ), + # The P4 mask ROM has its own 128 kB window at 0x4fc00000; 0x44000000 is + # the free hole between the flash-cache window and PSRAM, used only as the + # flash image's backing store. + "esp32p4": Layout( + chip="esp32p4", + reset=0x4FC00000, + rom_size=0x20000, + flash_addr=0x44000000, + source=RomSource( + _ROM_ELFS, + "921f000164a421c7628fbfee55b173384aafaa51883adc65cd27bf9b0af9e9a9", + member="esp32p4_rev0_rom.elf", + member_sha256="948f2c7d108d04a9c3a982f9c1c498fb17f2c4a68abc51f87782592034d7ce44", + ), + ), +} + + +def chips() -> list[str]: + return sorted(LAYOUTS) + + +def layout(chip: str) -> Layout: + """Accepts ``esp32c3``, ``ESP32-C3`` or ``c3``.""" + key = re.sub(r"[^a-z0-9]", "", chip.lower()) + if not key.startswith("esp32"): + key = "esp32" + key + try: + return LAYOUTS[key] + except KeyError: + raise EspImageError(f"unsupported chip {chip!r}; expected one of {', '.join(chips())}") from None + + +# --- the ROM --------------------------------------------------------------- + + +def rom_cache_dir() -> Path: + return simantic_home() / "esp-rom" + + +def _read(value: str | os.PathLike | bytes, what: str) -> bytes: + if isinstance(value, (bytes, bytearray)): + return bytes(value) + try: + return Path(value).read_bytes() + except OSError as exc: + raise EspImageError(f"cannot read {what} {value}: {exc.strerror}") from None + + +def _sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def fetch_rom(chip: str, *, force: bool = False, timeout: float = 120) -> Path: + """The pinned Espressif ROM for ``chip``, downloaded once and cached. + + Every byte is checked against the pinned SHA-256 before it is written, and + again when read back from the cache, so a tampered or truncated file is + never used. + """ + lay = layout(chip) + src = lay.source + want = src.member_sha256 or src.sha256 + name = src.member or src.url.rsplit("/", 1)[-1] + cached = rom_cache_dir() / f"{lay.chip}-{want[:16]}-{name}" + if cached.exists() and not force: + if _sha256(cached.read_bytes()) == want: + return cached + cached.unlink() + + try: + with urllib.request.urlopen(urllib.request.Request(src.url), timeout=timeout) as response: + payload = response.read() + except urllib.error.HTTPError as exc: + raise EspImageError( + f"ROM download failed: HTTP {exc.code} from {src.url}. Pass the ROM yourself with rom=/--rom." + ) from None + except urllib.error.URLError as exc: + raise EspImageError( + f"cannot reach {src.url}: {exc.reason}. Pass the ROM yourself with rom=/--rom." + ) from None + + got = _sha256(payload) + if got != src.sha256: + raise EspImageError(f"ROM download checksum mismatch (expected {src.sha256}, got {got}); refusing to use it") + if src.member: + try: + with tarfile.open(fileobj=io.BytesIO(payload), mode="r:gz") as tar: + member = tar.extractfile(src.member) + if member is None: + raise KeyError(src.member) + payload = member.read() + except (tarfile.TarError, KeyError) as exc: + raise EspImageError(f"{src.member} not found in {src.url}: {exc}") from None + got = _sha256(payload) + if got != src.member_sha256: + raise EspImageError( + f"{src.member} checksum mismatch (expected {src.member_sha256}, got {got}); refusing to use it" + ) + + cached.parent.mkdir(parents=True, exist_ok=True) + tmp = cached.with_name(cached.name + f".{os.getpid()}.tmp") + tmp.write_bytes(payload) + tmp.replace(cached) + return cached + + +def rom_image(chip: str, rom: str | os.PathLike | bytes | None = None) -> bytes: + """The exact bytes the image maps at the reset vector. + + ``rom`` may be a path or bytes: a raw dump for C3/C6; for P4 either the + ``esp32p4_rev0_rom.elf`` debug ELF or an already rebuilt 0x20000-byte bin. + ``None`` uses the pinned download. + """ + lay = layout(chip) + if rom is None: + rom = fetch_rom(lay.chip) + data = _read(rom, "ROM") + + if lay.chip == "esp32p4": + if data[:4] == b"\x7fELF": + return _p4_rom_from_elf(data) + if len(data) == lay.rom_size: + return data + raise EspImageError( + f"P4 ROM must be esp32p4_rev0_rom.elf or the rebuilt {lay.rom_size:#x}-byte bin, got {len(data):#x} bytes. " + "A plain objcopy extract is not enough: it zero-fills the ROM's .data init bytes and the ROM " + "later jumps through a NULL table pointer." + ) + + if data[:4] == b"\x7fELF": + raise EspImageError( + f"{lay.chip} needs a RAW mask-ROM dump, not an ELF: the esp-rom-elfs debug ELF has its .data " + "initialisers relocated. Omit the ROM to download the pinned raw dump." + ) + if len(data) < lay.rom_size: + raise EspImageError(f"{lay.chip} ROM too small: {len(data):#x} < {lay.rom_size:#x}") + data = data[: lay.rom_size] + if lay.chip == "esp32c3" and data[0x1C34:0x1C38] == b"\0\0\0\0": + # A circulating C3 dump (esp32c3-api1-20210111-dirty) has a zeroed hole + # in the PHY dispatch table: every radio enable then dies with an + # illegal instruction at 0x40001c34. + raise EspImageError( + "this C3 ROM has a zeroed PHY jump table at 0x40001c34 (the 20210111-dirty dump); " + "Bluetooth and Wi-Fi would crash on enable. Omit the ROM to download the good one." + ) + return data + + +def _sections(elf: bytes) -> list[dict]: + if elf[:4] != b"\x7fELF" or elf[4] != 1 or elf[5] != 1: + raise EspImageError("expected a 32-bit little-endian ELF") + (shoff,) = struct.unpack_from(" int: + symtab = next(s for s in secs if s["name"] == ".symtab") + strtab = next(s for s in secs if s["name"] == ".strtab") + for i in range(symtab["size"] // 16): + name_off, value = struct.unpack_from(" bytes: + """Rebuild the P4 mask-ROM window from ``esp32p4_rev0_rom.elf``. + + 1. Lay the loaded code and rodata sections at their addresses in the + 128 kB window (what ``objcopy -O binary --only-section=...`` gives). + 2. Put back the ROM's ``.data`` initialisers. On silicon they sit in the + gap after ``.text`` and the ROM's startup copies them into RAM using the + table at ``_data_table_start`` (12-byte dst_start, dst_end, src + entries). The debug ELF stores those bytes in separate sections at the + destination address instead, so each entry's section is found by + (address, size) and its bytes are written back at ``src``. + 3. Copy every PROGBITS section the ELF places inside the window, which + brings in the interface pointer table at its very top. + + Left out, step 2 makes ``Cache_Invalidate_All`` call through a NULL + ``rom_cache_internal_table_ptr``; step 3, a NULL ``ets_rom_layout_p`` + aborts heap init. + """ + lay = LAYOUTS["esp32p4"] + base, size = lay.reset, lay.rom_size + secs = _sections(elf) + rom = bytearray(size) + + def put(addr: int, data: bytes) -> None: + off = addr - base + if off < 0 or off + len(data) > size: + raise EspImageError(f"ROM data at {addr:#x}+{len(data):#x} falls outside the {size:#x}-byte window") + rom[off : off + len(data)] = data + + for sec in secs: + if sec["name"] in _P4_CODE_SECTIONS and sec["size"]: + put(sec["addr"], elf[sec["offset"] : sec["offset"] + sec["size"]]) + + table_start = _symbol(elf, secs, "_data_table_start") + table_end = _symbol(elf, secs, "_bss_start") + text = next(s for s in secs if s["name"] == ".text") + table_off = text["offset"] + (table_start - text["addr"]) + for i in range((table_end - table_start) // 12): + dst_start, dst_end, src = struct.unpack_from(" int: + """``16MB``, ``4M``, ``0x1000000`` or ``16777216``.""" + m = re.fullmatch(r"\s*(0x[0-9a-fA-F]+|\d+)\s*([kKmM][bB]?)?\s*", text) + if not m: + raise EspImageError(f"cannot read size {text!r}; use e.g. 16MB or 0x1000000") + value = int(m.group(1), 0) + unit = (m.group(2) or "").lower()[:1] + return value * {"": 1, "k": 1 << 10, "m": 1 << 20}[unit] + + +def merge_flash(parts: list[tuple[int, bytes]], *, size: int | None = None, fill: int = 0xFF) -> bytes: + """Lay ``(offset, data)`` parts into one flash image. + + Gaps are 0xFF, the erased state of NOR flash and what ``esptool merge_bin`` + writes, so an unwritten otadata sector reads as empty and the bootloader + picks the factory/ota_0 app. + """ + if not parts: + raise EspImageError("no flash parts given") + ordered = sorted(parts, key=lambda p: p[0]) + end = 0 + for offset, data in ordered: + if offset < end: + raise EspImageError(f"flash part at {offset:#x} overlaps the previous part (which ends at {end:#x})") + end = offset + len(data) + if size is not None and size < end: + raise EspImageError(f"flash size {size:#x} is smaller than the parts ({end:#x})") + image = bytearray([fill]) * (size if size is not None else end) + for offset, data in ordered: + image[offset : offset + len(data)] = data + return bytes(image) + + +def _elf32(entry: int, segments: list[tuple[int, int, bytes]]) -> bytes: + """A minimal ELF32 with one PT_LOAD per (vaddr, flags, data).""" + ehsize, phentsize = 52, 32 + header = b"\x7fELF" + bytes([1, 1, 1, 0]) + b"\0" * 8 + struct.pack( + " bytes: + """The bootable ELF for ``chip``: mask ROM at the reset vector plus ``flash``. + + ``flash`` is the whole flash image (bootloader, partition table and app at + their offsets), a path or bytes; ``merge_flash`` builds one from parts. + Writes ``out`` when given and returns the ELF bytes either way. + """ + lay = layout(chip) + rom_bytes = rom_image(lay.chip, rom) + flash_bytes = _read(flash, "flash image") + if not flash_bytes: + raise EspImageError("flash image is empty") + + segments = [(lay.reset, PF_R | PF_X, rom_bytes)] + if lay.drom: + vaddr, start, end = lay.drom + segments.append((vaddr, PF_R, rom_bytes[start:end])) + segments.append((lay.flash_addr, PF_R | PF_W, flash_bytes)) + elf = _elf32(lay.reset, segments) + if out is not None: + Path(out).write_bytes(elf) + return elf diff --git a/tests/test_esp_image.py b/tests/test_esp_image.py new file mode 100644 index 0000000..f7e5851 --- /dev/null +++ b/tests/test_esp_image.py @@ -0,0 +1,274 @@ +"""esp_image: ESP32 ROM-boot image packing, ROM pinning and flash merging. + +Pure Python and offline by default. Two tests use real Espressif files when +they are already on the machine (an ESP-IDF install), and one reaches the +network only with SIMANTIC_NETWORK_TESTS=1. +""" + +from __future__ import annotations + +import dataclasses +import hashlib +import io +import os +import struct +import tarfile +import urllib.error +from pathlib import Path + +import pytest + +from simantic import _cli, esp_image as ei + + +def _phdrs(elf: bytes) -> tuple[int, list[tuple[int, int, bytes]]]: + (entry,) = struct.unpack_from(" bytes: + data = bytearray(hashlib.shake_256(b"rom").digest(size)) + data[0x1C34:0x1C38] = b"\x6f\xa0\xd3\x3e" # a real jal, like the good C3 dump + return bytes(data) + + +@pytest.fixture +def home(tmp_path, monkeypatch): + monkeypatch.setenv("SIMANTIC_HOME", str(tmp_path / "home")) + return tmp_path / "home" + + +# --- layouts --------------------------------------------------------------- + + +def test_c3_image_has_rom_drom_alias_and_flash(): + rom, flash = _rom(0x60000), b"\xe9" + os.urandom(999) + entry, segs = _phdrs(ei.build_image("esp32c3", flash, rom=rom + b"extra")) + assert entry == 0x40000000 + assert segs == [ + (0x40000000, ei.PF_R | ei.PF_X, rom), + (0x3FF00000, ei.PF_R, rom[0x40000:0x60000]), + (0x30000000, ei.PF_R | ei.PF_W, flash), + ] + + +def test_c6_image_has_no_drom_alias(): + rom = _rom(0x50000) + entry, segs = _phdrs(ei.build_image("ESP32-C6", b"\xe9flash", rom=rom)) + assert entry == 0x40000000 + assert [(v, len(d)) for v, _, d in segs] == [(0x40000000, 0x50000), (0x30000000, 6)] + + +def test_writes_out_file(tmp_path): + out = tmp_path / "image.elf" + elf = ei.build_image("c3", b"\xe9", rom=_rom(0x60000), out=out) + assert out.read_bytes() == elf + + +@pytest.mark.parametrize("name", ["esp32c3", "ESP32-C3", "c3", "esp32_c3"]) +def test_chip_names(name): + assert ei.layout(name).chip == "esp32c3" + + +def test_unknown_chip(): + with pytest.raises(ei.EspImageError, match="unsupported chip"): + ei.layout("esp32h2") + + +# --- ROM checks ------------------------------------------------------------ + + +def test_c3_rom_with_zeroed_phy_table_is_refused(): + rom = bytearray(_rom(0x60000)) + rom[0x1C1C:0x1D00] = bytes(0xE4) + with pytest.raises(ei.EspImageError, match="PHY"): + ei.build_image("esp32c3", b"\xe9", rom=bytes(rom)) + + +def test_short_rom_is_refused(): + with pytest.raises(ei.EspImageError, match="too small"): + ei.build_image("esp32c6", b"\xe9", rom=_rom(0x4FFFF)) + + +def test_debug_elf_is_refused_for_c3(): + with pytest.raises(ei.EspImageError, match="RAW"): + ei.build_image("esp32c3", b"\xe9", rom=b"\x7fELF" + bytes(0x60000)) + + +def test_p4_objcopy_extract_is_refused(): + with pytest.raises(ei.EspImageError, match="objcopy"): + ei.build_image("esp32p4", b"\xe9", rom=bytes(0x1FC18)) + + +def test_empty_flash_is_refused(): + with pytest.raises(ei.EspImageError, match="empty"): + ei.build_image("esp32c3", b"", rom=_rom(0x60000)) + + +# --- flash merging --------------------------------------------------------- + + +def test_merge_flash_fills_gaps_with_erased_bytes(): + image = ei.merge_flash([(0x10, b"app"), (0x0, b"bl")], size=0x20) + assert image == b"bl" + b"\xff" * 14 + b"app" + b"\xff" * 13 + + +def test_merge_flash_without_size_ends_at_last_part(): + assert ei.merge_flash([(4, b"x")]) == b"\xff" * 4 + b"x" + + +def test_merge_flash_rejects_overlap_and_small_size(): + with pytest.raises(ei.EspImageError, match="overlaps"): + ei.merge_flash([(0, b"abcd"), (2, b"x")]) + with pytest.raises(ei.EspImageError, match="smaller"): + ei.merge_flash([(0, b"abcd")], size=2) + + +@pytest.mark.parametrize("text,value", [("16MB", 16 << 20), ("4M", 4 << 20), ("512k", 512 << 10), ("0x1000", 0x1000), ("100", 100)]) +def test_parse_size(text, value): + assert ei.parse_size(text) == value + + +# --- fetching -------------------------------------------------------------- + + +class _Response(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +def _pin(monkeypatch, chip, source): + monkeypatch.setitem(ei.LAYOUTS, chip, dataclasses.replace(ei.LAYOUTS[chip], source=source)) + + +def test_fetch_verifies_caches_and_reuses(home, monkeypatch): + blob = _rom(0x50000) + _pin(monkeypatch, "esp32c6", ei.RomSource("https://example.invalid/esp32c6-rom.bin", hashlib.sha256(blob).hexdigest())) + calls = [] + + def urlopen(request, timeout): + calls.append(request.full_url) + return _Response(blob) + + monkeypatch.setattr(ei.urllib.request, "urlopen", urlopen) + first = ei.fetch_rom("esp32c6") + assert first.read_bytes() == blob and first.is_relative_to(home) + assert ei.fetch_rom("esp32c6") == first + assert len(calls) == 1 + assert _phdrs(ei.build_image("esp32c6", b"\xe9"))[1][0][2] == blob + + +def test_fetch_refuses_checksum_mismatch(home, monkeypatch): + _pin(monkeypatch, "esp32c6", ei.RomSource("https://example.invalid/rom.bin", "0" * 64)) + monkeypatch.setattr(ei.urllib.request, "urlopen", lambda request, timeout: _Response(b"tampered")) + with pytest.raises(ei.EspImageError, match="checksum mismatch"): + ei.fetch_rom("esp32c6") + assert not (home / "esp-rom").exists() or not any((home / "esp-rom").iterdir()) + + +def test_fetch_replaces_corrupted_cache(home, monkeypatch): + blob = _rom(0x50000) + _pin(monkeypatch, "esp32c6", ei.RomSource("https://example.invalid/esp32c6-rom.bin", hashlib.sha256(blob).hexdigest())) + monkeypatch.setattr(ei.urllib.request, "urlopen", lambda request, timeout: _Response(blob)) + path = ei.fetch_rom("esp32c6") + path.write_bytes(b"corrupt") + assert ei.fetch_rom("esp32c6").read_bytes() == blob + + +def test_fetch_extracts_and_verifies_tar_member(home, monkeypatch): + member = b"\x7fELF not really" + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + info = tarfile.TarInfo("esp32p4_rev0_rom.elf") + info.size = len(member) + tar.addfile(info, io.BytesIO(member)) + payload = buf.getvalue() + _pin( + monkeypatch, + "esp32p4", + ei.RomSource( + "https://example.invalid/roms.tar.gz", + hashlib.sha256(payload).hexdigest(), + member="esp32p4_rev0_rom.elf", + member_sha256=hashlib.sha256(member).hexdigest(), + ), + ) + monkeypatch.setattr(ei.urllib.request, "urlopen", lambda request, timeout: _Response(payload)) + assert ei.fetch_rom("esp32p4").read_bytes() == member + + +def test_fetch_network_error_points_at_rom_option(home, monkeypatch): + def urlopen(request, timeout): + raise urllib.error.URLError("offline") + + monkeypatch.setattr(ei.urllib.request, "urlopen", urlopen) + with pytest.raises(ei.EspImageError, match="--rom"): + ei.fetch_rom("esp32c3") + + +# --- CLI ------------------------------------------------------------------- + + +def test_cli_builds_from_parts(tmp_path, capsys): + (tmp_path / "rom.bin").write_bytes(_rom(0x60000)) + (tmp_path / "bl.bin").write_bytes(b"BOOT") + (tmp_path / "app.bin").write_bytes(b"APP") + out = tmp_path / "image.elf" + rc = _cli.main([ + "esp-image", "--chip", "esp32c3", "--rom", str(tmp_path / "rom.bin"), + "--part", f"0x0:{tmp_path / 'bl.bin'}", "--part", f"0x10000:{tmp_path / 'app.bin'}", + "--flash-size", "128k", "-o", str(out), + ]) + assert rc == 0 + flash = _phdrs(out.read_bytes())[1][-1][2] + assert len(flash) == 128 << 10 and flash[:4] == b"BOOT" and flash[0x10000 - 1] == 0xFF + assert flash[0x10000:0x10003] == b"APP" + assert "sim --elf" in capsys.readouterr().out + + +@pytest.mark.parametrize( + "args,message", + [ + (["--part", "zz"], "OFFSET:PATH"), + (["--flash", "missing.bin"], "cannot read flash image"), + (["--flash", "FLASH", "--rom", "missing-rom.bin"], "cannot read ROM"), + (["--chip", "esp32h2", "--flash", "FLASH"], "unsupported chip"), + ], +) +def test_cli_reports_errors_without_traceback(tmp_path, capsys, monkeypatch, args, message): + monkeypatch.chdir(tmp_path) + (tmp_path / "FLASH").write_bytes(b"\xe9") + argv = ["esp-image", "--chip", "esp32c3", "-o", "o.elf"] + args + if "--rom" not in args: + argv += ["--rom", "missing-rom.bin"] if "--chip" in args else [] + assert _cli.main(argv) == 1 + assert message in capsys.readouterr().err + + +# --- real Espressif files, when present ------------------------------------ + +_IDF_P4 = Path.home() / ".espressif/tools/esp-rom-elfs/20241011/esp32p4_rev0_rom.elf" + + +@pytest.mark.skipif(not _IDF_P4.exists(), reason="needs ESP-IDF's esp-rom-elfs 20241011") +def test_p4_rom_rebuilt_from_idf_elf_matches_known_good(): + rom = ei.rom_image("esp32p4", _IDF_P4) + # The ROM image that boots ESP-IDF apps on the simulated P4. + assert hashlib.sha256(rom).hexdigest() == "a75a4a6e2efc5cefb0b67f52e9871ba5f2fc81bab93f46732f80658cb8eb9cf4" + + +@pytest.mark.skipif(not os.environ.get("SIMANTIC_NETWORK_TESTS"), reason="set SIMANTIC_NETWORK_TESTS=1 to reach Espressif") +@pytest.mark.parametrize("chip", ei.chips()) +def test_pinned_rom_still_downloads(home, chip): + path = ei.fetch_rom(chip) + assert ei.rom_image(chip, path)