diff --git a/pm3py/cli/rawcli/app.py b/pm3py/cli/rawcli/app.py index 005604b..5a2c254 100644 --- a/pm3py/cli/rawcli/app.py +++ b/pm3py/cli/rawcli/app.py @@ -58,103 +58,105 @@ def _raw_exchange(device, payload: bytes, protocol: str = "hf14a") -> bytes | No return None -def dispatch(state: RawSession, line: str) -> bool: - """Handle one input line. Returns False to exit the REPL, True to keep going. Testable - without a live prompt.""" +def dispatch(state: RawSession, line: str, out=print) -> bool: + """Handle one input line. ``out`` prints a (possibly ANSI-colored) string. Returns False to + exit the REPL, True to keep going. Testable without a live prompt (default ``out=print``).""" try: cmd = parse_line(line, state.entry_mode) except ValueError as exc: - print(f"rawcli: {exc}") + out(f"rawcli: {exc}") return True if cmd.kind == "control": if cmd.name in ("quit", "exit", "q"): return False if cmd.name == "help": - _handle_help(state, cmd.args) + _handle_help(state, cmd.args, out) elif cmd.name == "identify": - result = identify(state, emit=print) # stream the probe exchanges to the trace - print(format_summary(result)) + result = identify(state, emit=out) # stream the probe exchanges to the trace + out(format_summary(result)) elif cmd.name == "transponder": - print(state.transponder or "no transponder identified — run 'identify'") + out(state.transponder or "no transponder identified — run 'identify'") elif cmd.name == "close": clear(state) - print("connection closed") + out("connection closed") elif cmd.name == "tlv": - _handle_tlv(state, cmd.args) + _handle_tlv(state, cmd.args, out) return True if cmd.kind == "call": - _handle_call(state, cmd) + _handle_call(state, cmd, out) return True - # raw payload if not cmd.payload: return True response = _raw_exchange(state.device, cmd.payload, protocol=state.protocol) - print(render_exchange(cmd.payload, response, protocol=state.protocol, - is_tty=sys.stdout.isatty())) + out(render_exchange(cmd.payload, response, protocol=state.protocol, is_tty=True)) return True -def _handle_call(state: RawSession, cmd) -> None: +def _handle_call(state: RawSession, cmd, out=print) -> None: catalog = catalog_for(state) if catalog is None: - print("no transponder identified — run 'identify' first") + out("no transponder identified — run 'identify' first") return tc = catalog.get(cmd.name) if tc is None: - print(f"unknown command {cmd.name!r} for {catalog.name}; try 'help'") + out(f"unknown command {cmd.name!r} for {catalog.name}; try 'help'") return try: payload = tc.build(*cmd.args) except (TypeError, ValueError) as exc: - print(f"usage: {tc.usage()} ({exc})") + out(f"usage: {tc.usage()} ({exc})") return response = _raw_exchange(state.device, payload, protocol=catalog.protocol) - print(render_exchange(payload, response, protocol=catalog.protocol, - is_tty=sys.stdout.isatty())) + out(render_exchange(payload, response, protocol=catalog.protocol, is_tty=True)) -def _handle_tlv(state: RawSession, args) -> None: +def _handle_tlv(state: RawSession, args, out=print) -> None: from .tlv import format_tlv, prompt_tlv from .parser import parse_bytes if args: try: data = parse_bytes(" ".join(args), state.entry_mode) except ValueError as exc: - print(f"rawcli: {exc}") + out(f"rawcli: {exc}") return else: data = prompt_tlv(state) # interactive multi-line editor if not data: return - print(format_tlv(data)) + out(format_tlv(data)) -def _handle_help(state: RawSession, args) -> None: +def _handle_help(state: RawSession, args, out=print) -> None: catalog = catalog_for(state) if args and catalog is not None: tc = catalog.get(args[0]) if tc is not None: - print(f"{tc.usage()} — {tc.help}") + out(f"{tc.usage()} — {tc.help}") return - print(_HELP) + lines = [_HELP] if catalog is not None: - print(f"\n{catalog.name} commands:") - for name in catalog.names(): - tc = catalog.get(name) - print(f" {tc.usage():<28}{tc.help}") + lines.append(f"\n{catalog.name} commands:") + lines += [f" {catalog.get(n).usage():<28}{catalog.get(n).help}" for n in catalog.names()] + out("\n".join(lines)) def run(port=None) -> int: - from prompt_toolkit import PromptSession + from prompt_toolkit import PromptSession, print_formatted_text from prompt_toolkit.auto_suggest import AutoSuggestFromHistory + from prompt_toolkit.formatted_text import ANSI from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.patch_stdout import patch_stdout from .completer import RawCompleter + def out(text): + # parse the ANSI our formatters emit and render it through prompt_toolkit (renders + # color on a terminal, strips it when piped) — plain print() shows the codes literally + print_formatted_text(ANSI(str(text))) + state = RawSession(device=_connect(port)) kb = KeyBindings() install_key_bindings(kb, state) @@ -176,7 +178,7 @@ def run(port=None) -> int: break if not line.strip(): continue - if not dispatch(state, line): + if not dispatch(state, line, out): break - print("bye") + out("bye") return 0 diff --git a/pm3py/cli/rawcli/catalog.py b/pm3py/cli/rawcli/catalog.py index 99d0e63..ec40c87 100644 --- a/pm3py/cli/rawcli/catalog.py +++ b/pm3py/cli/rawcli/catalog.py @@ -62,9 +62,15 @@ TYPE2 = _catalog( _c("READ_SIG", [], lambda: bytes([0x3C, 0x00]), "read the ECC originality signature (0x3C)"), _c("READ_CNT", ["counter"], lambda counter="0": bytes([0x39, _int(counter)]), "read the 24-bit NFC counter (0x39)"), + _c("PWD_AUTH", ["password"], + lambda password: bytes([0x1B]) + _hex(password).ljust(4, b"\x00")[:4], + "authenticate with the 4-byte password → PACK (0x1B)"), _c("WRITE", ["page", "data"], lambda page, data: bytes([0xA2, _int(page)]) + _hex(data).ljust(4, b"\x00")[:4], "write 4 bytes to (0xA2)"), + _c("COMPAT_WRITE", ["page"], lambda page: bytes([0xA0, _int(page)]), + "compatibility write, two-phase (0xA0)"), + _c("HALT", [], lambda: bytes([0x50, 0x00]), "halt the tag (0x50 00)"), ) # ---- MIFARE Classic (block ops; auth is the reader's job before these) ---- diff --git a/pm3py/cli/rawcli/entry.py b/pm3py/cli/rawcli/entry.py index a86b811..f9fcb18 100644 --- a/pm3py/cli/rawcli/entry.py +++ b/pm3py/cli/rawcli/entry.py @@ -17,14 +17,18 @@ def group_size(mode: str) -> int: def byte_space(text: str, mode: str = HEX) -> str: - """Re-group a run of hex/binary digits into space-separated byte groups. + """Re-group a **pure** run of hex/binary digits into space-separated byte groups. - Leaves a token that carries an explicit ``0x``/``0b`` prefix untouched (so intermixed input - isn't mangled); only a bare digit run is regrouped.""" - if "x" in text.lower() or "b" in text.lower(): - return text - n = group_size(mode) + Anything that isn't purely digits of the current mode is returned untouched — command words + ("help", "transponder"), function calls, and explicit ``0x``/``0b`` prefixed tokens are never + regrouped. This is what keeps auto-spacing from mangling non-hex input.""" + low = text.strip().lower() + if low.startswith("0x") or low.startswith("0b"): + return text # explicit prefix — leave intact compact = text.replace(" ", "") + if not compact or any(ch not in _DIGITS[mode] for ch in compact): + return text # not a pure numeric run (e.g. a command) + n = group_size(mode) return " ".join(compact[i:i + n] for i in range(0, len(compact), n)) diff --git a/pm3py/cli/rawcli/identify.py b/pm3py/cli/rawcli/identify.py index 12f0e9f..08fc19d 100644 --- a/pm3py/cli/rawcli/identify.py +++ b/pm3py/cli/rawcli/identify.py @@ -147,7 +147,8 @@ def identify(session, emit=None) -> dict: scan = _try(lambda: dev.hf.iso14a.scan(), {"found": False}) if scan.get("found"): session.field, session.protocol = "hf", "hf14a" - fmt = TraceFormatter(mode="reader", decoder=_ident_decode, crc_len=0) + # always emit ANSI; the app's printer renders it (and strips it off a non-terminal) + fmt = TraceFormatter(mode="reader", decoder=_ident_decode, crc_len=0, is_tty=True) for direction, data in _activation_frames(scan): emit(fmt.format(direction, data).lstrip("\n")) model, version = None, None @@ -184,8 +185,9 @@ def _probe_version(dev, fmt, emit) -> tuple[str | None, bytes | None]: raw = resp.get("raw") if isinstance(resp, dict) else None if not raw: return None, None - emit(fmt.format(1, bytes(raw)).lstrip("\n")) version = bytes(raw[:8]) + display = bytes(raw).rstrip(b"\x00") or version # drop the firmware buffer padding + emit(fmt.format(1, display).lstrip("\n")) model = decode_version(version) if model: emit(f" → {model}") diff --git a/tests/test_rawcli.py b/tests/test_rawcli.py index a9dbdda..0880b60 100644 --- a/tests/test_rawcli.py +++ b/tests/test_rawcli.py @@ -127,6 +127,17 @@ class TestByteSpace: def test_prefixed_left_alone(self): assert byte_space("0x3004", "hex") == "0x3004" # explicit prefix not mangled + assert byte_space("0b0011", "hex") == "0b0011" + + def test_leaves_command_words_alone(self): + # regression: auto-spacing must not mangle non-hex input (a-f in words used to break it) + assert byte_space("help transponder", "hex") == "help transponder" + assert byte_space("identify", "hex") == "identify" + assert byte_space("close", "hex") == "close" # c,e are hex digits but l,o,s aren't + assert byte_space("READ(4)", "hex") == "READ(4)" + + def test_spaces_pure_hex(self): + assert byte_space("deadbeef", "hex") == "de ad be ef" class TestDispatch: @@ -253,6 +264,7 @@ class TestCatalog: assert TYPE2.get("FAST_READ").build("0", "3") == b"\x3A\x00\x03" assert TYPE2.get("GET_VERSION").build() == b"\x60" assert TYPE2.get("WRITE").build("4", "01020304") == b"\xA2\x04\x01\x02\x03\x04" + assert TYPE2.get("PWD_AUTH").build("FFFFFFFF") == b"\x1B\xFF\xFF\xFF\xFF" def test_iso15_builds(self): assert ISO15.get("READ_BLOCK").build("4") == b"\x02\x20\x04"