The page-role annotations and completion were gated on identify resolving an *exact* model string (NTAG216). On a flaky NG link a single lost GET_VERSION dropped identify to the generic SAK name "MIFARE Ultralight / NTAG", which isn't in the layout table — so every region annotation and memory-map completion silently went blank. (The happy-path checks always got a clean identify, so this never showed in earlier verification.) Two fixes: - _probe_version retries GET_VERSION up to 3x, re-selecting between attempts, so a lost shot on a flaky link no longer costs the exact model (and its full memory map). - memory._resolve_layout falls back to a generic Type 2 map (UID / CC / the always-user pages 0x04-0x0F) when only the NTAG/Ultralight family is known — so READ(0)/READ(4) still annotate even when the exact model can't be determined (or an original Ultralight has no GET_VERSION). Config pages differ by model and are left unnamed rather than guessed. Tests: retry recovers the model after two lost shots; a never-answering GET_VERSION yields the generic name yet still annotates the universal pages; generic names resolve UID/CC/user but not model-specific pages. 1328 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
705 lines
32 KiB
Python
705 lines
32 KiB
Python
"""rawcli Phase 1 — session state, breadcrumb, trace rendering, CLI dispatch. Hardware-free."""
|
|
import re
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
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.identify import identify, clear, name_14a, format_summary
|
|
from pm3py.cli.rawcli.catalog import TYPE2, ISO15, T5577, LF_READONLY, catalog_for, catalog_for as _cf
|
|
from pm3py.cli.rawcli.tlv import parse_tlv, format_tlv
|
|
from pm3py.cli.rawcli.completer import RawCompleter
|
|
from pm3py.cli.rawcli.app import dispatch
|
|
|
|
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
|
|
|
|
|
def _plain(s: str) -> str:
|
|
return _ANSI.sub("", s)
|
|
|
|
|
|
class TestBreadcrumb:
|
|
def test_default(self):
|
|
assert RawSession().breadcrumb() == "[raw / 0x]"
|
|
|
|
def test_toggle_to_binary(self):
|
|
s = RawSession()
|
|
assert s.toggle_entry_mode() == "bin"
|
|
assert s.entry_prefix == "0b"
|
|
assert s.breadcrumb() == "[raw / 0b]"
|
|
assert s.toggle_entry_mode() == "hex"
|
|
assert s.breadcrumb() == "[raw / 0x]"
|
|
|
|
def test_identified_fills_segments(self):
|
|
s = RawSession()
|
|
s.field = "hf"
|
|
s.transponder = "NTAG215"
|
|
assert s.breadcrumb() == "[raw / hf / NTAG215 / 0x]"
|
|
|
|
def test_field_only(self):
|
|
s = RawSession()
|
|
s.field = "lf"
|
|
assert s.breadcrumb() == "[raw / lf / 0x]"
|
|
|
|
def test_colored_matches_plain(self):
|
|
s = RawSession()
|
|
s.field, s.transponder = "hf", "NTAG213"
|
|
assert "\x1b[" in s.colored_breadcrumb() # actually colored
|
|
assert _plain(s.colored_breadcrumb()) == s.breadcrumb() # same content, ANSI stripped
|
|
|
|
def test_colored_mode_color_changes(self):
|
|
s = RawSession()
|
|
hex_prompt = s.colored_breadcrumb()
|
|
s.toggle_entry_mode()
|
|
assert hex_prompt != s.colored_breadcrumb() # hex vs binary colored differently
|
|
|
|
|
|
class TestTraceView:
|
|
def test_command_and_response_lines(self):
|
|
out = _plain(render_exchange(bytes([0x30, 0x04]),
|
|
bytes([0x00, 0x01, 0x02, 0x03]),
|
|
protocol="hf14a", is_tty=False))
|
|
assert "Reader → Tag" in out
|
|
assert "Tag → Reader" in out
|
|
assert "30 04" in out # command bytes shown
|
|
|
|
def test_command_only_when_no_response(self):
|
|
out = _plain(render_exchange(bytes([0x26]), None, protocol="hf14a", is_tty=False))
|
|
assert "Reader → Tag" in out
|
|
assert "Tag → Reader" not in out
|
|
|
|
def test_annotation_present_for_known_command(self):
|
|
# 0x30 = 14a READ; the decoder should annotate it (exact text may vary)
|
|
out = _plain(render_exchange(bytes([0x30, 0x04]), None, protocol="hf14a", is_tty=False))
|
|
assert "READ" in out.upper()
|
|
|
|
def test_accepts_hex_string_response(self):
|
|
# reader raw() returns `data` as a hex STRING — must not crash on bytes()
|
|
out = _plain(render_exchange(b"\x60", "0004040201000f03", protocol="hf14a", is_tty=False))
|
|
assert "60" in out and "00 04 04" in out
|
|
|
|
|
|
class TestCliDispatch:
|
|
def test_rawcli_subcommand_parsed(self):
|
|
args = build_parser().parse_args(["rawcli", "--port", "/dev/ttyACM0"])
|
|
assert args.command == "rawcli" and args.port == "/dev/ttyACM0"
|
|
|
|
def test_no_command_prints_help(self, capsys):
|
|
rc = main([])
|
|
assert rc == 1
|
|
assert "rawcli" in capsys.readouterr().out
|
|
|
|
|
|
class TestParser:
|
|
def test_hex_tokens(self):
|
|
assert parse_token("30") == b"\x30"
|
|
assert parse_token("0x3004") == b"\x30\x04"
|
|
assert parse_bytes("30 04") == b"\x30\x04"
|
|
assert parse_bytes("3004") == b"\x30\x04" # default hex mode
|
|
|
|
def test_binary_tokens(self):
|
|
assert parse_token("0b00110000") == b"\x30"
|
|
assert parse_token("00110000", "bin") == b"\x30"
|
|
|
|
def test_intermixed(self):
|
|
assert parse_bytes("0b00110000 0x04") == b"\x30\x04"
|
|
assert parse_bytes("0x30 00000100", "bin") == b"\x30\x04" # bare token uses mode
|
|
|
|
def test_bad_tokens(self):
|
|
with pytest.raises(ValueError):
|
|
parse_token("303") # odd hex
|
|
with pytest.raises(ValueError):
|
|
parse_token("0b0011") # not a whole byte
|
|
|
|
def test_classify_control(self):
|
|
c = parse_line("identify")
|
|
assert c.kind == "control" and c.name == "identify"
|
|
assert parse_line("help ntag215").kind == "control"
|
|
|
|
def test_classify_call(self):
|
|
c = parse_line("READ(4)")
|
|
assert c.kind == "call" and c.name == "READ" and c.args == ["4"]
|
|
c = parse_line("WRITE(4, 0x00112233)")
|
|
assert c.args == ["4", "0x00112233"]
|
|
assert parse_line("GET_DATA()").kind == "call"
|
|
|
|
def test_classify_raw(self):
|
|
c = parse_line("30 04")
|
|
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
|
|
|
|
def test_binary_grouping(self):
|
|
assert byte_space("0011000000000100", "bin") == "00110000 00000100"
|
|
|
|
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:
|
|
def test_quit_returns_false(self):
|
|
assert dispatch(RawSession(), "quit") is False
|
|
|
|
def test_help_prints(self, capsys):
|
|
assert dispatch(RawSession(), "help") is True
|
|
assert "rawcli" in capsys.readouterr().out
|
|
|
|
def test_raw_renders_without_device(self, capsys):
|
|
assert dispatch(RawSession(), "30 04") is True
|
|
assert "30 04" in _plain(capsys.readouterr().out)
|
|
|
|
def test_bad_hex_reports(self, capsys):
|
|
assert dispatch(RawSession(), "0b0011") is True
|
|
assert "whole number of bytes" in capsys.readouterr().out
|
|
|
|
|
|
NTAG216_VERSION = bytes.fromhex("0004040201001303")
|
|
|
|
|
|
def _mock_device(scan14=None, scan15=None, version=None):
|
|
dev = MagicMock()
|
|
dev.hf.iso14a.scan.return_value = scan14 or {"found": False}
|
|
dev.hf.iso15.scan.return_value = scan15 or {"found": False}
|
|
dev.lf.search.return_value = None
|
|
dev.lf.t55.readbl.return_value = {"status": 0}
|
|
dev.lf.read.return_value = {"status": 0}
|
|
dev.lf.download_samples.return_value = bytes([128] * 2000) # flat -> no LF tag by default
|
|
dev.hf.iso14a.raw.return_value = {"raw": (version + b"\x99\x88") if version else None}
|
|
return dev
|
|
|
|
|
|
class TestIdentify:
|
|
def test_no_device(self):
|
|
s = RawSession()
|
|
r = identify(s)
|
|
assert r["found"] is False and s.field is None
|
|
|
|
def test_14a_sak_only_name(self):
|
|
# SAK != 0 -> named from AN10833 Table 4, no GET_VERSION
|
|
s = RawSession(device=_mock_device(
|
|
scan14={"found": True, "sak": 0x08, "atqa": "0400", "uid": "01020304"}))
|
|
r = identify(s)
|
|
assert r["found"] and s.field == "hf" and s.protocol == "hf14a"
|
|
assert s.transponder == "MIFARE Classic 1K"
|
|
|
|
def test_14a_getversion_exact(self):
|
|
s = RawSession(device=_mock_device(
|
|
scan14={"found": True, "sak": 0x00, "atqa": "4400", "uid": "04a1b2c3d4e5f6"},
|
|
version=NTAG216_VERSION))
|
|
r = identify(s)
|
|
assert s.transponder == "NTAG216" # exact, not "MIFARE UL/NTAG"
|
|
assert r["version"] == NTAG216_VERSION.hex()
|
|
s.device.hf.iso14a.raw.assert_called_with(b"\x60", flags=0x20) # real GET_VERSION+CRC
|
|
|
|
def test_14a_getversion_retries_flaky_link(self):
|
|
# a lost GET_VERSION shot must not drop us to the generic name — retry recovers the model
|
|
s = RawSession(device=_mock_device(
|
|
scan14={"found": True, "sak": 0x00, "atqa": "4400", "uid": "04a1b2c3d4e5f6"}))
|
|
s.device.hf.iso14a.raw.side_effect = [
|
|
{"raw": None}, # 1st shot lost
|
|
{"raw": None}, # 2nd shot lost
|
|
{"raw": NTAG216_VERSION + b"\x99\x88"}, # 3rd shot lands
|
|
]
|
|
identify(s)
|
|
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):
|
|
# 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
|
|
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"
|
|
|
|
def test_15693_when_e0_uid(self):
|
|
s = RawSession(device=_mock_device(scan15={"found": True, "uid": "e004010203040506"}))
|
|
r = identify(s)
|
|
assert r["found"] and s.field == "hf" and s.protocol == "hf15"
|
|
assert s.transponder == "ISO15693"
|
|
|
|
def test_15693_rejects_bogus_uid(self):
|
|
# a flaky 14a miss can leave a non-E0 "uid" — must NOT be reported as a 15693 tag
|
|
s = RawSession(device=_mock_device(scan15={"found": True, "uid": "0000000000003d42"}))
|
|
r = identify(s)
|
|
assert r["found"] is False and s.transponder is None
|
|
|
|
def test_no_flat_lf(self):
|
|
# nothing on HF and a flat LF envelope must report not-found, not a bogus "LF tag"
|
|
assert identify(RawSession(device=_mock_device()))["found"] is False
|
|
|
|
def test_lf_wiring_sets_label(self, monkeypatch):
|
|
# the LF path folds pm3py.lf.identify_lf's result into the session (demod itself is
|
|
# covered in test_lf_demod.py); here we assert the rawcli wiring/label/summary.
|
|
import pm3py.lf
|
|
monkeypatch.setattr(pm3py.lf, "identify_lf", lambda dev, emit=None: {
|
|
"found": True, "field": "lf", "protocol": "lf", "chip": "T5577",
|
|
"config": "00148040", "emulating": "EM4100 / EM4102",
|
|
"emitted": {"protocol": "EM4100", "id_hex": "0102030405"},
|
|
"label": "T5577 (EM4100 / EM4102)"})
|
|
s = RawSession(device=_mock_device()) # HF finds nothing -> LF path
|
|
r = identify(s)
|
|
assert r["found"] and s.field == "lf" and s.protocol == "lf"
|
|
assert s.transponder == "T5577 (EM4100 / EM4102)"
|
|
assert r["config"] == "00148040" and r["id"] == "0102030405"
|
|
assert "config 0x00148040" in format_summary(r)
|
|
|
|
def test_lf_native_credential_label(self, monkeypatch):
|
|
import pm3py.lf
|
|
monkeypatch.setattr(pm3py.lf, "identify_lf", lambda dev, emit=None: {
|
|
"found": True, "field": "lf", "protocol": "lf", "chip": None, "config": None,
|
|
"emulating": None, "emitted": {"protocol": "EM4100", "id_hex": "1122334455"},
|
|
"label": "EM4100 1122334455"})
|
|
s = RawSession(device=_mock_device())
|
|
r = identify(s)
|
|
assert s.transponder == "EM4100 1122334455"
|
|
assert "id 1122334455" in format_summary(r)
|
|
|
|
def test_identify_clears_stale_result(self):
|
|
# a second identify that finds nothing must clear the previous tag from the session
|
|
dev = _mock_device(scan14={"found": True, "sak": 0x08, "atqa": "0400", "uid": "b978423d"})
|
|
s = RawSession(device=dev)
|
|
identify(s)
|
|
assert s.transponder == "MIFARE Classic 1K"
|
|
dev.hf.iso14a.scan.return_value = {"found": False} # tag removed
|
|
identify(s)
|
|
assert s.transponder is None and s.field is None # no stale data
|
|
|
|
def test_sak_names_from_an10833(self):
|
|
assert name_14a({"sak": 0x18}) == "MIFARE Classic 4K"
|
|
assert name_14a({"sak": 0x20}).startswith("ISO14443-4")
|
|
assert name_14a({"sak": 0x99}) == "ISO14443-A (SAK 99)"
|
|
|
|
def test_clear_forgets_tag(self):
|
|
s = RawSession(device=_mock_device(scan14={"found": True, "sak": 0x08, "uid": "01020304"}))
|
|
identify(s)
|
|
clear(s)
|
|
assert s.field is None and s.transponder is None and s.protocol == "hf14a"
|
|
|
|
|
|
class TestVersionId:
|
|
def test_exact_models(self):
|
|
from pm3py.cli.rawcli.identify import decode_version
|
|
assert decode_version(bytes.fromhex("0004040201001303")) == "NTAG216"
|
|
assert decode_version(bytes.fromhex("0004040201000f03")) == "NTAG213"
|
|
assert "Ultralight EV1" in decode_version(bytes.fromhex("0004030101000b03"))
|
|
|
|
def test_structural_range_for_unknown(self):
|
|
from pm3py.cli.rawcli.identify import decode_version
|
|
# unknown NTAG-family version -> product + honest size range (AN10833)
|
|
out = decode_version(bytes.fromhex("0004040201001503"))
|
|
assert out.startswith("NTAG (") and "B)" in out
|
|
|
|
def test_static_map_matches_models(self):
|
|
# guard against drift between the static version map and the transponder models
|
|
from pm3py.cli.rawcli.identify import _VERSION_MODELS
|
|
from pm3py.sim import (NTAG210, NTAG212, NTAG213, NTAG215, NTAG216,
|
|
MF0UL11, MF0UL21, NT3H2111, NT3H2211)
|
|
model_vbytes = {c._version_bytes.hex() for c in
|
|
(NTAG210, NTAG212, NTAG213, NTAG215, NTAG216,
|
|
MF0UL11, MF0UL21, NT3H2111, NT3H2211)}
|
|
assert set(_VERSION_MODELS) == model_vbytes
|
|
|
|
|
|
class TestControlCommands:
|
|
def test_identify_command(self, capsys):
|
|
s = RawSession(device=_mock_device(
|
|
scan14={"found": True, "sak": 0x00, "atqa": "4400", "uid": "04a1b2c3d4e5f6"},
|
|
version=NTAG216_VERSION))
|
|
dispatch(s, "identify")
|
|
out = _plain(capsys.readouterr().out)
|
|
assert "NTAG216" in out # exact model in the summary
|
|
assert "WUPA" in out and "GET_VERSION" in out # probe exchanges streamed to the trace
|
|
assert s.breadcrumb() == "[raw / hf / NTAG216 / 0x]"
|
|
|
|
def test_transponder_when_none(self, capsys):
|
|
dispatch(RawSession(), "transponder")
|
|
assert "run 'identify'" in capsys.readouterr().out
|
|
|
|
def test_close(self, capsys):
|
|
s = RawSession(device=_mock_device(scan14={"found": True, "sak": 0x08}))
|
|
dispatch(s, "identify")
|
|
dispatch(s, "close")
|
|
assert "closed" in capsys.readouterr().out
|
|
assert s.transponder is None
|
|
|
|
|
|
class TestCatalog:
|
|
def test_type2_builds(self):
|
|
assert TYPE2.get("READ").build("4") == b"\x30\x04"
|
|
assert TYPE2.get("read").build("0x04") == b"\x30\x04" # case + hex arg
|
|
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"
|
|
# two-phase write builds a frame list: command frame + 16-byte data frame
|
|
assert TYPE2.get("COMPAT_WRITE").build("4", "0102") == [b"\xA0\x04", b"\x01\x02" + b"\x00" * 14]
|
|
|
|
def test_iso15_builds(self):
|
|
assert ISO15.get("READ_BLOCK").build("4") == b"\x02\x20\x04"
|
|
assert ISO15.get("INVENTORY").build() == b"\x26\x01\x00"
|
|
|
|
def test_resolver(self):
|
|
s = RawSession()
|
|
assert catalog_for(s) is None # nothing identified
|
|
s.protocol, s.transponder = "hf14a", "NTAG215"
|
|
assert catalog_for(s) is TYPE2
|
|
s.transponder = "MIFARE Classic 1K"
|
|
assert catalog_for(s).name == "MIFARE Classic"
|
|
s.protocol, s.transponder = "hf15", "ISO15693"
|
|
assert catalog_for(s) is ISO15
|
|
|
|
def test_resolver_lf(self):
|
|
# identify sets an LF transponder -> the completer/hints get a catalog, not None
|
|
s = RawSession()
|
|
s.protocol, s.transponder = "lf", "T5577 (EM4100 20260716FF)"
|
|
assert catalog_for(s) is T5577 # command-rich emulator
|
|
s.transponder = "EM4100 20260716FF" # a native read-only credential
|
|
assert catalog_for(s) is LF_READONLY
|
|
|
|
def test_t5577_opcodes(self):
|
|
# build() exposes the downlink opcode for hex/binary completion (execution is via run)
|
|
assert T5577.get("READ").build("0")[0] == 0x01
|
|
assert T5577.get("WRITE").build("0", "0")[0] == 0x02
|
|
assert T5577.get("WAKE").build("0")[0] == 0x03
|
|
assert {c.name for c in T5577.commands.values()} == {"READ", "WRITE", "WAKE", "DETECT"}
|
|
assert T5577.get("READ").run is not None and LF_READONLY.get("INFO").run is not None
|
|
|
|
def test_opcode_covers_two_phase_write(self):
|
|
# opcode() tolerates data args (which need valid hex) and multi-frame builds
|
|
assert TYPE2.get("WRITE").opcode() == 0xA2
|
|
assert TYPE2.get("COMPAT_WRITE").opcode() == 0xA0 # first frame's opcode, not None
|
|
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
|
|
|
|
|
|
class TestFunctionCalls:
|
|
def _id(self, sak=0x00):
|
|
return RawSession(device=_mock_device(
|
|
scan14={"found": True, "sak": sak, "atqa": "4400", "uid": "04010203"}))
|
|
|
|
def test_call_read_sends_correct_bytes(self, capsys):
|
|
s = self._id()
|
|
dispatch(s, "identify")
|
|
s.device.hf.iso14a.raw.return_value = {"raw": b"\xAA\xBB\xCC\xDD"}
|
|
capsys.readouterr()
|
|
dispatch(s, "READ(4)")
|
|
out = _plain(capsys.readouterr().out)
|
|
assert "30 04" in out # the built command hit the trace
|
|
s.device.hf.iso14a.raw.assert_called_with(b"\x30\x04", flags=0x20) # CRC appended
|
|
|
|
def test_two_phase_write_sends_both_frames(self, capsys):
|
|
s = self._id() # SAK 0 -> Type 2 catalog
|
|
dispatch(s, "identify")
|
|
s.device.hf.iso14a.raw.return_value = {"raw": b"\x0A"} # ACK
|
|
s.device.hf.iso14a.raw.reset_mock()
|
|
dispatch(s, "COMPAT_WRITE(4, 01020304)")
|
|
calls = s.device.hf.iso14a.raw.call_args_list
|
|
assert len(calls) == 2 # command frame + data frame
|
|
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)
|
|
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, "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
|
|
assert "user memory" not in _plain(capsys.readouterr().out)
|
|
|
|
def test_call_without_identify(self, capsys):
|
|
dispatch(RawSession(), "READ(4)")
|
|
assert "identify" in capsys.readouterr().out
|
|
|
|
def test_unknown_call(self, capsys):
|
|
s = self._id(); dispatch(s, "identify"); capsys.readouterr()
|
|
dispatch(s, "FLY(4)")
|
|
assert "unknown command" in capsys.readouterr().out
|
|
|
|
def test_bad_args_shows_usage(self, capsys):
|
|
s = self._id(); dispatch(s, "identify"); capsys.readouterr()
|
|
dispatch(s, "READ()") # missing page arg
|
|
assert "usage: READ(page)" in capsys.readouterr().out
|
|
|
|
def test_help_lists_catalog(self, capsys):
|
|
s = self._id(); dispatch(s, "identify"); capsys.readouterr()
|
|
dispatch(s, "help")
|
|
out = capsys.readouterr().out
|
|
assert "READ(page)" in out and "GET_VERSION" in out
|
|
|
|
def test_help_command_detail(self, capsys):
|
|
s = self._id(); dispatch(s, "identify"); capsys.readouterr()
|
|
dispatch(s, "help READ")
|
|
assert "0x30" in capsys.readouterr().out
|
|
|
|
|
|
class TestTLV:
|
|
# NULL + NDEF (a well-known text record "Hi"/en) + TERMINATOR
|
|
TEXT_REC = bytes.fromhex("D101055402656E4869") # D1 01 05 T 02 'en' 'Hi'
|
|
BLOCK = bytes([0x00, 0x03, len(TEXT_REC)]) + TEXT_REC + bytes([0xFE])
|
|
|
|
def test_parse_structure(self):
|
|
tlvs = parse_tlv(self.BLOCK)
|
|
tags = [t.tag for t in tlvs]
|
|
assert tags == [0x00, 0x03, 0xFE]
|
|
ndef = tlvs[1]
|
|
assert ndef.length == len(self.TEXT_REC) and ndef.value == self.TEXT_REC
|
|
|
|
def test_extended_length(self):
|
|
big = bytes([0x03, 0xFF, 0x01, 0x00]) + b"\x00" * 256 + bytes([0xFE])
|
|
tlvs = parse_tlv(big)
|
|
assert tlvs[0].tag == 0x03 and tlvs[0].length == 256
|
|
|
|
def test_format_multiline(self):
|
|
out = format_tlv(self.BLOCK)
|
|
assert "NULL" in out
|
|
assert "NDEF MESSAGE len=9" in out
|
|
assert "TERMINATOR" in out
|
|
assert "\n" in out # genuinely multi-line
|
|
assert "→" in out # NDEF annotation line present
|
|
|
|
def test_tlv_command(self, capsys):
|
|
dispatch(RawSession(), "tlv " + self.BLOCK.hex())
|
|
assert "NDEF MESSAGE" in capsys.readouterr().out
|
|
|
|
|
|
class TestCompleter:
|
|
def _complete(self, session, text):
|
|
from prompt_toolkit.document import Document
|
|
return list(RawCompleter(session).get_completions(Document(text), None))
|
|
|
|
def test_completes_control_verbs(self):
|
|
cs = self._complete(RawSession(), "ide")
|
|
assert any(c.text == "identify" for c in cs)
|
|
|
|
def test_completes_catalog_commands_with_meta(self):
|
|
s = RawSession()
|
|
s.protocol, s.transponder = "hf14a", "NTAG215" # -> Type 2 catalog
|
|
cs = self._complete(s, "REA")
|
|
texts = [c.text for c in cs]
|
|
assert "READ(" in texts
|
|
read = next(c for c in cs if c.text == "READ(")
|
|
assert "0x30" in read.display_meta_text # tooltip = help
|
|
|
|
def test_no_catalog_before_identify(self):
|
|
cs = self._complete(RawSession(), "REA")
|
|
assert all(c.text != "READ(" for c in cs) # no tag => no catalog commands
|
|
|
|
def test_help_argument_completes_command_names(self):
|
|
s = RawSession()
|
|
s.protocol, s.transponder = "hf14a", "NTAG215"
|
|
cs = self._complete(s, "help GET")
|
|
assert any(c.text == "GET_VERSION" for c in cs)
|
|
|
|
def test_opcode_completion_hex(self):
|
|
s = RawSession()
|
|
s.protocol, s.transponder = "hf14a", "NTAG213"
|
|
cs = self._complete(s, "60") # GET_VERSION opcode
|
|
assert any(c.text == "GET_VERSION(" for c in cs)
|
|
|
|
def test_opcode_completion_binary(self):
|
|
s = RawSession()
|
|
s.protocol, s.transponder = "hf14a", "NTAG213"
|
|
s.entry_mode = "bin"
|
|
cs = self._complete(s, "01100000") # 0x60 in binary
|
|
assert any(c.text == "GET_VERSION(" for c in cs)
|
|
|
|
def test_page_arg_lists_memory_map(self):
|
|
# typing the page argument suggests the tag's memory landmarks with their roles
|
|
s = RawSession()
|
|
s.protocol, s.transponder = "hf14a", "NTAG216"
|
|
cs = self._complete(s, "READ(")
|
|
by_text = {c.text: c.display_meta_text for c in cs}
|
|
assert by_text["0x00"] == "UID / serial number"
|
|
assert by_text["0x03"] == "Capability Container (CC)"
|
|
assert by_text["0x04"] == "user memory"
|
|
assert "PWD" in by_text["0xE5"]
|
|
|
|
def test_page_arg_filters_by_partial(self):
|
|
s = RawSession()
|
|
s.protocol, s.transponder = "hf14a", "NTAG216"
|
|
texts = [c.text for c in self._complete(s, "READ(0xE")]
|
|
assert texts == ["0xE2", "0xE3", "0xE4", "0xE5", "0xE6"] # only the E-page landmarks
|
|
# a decimal/bare-hex partial matches too
|
|
assert [c.text for c in self._complete(s, "READ(4")] == ["0x04"]
|
|
|
|
def test_page_arg_second_arg_untouched(self):
|
|
# a comma ends the page argument — the data argument gets no memory suggestions
|
|
s = RawSession()
|
|
s.protocol, s.transponder = "hf14a", "NTAG216"
|
|
assert self._complete(s, "WRITE(0x04, ") == []
|
|
|
|
def test_page_arg_t5577_blocks(self):
|
|
s = RawSession()
|
|
s.protocol, s.transponder = "lf", "T5577 / ATA5577"
|
|
by_text = {c.text: c.display_meta_text for c in self._complete(s, "READ(")}
|
|
assert "config" in by_text["0x00"]
|
|
assert "password" in by_text["0x07"]
|
|
|
|
def test_page_arg_none_without_layout(self):
|
|
s = RawSession()
|
|
s.protocol, s.transponder = "hf14a", "MIFARE Classic 1K" # no page layout
|
|
assert self._complete(s, "READ(") == []
|
|
|
|
def test_page_arg_binary_mode(self):
|
|
# in binary entry mode the landmarks render as full 8-bit values (each bit visible)
|
|
s = RawSession()
|
|
s.protocol, s.transponder = "hf14a", "NTAG216"
|
|
s.entry_mode = "bin"
|
|
by_text = {c.text: c.display_meta_text for c in self._complete(s, "READ(")}
|
|
assert by_text["0b00000100"] == "user memory" # 0x04
|
|
assert "PWD" in by_text["0b11100101"] # 0xE5
|
|
assert "0x04" not in by_text # not hex form in binary mode
|
|
# a binary partial filters bit-prefix-wise
|
|
assert [c.text for c in self._complete(s, "READ(0b111001")] == \
|
|
["0b11100100", "0b11100101", "0b11100110"] # 0xE4..0xE6
|
|
|
|
def test_page_arg_prefix_overrides_mode(self):
|
|
# a 0b prefix forces binary rendering even though the session is in hex mode
|
|
s = RawSession()
|
|
s.protocol, s.transponder = "hf14a", "NTAG216" # default hex
|
|
assert [c.text for c in self._complete(s, "READ(0b000001")] == ["0b00000100"]
|
|
|
|
|
|
class TestMemoryHint:
|
|
def _sess(self, tp="NTAG213"):
|
|
s = RawSession()
|
|
s.protocol, s.transponder = "hf14a", tp
|
|
return s
|
|
|
|
def test_page_role(self):
|
|
from pm3py.cli.rawcli.memory import page_role
|
|
assert page_role("NTAG213", 0) == "UID / serial number"
|
|
assert page_role("NTAG213", 3) == "Capability Container (CC)"
|
|
assert page_role("NTAG213", 4) == "user memory"
|
|
assert "AUTH0" in page_role("NTAG213", 0x29) # CFG0
|
|
assert page_role("NTAG213", 0x2A).startswith("CFG1")
|
|
assert "PWD" in page_role("NTAG213", 0x2B)
|
|
assert page_role("NTAG216", 0x04) == "user memory" # different IC, different pages
|
|
assert "AUTH0" in page_role("NTAG216", 0xE3)
|
|
|
|
def test_hint_command_and_page_location(self):
|
|
from pm3py.cli.rawcli.memory import input_hint
|
|
s = self._sess()
|
|
assert "user memory" in input_hint(s, "READ(4)")
|
|
assert "PWD" in input_hint(s, "READ(0x2B") # partial input + hex arg
|
|
assert "Capability Container" in input_hint(s, "WRITE(3,")
|
|
|
|
def test_hint_command_only(self):
|
|
from pm3py.cli.rawcli.memory import input_hint
|
|
s = self._sess()
|
|
assert input_hint(s, "GET_VERSION").startswith("GET_VERSION()")
|
|
assert input_hint(s, "") is None
|
|
|
|
def test_hint_needs_transponder(self):
|
|
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_generic_type2_fallback(self):
|
|
from pm3py.cli.rawcli.memory import region_annotation, 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, 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
|
|
|
|
def test_t5577_block_roles(self):
|
|
from pm3py.cli.rawcli.memory import page_role, input_hint
|
|
tp = "T5577 (EM4100 20260716FF)"
|
|
assert "config" in page_role(tp, 0)
|
|
assert "password" in page_role(tp, 7)
|
|
assert "data" in page_role(tp, 3)
|
|
s = RawSession()
|
|
s.protocol, s.transponder = "lf", tp
|
|
assert "config" in input_hint(s, "READ(0") # block-role relevance for LF too
|
|
assert "password" in input_hint(s, "WRITE(7,")
|
|
|
|
def test_layouts_match_models(self):
|
|
from pm3py.cli.rawcli.memory import _LAYOUTS
|
|
from pm3py.sim import NTAG210, NTAG212, NTAG213, NTAG215, NTAG216
|
|
for cls, name in [(NTAG210, "NTAG210"), (NTAG212, "NTAG212"), (NTAG213, "NTAG213"),
|
|
(NTAG215, "NTAG215"), (NTAG216, "NTAG216")]:
|
|
L = _LAYOUTS[name]
|
|
assert (L["cfg0"], L["cfg1"], L["pwd"], L["pack"]) == \
|
|
(cls._cfg0_page, cls._cfg1_page, cls._pwd_page, cls._pack_page)
|
|
|
|
|
|
class TestKeyBindings:
|
|
def test_install_does_not_raise(self):
|
|
# regression: "c-/" is not a valid prompt_toolkit key and used to crash launch
|
|
from prompt_toolkit.key_binding import KeyBindings
|
|
from pm3py.cli.rawcli.entry import install_key_bindings, TOGGLE_KEYS
|
|
kb = KeyBindings()
|
|
install_key_bindings(kb, RawSession())
|
|
assert len(kb.bindings) >= len(TOGGLE_KEYS)
|