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>
282 lines
11 KiB
Python
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)
|