fix(rawcli): memory locations live only in suggestions; base-10 call args
Two corrections:
1. Memory-location names are a SUGGESTION, never command output. Removed the region annotation
printed after each exchange (both the function-call and raw-hex paths) along with its dead
machinery (region_annotation, pages_for_payload, TagCommand.pages). The location now shows in
exactly two places: the completion menu (page landmarks) and the live bottom-bar hint —
which now also covers raw byte entry (30 04 -> "READ(page) … · page 0x04 → user memory")
via Catalog.by_opcode.
2. Function-call arguments are base 10 by default. READ(04) crashed on int("04", 0) (Python
rejects leading-zero decimals in base 0). parser.parse_arg_int parses a call argument as
decimal unless it carries a 0x/0b/0o prefix, so READ(04)/READ(40) work and READ(0x28) still
overrides to hex. Raw byte entry keeps its opposite default (hex, 0b to intermix) — unchanged.
Hardware-verified on NTAG213: READ(04) sends 30 04 with no region line in the output; the hint
shows the page role for READ(4), READ(04), READ(0x28), raw 30 04 and 30 28; completion still
lists the memory map. 1331 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -131,21 +131,9 @@ def dispatch(state: RawSession, line: str, out=print) -> bool:
|
|||||||
response = _raw_exchange(state.device, cmd.payload, protocol=state.protocol,
|
response = _raw_exchange(state.device, cmd.payload, protocol=state.protocol,
|
||||||
crc=state.protocol == "hf14a") # 14a frames need CRC; bare `60` works
|
crc=state.protocol == "hf14a") # 14a frames need CRC; bare `60` works
|
||||||
out(render_exchange(cmd.payload, response, protocol=state.protocol, is_tty=True))
|
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
|
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:
|
def _handle_call(state: RawSession, cmd, out=print) -> None:
|
||||||
catalog = catalog_for(state)
|
catalog = catalog_for(state)
|
||||||
if catalog is None:
|
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)
|
response = _raw_exchange(state.device, frame, protocol=catalog.protocol, crc=crc)
|
||||||
out(render_exchange(frame, response, protocol=catalog.protocol, is_tty=True))
|
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:
|
def _handle_tlv(state: RawSession, args, out=print) -> None:
|
||||||
from .tlv import format_tlv, prompt_tlv
|
from .tlv import format_tlv, prompt_tlv
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
|
from .parser import parse_arg_int
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class TagCommand:
|
class TagCommand:
|
||||||
@@ -18,7 +20,6 @@ class TagCommand:
|
|||||||
build: Callable[..., bytes] | None = None # HF: string args -> raw payload bytes to exchange
|
build: Callable[..., bytes] | None = None # HF: string args -> raw payload bytes to exchange
|
||||||
help: str = ""
|
help: str = ""
|
||||||
run: Callable[..., object] | None = None # LF: (device, *args) -> a human result line
|
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:
|
def usage(self) -> str:
|
||||||
return f"{self.name}({', '.join(self.params)})"
|
return f"{self.name}({', '.join(self.params)})"
|
||||||
@@ -46,12 +47,20 @@ class Catalog:
|
|||||||
def get(self, name: str) -> TagCommand | None:
|
def get(self, name: str) -> TagCommand | None:
|
||||||
return self.commands.get(name.upper())
|
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]:
|
def names(self) -> list[str]:
|
||||||
return list(self.commands)
|
return list(self.commands)
|
||||||
|
|
||||||
|
|
||||||
def _int(s: str) -> int:
|
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:
|
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})
|
return Catalog(name, protocol, {c.name.upper(): c for c in cmds})
|
||||||
|
|
||||||
|
|
||||||
def _c(name, params, build=None, help="", run=None, pages=None) -> TagCommand:
|
def _c(name, params, build=None, help="", run=None) -> TagCommand:
|
||||||
return TagCommand(name, tuple(params), build, help, run, pages)
|
return TagCommand(name, tuple(params), build, help, run)
|
||||||
|
|
||||||
|
|
||||||
# ---- ISO 14443-A Type 2 (NTAG / Ultralight) ----
|
# ---- ISO 14443-A Type 2 (NTAG / Ultralight) ----
|
||||||
TYPE2 = _catalog(
|
TYPE2 = _catalog(
|
||||||
"NTAG / Ultralight (Type 2)", "hf14a",
|
"NTAG / Ultralight (Type 2)", "hf14a",
|
||||||
_c("READ", ["page"], lambda page: bytes([0x30, _int(page)]),
|
_c("READ", ["page"], lambda page: bytes([0x30, _int(page)]),
|
||||||
"read 4 pages starting at <page> (0x30)",
|
"read 4 pages starting at <page> (0x30)"),
|
||||||
pages=lambda page: list(range(_int(page), _int(page) + 4))),
|
|
||||||
_c("FAST_READ", ["start", "end"], lambda start, end: bytes([0x3A, _int(start), _int(end)]),
|
_c("FAST_READ", ["start", "end"], lambda start, end: bytes([0x3A, _int(start), _int(end)]),
|
||||||
"read pages <start>..<end> in one frame (0x3A)",
|
"read pages <start>..<end> in one frame (0x3A)"),
|
||||||
pages=lambda start, end: list(range(_int(start), _int(end) + 1))),
|
|
||||||
_c("GET_VERSION", [], lambda: bytes([0x60]), "product/version info (0x60)"),
|
_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_SIG", [], lambda: bytes([0x3C, 0x00]), "read the ECC originality signature (0x3C)"),
|
||||||
_c("READ_CNT", ["counter"], lambda counter="0": bytes([0x39, _int(counter)]),
|
_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)"),
|
"authenticate with the 4-byte password → PACK (0x1B)"),
|
||||||
_c("WRITE", ["page", "data"],
|
_c("WRITE", ["page", "data"],
|
||||||
lambda page, data: bytes([0xA2, _int(page)]) + _hex(data).ljust(4, b"\x00")[:4],
|
lambda page, data: bytes([0xA2, _int(page)]) + _hex(data).ljust(4, b"\x00")[:4],
|
||||||
"write 4 bytes <data> to <page> (0xA2)",
|
"write 4 bytes <data> to <page> (0xA2)"),
|
||||||
pages=lambda page, data: [_int(page)]),
|
|
||||||
_c("COMPAT_WRITE", ["page", "data"],
|
_c("COMPAT_WRITE", ["page", "data"],
|
||||||
lambda page, data: [bytes([0xA0, _int(page)]), _hex(data).ljust(16, b"\x00")[:16]],
|
lambda page, data: [bytes([0xA0, _int(page)]), _hex(data).ljust(16, b"\x00")[:16]],
|
||||||
"compatibility write: 16-byte <data> to <page>, two-phase (0xA0)",
|
"compatibility write: 16-byte <data> to <page>, two-phase (0xA0)"),
|
||||||
pages=lambda page, data: [_int(page)]),
|
|
||||||
_c("HALT", [], lambda: bytes([0x50, 0x00]), "halt the tag (0x50 00)"),
|
_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:
|
def catalog_for(session) -> Catalog | None:
|
||||||
"""Resolve the command catalog for the session's identified transponder, or None."""
|
"""Resolve the command catalog for the session's identified transponder, or None."""
|
||||||
name = (session.transponder or "").upper()
|
name = (session.transponder or "").upper()
|
||||||
|
|||||||
@@ -107,38 +107,22 @@ def landmark_pages(transponder: str | None) -> list[tuple[int, str]]:
|
|||||||
return sorted(out.items())
|
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:
|
def _to_int(text: str) -> int | None:
|
||||||
|
from .parser import parse_arg_int # function-call arg: base 10 by default (0x/0b)
|
||||||
try:
|
try:
|
||||||
return int(text, 0)
|
return parse_arg_int(text)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_ADDR_PARAMS = ("page", "block", "start")
|
||||||
|
|
||||||
|
|
||||||
def input_hint(session, text: str) -> str | None:
|
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/
|
"""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
|
from .catalog import catalog_for
|
||||||
|
|
||||||
stripped = (text or "").strip()
|
stripped = (text or "").strip()
|
||||||
@@ -148,19 +132,37 @@ def input_hint(session, text: str) -> str | None:
|
|||||||
if catalog is None:
|
if catalog is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# function-style call: READ(4 — base-10 arg; hint the command and where that page lives
|
||||||
m = _CALL_HEAD.match(stripped)
|
m = _CALL_HEAD.match(stripped)
|
||||||
if m:
|
if m:
|
||||||
tc = catalog.get(m.group(1))
|
tc = catalog.get(m.group(1))
|
||||||
first = m.group(2).strip()
|
first = m.group(2).strip()
|
||||||
if tc:
|
if tc:
|
||||||
base = f"{tc.usage()} — {tc.help}"
|
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))
|
role = page_role(session.transponder, _to_int(first))
|
||||||
if role:
|
if role:
|
||||||
return f"{base} · {tc.params[0]} {first} → {role}"
|
return f"{base} · {tc.params[0]} {first} → {role}"
|
||||||
return base
|
return base
|
||||||
|
|
||||||
|
# a bare catalog command name
|
||||||
tc = catalog.get(stripped)
|
tc = catalog.get(stripped)
|
||||||
if tc:
|
if tc:
|
||||||
return f"{tc.usage()} — {tc.help}"
|
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
|
return None
|
||||||
|
|||||||
@@ -28,6 +28,17 @@ class Command:
|
|||||||
payload: bytes = b""
|
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:
|
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
|
"""Parse one hex/binary token to bytes. A ``0x``/``0b`` prefix picks the base; otherwise
|
||||||
``default_mode`` is used."""
|
``default_mode`` is used."""
|
||||||
|
|||||||
@@ -225,16 +225,16 @@ class TestIdentify:
|
|||||||
assert s.transponder == "NTAG216" # recovered, not the generic name
|
assert s.transponder == "NTAG216" # recovered, not the generic name
|
||||||
assert s.device.hf.iso14a.raw.call_count == 3
|
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
|
# GET_VERSION never answers -> generic family name, but the universal Type 2 pages still
|
||||||
# annotate (this is the path that silently broke before)
|
# resolve a role (this is the path that silently broke before)
|
||||||
from pm3py.cli.rawcli.memory import region_annotation
|
from pm3py.cli.rawcli.memory import page_role
|
||||||
s = RawSession(device=_mock_device(
|
s = RawSession(device=_mock_device(
|
||||||
scan14={"found": True, "sak": 0x00, "atqa": "4400", "uid": "04a1b2c3d4e5f6"}))
|
scan14={"found": True, "sak": 0x00, "atqa": "4400", "uid": "04a1b2c3d4e5f6"}))
|
||||||
s.device.hf.iso14a.raw.return_value = {"raw": None}
|
s.device.hf.iso14a.raw.return_value = {"raw": None}
|
||||||
identify(s)
|
identify(s)
|
||||||
assert s.transponder == "MIFARE Ultralight / NTAG"
|
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):
|
def test_15693_when_e0_uid(self):
|
||||||
s = RawSession(device=_mock_device(scan15={"found": True, "uid": "e004010203040506"}))
|
s = RawSession(device=_mock_device(scan15={"found": True, "uid": "e004010203040506"}))
|
||||||
@@ -396,15 +396,18 @@ class TestCatalog:
|
|||||||
assert TYPE2.get("GET_VERSION").opcode() == 0x60
|
assert TYPE2.get("GET_VERSION").opcode() == 0x60
|
||||||
assert LF_READONLY.get("INFO").opcode() is None # run-only, no build
|
assert LF_READONLY.get("INFO").opcode() is None # run-only, no build
|
||||||
|
|
||||||
def test_pages_for_payload(self):
|
def test_call_arg_is_base_ten(self):
|
||||||
# raw bytes reverse-map to the pages a page/block command touches
|
# a function-call page argument is base 10 unless prefixed — READ(04)/READ(40) must not crash
|
||||||
from pm3py.cli.rawcli.catalog import pages_for_payload
|
assert TYPE2.get("READ").build("04") == b"\x30\x04" # leading-zero decimal
|
||||||
assert pages_for_payload(TYPE2, b"\x30\x04") == [4, 5, 6, 7] # READ(4)
|
assert TYPE2.get("READ").build("40") == b"\x30\x28" # 40 decimal = page 0x28
|
||||||
assert pages_for_payload(TYPE2, b"\x3A\x00\x06") == [0, 1, 2, 3, 4, 5, 6] # FAST_READ(0,6)
|
assert TYPE2.get("READ").build("0x28") == b"\x30\x28" # hex override
|
||||||
assert pages_for_payload(TYPE2, b"\xA2\x04\x01\x02\x03\x04") == [4] # WRITE(4, ..)
|
assert TYPE2.get("READ").build("0b101000") == b"\x30\x28" # binary override
|
||||||
assert pages_for_payload(TYPE2, b"\x60") is None # GET_VERSION — not a page command
|
assert TYPE2.get("READ").build("4") == b"\x30\x04"
|
||||||
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_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:
|
class TestFunctionCalls:
|
||||||
@@ -433,8 +436,8 @@ class TestFunctionCalls:
|
|||||||
assert calls[0].args[0] == b"\xA0\x04"
|
assert calls[0].args[0] == b"\xA0\x04"
|
||||||
assert calls[1].args[0] == b"\x01\x02\x03\x04" + b"\x00" * 12
|
assert calls[1].args[0] == b"\x01\x02\x03\x04" + b"\x00" * 12
|
||||||
|
|
||||||
def test_read_annotates_memory_region(self, capsys):
|
def test_memory_region_stays_out_of_output(self, capsys):
|
||||||
# READ output names the datasheet regions the pages cover (the user's ask)
|
# the memory-location info belongs ONLY in the hint/completion — never printed as output
|
||||||
s = RawSession(device=_mock_device(
|
s = RawSession(device=_mock_device(
|
||||||
scan14={"found": True, "sak": 0x00, "atqa": "4400", "uid": "04a1b2c3d4e5f6"},
|
scan14={"found": True, "sak": 0x00, "atqa": "4400", "uid": "04a1b2c3d4e5f6"},
|
||||||
version=NTAG216_VERSION))
|
version=NTAG216_VERSION))
|
||||||
@@ -442,27 +445,9 @@ class TestFunctionCalls:
|
|||||||
assert s.transponder == "NTAG216"
|
assert s.transponder == "NTAG216"
|
||||||
s.device.hf.iso14a.raw.return_value = {"raw": b"\x03\x00\xFE\x00" * 4}
|
s.device.hf.iso14a.raw.return_value = {"raw": b"\x03\x00\xFE\x00" * 4}
|
||||||
capsys.readouterr()
|
capsys.readouterr()
|
||||||
dispatch(s, "READ(4)")
|
dispatch(s, "READ(4)") # function call
|
||||||
assert "04-07 → user memory" in _plain(capsys.readouterr().out)
|
assert "user memory" not in _plain(capsys.readouterr().out)
|
||||||
dispatch(s, "READ(0)")
|
dispatch(s, "30 04") # raw hex
|
||||||
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
|
|
||||||
assert "user memory" not in _plain(capsys.readouterr().out)
|
assert "user memory" not in _plain(capsys.readouterr().out)
|
||||||
|
|
||||||
def test_call_without_identify(self, capsys):
|
def test_call_without_identify(self, capsys):
|
||||||
@@ -653,25 +638,25 @@ class TestMemoryHint:
|
|||||||
from pm3py.cli.rawcli.memory import input_hint
|
from pm3py.cli.rawcli.memory import input_hint
|
||||||
assert input_hint(RawSession(), "READ(4)") is None
|
assert input_hint(RawSession(), "READ(4)") is None
|
||||||
|
|
||||||
def test_region_annotation(self):
|
def test_hint_raw_bytes(self):
|
||||||
from pm3py.cli.rawcli.memory import region_annotation
|
# raw hex (30 04) gets the location hint too — the opcode maps to READ, byte 1 is the page
|
||||||
assert region_annotation("NTAG216", [0, 1, 2, 3]) == \
|
from pm3py.cli.rawcli.memory import input_hint
|
||||||
"00-02 → UID / serial number · 03 → Capability Container (CC)"
|
s = self._sess() # NTAG213
|
||||||
assert region_annotation("NTAG216", [4, 5, 6, 7]) == "04-07 → user memory"
|
h = input_hint(s, "30 04")
|
||||||
assert "PWD" in region_annotation("NTAG213", [0x2B])
|
assert h.startswith("READ(page)") and "page 0x04 → user memory" in h
|
||||||
assert region_annotation("MIFARE Classic 1K", [4]) is None # no layout -> no annotation
|
assert "PWD" in input_hint(s, "30 2B") # page 0x2B = PWD on a 213
|
||||||
assert region_annotation("NTAG216", []) is None
|
assert input_hint(s, "60").startswith("GET_VERSION") # opcode-only command, no page part
|
||||||
|
|
||||||
def test_generic_type2_fallback(self):
|
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
|
# only the family is known (flaky/absent GET_VERSION) -> universal pages still resolve
|
||||||
for name in ("MIFARE Ultralight / NTAG", "NTAG (144 B)"):
|
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, 0) == "UID / serial number"
|
||||||
assert page_role(name, 3) == "Capability Container (CC)"
|
assert page_role(name, 3) == "Capability Container (CC)"
|
||||||
assert page_role(name, 0x20) is None # model-specific -> not guessed
|
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 [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):
|
def test_t5577_block_roles(self):
|
||||||
from pm3py.cli.rawcli.memory import page_role, input_hint
|
from pm3py.cli.rawcli.memory import page_role, input_hint
|
||||||
|
|||||||
Reference in New Issue
Block a user