feat(flash): pm3flash --build PLATFORM (build from the submodule, then flash)
build_firmware()/find_firmware_src() cross-compile fullimage.elf via 'make -C firmware PLATFORM=<explicit> armsrc/all' and pm3flash --build flashes it. PLATFORM is explicit (never inferred from is_rdv4 — that's the running firmware's flag, not the board). Hardened per an adversarial multi-agent review of the change: - --build now implies --force: it must flash the image it just built. A dirty rebuild keeps the same base SHA, so matches_pin can't distinguish it from the old firmware and would otherwise silently skip the flash (exit 0). - make missing/not-executable -> FlashError (was an uncaught OSError traceback). - find_firmware_src no longer falls back to a CWD-relative ./firmware — running make on a stray Makefile is arbitrary code execution; only explicit arg / $PM3PY_FIRMWARE_SRC / the package repo root. Suite 1045 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ ever change the OS image anyway.
|
|||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import struct
|
import struct
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .protocol import (
|
from .protocol import (
|
||||||
@@ -43,6 +44,10 @@ _DEFAULT_IMAGE_CANDIDATES = (
|
|||||||
"firmware/armsrc/obj/fullimage.elf",
|
"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):
|
class FlashError(PM3Error):
|
||||||
"""Firmware flashing failed."""
|
"""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:
|
class Flasher:
|
||||||
"""Flash Proxmark3 firmware over the wire (OLD-frame bootloader protocol)."""
|
"""Flash Proxmark3 firmware over the wire (OLD-frame bootloader protocol)."""
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import sys
|
|||||||
|
|
||||||
from .core.client import Proxmark3
|
from .core.client import Proxmark3
|
||||||
from .core.transport import PM3Error
|
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
|
from ._firmware import matches
|
||||||
|
|
||||||
|
|
||||||
@@ -99,8 +99,12 @@ def main(argv=None) -> int:
|
|||||||
prog="pm3flash",
|
prog="pm3flash",
|
||||||
description="Flash the pinned Proxmark3 fork firmware.")
|
description="Flash the pinned Proxmark3 fork firmware.")
|
||||||
p.add_argument("--port", help="serial port (default: auto-detect)")
|
p.add_argument("--port", help="serial port (default: auto-detect)")
|
||||||
p.add_argument("--image", help="path to fullimage.elf "
|
src = p.add_mutually_exclusive_group()
|
||||||
"(default: $PM3PY_FIRMWARE or local build)")
|
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",
|
p.add_argument("--force", action="store_true",
|
||||||
help="flash even if the device already matches the pin")
|
help="flash even if the device already matches the pin")
|
||||||
p.add_argument("--allow-bootrom", action="store_true",
|
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",
|
p.add_argument("-y", "--yes", action="store_true",
|
||||||
help="do not prompt for confirmation")
|
help="do not prompt for confirmation")
|
||||||
args = p.parse_args(argv)
|
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))
|
return asyncio.run(_run(args))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Tests for the firmware pin + needs-reflash detection (no hardware)."""
|
"""Tests for the firmware pin + needs-reflash detection (no hardware)."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import pytest
|
||||||
|
|
||||||
from pm3py._firmware import FirmwarePin, FIRMWARE_PIN, matches
|
from pm3py._firmware import FirmwarePin, FIRMWARE_PIN, matches
|
||||||
from pm3py.core.client import Proxmark3
|
from pm3py.core.client import Proxmark3
|
||||||
@@ -73,3 +74,30 @@ def test_cli_help_exits_clean(capsys):
|
|||||||
assert e.code == 0
|
assert e.code == 0
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "pm3flash" in 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
|
||||||
|
|||||||
@@ -12,10 +12,23 @@ from pm3py.core.protocol import (
|
|||||||
DEVICE_INFO_FLAG_CURRENT_MODE_BOOTROM, DEVICE_INFO_FLAG_CURRENT_MODE_OS,
|
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.transport import PM3Response, encode_old_frame, decode_old_frame
|
||||||
|
from pm3py.core import flash as flashmod
|
||||||
from pm3py.core.flash import (
|
from pm3py.core.flash import (
|
||||||
Flasher, FlashError, parse_elf_segments, build_blocks, resolve_image,
|
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
|
OS_FLAGS = (DEVICE_INFO_FLAG_BOOTROM_PRESENT | DEVICE_INFO_FLAG_OSIMAGE_PRESENT
|
||||||
| DEVICE_INFO_FLAG_CURRENT_MODE_OS)
|
| DEVICE_INFO_FLAG_CURRENT_MODE_OS)
|
||||||
BOOTROM_FLAGS = (DEVICE_INFO_FLAG_BOOTROM_PRESENT | DEVICE_INFO_FLAG_OSIMAGE_PRESENT
|
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)
|
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 ---
|
# --- Flasher: probing ---
|
||||||
|
|
||||||
def test_device_info_parses_flags():
|
def test_device_info_parses_flags():
|
||||||
|
|||||||
Reference in New Issue
Block a user