diff --git a/pm3py/cli/rawcli/app.py b/pm3py/cli/rawcli/app.py index 2615ee0..cbebfd0 100644 --- a/pm3py/cli/rawcli/app.py +++ b/pm3py/cli/rawcli/app.py @@ -131,21 +131,9 @@ def dispatch(state: RawSession, line: str, out=print) -> bool: response = _raw_exchange(state.device, cmd.payload, protocol=state.protocol, crc=state.protocol == "hf14a") # 14a frames need CRC; bare `60` works out(render_exchange(cmd.payload, response, protocol=state.protocol, is_tty=True)) - _annotate_pages(state, cmd.payload, out) # raw '30 04' -> "04-07 → user memory" return True -def _annotate_pages(state: RawSession, payload: bytes, out=print) -> None: - """Name the datasheet regions a raw command touched, when its bytes map to a known page/block - op — so raw hex/binary gets the same annotation as the function-call form (``READ(4)``).""" - from .catalog import pages_for_payload - from .memory import region_annotation - covered = pages_for_payload(catalog_for(state), payload) - ann = region_annotation(state.transponder, covered) if covered else None - if ann: - out(f" {ann}") - - def _handle_call(state: RawSession, cmd, out=print) -> None: catalog = catalog_for(state) if catalog is None: @@ -182,17 +170,6 @@ def _handle_call(state: RawSession, cmd, out=print) -> None: response = _raw_exchange(state.device, frame, protocol=catalog.protocol, crc=crc) out(render_exchange(frame, response, protocol=catalog.protocol, is_tty=True)) - # name the datasheet memory regions the command covered (READ(4) -> "04-07 → user memory") - if tc.pages is not None: - from .memory import region_annotation - try: - covered = tc.pages(*cmd.args) - except (TypeError, ValueError): - covered = None - ann = region_annotation(state.transponder, covered) if covered else None - if ann: - out(f" {ann}") - def _handle_tlv(state: RawSession, args, out=print) -> None: from .tlv import format_tlv, prompt_tlv diff --git a/pm3py/cli/rawcli/catalog.py b/pm3py/cli/rawcli/catalog.py index 31e6e4b..7f0ff6d 100644 --- a/pm3py/cli/rawcli/catalog.py +++ b/pm3py/cli/rawcli/catalog.py @@ -10,6 +10,8 @@ from __future__ import annotations from dataclasses import dataclass from typing import Callable +from .parser import parse_arg_int + @dataclass class TagCommand: @@ -18,7 +20,6 @@ class TagCommand: build: Callable[..., bytes] | None = None # HF: string args -> raw payload bytes to exchange help: str = "" run: Callable[..., object] | None = None # LF: (device, *args) -> a human result line - pages: Callable[..., list[int]] | None = None # the page/block numbers the command reads/writes def usage(self) -> str: return f"{self.name}({', '.join(self.params)})" @@ -46,12 +47,20 @@ class Catalog: def get(self, name: str) -> TagCommand | None: return self.commands.get(name.upper()) + def by_opcode(self, op: int) -> TagCommand | None: + """The command whose first sent byte is ``op`` — for hinting a raw payload (``30 …`` → + READ). None if no command matches.""" + for tc in self.commands.values(): + if tc.opcode() == op: + return tc + return None + def names(self) -> list[str]: return list(self.commands) def _int(s: str) -> int: - return int(s, 0) # accepts decimal or 0x-prefixed + return parse_arg_int(s) # base 10 by default; 0x/0b/0o to override def _hex(s: str) -> bytes: @@ -62,19 +71,17 @@ def _catalog(name: str, protocol: str, *cmds: TagCommand) -> Catalog: return Catalog(name, protocol, {c.name.upper(): c for c in cmds}) -def _c(name, params, build=None, help="", run=None, pages=None) -> TagCommand: - return TagCommand(name, tuple(params), build, help, run, pages) +def _c(name, params, build=None, help="", run=None) -> TagCommand: + return TagCommand(name, tuple(params), build, help, run) # ---- ISO 14443-A Type 2 (NTAG / Ultralight) ---- TYPE2 = _catalog( "NTAG / Ultralight (Type 2)", "hf14a", _c("READ", ["page"], lambda page: bytes([0x30, _int(page)]), - "read 4 pages starting at (0x30)", - pages=lambda page: list(range(_int(page), _int(page) + 4))), + "read 4 pages starting at (0x30)"), _c("FAST_READ", ["start", "end"], lambda start, end: bytes([0x3A, _int(start), _int(end)]), - "read pages .. in one frame (0x3A)", - pages=lambda start, end: list(range(_int(start), _int(end) + 1))), + "read pages .. in one frame (0x3A)"), _c("GET_VERSION", [], lambda: bytes([0x60]), "product/version info (0x60)"), _c("READ_SIG", [], lambda: bytes([0x3C, 0x00]), "read the ECC originality signature (0x3C)"), _c("READ_CNT", ["counter"], lambda counter="0": bytes([0x39, _int(counter)]), @@ -84,12 +91,10 @@ TYPE2 = _catalog( "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)", - pages=lambda page, data: [_int(page)]), + "write 4 bytes to (0xA2)"), _c("COMPAT_WRITE", ["page", "data"], lambda page, data: [bytes([0xA0, _int(page)]), _hex(data).ljust(16, b"\x00")[:16]], - "compatibility write: 16-byte to , two-phase (0xA0)", - pages=lambda page, data: [_int(page)]), + "compatibility write: 16-byte to , two-phase (0xA0)"), _c("HALT", [], lambda: bytes([0x50, 0x00]), "halt the tag (0x50 00)"), ) @@ -181,30 +186,6 @@ LF_READONLY = _catalog( ) -def pages_for_payload(catalog: Catalog | None, payload: bytes) -> list[int] | None: - """Reverse-map raw command bytes to the memory pages the command touches, so raw hex like - ``30 04`` earns the same region annotation as ``READ(4)``. Matches a page/block command by - opcode (``payload[0]``) and reads its page argument from ``payload[1:]``. Returns the page - list, or None (no catalog / unknown opcode / not a page command / no page byte).""" - if catalog is None or not payload: - return None - op = payload[0] - for name in catalog.names(): - tc = catalog.get(name) - if tc.pages is None: # only commands that touch memory pages - continue - if tc.opcode() != op or len(payload) < 2: # need the opcode + at least the page byte - continue - n = len(tc.params) - args = [str(b) for b in payload[1:1 + n]] - args += ["0"] * (n - len(args)) # pad trailing data args; the page is present - try: - return tc.pages(*args) - except (TypeError, ValueError): - return None - return None - - def catalog_for(session) -> Catalog | None: """Resolve the command catalog for the session's identified transponder, or None.""" name = (session.transponder or "").upper() diff --git a/pm3py/cli/rawcli/memory.py b/pm3py/cli/rawcli/memory.py index 89040be..b8a1ae1 100644 --- a/pm3py/cli/rawcli/memory.py +++ b/pm3py/cli/rawcli/memory.py @@ -107,38 +107,22 @@ def landmark_pages(transponder: str | None) -> list[tuple[int, str]]: return sorted(out.items()) -def region_annotation(transponder: str | None, pages: list[int]) -> str | None: - """Name the memory regions a read/write touched, grouping consecutive same-role pages: - ``04-07 → user memory`` or ``00-02 → UID / serial number · 03 → Capability Container (CC)``. - Returns None when the transponder has no known layout (nothing useful to say).""" - if not pages: - return None - segs: list[list] = [] # [lo, hi, role] - for p in pages: - role = page_role(transponder, p) - if segs and segs[-1][2] == role and segs[-1][1] == p - 1: - segs[-1][1] = p - else: - segs.append([p, p, role]) - if all(role is None for _, _, role in segs): # no layout for this tag -> nothing to add - return None - parts = [] - for lo, hi, role in segs: - loc = f"{lo:02X}" if lo == hi else f"{lo:02X}-{hi:02X}" - parts.append(f"{loc} → {role or 'reserved / unknown'}") - return " · ".join(parts) - - def _to_int(text: str) -> int | None: + from .parser import parse_arg_int # function-call arg: base 10 by default (0x/0b) try: - return int(text, 0) + return parse_arg_int(text) except (ValueError, TypeError): return None +_ADDR_PARAMS = ("page", "block", "start") + + def input_hint(session, text: str) -> str | None: """A one-line relevance hint for the current input: the command's purpose, and — for a page/ - block argument — where that page lives on the identified transponder.""" + block argument — where that page lives on the identified transponder. Covers the function-call + form (``READ(4``) and raw byte entry (``30 04``); the memory-location info lives *only* here and + in completion, never in command output.""" from .catalog import catalog_for stripped = (text or "").strip() @@ -148,19 +132,37 @@ def input_hint(session, text: str) -> str | None: if catalog is None: return None + # function-style call: READ(4 — base-10 arg; hint the command and where that page lives m = _CALL_HEAD.match(stripped) if m: tc = catalog.get(m.group(1)) first = m.group(2).strip() if tc: base = f"{tc.usage()} — {tc.help}" - if tc.params and tc.params[0] in ("page", "block") and first: + if tc.params and tc.params[0] in _ADDR_PARAMS and first: role = page_role(session.transponder, _to_int(first)) if role: return f"{base} · {tc.params[0]} {first} → {role}" return base + # a bare catalog command name tc = catalog.get(stripped) if tc: return f"{tc.usage()} — {tc.help}" + + # raw hex/binary bytes: 30 04 — if the opcode maps to a page command, hint it + the page's role + from .parser import parse_bytes + try: + payload = parse_bytes(stripped, getattr(session, "entry_mode", "hex")) + except ValueError: + payload = b"" + if payload: + tc = catalog.by_opcode(payload[0]) + if tc: + base = f"{tc.usage()} — {tc.help}" + if len(payload) >= 2 and tc.params and tc.params[0] in _ADDR_PARAMS: + role = page_role(session.transponder, payload[1]) + if role: + return f"{base} · {tc.params[0]} 0x{payload[1]:02X} → {role}" + return base return None diff --git a/pm3py/cli/rawcli/parser.py b/pm3py/cli/rawcli/parser.py index f72c1de..fc3abe2 100644 --- a/pm3py/cli/rawcli/parser.py +++ b/pm3py/cli/rawcli/parser.py @@ -28,6 +28,17 @@ class Command: payload: bytes = b"" +def parse_arg_int(s: str) -> int: + """A function-style command's numeric argument (``READ(4)``, ``WRITE(40, …)``): **base 10 by + default**, with a ``0x`` / ``0b`` / ``0o`` prefix selecting the base. This is the opposite + default from raw byte entry (which is hex) — a page number reads naturally as decimal, so + ``READ(40)`` is page 40 and ``READ(0x28)`` is the same page in hex.""" + s = s.strip() + if s[:2].lower() in ("0x", "0b", "0o"): + return int(s, 0) + return int(s, 10) + + def parse_token(token: str, default_mode: str = HEX) -> bytes: """Parse one hex/binary token to bytes. A ``0x``/``0b`` prefix picks the base; otherwise ``default_mode`` is used.""" diff --git a/tests/test_rawcli.py b/tests/test_rawcli.py index 9b29e79..4953f60 100644 --- a/tests/test_rawcli.py +++ b/tests/test_rawcli.py @@ -225,16 +225,16 @@ class TestIdentify: assert s.transponder == "NTAG216" # recovered, not the generic name assert s.device.hf.iso14a.raw.call_count == 3 - def test_14a_getversion_fails_generic_still_annotates(self): + def test_14a_getversion_fails_generic_still_resolves(self): # GET_VERSION never answers -> generic family name, but the universal Type 2 pages still - # annotate (this is the path that silently broke before) - from pm3py.cli.rawcli.memory import region_annotation + # resolve a role (this is the path that silently broke before) + from pm3py.cli.rawcli.memory import page_role s = RawSession(device=_mock_device( scan14={"found": True, "sak": 0x00, "atqa": "4400", "uid": "04a1b2c3d4e5f6"})) s.device.hf.iso14a.raw.return_value = {"raw": None} identify(s) assert s.transponder == "MIFARE Ultralight / NTAG" - assert region_annotation(s.transponder, [4, 5, 6, 7]) == "04-07 → user memory" + assert page_role(s.transponder, 4) == "user memory" def test_15693_when_e0_uid(self): s = RawSession(device=_mock_device(scan15={"found": True, "uid": "e004010203040506"})) @@ -396,15 +396,18 @@ class TestCatalog: assert TYPE2.get("GET_VERSION").opcode() == 0x60 assert LF_READONLY.get("INFO").opcode() is None # run-only, no build - def test_pages_for_payload(self): - # raw bytes reverse-map to the pages a page/block command touches - from pm3py.cli.rawcli.catalog import pages_for_payload - assert pages_for_payload(TYPE2, b"\x30\x04") == [4, 5, 6, 7] # READ(4) - assert pages_for_payload(TYPE2, b"\x3A\x00\x06") == [0, 1, 2, 3, 4, 5, 6] # FAST_READ(0,6) - assert pages_for_payload(TYPE2, b"\xA2\x04\x01\x02\x03\x04") == [4] # WRITE(4, ..) - assert pages_for_payload(TYPE2, b"\x60") is None # GET_VERSION — not a page command - assert pages_for_payload(TYPE2, b"\x30") is None # opcode only, no page byte - assert pages_for_payload(None, b"\x30\x04") is None # no catalog + def test_call_arg_is_base_ten(self): + # a function-call page argument is base 10 unless prefixed — READ(04)/READ(40) must not crash + assert TYPE2.get("READ").build("04") == b"\x30\x04" # leading-zero decimal + assert TYPE2.get("READ").build("40") == b"\x30\x28" # 40 decimal = page 0x28 + assert TYPE2.get("READ").build("0x28") == b"\x30\x28" # hex override + assert TYPE2.get("READ").build("0b101000") == b"\x30\x28" # binary override + assert TYPE2.get("READ").build("4") == b"\x30\x04" + + def test_by_opcode(self): + assert TYPE2.by_opcode(0x30).name == "READ" + assert TYPE2.by_opcode(0xA2).name == "WRITE" + assert TYPE2.by_opcode(0x99) is None class TestFunctionCalls: @@ -433,8 +436,8 @@ class TestFunctionCalls: assert calls[0].args[0] == b"\xA0\x04" assert calls[1].args[0] == b"\x01\x02\x03\x04" + b"\x00" * 12 - def test_read_annotates_memory_region(self, capsys): - # READ output names the datasheet regions the pages cover (the user's ask) + def test_memory_region_stays_out_of_output(self, capsys): + # the memory-location info belongs ONLY in the hint/completion — never printed as output s = RawSession(device=_mock_device( scan14={"found": True, "sak": 0x00, "atqa": "4400", "uid": "04a1b2c3d4e5f6"}, version=NTAG216_VERSION)) @@ -442,27 +445,9 @@ class TestFunctionCalls: assert s.transponder == "NTAG216" s.device.hf.iso14a.raw.return_value = {"raw": b"\x03\x00\xFE\x00" * 4} capsys.readouterr() - dispatch(s, "READ(4)") - assert "04-07 → user memory" in _plain(capsys.readouterr().out) - dispatch(s, "READ(0)") - o = _plain(capsys.readouterr().out) - assert "UID / serial number" in o and "Capability Container" in o - - def test_raw_hex_annotates_memory_region(self, capsys): - # raw '30 04' is a READ(4) — it earns the same region annotation as the function form - s = RawSession(device=_mock_device( - scan14={"found": True, "sak": 0x00, "atqa": "4400", "uid": "04a1b2c3d4e5f6"}, - version=NTAG216_VERSION)) - dispatch(s, "identify") - assert s.transponder == "NTAG216" - s.device.hf.iso14a.raw.return_value = {"raw": b"\x03\x00\xFE\x00" * 4} - capsys.readouterr() - dispatch(s, "30 04") # raw READ(4) - assert "04-07 → user memory" in _plain(capsys.readouterr().out) - dispatch(s, "3A 00 06") # raw FAST_READ(0,6) - o = _plain(capsys.readouterr().out) - assert "UID / serial number" in o and "04-06 → user memory" in o - dispatch(s, "60") # GET_VERSION — no page op, no region + dispatch(s, "READ(4)") # function call + assert "user memory" not in _plain(capsys.readouterr().out) + dispatch(s, "30 04") # raw hex assert "user memory" not in _plain(capsys.readouterr().out) def test_call_without_identify(self, capsys): @@ -653,25 +638,25 @@ class TestMemoryHint: from pm3py.cli.rawcli.memory import input_hint assert input_hint(RawSession(), "READ(4)") is None - def test_region_annotation(self): - from pm3py.cli.rawcli.memory import region_annotation - assert region_annotation("NTAG216", [0, 1, 2, 3]) == \ - "00-02 → UID / serial number · 03 → Capability Container (CC)" - assert region_annotation("NTAG216", [4, 5, 6, 7]) == "04-07 → user memory" - assert "PWD" in region_annotation("NTAG213", [0x2B]) - assert region_annotation("MIFARE Classic 1K", [4]) is None # no layout -> no annotation - assert region_annotation("NTAG216", []) is None + def test_hint_raw_bytes(self): + # raw hex (30 04) gets the location hint too — the opcode maps to READ, byte 1 is the page + from pm3py.cli.rawcli.memory import input_hint + s = self._sess() # NTAG213 + h = input_hint(s, "30 04") + assert h.startswith("READ(page)") and "page 0x04 → user memory" in h + assert "PWD" in input_hint(s, "30 2B") # page 0x2B = PWD on a 213 + assert input_hint(s, "60").startswith("GET_VERSION") # opcode-only command, no page part def test_generic_type2_fallback(self): - from pm3py.cli.rawcli.memory import region_annotation, landmark_pages, page_role + from pm3py.cli.rawcli.memory import landmark_pages, page_role # only the family is known (flaky/absent GET_VERSION) -> universal pages still resolve for name in ("MIFARE Ultralight / NTAG", "NTAG (144 B)"): - assert region_annotation(name, [4, 5, 6, 7]) == "04-07 → user memory" + assert page_role(name, 4) == "user memory" assert page_role(name, 0) == "UID / serial number" assert page_role(name, 3) == "Capability Container (CC)" assert page_role(name, 0x20) is None # model-specific -> not guessed assert [p for p, _ in landmark_pages(name)] == [0, 3, 4] # no config landmarks - assert region_annotation("MIFARE Classic 1K", [4]) is None # not Type 2 -> nothing + assert page_role("MIFARE Classic 1K", 4) is None # not Type 2 -> nothing def test_t5577_block_roles(self): from pm3py.cli.rawcli.memory import page_role, input_hint