Files
pm3py/pm3py/cli/clientcli/driver.py
michael 4f66a93de9 feat(cli): add pm3py client — autocompleting console over the stock proxmark3 client
Wraps the standard Proxmark3 C client (so it works with any Proxmark/firmware) in a prompt_toolkit REPL: full command-tree completion sourced from the client's commands.json, option-flag completion, Tab-to-descend, a press-right help toggle, and the client's own coloured output.

Two swappable backends behind ClientDriver: in-process SWIG (_pm3) by default, a pexpect-around-the-binary fallback otherwise. Both auto-detect /dev/ttyACM* and discover the checkout via its doc/commands.json marker (walk-up + side-by-side), so no path is hardcoded.

`pm3py client --pack OUT` spins off a self-contained drop-in — a readable .py, or a .pyz with prompt_toolkit/pexpect bundled — amalgamated from the package (the tested source of truth) so it can't drift. Adds pexpect to deps; hardware-free tests in tests/test_clientcli.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 21:05:53 -07:00

282 lines
11 KiB
Python

"""Drivers for the stock Proxmark3 C client.
``pm3py client`` does not reimplement the wire protocol — it drives the real client, so it works
with any Proxmark3 / firmware. Two interchangeable backends behind :class:`ClientDriver`:
- :class:`SwigDriver` (default) — the in-process ``_pm3`` SWIG bindings: ``pm3.pm3(port).console()``
+ ``grabbed_output``. Cleaner (no prompt-matching) but needs the built ``_pm3`` extension and a
connected device (the C ``pm3_open`` calls ``exit()`` on a real port with no responding PM3).
- :class:`PexpectDriver` — spawns the ``proxmark3`` binary and reads output up to the next
``pm3 -->`` prompt. Zero build; the fallback when ``_pm3`` isn't importable. The client's ANSI
colour is preserved (we ANSI-strip only for prompt/echo matching), and we never mutate ``prefs``.
:func:`make_driver` picks SWIG when ``_pm3`` is importable, else pexpect.
"""
from __future__ import annotations
import glob
import importlib.machinery
import importlib.util
import os
import re
import shutil
import sys
from pathlib import Path
from .tree import find_repo_root
#: full prompt matcher for clean text: ``"[<dev><net><ctx>] pm3 --> "``. Used to parse a label the
#: caller already has in hand (and in tests); pexpect matches on the tail instead — see below.
PROMPT_RE = re.compile(r"\[([^\]]*)\] pm3 -->\s*$")
#: what pexpect waits for. The ``pm3 --> `` tail is emitted uncoloured, so this matches regardless
#: of colour or the bracketed-paste sequences readline emits around the ``[...]`` label — matching
#: the label directly would let a stray ``[`` (from ``\x1b[?2004h``) capture the wrong bracket.
_EXPECT_RE = re.compile(r"pm3 -->\s*$")
#: the ``[label]`` at the end of the ANSI-stripped text just before the tail.
_LABEL_RE = re.compile(r"\[([^\]]*)\]\s*$")
_ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
#: every CSI sequence except SGR colour (final byte ``m``) — strips readline's bracketed-paste /
#: cursor / erase controls from passed-through output while keeping the client's colour intact.
_NONSGR = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-ln-~]")
#: fallback locations searched when a bare client name isn't on PATH (typical fork build layouts).
_CLIENT_FALLBACKS = (
"~/proxmark3/client/proxmark3",
"/usr/local/bin/proxmark3",
"/usr/bin/proxmark3",
)
def resolve_client(client: str) -> str:
"""Resolve the client binary. An explicit path is used verbatim; a bare name is looked up on
PATH. Only the *default* ``proxmark3`` name falls back to known fork build locations — an
explicitly-named client that isn't found is returned unchanged, so the spawn fails clearly
rather than silently running a different binary. """
if os.sep in client or (os.altsep and os.altsep in client):
return client
found = shutil.which(client)
if found:
return found
if client == "proxmark3": # auto-locate only the default binary
candidates = []
root = find_repo_root()
if root is not None: # the checkout this file was dropped beside
candidates.append(root / "client" / "proxmark3")
candidates += [Path(c).expanduser() for c in _CLIENT_FALLBACKS]
for p in candidates:
if p.is_file() and os.access(p, os.X_OK):
return str(p)
return client
class DriverError(RuntimeError):
"""The underlying client could not be launched, timed out, or exited."""
def strip_ansi(text: str) -> str:
return _ANSI.sub("", text or "")
def _scrubbed_env() -> dict:
"""A minimal environment for spawning the client. Snap / GTK / ``LD_*`` vars leaking in from a
sandboxed terminal (e.g. VSCode's snap) make the Qt-linked client crash on a libpthread symbol
clash, so we hand it only what it needs."""
env = {"TERM": os.environ.get("TERM", "xterm")}
for key in ("HOME", "PATH", "USER", "LANG", "LC_ALL"):
if key in os.environ:
env[key] = os.environ[key]
env.setdefault("PATH", "/usr/local/bin:/usr/bin:/bin")
return env
def _extract_output(raw: str, sent: str) -> str:
"""A command's output: ``child.before`` minus the echoed command line and framing blank lines.
The client's ANSI colour is preserved; readline's non-colour control sequences (bracketed
paste, cursor moves) are stripped, and the echo comparison is fully ANSI-stripped."""
lines = _NONSGR.sub("", raw or "").replace("\r", "").split("\n")
while lines and not strip_ansi(lines[0]).strip():
lines.pop(0)
if lines and strip_ansi(lines[0]).strip() == sent.strip():
lines.pop(0)
return "\n".join(lines).strip("\n")
class ClientDriver:
"""Driver interface. ``prompt`` is the last-seen context label (``usb`` / ``offline`` / …);
``backend`` names the mechanism (``swig`` / ``pexpect``)."""
prompt = "offline"
backend = "?"
def start(self) -> None: ...
def execute(self, line: str) -> str: ...
def close(self) -> None: ...
class PexpectDriver(ClientDriver):
backend = "pexpect"
def __init__(self, port=None, client="proxmark3", timeout=30):
self._port = port
self._client = resolve_client(client)
self._timeout = timeout
self._child = None
self.prompt = "offline"
def start(self) -> None:
import pexpect
# the stock binary does NOT auto-detect a port (that's the `pm3` wrapper's job) — without
# one it drops to offline mode, so resolve a port ourselves when none was given
port = self._port or _autodetect_port()
args = [port] if port else []
try:
self._child = pexpect.spawn(
self._client, args, env=_scrubbed_env(),
encoding="utf-8", codec_errors="replace", timeout=self._timeout,
)
self._child.expect(_EXPECT_RE)
except pexpect.ExceptionPexpect as exc:
raise DriverError(
f"could not start the proxmark3 client ({self._client!r}): {exc}\n"
" pass --client /path/to/proxmark3, or add it to your PATH"
) from exc
self._sync_prompt()
def execute(self, line: str) -> str:
import pexpect
if self._child is None:
raise DriverError("client not started")
try:
self._child.sendline(line)
self._child.expect(_EXPECT_RE)
except pexpect.EOF as exc:
raise DriverError("client exited") from exc
except pexpect.TIMEOUT as exc:
raise DriverError(f"client did not respond within {self._timeout}s") from exc
out = _extract_output(self._child.before, line)
self._sync_prompt()
return out
def _sync_prompt(self) -> None:
# the "[label]" sits at the end of the text just before the matched "pm3 -->" tail
m = _LABEL_RE.search(strip_ansi(self._child.before or ""))
if m:
self.prompt = m.group(1).strip() or "offline"
def close(self) -> None:
if self._child is None:
return
try:
self._child.sendline("quit")
self._child.close()
except Exception:
pass
self._child = None
#: extra checkout roots probed for the SWIG build (the discovered repo is tried first).
_SWIG_ROOTS = ("~/proxmark3",)
def _load_pm3_module():
"""Import and return the SWIG ``pm3`` module. If it isn't already importable, locate the built
library (``libpm3rrg_rdv4.so``, which exports ``PyInit__pm3``) and the ``pm3.py`` stub from the
discovered checkout and load the extension under the name ``_pm3`` — no symlink or filesystem
write. Raises :class:`ImportError` if the build can't be found."""
try:
import pm3
return pm3
except ImportError:
pass
roots = []
if os.environ.get("PM3_SWIG_PATH"):
roots.append(os.environ["PM3_SWIG_PATH"])
repo = find_repo_root()
if repo is not None:
roots.append(str(repo))
roots += [os.path.expanduser(r) for r in _SWIG_ROOTS]
for root in roots:
build = os.path.join(root, "client", "experimental_lib", "build")
pyscripts = os.path.join(root, "client", "pyscripts")
lib = os.path.join(build, "libpm3rrg_rdv4.so")
stub = os.path.join(pyscripts, "pm3.py")
if not (os.path.isfile(lib) and os.path.isfile(stub)):
# PM3_SWIG_PATH may itself be the dir holding both files
lib, stub, pyscripts = (os.path.join(root, "libpm3rrg_rdv4.so"),
os.path.join(root, "pm3.py"), root)
if not (os.path.isfile(lib) and os.path.isfile(stub)):
continue
if "_pm3" not in sys.modules:
loader = importlib.machinery.ExtensionFileLoader("_pm3", lib)
spec = importlib.util.spec_from_loader("_pm3", loader)
mod = importlib.util.module_from_spec(spec)
loader.exec_module(mod)
sys.modules["_pm3"] = mod
if pyscripts not in sys.path:
sys.path.insert(0, pyscripts)
import pm3
return pm3
raise ImportError(
"the pm3 SWIG extension (_pm3) isn't importable. Build it (in client/experimental_lib: "
"cmake .. && make -j), set $PM3_SWIG_PATH, or use --driver pexpect"
)
def _autodetect_port():
ports = sorted(glob.glob("/dev/ttyACM*")) + sorted(glob.glob("/dev/ttyUSB*"))
return ports[0] if ports else None
class SwigDriver(ClientDriver):
"""In-process driver over the ``_pm3`` SWIG bindings. Needs a connected device: the C
``pm3_open`` calls ``exit()`` on a real port with no responding PM3, so we can't recover from a
bad connection — we only open an auto-detected or explicitly given port."""
backend = "swig"
def __init__(self, port=None, pm3_module=None):
self._port = port
self._mod = pm3_module
self._p = None
self.prompt = "offline"
def start(self) -> None:
mod = self._mod or _load_pm3_module()
port = self._port or _autodetect_port()
if port is None:
raise DriverError(
"no serial device found for the SWIG driver (no /dev/ttyACM*). plug in a "
"Proxmark, pass --port, or use --driver pexpect"
)
self._p = mod.pm3(port) # exits the process if this port has no PM3
self.prompt = "usb"
def execute(self, line: str) -> str:
if self._p is None:
raise DriverError("client not started")
# capture=False, quiet=False: let the client print its OWN coloured output straight to
# stdout. The grab buffer always strips ANSI (ui.c force-filters it), so capturing would
# lose the Proxmark's colours — the whole point of the PRINT path. Returns "" (already out).
self._p.console(line, False, False)
return ""
def close(self) -> None:
self._p = None
def make_driver(port=None, client="proxmark3", driver="auto"):
"""The driver for ``pm3py client``. ``driver`` is ``auto`` (SWIG if ``_pm3`` importable, else
pexpect), ``swig`` (error if unavailable), or ``pexpect``."""
if driver in ("auto", "swig"):
try:
mod = _load_pm3_module()
except ImportError as exc:
if driver == "swig":
raise DriverError(str(exc)) from exc
mod = None # auto → fall back to pexpect
if mod is not None:
return SwigDriver(port=port, pm3_module=mod)
return PexpectDriver(port=port, client=client)