"""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_panel, panel_for, 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 key, 6 hex bytes") == ( ["-k", "--key"], "", "key, 6 hex bytes") def test_long_with_meta(self): assert parse_option("--blk block number") == (["--blk"], "", "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_panel_multiline(self): panel = _plain(help_panel(_tree().entry_of(["hf", "mf", "rdbl"]))) assert "hf mf rdbl" in panel assert "usage:" in panel assert "--blk" in panel and "--key " in panel assert "This help" not in panel # -h/--help is skipped assert "hf mf rdbl --blk 0" in panel # a note def test_panel_for_leaf(self): node = _tree().node_at(["hf", "mf", "rdbl"]) assert panel_for(node) == help_panel(node.entry) def test_panel_for_interior(self): panel = _plain(panel_for(_tree().node_at(["hf", "mf"]))) assert "2 subcommands" in panel and "rdbl" in panel and "wrbl" in panel def test_resolve_target_subcommand(self): 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 # --------------------------------------------------------------------------- keys class TestKeys: def test_open_help_requires_selection(self): s = _session() assert open_help(s, has_selection=False) is False assert s.help_open is False assert open_help(s, has_selection=True) is True assert s.help_open is True def test_close_help(self): s = _session() s.help_open = True assert close_help(s) is False assert s.help_open is False def test_install_does_not_raise(self): install_key_bindings(KeyBindings(), _session()) 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_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")