diff --git a/pm3py/core/flash.py b/pm3py/core/flash.py index 5f8c384..ef0e84b 100644 --- a/pm3py/core/flash.py +++ b/pm3py/core/flash.py @@ -15,6 +15,7 @@ ever change the OS image anyway. """ import os import struct +import subprocess from pathlib import Path from .protocol import ( @@ -43,6 +44,10 @@ _DEFAULT_IMAGE_CANDIDATES = ( "firmware/armsrc/obj/fullimage.elf", ) +# Repo root in a source checkout (…/pm3py/core/flash.py → …/). Used to find the +# firmware submodule for --build; an installed wheel has no submodule here. +_PACKAGE_ROOT = Path(__file__).resolve().parents[2] + class FlashError(PM3Error): """Firmware flashing failed.""" @@ -165,6 +170,59 @@ def resolve_image(image=None) -> bytes: ) +def find_firmware_src(firmware_dir=None) -> Path | None: + """Locate the firmware source tree (the proxmark3-pm3py submodule) to build. + + Checks the explicit arg, then ``$PM3PY_FIRMWARE_SRC``, then the repo root + relative to this package. Returns None if no buildable tree is found — e.g. an + installed wheel carries no submodule. Deliberately does NOT search the current + working directory: build_firmware runs ``make`` on the result, and picking up a + stray ``./firmware`` would execute an untrusted Makefile. + """ + candidates = [] + if firmware_dir: + candidates.append(Path(firmware_dir)) + env = os.environ.get("PM3PY_FIRMWARE_SRC") + if env: + candidates.append(Path(env)) + candidates.append(_PACKAGE_ROOT / "firmware") + for c in candidates: + if (c / "Makefile").exists() and (c / "armsrc" / "Makefile").exists(): + return c + return None + + +def build_firmware(platform: str, firmware_dir=None) -> Path: + """Cross-compile the fork's fullimage.elf for a PM3 platform and return its path. + + ``platform`` is the PM3 build platform and must be given explicitly + (``PM3GENERIC`` for the Easy/generic target, ``PM3RDV4`` for the RDV4, ...). + Never infer it from a connected device: the running firmware's ``is_rdv4`` + flag reflects the firmware it was built for, not the physical board. + """ + if not platform or not platform.replace("_", "").isalnum(): + raise FlashError(f"Invalid PLATFORM {platform!r} " + f"(expected e.g. PM3GENERIC or PM3RDV4)") + src = find_firmware_src(firmware_dir) + if src is None: + raise FlashError( + "Firmware source not found — --build needs a source checkout with the " + "firmware/ submodule (an installed wheel has none). Set " + "PM3PY_FIRMWARE_SRC, run from the repo, or use --image with a prebuilt " + "fullimage.elf.") + cmd = ["make", "-C", str(src), f"PLATFORM={platform}", "armsrc/all"] + try: + proc = subprocess.run(cmd) # inherits stdio so the build streams to the user + except OSError as e: # make missing / not executable + raise FlashError(f"could not run make: {e}") + if proc.returncode != 0: + raise FlashError(f"firmware build failed (make exited {proc.returncode})") + elf = src / "armsrc" / "obj" / "fullimage.elf" + if not elf.exists(): + raise FlashError(f"build reported success but {elf} is missing") + return elf + + class Flasher: """Flash Proxmark3 firmware over the wire (OLD-frame bootloader protocol).""" diff --git a/pm3py/flash_cli.py b/pm3py/flash_cli.py index e873d40..78d7e0e 100644 --- a/pm3py/flash_cli.py +++ b/pm3py/flash_cli.py @@ -19,7 +19,7 @@ import sys from .core.client import Proxmark3 from .core.transport import PM3Error -from .core.flash import FlashError, resolve_image +from .core.flash import FlashError, resolve_image, build_firmware from ._firmware import matches @@ -99,8 +99,12 @@ def main(argv=None) -> int: prog="pm3flash", description="Flash the pinned Proxmark3 fork firmware.") p.add_argument("--port", help="serial port (default: auto-detect)") - p.add_argument("--image", help="path to fullimage.elf " - "(default: $PM3PY_FIRMWARE or local build)") + src = p.add_mutually_exclusive_group() + src.add_argument("--image", help="path to fullimage.elf " + "(default: $PM3PY_FIRMWARE or local build)") + src.add_argument("--build", metavar="PLATFORM", + help="build the firmware for PLATFORM (e.g. PM3GENERIC for " + "Easy, PM3RDV4) from the submodule, then flash it") p.add_argument("--force", action="store_true", help="flash even if the device already matches the pin") p.add_argument("--allow-bootrom", action="store_true", @@ -108,6 +112,16 @@ def main(argv=None) -> int: p.add_argument("-y", "--yes", action="store_true", help="do not prompt for confirmation") args = p.parse_args(argv) + if args.build: + try: + args.image = str(build_firmware(args.build)) + except FlashError as e: + print(f"error: {e}", file=sys.stderr) + return 2 + # --build is an explicit "put what I just built on the device" — flash it + # regardless of the pin. A dirty/iterated build keeps the same base SHA, so + # matches_pin can't tell the new image apart from the old firmware. + args.force = True return asyncio.run(_run(args)) diff --git a/tests/test_firmware_pin.py b/tests/test_firmware_pin.py index 65fd17e..301f528 100644 --- a/tests/test_firmware_pin.py +++ b/tests/test_firmware_pin.py @@ -1,5 +1,6 @@ """Tests for the firmware pin + needs-reflash detection (no hardware).""" import asyncio +import pytest from pm3py._firmware import FirmwarePin, FIRMWARE_PIN, matches from pm3py.core.client import Proxmark3 @@ -73,3 +74,30 @@ def test_cli_help_exits_clean(capsys): assert e.code == 0 out = capsys.readouterr().out assert "pm3flash" in out + + +def test_cli_build_and_image_mutually_exclusive(capsys): + with pytest.raises(SystemExit): + flash_cli.main(["--build", "PM3GENERIC", "--image", "x.elf"]) + + +def test_cli_build_invokes_builder(monkeypatch, tmp_path): + elf = tmp_path / "fullimage.elf" + elf.write_bytes(b"\x7fELF") + monkeypatch.setattr(flash_cli, "build_firmware", lambda platform: elf) + seen = {} + + async def fake_run(args): + seen["image"] = args.image + seen["force"] = args.force + return 0 + + monkeypatch.setattr(flash_cli, "_run", fake_run) + # main() uses asyncio.run(); route it through the suite's shared loop so it + # doesn't close/null the global loop and break later get_event_loop() tests. + monkeypatch.setattr(flash_cli.asyncio, "run", + lambda coro: asyncio.get_event_loop().run_until_complete(coro)) + rc = flash_cli.main(["--build", "PM3GENERIC"]) + assert rc == 0 + assert seen["image"] == str(elf) + assert seen["force"] is True # --build flashes what it built, regardless of the pin diff --git a/tests/test_flash.py b/tests/test_flash.py index 57a4605..cf6c3ad 100644 --- a/tests/test_flash.py +++ b/tests/test_flash.py @@ -12,10 +12,23 @@ from pm3py.core.protocol import ( DEVICE_INFO_FLAG_CURRENT_MODE_BOOTROM, DEVICE_INFO_FLAG_CURRENT_MODE_OS, ) from pm3py.core.transport import PM3Response, encode_old_frame, decode_old_frame +from pm3py.core import flash as flashmod from pm3py.core.flash import ( Flasher, FlashError, parse_elf_segments, build_blocks, resolve_image, + find_firmware_src, build_firmware, ) + +def _proc(returncode): + return type("P", (), {"returncode": returncode})() + + +def _fake_src(tmp_path): + (tmp_path / "Makefile").write_text("x") + (tmp_path / "armsrc").mkdir() + (tmp_path / "armsrc" / "Makefile").write_text("x") + return tmp_path + OS_FLAGS = (DEVICE_INFO_FLAG_BOOTROM_PRESENT | DEVICE_INFO_FLAG_OSIMAGE_PRESENT | DEVICE_INFO_FLAG_CURRENT_MODE_OS) BOOTROM_FLAGS = (DEVICE_INFO_FLAG_BOOTROM_PRESENT | DEVICE_INFO_FLAG_OSIMAGE_PRESENT @@ -160,6 +173,74 @@ def test_resolve_image_none_no_build(tmp_path, monkeypatch): resolve_image(None) +# --- build_firmware / find_firmware_src --- + +def test_find_firmware_src_env(tmp_path, monkeypatch): + src = _fake_src(tmp_path) + monkeypatch.setenv("PM3PY_FIRMWARE_SRC", str(src)) + assert find_firmware_src() == src + + +def test_build_firmware_invalid_platform(): + with pytest.raises(FlashError, match="PLATFORM"): + build_firmware("PM3; rm -rf /") + + +def test_build_firmware_runs_make(tmp_path, monkeypatch): + src = _fake_src(tmp_path) + obj = src / "armsrc" / "obj" + obj.mkdir() + (obj / "fullimage.elf").write_bytes(b"\x7fELF") + seen = {} + + def fake_run(cmd, *a, **k): + seen["cmd"] = cmd + return _proc(0) + + monkeypatch.setattr(flashmod.subprocess, "run", fake_run) + elf = build_firmware("PM3GENERIC", firmware_dir=str(src)) + assert elf == obj / "fullimage.elf" + # explicit platform, non-shell argv, armsrc/all target + assert seen["cmd"] == ["make", "-C", str(src), "PLATFORM=PM3GENERIC", "armsrc/all"] + + +def test_build_firmware_make_fails(tmp_path, monkeypatch): + src = _fake_src(tmp_path) + monkeypatch.setattr(flashmod.subprocess, "run", lambda *a, **k: _proc(2)) + with pytest.raises(FlashError, match="build failed"): + build_firmware("PM3RDV4", firmware_dir=str(src)) + + +def test_build_firmware_missing_source(monkeypatch): + monkeypatch.setattr(flashmod, "find_firmware_src", lambda fd=None: None) + with pytest.raises(FlashError, match="source not found"): + build_firmware("PM3GENERIC") + + +def test_build_firmware_make_not_on_path(tmp_path, monkeypatch): + # make missing/not-executable -> FlashError, not a raw OSError traceback + src = _fake_src(tmp_path) + + def boom(*a, **k): + raise FileNotFoundError(2, "No such file or directory", "make") + + monkeypatch.setattr(flashmod.subprocess, "run", boom) + with pytest.raises(FlashError, match="could not run make"): + build_firmware("PM3GENERIC", firmware_dir=str(src)) + + +def test_find_firmware_src_ignores_cwd(tmp_path, monkeypatch): + # A firmware/ in the CWD must NOT be picked up — build_firmware runs `make` on + # the result, so a stray ./firmware would execute an untrusted Makefile. + cwd_fw = tmp_path / "firmware" + cwd_fw.mkdir() + _fake_src(cwd_fw) + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("PM3PY_FIRMWARE_SRC", raising=False) + monkeypatch.setattr(flashmod, "_PACKAGE_ROOT", tmp_path / "nonexistent") + assert find_firmware_src() is None + + # --- Flasher: probing --- def test_device_info_parses_flags():