feat(rawcli): per-byte base intermixed entry; fix MFC hf.mf; help + strict mixing
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user