From 2b087a302a9b525d085abeffcdcc9155c5ee4dcb Mon Sep 17 00:00:00 2001 From: michael Date: Wed, 15 Jul 2026 20:43:04 -0700 Subject: [PATCH] feat(rawcli): per-byte base intermixed entry; fix MFC hf.mf; help + strict mixing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entry now tracks each raw byte's base, so hex and binary can share one line with a clean display: `30` (hex) then Ctrl-/ then `00000100` (binary) shows `30 00000100` and sends 0x30 0x04. The toggle only changes the base of the NEXT byte — it never rewrites what you've typed. Each byte auto-seals at its base width (2 hex / 8 binary); the next digit starts a fresh byte in the current mode; a digit invalid for its byte's base is dropped. Applies to raw byte entry only (function args stay base-10). - entry.type_digit / is_byte_line / reconcile_bases carry the logic (pure, unit-tested); the digit and backspace key bindings maintain session.byte_bases; the toggle just flips the mode. - parser.parse_bytes/parse_line take per-byte bases (explicit 0x/0b still wins). A line mixing a command with raw bytes (`READ 30`) raises instead of transmitting. - completer parses the opcode token in its own base, so `30`+binary still autocompletes the page. - help documents all of it (per-byte base, toggle, auto-seal, base-10 args, no command/byte mix). Also fixes a real bug found on hardware: the MFC catalog called hf.mfc (nonexistent) — it's hf.mf. Hardware-verified on a MIFARE Classic 1K: identify, CHK(0)=FFFFFFFFFFFF, READ(0) returns the manufacturer block, READ(4)/READ(1) the data blocks. Intermixed entry verified end-to-end in the live prompt (30 00000100 -> 0x30 0x04). 1351 green. Co-Authored-By: Claude Opus 4.8 --- pm3py/cli/rawcli/app.py | 21 +++++-- pm3py/cli/rawcli/catalog.py | 8 +-- pm3py/cli/rawcli/completer.py | 6 +- pm3py/cli/rawcli/entry.py | 93 +++++++++++++++++++++---------- pm3py/cli/rawcli/parser.py | 19 ++++--- pm3py/cli/rawcli/session.py | 1 + tests/test_rawcli.py | 102 +++++++++++++++++++++++++--------- 7 files changed, 181 insertions(+), 69 deletions(-) diff --git a/pm3py/cli/rawcli/app.py b/pm3py/cli/rawcli/app.py index 720ca04..37ad751 100644 --- a/pm3py/cli/rawcli/app.py +++ b/pm3py/cli/rawcli/app.py @@ -19,15 +19,26 @@ from .trace_view import render_exchange _HELP = """\ rawcli — raw command interface - help show this help + help [cmd] show this help (or one command's usage) identify probe the field; sets HF/LF + transponder, keeps the connection transponder show the identified transponder + tlv [bytes] decode / edit a TLV (NDEF) blob close drop the tag connection quit | exit | q leave rawcli send raw bytes to the tag (CRC auto-appended on 14a); trace prints above + NAME(args) run a command from the identified tag (READ(4), GET_VERSION()) — listed below -Type hex as "30 04" or "3004"; Ctrl-/ toggles hex/binary entry (0x/0b). Function-style -commands (READ(4), GET_VERSION()) come from the identified tag — listed below.""" +Entry: + * Raw bytes are hex by default ("30 04" or "3004" auto-spaces into bytes). + * Ctrl-/ (or Ctrl-t) toggles hex (0x) / binary (0b) entry — shown in the breadcrumb. + * Base is tracked PER BYTE: the toggle only changes the base of the NEXT byte, it never + rewrites what you've typed. So `30` then toggle then `00000100` gives 30 00000100 and + sends 0x30 0x04. A byte auto-seals at its width (2 hex / 8 binary) and the next digit + starts a fresh byte. A 0x/0b prefix on one token overrides the mode for that token. + * Function-call arguments are base-10 unless prefixed: READ(4) = page 4, READ(0x2B) = hex. + * Suggestions (commands, opcodes by value, and the tag's memory map) appear in the + completion menu as you type — in whichever base you're entering. + * You can't mix a command and raw bytes on one line — that's an error, not a transmit.""" def _connect(port): @@ -99,10 +110,12 @@ 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) + cmd = parse_line(line, state.entry_mode, state.byte_bases) except ValueError as exc: out(f"rawcli: {exc}") return True + finally: + state.byte_bases = [] # the line is consumed — start the next one fresh if cmd.kind == "control": if cmd.name in ("quit", "exit", "q"): diff --git a/pm3py/cli/rawcli/catalog.py b/pm3py/cli/rawcli/catalog.py index dbeb4e2..97878b9 100644 --- a/pm3py/cli/rawcli/catalog.py +++ b/pm3py/cli/rawcli/catalog.py @@ -100,7 +100,7 @@ TYPE2 = _catalog( # ---- MIFARE Classic --------------------------------------------------------------------------- # Classic reads/writes are gated by a crypto1 auth per sector, so — unlike NTAG — a bare 0x30 does -# nothing. These run via hf.mfc, which does the auth + block op in firmware. build() still exposes +# nothing. These run via hf.mf, which does the auth + block op in firmware. build() still exposes # the wire opcode (0x30/0xA0) for hex/binary completion. Key defaults to the transport key # FFFFFFFFFFFF / key A; override as READ(, <12-hex key>, A|B). _MFC_KEYTYPE = {"A": 0, "B": 1, "0": 0, "1": 1} @@ -114,7 +114,7 @@ def _kt(keytype) -> int: def _mfc_read(dev, block, key="FFFFFFFFFFFF", keytype="A"): - r = dev.hf.mfc.rdbl(_int(block), key=key, key_type=_kt(keytype)) + r = dev.hf.mf.rdbl(_int(block), key=key, key_type=_kt(keytype)) if isinstance(r, dict) and r.get("success"): return f"block {_int(block)} = {r['data']} (key {str(keytype).upper()})" err = r.get("error") if isinstance(r, dict) else "?" @@ -122,13 +122,13 @@ def _mfc_read(dev, block, key="FFFFFFFFFFFF", keytype="A"): def _mfc_write(dev, block, data, key="FFFFFFFFFFFF", keytype="A"): - r = dev.hf.mfc.wrbl(_int(block), _hex(data).ljust(16, b"\x00")[:16], key=key, key_type=_kt(keytype)) + r = dev.hf.mf.wrbl(_int(block), _hex(data).ljust(16, b"\x00")[:16], key=key, key_type=_kt(keytype)) ok = isinstance(r, dict) and r.get("success") return f"WRITE block {_int(block)} <- {_hex(data).hex()}: {'ok' if ok else 'failed'} (key {str(keytype).upper()})" def _mfc_chk(dev, block, keytype="A"): - r = dev.hf.mfc.chk(_int(block), _MFC_KEYS, key_type=_kt(keytype)) + r = dev.hf.mf.chk(_int(block), _MFC_KEYS, key_type=_kt(keytype)) if isinstance(r, dict) and r.get("found"): return f"block {_int(block)} key {str(keytype).upper()} = {r['key']}" return f"CHK block {_int(block)} key {str(keytype).upper()}: no key from the default list works" diff --git a/pm3py/cli/rawcli/completer.py b/pm3py/cli/rawcli/completer.py index 353d247..45cfdf1 100644 --- a/pm3py/cli/rawcli/completer.py +++ b/pm3py/cli/rawcli/completer.py @@ -70,8 +70,12 @@ class RawCompleter(Completer): catalog = catalog_for(self._session) if catalog is None: return False + # parse the opcode byte in ITS base — the base it was typed in, not the current entry mode + # (after `30` in hex you toggle to binary to type the page byte; `30` is still hex) + bases = getattr(self._session, "byte_bases", []) or [] + base = bases[0] if bases else getattr(self._session, "entry_mode", "hex") try: - b = parse_token(tok, getattr(self._session, "entry_mode", "hex")) + b = parse_token(tok, base) except ValueError: return False if len(b) != 1: diff --git a/pm3py/cli/rawcli/entry.py b/pm3py/cli/rawcli/entry.py index f9fcb18..cf6079a 100644 --- a/pm3py/cli/rawcli/entry.py +++ b/pm3py/cli/rawcli/entry.py @@ -1,8 +1,15 @@ -"""Numeric entry helpers: byte-group spacing for hex/binary input and the prompt_toolkit key -bindings that toggle entry mode (Ctrl-/) and auto-space digits as you type. +"""Numeric entry: per-byte base-aware input for raw hex/binary, plus the prompt_toolkit key +bindings that drive it. -The pure ``byte_space`` / ``group_size`` functions carry the logic and are unit-tested; the key -bindings are thin wiring exercised in the live REPL. +Raw byte entry mixes bases **per byte** — each byte remembers the base it was typed in, so +``30`` (hex) and ``00000100`` (binary) can sit on one line as ``30 00000100`` and send ``0x30 +0x04``. The display stays clean (no ``0x``/``0b`` clutter); the base of each byte is tracked +alongside the text in ``session.byte_bases``. A byte auto-seals at its base width (2 hex / 8 +binary) and the next digit starts a fresh byte in the current entry mode; toggling the mode +(Ctrl-/) only changes the base of the *next* byte — it never rewrites what you've typed. + +The pure ``type_digit`` / ``group_size`` / ``is_byte_line`` functions carry the logic and are +unit-tested; the key bindings are thin wiring exercised in the live REPL. """ from __future__ import annotations @@ -16,20 +23,39 @@ def group_size(mode: str) -> int: return 2 if mode == HEX else 8 -def byte_space(text: str, mode: str = HEX) -> str: - """Re-group a **pure** run of hex/binary digits into space-separated byte groups. +def is_byte_line(text: str) -> bool: + """Whether ``text`` (spaces ignored) is a pure hex/binary byte run — i.e. raw entry, not a + command word or function call. Empty is a byte line (you're about to type bytes).""" + return all(ch in _DIGITS[HEX] for ch in text.replace(" ", "")) - 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)) + +def type_digit(text: str, bases: list[str], mode: str, digit: str) -> tuple[str, list[str]]: + """Apply one digit keystroke to a raw-byte line, base-aware. Returns ``(new_text, new_bases)``. + + A byte seals at its base's width (2 hex / 8 binary); once full, the next digit starts a new + byte in ``mode``. A digit that isn't valid for the byte it would land in is dropped (no + change), so binary bytes can't gain hex digits and vice-versa.""" + d = digit.lower() + groups = text.split(" ") if text else [] + aligned = len(bases) == len(groups) + # extend the trailing byte if it's still open (present, aligned, below its base width) + if groups and aligned and groups[-1] and len(groups[-1]) < group_size(bases[-1]): + b = bases[-1] + if d in _DIGITS[b]: + groups[-1] += digit + return " ".join(groups), list(bases) + return text, list(bases) # wrong base for this byte — drop + # otherwise begin a new byte in the current entry mode + if d in _DIGITS[mode]: + return (" ".join(groups + [digit]) if groups else digit), list(bases) + [mode] + return text, list(bases) + + +def reconcile_bases(text: str, bases: list[str]) -> list[str]: + """Trim ``bases`` to the number of byte groups left in ``text`` (after a backspace/edit). Only + a best-effort end-alignment — enough for append + backspace, which is what raw entry does.""" + groups = [g for g in text.split(" ") if g] + return list(bases[:len(groups)]) #: keys that toggle entry mode. Ctrl-/ is emitted as Ctrl-_ (0x1F) by most terminals; c-t is an @@ -46,25 +72,36 @@ def _bind(kb, key, handler) -> None: def install_key_bindings(kb, session) -> None: - """Wire the entry-mode toggle (Ctrl-/ , via Ctrl-_ , plus Ctrl-t) and live auto-byte-spacing - of digit input onto ``kb``. ``session`` is the :class:`RawSession` whose ``entry_mode`` is - toggled and read. Never raises on an unknown key name.""" + """Wire the entry-mode toggle (Ctrl-/ via Ctrl-_, plus Ctrl-t) and per-byte base-aware digit + entry onto ``kb``. ``session`` is the :class:`RawSession` whose ``entry_mode`` is toggled/read + and whose ``byte_bases`` records each raw byte's base. Never raises on an unknown key name.""" from prompt_toolkit.document import Document def _toggle(event): - session.toggle_entry_mode() + session.toggle_entry_mode() # only flips the mode — the next byte you type uses it event.app.invalidate() # redraw the breadcrumb for key in TOGGLE_KEYS: _bind(kb, key, _toggle) - def _regroup_after(event): + def _digit(event): buf = event.current_buffer - buf.insert_text(event.data) - # only auto-space a bare digit run (no prefix), matching byte_space's guard - grouped = byte_space(buf.text, session.entry_mode) - if grouped != buf.text: - buf.document = Document(grouped, len(grouped)) + d = event.data + text = buf.text + # only base-group a raw byte line; a command/function-call being typed inserts verbatim + if not is_byte_line(text.replace(" ", "") + d): + buf.insert_text(d) + return + new_text, session.byte_bases = type_digit(text, session.byte_bases, session.entry_mode, d) + buf.document = Document(new_text, len(new_text)) for ch in sorted(_DIGITS[HEX] | _DIGITS[BIN]): - _bind(kb, ch, _regroup_after) + _bind(kb, ch, _digit) + + def _backspace(event): + buf = event.current_buffer + buf.delete_before_cursor() + session.byte_bases = reconcile_bases(buf.text, session.byte_bases) + + for key in ("backspace", "c-h"): + _bind(kb, key, _backspace) diff --git a/pm3py/cli/rawcli/parser.py b/pm3py/cli/rawcli/parser.py index fc3abe2..edebf9e 100644 --- a/pm3py/cli/rawcli/parser.py +++ b/pm3py/cli/rawcli/parser.py @@ -68,11 +68,15 @@ def parse_token(token: str, default_mode: str = HEX) -> bytes: raise ValueError(f"invalid binary token {token!r}") from None -def parse_bytes(text: str, default_mode: str = HEX) -> bytes: - """Parse a whitespace-separated raw payload (intermixed 0x/0b tokens allowed).""" +def parse_bytes(text: str, default_mode: str = HEX, bases: list[str] | None = None) -> bytes: + """Parse a whitespace-separated raw payload. Each token is parsed in its own base: an explicit + ``0x``/``0b`` prefix wins, else ``bases[i]`` (the base that token was typed in, when known), + else ``default_mode``. A token that isn't a valid byte raises ``ValueError`` — so a line that + mixes a command with raw bytes (``READ 30``) errors here instead of transmitting.""" out = bytearray() - for tok in text.split(): - out += parse_token(tok, default_mode) + for i, tok in enumerate(text.split()): + mode = bases[i] if bases and i < len(bases) else default_mode + out += parse_token(tok, mode) return bytes(out) @@ -80,8 +84,9 @@ def _split_args(inside: str) -> list[str]: return [a.strip() for a in inside.split(",")] if inside.strip() else [] -def parse_line(line: str, default_mode: str = HEX) -> Command: - """Classify and parse a full input line.""" +def parse_line(line: str, default_mode: str = HEX, bases: list[str] | None = None) -> Command: + """Classify and parse a full input line. ``bases`` gives the per-byte base for a raw line + (from the live entry tracker); a raw line that isn't pure bytes raises ``ValueError``.""" stripped = line.strip() m = _CALL_RE.match(stripped) if m: @@ -91,4 +96,4 @@ def parse_line(line: str, default_mode: str = HEX) -> Command: if verb in CONTROL_VERBS: rest = first[1].split() if len(first) > 1 else [] return Command("control", name=verb, args=rest) - return Command("raw", payload=parse_bytes(stripped, default_mode)) + return Command("raw", payload=parse_bytes(stripped, default_mode, bases)) diff --git a/pm3py/cli/rawcli/session.py b/pm3py/cli/rawcli/session.py index d68a99c..d39541d 100644 --- a/pm3py/cli/rawcli/session.py +++ b/pm3py/cli/rawcli/session.py @@ -32,6 +32,7 @@ class RawSession: self.transponder: str | None = None self.protocol: str = "hf14a" # wire protocol for raw exchanges (set by identify) self.entry_mode: str = HEX + self.byte_bases: list[str] = [] # base of each raw byte typed on the current line self.connected: bool = device is not None @property diff --git a/tests/test_rawcli.py b/tests/test_rawcli.py index 4dad4c7..1526fba 100644 --- a/tests/test_rawcli.py +++ b/tests/test_rawcli.py @@ -9,7 +9,7 @@ from pm3py.cli.main import build_parser, main from pm3py.cli.rawcli.session import RawSession from pm3py.cli.rawcli.trace_view import render_exchange from pm3py.cli.rawcli.parser import parse_token, parse_bytes, parse_line -from pm3py.cli.rawcli.entry import byte_space +from pm3py.cli.rawcli.entry import type_digit, is_byte_line, reconcile_bases from pm3py.cli.rawcli.identify import identify, clear, name_14a, format_summary from pm3py.cli.rawcli.catalog import TYPE2, ISO15, T5577, LF_READONLY, MFC, catalog_for, catalog_for as _cf from pm3py.cli.rawcli.tlv import parse_tlv, format_tlv @@ -133,28 +133,40 @@ class TestParser: assert c.kind == "raw" and c.payload == b"\x30\x04" -class TestByteSpace: - def test_hex_grouping(self): - assert byte_space("3004", "hex") == "30 04" - assert byte_space("300", "hex") == "30 0" - assert byte_space("30 04", "hex") == "30 04" # idempotent +class TestEntry: + def _type(self, keys): + """Replay (digit, mode) keystrokes through type_digit; return (text, bases).""" + text, bases = "", [] + for digit, mode in keys: + text, bases = type_digit(text, bases, mode, digit) + return text, bases - def test_binary_grouping(self): - assert byte_space("0011000000000100", "bin") == "00110000 00000100" + def test_hex_auto_spacing(self): + text, bases = self._type([(d, "hex") for d in "300412"]) + assert text == "30 04 12" and bases == ["hex", "hex", "hex"] - def test_prefixed_left_alone(self): - assert byte_space("0x3004", "hex") == "0x3004" # explicit prefix not mangled - assert byte_space("0b0011", "hex") == "0b0011" + def test_binary_auto_spacing(self): + text, bases = self._type([(d, "bin") for d in "0011000000000100"]) + assert text == "00110000 00000100" and bases == ["bin", "bin"] - 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_per_byte_base_mix(self): + # 30 in hex, then toggle to binary and type a binary byte -> keeps 30 hex, new byte binary + text, bases = self._type([("3", "hex"), ("0", "hex")] + [(d, "bin") for d in "00000100"]) + assert text == "30 00000100" and bases == ["hex", "bin"] - def test_spaces_pure_hex(self): - assert byte_space("deadbeef", "hex") == "de ad be ef" + def test_invalid_digit_dropped(self): + # a binary byte can't take a hex digit; a hex-only digit in binary mode is dropped + assert type_digit("30 000", ["hex", "bin"], "bin", "3") == ("30 000", ["hex", "bin"]) + assert type_digit("", [], "bin", "f") == ("", []) # f invalid to start a bin byte + + def test_is_byte_line(self): + assert is_byte_line("30 00000100") and is_byte_line("deadbeef") and is_byte_line("") + assert not is_byte_line("READ") and not is_byte_line("help") and not is_byte_line("30 x") + + def test_reconcile_bases_after_backspace(self): + assert reconcile_bases("30", ["hex", "bin"]) == ["hex"] # binary byte deleted + assert reconcile_bases("30 0011", ["hex", "bin"]) == ["hex", "bin"] + assert reconcile_bases("", ["hex"]) == [] class TestDispatch: @@ -362,18 +374,18 @@ class TestCatalog: def test_mfc_authenticated_ops(self): # Classic READ/WRITE run via hf.mfc (crypto1 auth in firmware); default key FFFFFFFFFFFF/A dev = MagicMock() - dev.hf.mfc.rdbl.return_value = {"success": True, "block": 4, "data": "aa" * 16} - dev.hf.mfc.wrbl.return_value = {"success": True, "block": 4} - dev.hf.mfc.chk.return_value = {"found": True, "key": "A0A1A2A3A4A5"} + dev.hf.mf.rdbl.return_value = {"success": True, "block": 4, "data": "aa" * 16} + dev.hf.mf.wrbl.return_value = {"success": True, "block": 4} + dev.hf.mf.chk.return_value = {"found": True, "key": "A0A1A2A3A4A5"} assert MFC.get("READ").opcode() == 0x30 and MFC.get("WRITE").opcode() == 0xA0 # hint opcode assert MFC.get("READ").run is not None # executes, not raw exchange MFC.get("READ").run(dev, "4") - dev.hf.mfc.rdbl.assert_called_with(4, key="FFFFFFFFFFFF", key_type=0) # default key A + dev.hf.mf.rdbl.assert_called_with(4, key="FFFFFFFFFFFF", key_type=0) # default key A MFC.get("READ").run(dev, "4", "A0A1A2A3A4A5", "B") - dev.hf.mfc.rdbl.assert_called_with(4, key="A0A1A2A3A4A5", key_type=1) # override key + B + dev.hf.mf.rdbl.assert_called_with(4, key="A0A1A2A3A4A5", key_type=1) # override key + B assert "ok" in MFC.get("WRITE").run(dev, "4", "00" * 16) assert "A0A1A2A3A4A5" in MFC.get("CHK").run(dev, "3") - dev.hf.mfc.rdbl.return_value = {"success": False, "error": 1} + dev.hf.mf.rdbl.return_value = {"success": False, "error": 1} assert "failed" in MFC.get("READ").run(dev, "4") # clear failure line def test_iso15_builds(self): @@ -756,3 +768,43 @@ class TestKeyBindings: kb = KeyBindings() install_key_bindings(kb, RawSession()) assert len(kb.bindings) >= len(TOGGLE_KEYS) + + def test_toggle_switches_mode_without_touching_buffer(self): + # the toggle only flips the entry mode (for the NEXT byte); it never rewrites what's typed + from types import SimpleNamespace + from prompt_toolkit.key_binding import KeyBindings + from prompt_toolkit.buffer import Buffer + from pm3py.cli.rawcli.entry import install_key_bindings + s = RawSession() + kb = KeyBindings() + install_key_bindings(kb, s) + toggle = next(b for b in kb.bindings + if getattr(b.keys[0], "value", b.keys[0]) in ("c-t", "c-_")) + buf = Buffer() + buf.text = "30" + event = SimpleNamespace(current_buffer=buf, app=SimpleNamespace(invalidate=lambda: None), data="") + toggle.handler(event) + assert s.entry_mode == "bin" and buf.text == "30" # mode flipped, buffer intact + + def test_digit_binding_tracks_per_byte_base(self): + # replay keystrokes through the real digit binding: 30 (hex) then binary -> 30 00000100 + from types import SimpleNamespace + from prompt_toolkit.key_binding import KeyBindings + from prompt_toolkit.buffer import Buffer + from pm3py.cli.rawcli.entry import install_key_bindings + s = RawSession() + kb = KeyBindings() + install_key_bindings(kb, s) + digit = {getattr(b.keys[0], "value", b.keys[0]): b for b in kb.bindings} + buf = Buffer() + app = SimpleNamespace(invalidate=lambda: None) + + def press(ch): + digit[ch].handler(SimpleNamespace(current_buffer=buf, app=app, data=ch)) + for ch in "30": + press(ch) + assert buf.text == "30" and s.byte_bases == ["hex"] + s.toggle_entry_mode() + for ch in "00000100": + press(ch) + assert buf.text == "30 00000100" and s.byte_bases == ["hex", "bin"]