The right-arrow help never worked for subcommand completions and rendered into
the bottom toolbar (unreadable). Rework it to present in the completion dropdown
— the same two-column form as normal completions — and fix the surrounding
keyboard interactions, all verified by driving the real app through a terminal
emulator (not just unit tests).
- Help now renders as dropdown rows (hints.help_rows), never the bottom bar. The
completer yields the target command's name/usage/options/notes as non-inserting
Completion("") rows while help is open; the bottom bar keeps only the one-line
hint.
- resolve_target: strip the trailing space subcommand completions carry, and don't
re-append a token the menu already inserted into the buffer — both silently made
the panel resolve to nothing.
- RIGHT captures the highlighted node + buffer/complete_state; LEFT restores the
EXACT pre-RIGHT selection. Detach the menu with complete_state=None rather than
cancel_completion(), which calls go_to_completion(None) and wipes the highlighted
index (this is what made Tab commit the wrong command after a RIGHT/LEFT trip).
- Rebind backspace/delete/C-w/C-u to re-run completion: complete_while_typing only
fires on insert, so deleting characters left the dropdown stale/blank.
Tests updated to the real live states (trailing-space completions, post-insertion
buffer), plus guards pinning the detach-not-cancel invariant and the delete-key
rebinding.
620 lines
25 KiB
Python
620 lines
25 KiB
Python
"""clientcli — command tree, completer, hints, help-toggle, driver helpers, dispatch. Hardware-free
|
|
(the pexpect path is not driven here; a FakeDriver stands in for the client)."""
|
|
import json
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from prompt_toolkit.document import Document
|
|
from prompt_toolkit.key_binding import KeyBindings
|
|
|
|
from pm3py.cli.main import build_parser, main
|
|
from pm3py.cli.clientcli.tree import CommandTree, load_command_tree, parse_option
|
|
from pm3py.cli.clientcli.session import ClientSession
|
|
from pm3py.cli.clientcli.completer import ClientCompleter
|
|
from pm3py.cli.clientcli.hints import one_line_hint, help_rows, resolve_target
|
|
from pm3py.cli.clientcli.keys import open_help, close_help, install_key_bindings, apply_tab
|
|
from pm3py.cli.clientcli.driver import (
|
|
strip_ansi, _extract_output, PROMPT_RE, resolve_client,
|
|
make_driver, SwigDriver, PexpectDriver, DriverError,
|
|
)
|
|
from pm3py.cli.clientcli.app import dispatch
|
|
|
|
FIXTURE = Path(__file__).parent / "fixtures" / "commands_min.json"
|
|
|
|
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
|
|
|
|
|
def _plain(s: str) -> str:
|
|
return _ANSI.sub("", s)
|
|
|
|
|
|
def _tree() -> CommandTree:
|
|
return CommandTree(json.loads(FIXTURE.read_text())["commands"])
|
|
|
|
|
|
class FakeDriver:
|
|
"""Records executed lines and returns canned output; stands in for PexpectDriver."""
|
|
|
|
def __init__(self, output="", prompt="offline"):
|
|
self.prompt = prompt
|
|
self.lines = []
|
|
self._output = output
|
|
self.closed = False
|
|
|
|
def start(self):
|
|
pass
|
|
|
|
def execute(self, line):
|
|
self.lines.append(line)
|
|
return self._output
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
|
|
def _session(driver=None, tree=None):
|
|
return ClientSession(driver or FakeDriver(), tree or _tree())
|
|
|
|
|
|
def _complete(session, text):
|
|
return list(ClientCompleter(session).get_completions(Document(text), None))
|
|
|
|
|
|
def _texts(comps):
|
|
return [c.text for c in comps]
|
|
|
|
|
|
# --------------------------------------------------------------------------- tree
|
|
|
|
class TestTree:
|
|
def test_top_level_mixes_leaves_and_categories(self):
|
|
toks = [n.token for n in _tree().children_of([])]
|
|
assert toks == ["clear", "hf", "lf"]
|
|
|
|
def test_interior_children_synthesised(self):
|
|
assert [n.token for n in _tree().children_of(["hf"])] == ["14a", "mf"]
|
|
assert [n.token for n in _tree().children_of(["hf", "mf"])] == ["rdbl", "wrbl"]
|
|
|
|
def test_entry_of_leaf(self):
|
|
e = _tree().entry_of(["hf", "mf", "rdbl"])
|
|
assert e is not None and e["description"] == "Read MIFARE Classic block"
|
|
|
|
def test_interior_has_no_entry(self):
|
|
node = _tree().node_at(["hf", "mf"])
|
|
assert node is not None and node.entry is None and not node.is_leaf
|
|
|
|
def test_leaf_flags_as_leaf(self):
|
|
node = _tree().node_at(["clear"])
|
|
assert node.is_leaf and node.children == {}
|
|
|
|
def test_unknown_path(self):
|
|
assert _tree().node_at(["hf", "zz"]) is None
|
|
assert _tree().children_of(["hf", "zz"]) == []
|
|
|
|
|
|
class TestParseOption:
|
|
def test_short_and_long_with_meta(self):
|
|
assert parse_option("-k, --key <hex> key, 6 hex bytes") == (
|
|
["-k", "--key"], "<hex>", "key, 6 hex bytes")
|
|
|
|
def test_long_with_meta(self):
|
|
assert parse_option("--blk <dec> block number") == (["--blk"], "<dec>", "block number")
|
|
|
|
def test_short_no_meta(self):
|
|
assert parse_option("-a input key type is key A (def)") == (
|
|
["-a"], "", "input key type is key A (def)")
|
|
|
|
def test_numeric_short_flag(self):
|
|
assert parse_option("-1 Use data from Graphbuffer (offline mode)") == (
|
|
["-1"], "", "Use data from Graphbuffer (offline mode)")
|
|
|
|
def test_help(self):
|
|
assert parse_option("-h, --help This help") == (["-h", "--help"], "", "This help")
|
|
|
|
|
|
# --------------------------------------------------------------------------- completer
|
|
|
|
class TestCompleter:
|
|
def test_top_level(self):
|
|
assert _texts(_complete(_session(), "")) == ["clear ", "hf ", "lf "]
|
|
|
|
def test_partial_top_level(self):
|
|
assert _texts(_complete(_session(), "h")) == ["hf "]
|
|
|
|
def test_subcommand_partial(self):
|
|
assert _texts(_complete(_session(), "hf m")) == ["mf "]
|
|
|
|
def test_subcommands_after_space(self):
|
|
assert _texts(_complete(_session(), "hf mf ")) == ["rdbl ", "wrbl "]
|
|
|
|
def test_subcommand_completion_has_trailing_space(self):
|
|
# the trailing space makes accepting a completion commit + descend a level
|
|
assert all(t.endswith(" ") for t in _texts(_complete(_session(), "hf mf ")))
|
|
|
|
def test_accepting_subcommand_descends(self):
|
|
# "hf " → accept "14a " → "hf 14a " must offer 14a's children, not re-offer 14a
|
|
assert "14a " in _texts(_complete(_session(), "hf "))
|
|
assert _texts(_complete(_session(), "hf 14a ")) == ["info "]
|
|
|
|
def test_subcommand_display_omits_trailing_space(self):
|
|
disp = {c.display_text: c.text for c in _complete(_session(), "hf mf ")}
|
|
assert "rdbl" in disp and disp["rdbl"] == "rdbl "
|
|
|
|
def test_subcommand_meta_is_description(self):
|
|
comps = {c.text: c.display_meta_text for c in _complete(_session(), "hf mf ")}
|
|
assert comps["rdbl "] == "Read MIFARE Classic block"
|
|
|
|
def test_interior_meta_is_count(self):
|
|
comps = {c.text: c.display_meta_text for c in _complete(_session(), "hf ")}
|
|
assert comps["mf "] == "2 subcommands"
|
|
|
|
def test_option_flags_on_leaf(self):
|
|
texts = _texts(_complete(_session(), "hf mf rdbl --"))
|
|
assert "--blk" in texts and "--key" in texts and "--verbose" in texts
|
|
|
|
def test_option_meta_is_description(self):
|
|
comps = {c.text: c.display_meta_text for c in _complete(_session(), "hf mf rdbl --")}
|
|
assert comps["--blk"] == "block number"
|
|
|
|
def test_help_flag_excluded(self):
|
|
assert "--help" not in _texts(_complete(_session(), "hf mf rdbl --"))
|
|
|
|
def test_leaf_bare_lists_all_options(self):
|
|
texts = _texts(_complete(_session(), "hf mf rdbl "))
|
|
assert "--blk" in texts and "-a" in texts and "-k" in texts
|
|
|
|
def test_argument_value_slot_is_empty(self):
|
|
assert _complete(_session(), "hf mf rdbl -k ") == []
|
|
|
|
|
|
# --------------------------------------------------------------------------- hints
|
|
|
|
class TestHints:
|
|
def test_leaf_one_line(self):
|
|
h = one_line_hint(_tree(), "hf mf rdbl")
|
|
assert "Read MIFARE Classic block" in h and "usage" not in h.lower()
|
|
assert "hf mf rdbl [-habv]" in h
|
|
|
|
def test_interior_one_line(self):
|
|
assert "2 subcommands" in one_line_hint(_tree(), "hf mf")
|
|
|
|
def test_partial_word_resolves_prefix(self):
|
|
assert "2 subcommands" in one_line_hint(_tree(), "hf mf rd")
|
|
|
|
def test_unknown_is_none(self):
|
|
assert one_line_hint(_tree(), "zz") is None
|
|
assert one_line_hint(_tree(), "") is None
|
|
|
|
def test_help_rows_leaf(self):
|
|
# a leaf's help as dropdown rows: (display, meta) — name, usage, each option, each note
|
|
rows = help_rows(_tree().node_at(["hf", "mf", "rdbl"]))
|
|
displays = [d for d, _ in rows]
|
|
metas = [m for _, m in rows]
|
|
assert ("hf mf rdbl", "Read MIFARE Classic block") == rows[0]
|
|
assert any("--blk" in d for d in displays)
|
|
assert any("--key <hex>" in d for d in displays)
|
|
assert not any("This help" in d for d in displays) # -h/--help is skipped
|
|
assert "usage" in metas # the usage row
|
|
assert "hf mf rdbl --blk 0" in displays # a note row
|
|
|
|
def test_help_rows_non_inserting_and_readable(self):
|
|
# every row carries display + display_meta (the two-column dropdown form)
|
|
rows = help_rows(_tree().node_at(["hf", "mf", "rdbl"]))
|
|
assert all(isinstance(d, str) and d for d, _ in rows)
|
|
assert all(isinstance(m, str) for _, m in rows)
|
|
|
|
def test_help_rows_interior(self):
|
|
rows = help_rows(_tree().node_at(["hf", "mf"]))
|
|
displays = [d for d, _ in rows]
|
|
assert displays[0] == "mf" # header row
|
|
assert "rdbl" in displays and "wrbl" in displays
|
|
|
|
def test_help_rows_none(self):
|
|
assert help_rows(None) == []
|
|
|
|
def test_resolve_target_subcommand(self):
|
|
# real subcommand completions carry a trailing space (Tab-descend mechanism)
|
|
cur = SimpleNamespace(text="rdbl ")
|
|
node = resolve_target(_tree(), "hf mf ", cur)
|
|
assert node is not None and node.token == "rdbl" and node.is_leaf
|
|
|
|
def test_resolve_target_flag_points_at_leaf(self):
|
|
cur = SimpleNamespace(text="--blk")
|
|
node = resolve_target(_tree(), "hf mf rdbl --", cur)
|
|
assert node is not None and node.token == "rdbl"
|
|
|
|
def test_resolve_target_interior(self):
|
|
cur = SimpleNamespace(text="mf ")
|
|
node = resolve_target(_tree(), "hf ", cur)
|
|
assert node is not None and node.token == "mf" and not node.is_leaf
|
|
|
|
def test_resolve_target_after_menu_insertion(self):
|
|
# arrowing onto a completion inserts it into the buffer (go_to_completion), so the
|
|
# buffer already ends with the highlighted token — it must not be appended twice
|
|
cur = SimpleNamespace(text="rdbl ")
|
|
node = resolve_target(_tree(), "hf mf rdbl ", cur)
|
|
assert node is not None and node.token == "rdbl" and node.is_leaf
|
|
|
|
|
|
# --------------------------------------------------------------------------- keys
|
|
|
|
class TestKeys:
|
|
def test_open_help_requires_a_resolved_node(self):
|
|
s = _session()
|
|
assert open_help(s, None) is False # nothing highlighted → no help
|
|
assert s.help_open is False and s.help_target is None
|
|
node = s.tree.node_at(["hf", "mf", "rdbl"])
|
|
assert open_help(s, node) is True
|
|
assert s.help_open is True and s.help_target is node
|
|
|
|
def test_close_help(self):
|
|
s = _session()
|
|
s.help_open = True
|
|
s.help_target = object()
|
|
assert close_help(s) is False
|
|
assert s.help_open is False and s.help_target is None
|
|
|
|
def test_completer_yields_help_rows_when_open(self):
|
|
# while help_open, the dropdown renders the target's help as non-inserting completions
|
|
from prompt_toolkit.completion import CompleteEvent
|
|
from prompt_toolkit.document import Document
|
|
s = _session()
|
|
s.help_open = True
|
|
s.help_target = s.tree.node_at(["hf", "mf", "rdbl"])
|
|
comps = list(ClientCompleter(s).get_completions(Document("hf mf rdbl ", 11), CompleteEvent()))
|
|
assert comps and all(c.text == "" for c in comps) # selecting one inserts nothing
|
|
shown = [_plain(c.display[0][1]) for c in comps]
|
|
assert any("--blk" in d for d in shown)
|
|
|
|
def test_install_does_not_raise(self):
|
|
install_key_bindings(KeyBindings(), _session())
|
|
|
|
def test_delete_keys_rebound_to_refresh_dropdown(self):
|
|
# complete_while_typing only completes on insert, so the deletion keys must be rebound to
|
|
# re-run completion — else the dropdown goes stale/blank when you backspace.
|
|
kb = KeyBindings()
|
|
install_key_bindings(kb, _session())
|
|
bound = {str(k) for b in kb.bindings for k in b.keys}
|
|
assert "Keys.ControlH" in bound # backspace
|
|
assert "Keys.Delete" in bound # delete
|
|
assert "Keys.ControlW" in bound # delete word
|
|
assert "Keys.ControlU" in bound # delete to line start
|
|
|
|
def _buffer_with_menu(self, text):
|
|
from prompt_toolkit.buffer import Buffer, CompletionState
|
|
from prompt_toolkit.completion import CompleteEvent
|
|
from prompt_toolkit.document import Document
|
|
buff = Buffer()
|
|
buff.set_document(Document(text, len(text)))
|
|
comps = list(ClientCompleter(_session()).get_completions(Document(text, len(text)),
|
|
CompleteEvent()))
|
|
buff.complete_state = CompletionState(original_document=buff.document,
|
|
completions=comps, complete_index=None)
|
|
return buff
|
|
|
|
def test_left_restore_preserves_highlighted_index(self):
|
|
# The RIGHT→LEFT round-trip must return the user to the completion they had highlighted.
|
|
# Mechanism: help mode DETACHES the menu (complete_state = None) and re-attaches the same
|
|
# object on LEFT. It must NOT use cancel_completion(), which calls go_to_completion(None)
|
|
# and wipes the highlighted index — the bug that made Tab commit the wrong command.
|
|
buff = self._buffer_with_menu("hf mf ")
|
|
buff.go_to_completion(1) # DOWN onto the 2nd child (wrbl)
|
|
doc, state, idx = buff.document, buff.complete_state, buff.complete_state.complete_index
|
|
assert idx == 1 and buff.text == "hf mf wrbl "
|
|
|
|
buff.complete_state = None # RIGHT: detach without mutating `state`
|
|
assert state.complete_index == idx # ← the invariant; cancel_completion breaks it
|
|
|
|
buff.document = doc # LEFT: restore exactly
|
|
buff.complete_state = state
|
|
assert buff.text == "hf mf wrbl " and buff.complete_state.complete_index == 1
|
|
|
|
def test_cancel_completion_would_lose_the_index(self):
|
|
# Guard the contrast: prove cancel_completion() is the wrong tool here, so nobody swaps it
|
|
# back in "to simplify".
|
|
buff = self._buffer_with_menu("hf mf ")
|
|
buff.go_to_completion(1)
|
|
state = buff.complete_state
|
|
buff.cancel_completion()
|
|
assert state.complete_index is None # cancel wiped it — must not be used for RIGHT
|
|
|
|
def test_tab_commits_first_completion(self):
|
|
# with nothing highlighted, Tab commits the FIRST completion (+ its trailing space)
|
|
buff = self._buffer_with_menu("hf ")
|
|
apply_tab(buff)
|
|
assert buff.text == "hf 14a " # descends: next Tab would show 14a's kids
|
|
|
|
def test_tab_commits_highlighted_completion(self):
|
|
buff = self._buffer_with_menu("hf ")
|
|
buff.complete_state.complete_index = 1 # highlight the 2nd (mf)
|
|
apply_tab(buff)
|
|
assert buff.text == "hf mf "
|
|
|
|
def test_tab_completes_partial(self):
|
|
buff = self._buffer_with_menu("hf m")
|
|
apply_tab(buff)
|
|
assert buff.text == "hf mf "
|
|
|
|
|
|
# --------------------------------------------------------------------------- driver helpers
|
|
|
|
class TestDriverHelpers:
|
|
def test_strip_ansi(self):
|
|
assert strip_ansi("\x1b[1;32musb\x1b[0m") == "usb"
|
|
|
|
def test_prompt_re_variants(self):
|
|
cases = {
|
|
"[usb] pm3 --> ": "usb",
|
|
"[offline] pm3 --> ": "offline",
|
|
"[usb|tcp] pm3 --> ": "usb|tcp",
|
|
"[fpc|script] pm3 --> ": "fpc|script",
|
|
}
|
|
for text, label in cases.items():
|
|
m = PROMPT_RE.search(text)
|
|
assert m is not None and m.group(1) == label
|
|
|
|
def test_prompt_re_captures_through_ansi(self):
|
|
colored = "[\x1b[1;32musb\x1b[0m] pm3 --> "
|
|
m = PROMPT_RE.search(colored)
|
|
assert m is not None and strip_ansi(m.group(1)) == "usb"
|
|
|
|
def test_extract_output_drops_echo(self):
|
|
raw = "hf 14a info\r\n[+] UID: 04 11 22 33\r\n"
|
|
assert _extract_output(raw, "hf 14a info") == "[+] UID: 04 11 22 33"
|
|
|
|
def test_extract_output_preserves_color(self):
|
|
raw = "hw version\r\n\x1b[32m[+] ok\x1b[0m\r\n"
|
|
assert _extract_output(raw, "hw version") == "\x1b[32m[+] ok\x1b[0m"
|
|
|
|
def test_extract_output_no_echo(self):
|
|
assert _extract_output("just output\r\n", "cmd") == "just output"
|
|
|
|
def test_extract_output_strips_bracketed_paste(self):
|
|
raw = "hw status\x1b[?2004l\r\n\x1b[?2004h[+] ok\r\n"
|
|
assert _extract_output(raw, "hw status") == "[+] ok"
|
|
|
|
def test_resolve_client_explicit_path_verbatim(self):
|
|
assert resolve_client("/opt/pm3/proxmark3") == "/opt/pm3/proxmark3"
|
|
assert resolve_client("./proxmark3") == "./proxmark3"
|
|
|
|
def test_resolve_client_named_binary_not_substituted(self):
|
|
# an explicitly-named client that isn't found is returned unchanged (never swapped for a
|
|
# fallback), so the spawn fails clearly instead of running a different binary
|
|
assert resolve_client("definitely-not-a-real-pm3-binary") == "definitely-not-a-real-pm3-binary"
|
|
|
|
|
|
# --------------------------------------------------------------------------- dispatch
|
|
|
|
class _FakePm3:
|
|
def __init__(self, port):
|
|
self.name = port
|
|
self.calls = []
|
|
|
|
def console(self, cmd, capture=True, quiet=True):
|
|
self.calls.append((cmd, capture, quiet))
|
|
return 0
|
|
|
|
@property
|
|
def grabbed_output(self):
|
|
return ""
|
|
|
|
|
|
class _FakePm3Module:
|
|
pm3 = _FakePm3
|
|
|
|
|
|
class TestSwigDriver:
|
|
def test_execute_prints_via_client_not_grab(self):
|
|
# the client prints its own coloured output (capture=False, quiet=False); execute returns ""
|
|
d = SwigDriver(port="/dev/ttyACM0", pm3_module=_FakePm3Module())
|
|
d.start()
|
|
assert d.backend == "swig" and d.prompt == "usb"
|
|
assert d.execute("hw version") == ""
|
|
assert d._p.calls == [("hw version", False, False)]
|
|
|
|
def test_execute_before_start(self):
|
|
d = SwigDriver(port="/dev/ttyACM0", pm3_module=_FakePm3Module())
|
|
with pytest.raises(DriverError):
|
|
d.execute("hw status")
|
|
|
|
def test_no_device_errors_without_calling_pm3(self, monkeypatch):
|
|
import pm3py.cli.clientcli.driver as drv
|
|
monkeypatch.setattr(drv, "_autodetect_port", lambda: None)
|
|
d = SwigDriver(port=None, pm3_module=_FakePm3Module())
|
|
with pytest.raises(DriverError, match="no serial device"):
|
|
d.start()
|
|
assert d._p is None # never constructed → no exit() risk
|
|
|
|
|
|
class TestPexpectPort:
|
|
def test_autodetects_port_when_none_given(self, monkeypatch):
|
|
# the stock binary needs a port arg (no auto-detect); the driver must resolve one itself
|
|
import pexpect
|
|
import pm3py.cli.clientcli.driver as drv
|
|
|
|
class _FakeChild:
|
|
before = "[usb] "
|
|
def expect(self, pat):
|
|
return 0
|
|
|
|
captured = {}
|
|
def fake_spawn(command, args, **kw):
|
|
captured["command"], captured["args"] = command, args
|
|
return _FakeChild()
|
|
|
|
monkeypatch.setattr(pexpect, "spawn", fake_spawn)
|
|
monkeypatch.setattr(drv, "_autodetect_port", lambda: "/dev/ttyACM9")
|
|
d = drv.PexpectDriver(port=None, client="proxmark3")
|
|
d.start()
|
|
assert captured["args"] == ["/dev/ttyACM9"] # resolved, not empty (would be offline)
|
|
assert d.prompt == "usb" # label parsed from the prompt tail
|
|
|
|
def test_explicit_port_used_verbatim(self, monkeypatch):
|
|
import pexpect
|
|
import pm3py.cli.clientcli.driver as drv
|
|
|
|
class _FakeChild:
|
|
before = "[usb] "
|
|
def expect(self, pat):
|
|
return 0
|
|
|
|
captured = {}
|
|
def fake_spawn(command, args, **kw):
|
|
captured["args"] = args
|
|
return _FakeChild()
|
|
monkeypatch.setattr(pexpect, "spawn", fake_spawn)
|
|
monkeypatch.setattr(drv, "_autodetect_port", lambda: "/dev/ttyACM9")
|
|
drv.PexpectDriver(port="/dev/ttyACM0").start()
|
|
assert captured["args"] == ["/dev/ttyACM0"] # explicit --port wins over auto-detect
|
|
|
|
|
|
class TestMakeDriver:
|
|
def test_explicit_pexpect(self):
|
|
assert isinstance(make_driver(driver="pexpect"), PexpectDriver)
|
|
|
|
def test_auto_uses_swig_when_available(self, monkeypatch):
|
|
import pm3py.cli.clientcli.driver as drv
|
|
monkeypatch.setattr(drv, "_load_pm3_module", lambda: _FakePm3Module())
|
|
d = make_driver(port="/dev/ttyACM0", driver="auto")
|
|
assert isinstance(d, SwigDriver)
|
|
|
|
def test_auto_falls_back_to_pexpect(self, monkeypatch):
|
|
import pm3py.cli.clientcli.driver as drv
|
|
def boom():
|
|
raise ImportError("no _pm3")
|
|
monkeypatch.setattr(drv, "_load_pm3_module", boom)
|
|
assert isinstance(make_driver(driver="auto"), PexpectDriver)
|
|
|
|
def test_swig_explicit_unavailable_errors(self, monkeypatch):
|
|
import pm3py.cli.clientcli.driver as drv
|
|
def boom():
|
|
raise ImportError("no _pm3")
|
|
monkeypatch.setattr(drv, "_load_pm3_module", boom)
|
|
with pytest.raises(DriverError, match="_pm3"):
|
|
make_driver(driver="swig")
|
|
|
|
|
|
class TestDispatch:
|
|
def test_runs_command(self):
|
|
drv = FakeDriver(output="[+] done")
|
|
out = []
|
|
assert dispatch(_session(drv), "hf 14a info", out.append) is True
|
|
assert drv.lines == ["hf 14a info"]
|
|
assert out == ["[+] done"]
|
|
|
|
def test_quit_exits(self):
|
|
drv = FakeDriver()
|
|
assert dispatch(_session(drv), "quit", lambda *_: None) is False
|
|
assert drv.lines == []
|
|
|
|
def test_empty_output_not_printed(self):
|
|
out = []
|
|
dispatch(_session(FakeDriver(output="")), "clear", out.append)
|
|
assert out == []
|
|
|
|
def test_driver_error_ends_loop(self):
|
|
from pm3py.cli.clientcli.driver import DriverError
|
|
|
|
class Boom(FakeDriver):
|
|
def execute(self, line):
|
|
raise DriverError("client exited")
|
|
|
|
out = []
|
|
assert dispatch(_session(Boom()), "hw status", out.append) is False
|
|
assert any("client exited" in m for m in out)
|
|
|
|
|
|
# --------------------------------------------------------------------------- session + argparse + loader
|
|
|
|
class TestSession:
|
|
def test_breadcrumb_offline(self):
|
|
s = _session(FakeDriver(prompt="offline"))
|
|
assert s.breadcrumb() == "[client / offline] pm3py -->"
|
|
assert _plain(s.colored_breadcrumb()) == s.breadcrumb()
|
|
|
|
def test_breadcrumb_usb(self):
|
|
assert _session(FakeDriver(prompt="usb")).breadcrumb() == "[client / usb] pm3py -->"
|
|
|
|
|
|
class TestArgparse:
|
|
def test_client_subcommand(self):
|
|
args = build_parser().parse_args(["client", "--port", "X", "--client", "pm3"])
|
|
assert args.command == "client" and args.port == "X" and args.client == "pm3"
|
|
assert args.driver == "auto" # SWIG-first default
|
|
|
|
def test_client_driver_choice(self):
|
|
args = build_parser().parse_args(["client", "--driver", "pexpect"])
|
|
assert args.driver == "pexpect"
|
|
|
|
def test_no_subcommand_prints_help(self):
|
|
assert main([]) == 1
|
|
|
|
|
|
class TestLoader:
|
|
def test_loads_fixture(self):
|
|
tree, meta = load_command_tree(str(FIXTURE))
|
|
assert meta["commands_extracted"] == 5
|
|
assert tree.entry_of(["hf", "mf", "rdbl"])["command"] == "hf mf rdbl"
|
|
|
|
def test_missing_raises(self):
|
|
with pytest.raises(FileNotFoundError):
|
|
load_command_tree("/nonexistent/commands.json")
|
|
|
|
|
|
class TestRepoDiscovery:
|
|
def _make_repo(self, base):
|
|
repo = base / "proxmark3"
|
|
(repo / "doc").mkdir(parents=True)
|
|
(repo / "doc" / "commands.json").write_text('{"commands": {}}')
|
|
return repo
|
|
|
|
def test_via_env(self, tmp_path, monkeypatch):
|
|
from pm3py.cli.clientcli.tree import find_repo_root
|
|
repo = self._make_repo(tmp_path)
|
|
monkeypatch.setenv("PM3_REPO", str(repo))
|
|
assert find_repo_root() == repo.resolve()
|
|
|
|
def test_walks_up_from_subdir(self, tmp_path, monkeypatch):
|
|
from pm3py.cli.clientcli.tree import find_repo_root
|
|
monkeypatch.delenv("PM3_REPO", raising=False)
|
|
monkeypatch.delenv("PM3_ROOT", raising=False)
|
|
repo = self._make_repo(tmp_path)
|
|
deep = repo / "client" / "src"
|
|
deep.mkdir(parents=True)
|
|
assert find_repo_root(start=deep) == repo.resolve()
|
|
|
|
def test_finds_sibling_checkout(self, tmp_path, monkeypatch):
|
|
# dev layout: run from a sibling dir, discover ../proxmark3 (keeps `pm3py client` working)
|
|
from pm3py.cli.clientcli.tree import find_repo_root
|
|
monkeypatch.delenv("PM3_REPO", raising=False)
|
|
monkeypatch.delenv("PM3_ROOT", raising=False)
|
|
repo = self._make_repo(tmp_path)
|
|
sibling = tmp_path / "pm3py"
|
|
sibling.mkdir()
|
|
assert find_repo_root(start=sibling) == repo.resolve()
|
|
|
|
|
|
class TestPacker:
|
|
def test_amalgamate_is_self_contained(self):
|
|
from pm3py.cli.clientcli.pack import amalgamate
|
|
code = amalgamate()
|
|
compile(code, "pm3cli", "exec") # valid Python
|
|
assert code.count("from __future__") == 1 # exactly one, at the top
|
|
for ln in code.splitlines():
|
|
s = ln.lstrip()
|
|
assert not s.startswith("from ."), ln # no intra-package imports survive
|
|
assert not s.startswith(("from pm3py", "import pm3py")), ln
|
|
assert "def main(" in code and 'if __name__ == "__main__"' in code
|
|
assert "def run(" in code # the REPL entrypoint is present
|
|
|
|
def test_pack_writes_executable_py(self, tmp_path):
|
|
from pm3py.cli.clientcli.pack import pack
|
|
out = tmp_path / "pm3cli.py"
|
|
pack(str(out))
|
|
assert out.is_file() and os.access(out, os.X_OK)
|
|
compile(out.read_text(), str(out), "exec")
|