Files
pm3py/tests/test_rawcli.py
michael 4684d0cc83 feat(rawcli): identify NTAG5 and ICODE DNA (READ_CONFIG probe)
NTAG5, ICODE DNA and SLIX2 all share E0 04 and answer READ_SIGNATURE, so name_iso15
now splits them with an addressed, retried READ_CONFIG (0xC0) probe:
  - session register 0xA0 (STATUS_REG) succeeds only on NTAG5      -> "NTAG 5"
  - 0xA0 errors but config block 0x00 succeeds (DNA has config mem) -> "ICODE DNA"
  - neither (SLIX2 has no READ_CONFIG) -> the 0xBD signature step   -> "ICODE SLIX2"
  - plain SLIX answers neither                                      -> "ICODE SLIX"

Hardware-verified against a real NTAG5 (VivoKey VK Thermo, UID E0 04 01 58, 0xA0 ->
00 01400000) and a real ICODE DNA (UID E0 04 01 18, 0xA0 -> 01 0F error, 0x00 -> 00 ...).
DNA was previously mislabeled "ICODE SLIX2". catalog_for routes DNA to the SLIX2 catalog
for now (a dedicated DNA catalog with READ_CONFIG + AES is a follow-up). Adds a mocked
unit test covering all four branches from the real captures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:33:41 -07:00

1097 lines
56 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 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, _SLIX, _SLIX2, _NTAG5,
iso15_frame, iso15_flags_decode, 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 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_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_binary_auto_spacing(self):
text, bases = self._type([(d, "bin") for d in "0011000000000100"])
assert text == "00110000 00000100" and bases == ["bin", "bin"]
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_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:
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_resolves(self):
# GET_VERSION never answers -> generic family name, but the universal Type 2 pages still
# 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 page_role(s.transponder, 4) == "user memory"
def test_15693_when_e0_uid(self):
# E0 04 = NXP ICODE; no READ_SIGNATURE (mock) -> named ICODE SLIX, not generic ISO15693
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 == "ICODE SLIX"
def test_15693_non_nxp_generic(self):
# a non-NXP E0 UID stays generic ISO15693
s = RawSession(device=_mock_device(scan15={"found": True, "uid": "e007010203040506"}))
identify(s)
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_name_iso15_ntag5_dna_slix2_slix(self):
# split the E0 04 family by READ_CONFIG (0xC0) probe, using the real hardware captures:
# NTAG5 -> session reg 0xA0 succeeds; DNA -> 0xA0 errors but config 0x00 succeeds;
# SLIX2 -> no READ_CONFIG (timeout) but READ_SIGNATURE answers; SLIX -> neither.
from pm3py.cli.rawcli.identify import name_iso15
from unittest.mock import MagicMock
def dev_with(responses):
dev = MagicMock()
def _raw(frame):
f = bytes(frame)
if f[:2] == b"\x22\xc0": # READ_CONFIG addressed: key by block
return responses.get(("cfg", f[-2]), {"raw": None})
if f[:3] == b"\x02\xbd\x04": # READ_SIGNATURE
return responses.get("sig", {"raw": None})
return {"raw": None}
dev.hf.iso15.raw.side_effect = _raw
return dev
ntag5 = dev_with({("cfg", 0xA0): {"raw": bytes.fromhex("0001400000bad5")}})
assert name_iso15(ntag5, "e0040158108ee802") == "NTAG 5"
dna = dev_with({("cfg", 0xA0): {"raw": bytes.fromhex("010f68ee")},
("cfg", 0x00): {"raw": bytes.fromhex("001e4d3235a1b8")}})
assert name_iso15(dna, "e0040118009b4ccb") == "ICODE DNA"
slix2 = dev_with({"sig": {"raw": bytes([0x00]) + bytes(34)}}) # 35-byte signature reply
assert name_iso15(slix2, "e0040203040506") == "ICODE SLIX2"
assert name_iso15(dev_with({}), "e004a2110b37c06b") == "ICODE SLIX"
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_mfc_authenticated_ops(self):
# Classic READ/WRITE run via hf.mfc (crypto1 auth in firmware); default key FFFFFFFFFFFF/A
dev = MagicMock()
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.mf.rdbl.assert_called_with(4, key="FFFFFFFFFFFF", key_type=0) # default key A
MFC.get("READ").run(dev, "4", "A0A1A2A3A4A5", "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.mf.rdbl.return_value = {"success": False, "error": 1}
assert "failed" in MFC.get("READ").run(dev, "4") # clear failure line
def test_mfc_datasheet_command_set(self):
# the full MIFARE Classic wire command set is present with datasheet opcodes
opcodes = {n: MFC.get(n).opcode() for n in MFC.names()}
assert opcodes["AUTH_A"] == 0x60 and opcodes["AUTH_B"] == 0x61
assert opcodes["INCREMENT"] == 0xC1 and opcodes["DECREMENT"] == 0xC0
assert opcodes["RESTORE"] == 0xC2 and opcodes["TRANSFER"] == 0xB0
assert opcodes["PERSONALIZE_UID"] == 0x40 and opcodes["SET_MOD_TYPE"] == 0x43
for op, name in [(0x60, "AUTH_A"), (0xC1, "INCREMENT"), (0xB0, "TRANSFER"), (0x40, "PERSONALIZE_UID")]:
assert MFC.by_opcode(op).name == name # raw byte maps back to the command
# every page/block command's first param is a block -> gets the memory map
for n in ("AUTH_A", "AUTH_B", "READ", "WRITE", "INCREMENT", "DECREMENT", "RESTORE", "TRANSFER"):
assert MFC.get(n).params[0] == "block"
def test_mfc_value_ops_run(self):
dev = MagicMock()
dev.hf.mf.rdbl.return_value = {"success": True, "block": 4, "data": "aa" * 16}
dev.hf.mf.value.return_value = {"success": True, "block": 4}
MFC.get("AUTH_A").run(dev, "3")
dev.hf.mf.rdbl.assert_called_with(3, key="FFFFFFFFFFFF", key_type=0) # AUTH_A -> key A
MFC.get("AUTH_B").run(dev, "3", "A0A1A2A3A4A5")
dev.hf.mf.rdbl.assert_called_with(3, key="A0A1A2A3A4A5", key_type=1) # AUTH_B -> key B
MFC.get("INCREMENT").run(dev, "4", "10")
dev.hf.mf.value.assert_called_with(4, 0, value=10, key="FFFFFFFFFFFF", key_type=0)
MFC.get("DECREMENT").run(dev, "4", "3", "A0A1A2A3A4A5", "B")
dev.hf.mf.value.assert_called_with(4, 1, value=3, key="A0A1A2A3A4A5", key_type=1)
MFC.get("TRANSFER").run(dev, "4", "8") # copy block 4 -> 8 (restore+transfer)
dev.hf.mf.value.assert_called_with(4, 2, transfer_block=8, key="FFFFFFFFFFFF", key_type=0)
assert "not yet wired" in MFC.get("PERSONALIZE_UID").run(dev, "2") # EV1 opcode-only
def test_iso15_builds(self):
# 15693 frames are flags | command | [mfg] | params, assembled by iso15_frame
assert ISO15.get("READ_BLOCK").build("4") == b"\x02\x20\x04"
assert ISO15.get("GET_SYSTEM_INFO").build() == b"\x02\x2B"
assert ISO15.get("READ_BLOCK").opcode() == 0x20 # opcode = the command byte, not flags
def test_iso15_frame_builder(self):
assert iso15_frame(0x20, bytes([4])) == b"\x02\x20\x04" # non-addressed, high rate
assert iso15_frame(0xB2, mfg=0x04) == b"\x02\xB2\x04" # NXP custom -> mfg byte
# addressed: flags 0x22, UID appended LSByte-first
f = iso15_frame(0x20, bytes([4]), uid="e004010203040506", addressed=True)
assert f == bytes.fromhex("2220") + bytes.fromhex("e004010203040506")[::-1] + bytes([4])
assert iso15_frame(0x20, bytes([4]), option=True) == b"\x42\x20\x04"
# full flags coverage: every request-flag bit is settable
assert iso15_frame(0x20, bytes([4]), select=True) == b"\x12\x20\x04" # 0x10 select
assert iso15_frame(0x20, bytes([4]), proto_ext=True) == b"\x0A\x20\x04" # 0x08 proto-ext
assert iso15_frame(0x20, bytes([4]), two_subcarrier=True) == b"\x03\x20\x04" # 0x01
assert iso15_frame(0x20, bytes([4]), high_rate=False) == b"\x00\x20\x04" # low rate
assert iso15_frame(0x01, inventory=True, slots16=True) == b"\x06\x01" # 16-slot inventory
# inventory AFI: flag 0x10 set + AFI byte inserted before the params (mask/blocks)
assert iso15_frame(0x01, bytes([0]), inventory=True, afi=0x9A) == bytes.fromhex("36019a00")
def test_iso15_flags_decode(self):
assert iso15_flags_decode(0x02) == "high rate · non-addressed"
assert iso15_flags_decode(0x22) == "high rate · addressed"
assert "option" in iso15_flags_decode(0x42)
assert "inventory" in iso15_flags_decode(0x26)
# every bit decodes, in both modes
assert "select" in iso15_flags_decode(0x12)
assert "protocol-ext" in iso15_flags_decode(0x0A)
assert "two subcarrier" in iso15_flags_decode(0x03)
assert "16 slots" in iso15_flags_decode(0x06)
assert "AFI" in iso15_flags_decode(0x36)
assert iso15_flags_decode(0x00).startswith("low rate")
def test_slix_catalog(self):
# ICODE SLIX datasheet command set: standard + NXP custom, frames via the header builder
assert _SLIX.get("READ_BLOCK").build("4") == b"\x02\x20\x04"
assert _SLIX.get("GET_RANDOM").build() == b"\x02\xB2\x04" # NXP mfg inserted
assert _SLIX.get("SET_PASSWORD").build("4", "DEADBEEF") == bytes.fromhex("02b30404deadbeef")
assert _SLIX.get("READ_MULTIPLE").build("0", "4") == b"\x02\x23\x00\x03" # count-1 on wire
# opcode = the command byte (frame byte 1), so by_opcode maps correctly
assert _SLIX.get("GET_RANDOM").opcode() == 0xB2
assert _SLIX.by_opcode(0xB2).name == "GET_RANDOM" and _SLIX.by_opcode(0x20).name == "READ_BLOCK"
# INVENTORY READ family: inventory flags (0x26) | 0xA0/0xA1 | mfg | mask_len 0 | first | count-1
assert _SLIX.get("INVENTORY_READ").build("0", "4") == bytes.fromhex("26a004000003")
assert _SLIX.get("FAST_INVENTORY_READ").build("2", "1") == bytes.fromhex("26a104000200")
assert _SLIX.by_opcode(0xA0).name == "INVENTORY_READ"
assert _SLIX.by_opcode(0xA1).name == "FAST_INVENTORY_READ"
# full body: 16-bit mask (selective anticollision), and AFI filter (flag 0x10 -> flags 0x36)
assert _SLIX.get("INVENTORY_READ").build("0", "4", "16", "abcd") == bytes.fromhex("26a00410abcd0003")
assert _SLIX.get("INVENTORY_READ").build("0", "4", afi="0x9a") == bytes.fromhex("36a0049a000003")
# dispatch: an identified SLIX (or generic ICODE) resolves to this catalog
s = RawSession(); s.protocol, s.transponder = "hf15", "ICODE SLIX"
assert catalog_for(s) is _SLIX
def test_slix2_catalog(self):
# SLIX2 = the full SLIX datasheet set plus three SLIX2-only commands (0xBD/0xBB/0xBC)
assert set(_SLIX.commands) <= set(_SLIX2.commands) # superset of SLIX
for name, op in [("READ_SIGNATURE", 0xBD), ("PASSWORD_PROTECTION_64BIT", 0xBB),
("STAY_QUIET_PERSISTENT", 0xBC)]:
assert name not in _SLIX.commands # SLIX2-only
assert _SLIX2.get(name).opcode() == op
assert _SLIX2.by_opcode(op).name == name # raw opcode maps back
assert _SLIX2.get("READ_SIGNATURE").build() == b"\x02\xBD\x04" # NXP mfg inserted
assert _SLIX2.by_opcode(0xA0).name == "INVENTORY_READ" # inherited from SLIX
# dispatch: SLIX2 -> _SLIX2, plain SLIX still -> _SLIX (SLIX2 test comes first)
s = RawSession(); s.protocol = "hf15"
s.transponder = "ICODE SLIX2"; assert catalog_for(s) is _SLIX2
s.transponder = "ICODE SLIX"; assert catalog_for(s) is _SLIX
def test_ntag5_catalog(self):
# NTAG5 = SLIX2 minus STAY_QUIET_PERSISTENT (rejected), plus config / SRAM / pick-random-UID
assert "STAY_QUIET_PERSISTENT" not in _NTAG5.commands
assert "READ_SIGNATURE" in _NTAG5.commands # inherited from SLIX2
for name, op in [("READ_CONFIG", 0xC0), ("WRITE_CONFIG", 0xC1), ("PICK_RANDOM_UID", 0xC2),
("READ_SRAM", 0xD2), ("WRITE_SRAM", 0xD3)]:
assert _NTAG5.get(name).opcode() == op
assert _NTAG5.by_opcode(op).name == name
assert _NTAG5.get("READ_CONFIG").build("0xA1", "1") == bytes.fromhex("02c004a100") # CONFIG_REG
assert _NTAG5.get("WRITE_CONFIG").build("0xA1", "20000000") == bytes.fromhex("02c104a120000000")
assert _NTAG5.get("PICK_RANDOM_UID").build() == b"\x02\xC2\x04"
# dispatch (checked before SLIX2/SLIX): an identified NTAG5 resolves here
s = RawSession(); s.protocol, s.transponder = "hf15", "NTAG 5"
assert catalog_for(s) is _NTAG5
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.transponder = "MIFARE Mini" # Mini is a Classic -> MFC, not Type 2
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_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:
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_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))
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)") # 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):
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 _disp(self, comp):
return comp.display[0][1] if comp.display else comp.text
def test_opcode_completion_hex_inserts_raw_byte(self):
# entering raw hex, a matched opcode inserts the RAW BYTE (60), labelled with the command
s = RawSession()
s.protocol, s.transponder = "hf14a", "NTAG213"
cs = self._complete(s, "60") # GET_VERSION opcode
gv = next(c for c in cs if "GET_VERSION" in self._disp(c))
assert gv.text == "60" # not "GET_VERSION("
# a partial digit completes to the full opcode byte
assert "30" in [c.text for c in self._complete(s, "3")] # READ (0x30)
def test_opcode_completion_binary_inserts_raw_byte(self):
s = RawSession()
s.protocol, s.transponder = "hf14a", "NTAG213"
s.entry_mode = "bin"
cs = self._complete(s, "01100000") # 0x60 in binary
gv = next(c for c in cs if "GET_VERSION" in self._disp(c))
assert gv.text == "01100000" # raw binary byte, not the command name
def test_iso15_position_aware_completion(self):
# 15693 raw frame: byte 0 = flags menu (decoded), byte 1 = command, byte 2 = NXP mfg
s = RawSession()
s.protocol, s.transponder = "hf15", "ICODE SLIX"
flags = {c.text: c.display_meta_text for c in self._complete(s, "")}
assert flags["02"] == "high rate · non-addressed"
assert flags["22"] == "high rate · addressed"
cmds = {c.text: self._disp(c) for c in self._complete(s, "02 ")} # after flags -> command
assert "READ_BLOCK" in cmds["20"] and "GET_RANDOM" in cmds["B2"]
assert "04" in [c.text for c in self._complete(s, "02 B2 ")] # custom -> mfg byte
assert self._complete(s, "02 20 ") == [] # standard cmd -> no mfg
# function-call form still gets the block map, command names still complete by letters
assert any("user memory" in (c.display_meta_text or "") for c in self._complete(s, "READ_BLOCK("))
assert any("GET_RANDOM" in self._disp(c) for c in self._complete(s, "get_r"))
# binary flags render as 8 bits
s.entry_mode = "bin"
assert any(c.text == "00000010" for c in self._complete(s, ""))
def test_mfc_opcode_completion(self):
# the new MIFARE Classic datasheet opcodes surface by hex AND binary value, inserting the byte
s = RawSession()
s.protocol, s.transponder = "hf14a", "MIFARE Classic 1K"
for word, name, byte in [("c1", "INCREMENT", "C1"), ("60", "AUTH_A", "60"), ("b0", "TRANSFER", "B0")]:
cs = self._complete(s, word)
hit = next(c for c in cs if name in self._disp(c))
assert hit.text == byte # inserts the raw opcode byte
s.entry_mode = "bin"
cs = self._complete(s, "11000001") # 0xC1 in binary
assert any("INCREMENT" in self._disp(c) for c in cs)
s.entry_mode = "hex"
# the block argument gets the memory map for these commands too
assert any("manufacturer" in (c.display_meta_text or "")
for c in self._complete(s, "INCREMENT("))
def test_raw_page_byte_completion(self):
# after a page-command opcode in raw entry, the page byte gets the memory map (raw bytes)
s = RawSession()
s.protocol, s.transponder = "hf14a", "NTAG213"
by_text = {c.text: c.display_meta_text for c in self._complete(s, "30 ")}
assert by_text["04"] == "user memory" # inserts raw byte 04, not 0x04
assert by_text["28"] == "dynamic lock bytes"
assert "PWD" in by_text["2B"]
# narrows as you type the byte
assert [c.text for c in self._complete(s, "30 2")] == ["28", "29", "2A", "2B", "2C"]
# a non-page opcode (GET_VERSION) gets no page menu
assert self._complete(s, "60 ") == []
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_mfc_block_map(self):
# MIFARE Classic gets its block map in the dropdown, same as NTAG pages
s = RawSession()
s.protocol, s.transponder = "hf14a", "MIFARE Classic 1K"
by_text = {c.text: c.display_meta_text for c in self._complete(s, "READ(")}
assert "manufacturer" in by_text["0x00"]
assert by_text["0x03"].startswith("sector 0 trailer")
assert "0x3F" in by_text # block 63, sector 15 trailer
# raw byte form works too
assert "manufacturer" in {c.text: c.display_meta_text for c in self._complete(s, "30 ")}["00"]
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", 1) == "UID / serial number"
assert "static lock" in page_role("NTAG213", 2) # page 2 is lock/internal, not UID
assert page_role("NTAG213", 3) == "Capability Container (CC)"
assert page_role("NTAG213", 4) == "user memory"
assert page_role("NTAG213", 0x28) == "dynamic lock bytes"
assert "AUTH0" in page_role("NTAG213", 0x29) # CFG0
assert page_role("NTAG213", 0x2A).startswith("CFG1")
assert "PWD" in page_role("NTAG213", 0x2B)
assert "PACK" in page_role("NTAG213", 0x2C)
assert page_role("NTAG216", 0x04) == "user memory" # different IC, different pages
assert "AUTH0" in page_role("NTAG216", 0xE3)
def test_page_role_family_differences(self):
from pm3py.cli.rawcli.memory import page_role
# NTAG 213/215/216 have the NFC counter bits in CFG1; 210/212 don't
assert "NFC_CNT_EN" in page_role("NTAG213", 0x2A)
assert "NFC_CNT_EN" not in page_role("NTAG210", 0x11) # CFG1, no counter
assert "NFC_CNT_EN" not in page_role("NTAG212", 0x26)
# NTAG has the ASCII mirror in CFG0; Ultralight EV1 does not
assert "MIRROR" in page_role("NTAG213", 0x29)
assert "MIRROR" not in page_role("MIFARE Ultralight EV1 (MF0UL11)", 0x10)
# page 3 is CC on NTAG, OTP on Ultralight
assert page_role("MIFARE Ultralight EV1 (MF0UL11)", 3) == "OTP (one-time programmable)"
assert page_role("MIFARE Ultralight EV1 (MF0UL21)", 3).startswith("OTP")
# UL21 has a dynamic lock page (like NTAG212); UL11 doesn't (like NTAG210)
assert page_role("MIFARE Ultralight EV1 (MF0UL21)", 0x24) == "dynamic lock bytes"
def test_generic_type2_fallback(self):
from pm3py.cli.rawcli.memory import landmark_pages, page_role
# only the family is known (flaky/absent GET_VERSION) -> the shared header still resolves
for name in ("MIFARE Ultralight / NTAG", "NTAG (144 B)"):
assert page_role(name, 4) == "user memory"
assert page_role(name, 0) == "UID / serial number"
assert "static lock" in page_role(name, 2)
assert page_role(name, 3) == "Capability Container (CC) / OTP" # family unknown
assert page_role(name, 0x20) is None # model-specific -> not guessed
assert [p for p, _ in landmark_pages(name)] == [0, 2, 3, 4] # header, no config
assert page_role("MIFARE DESFire", 4) is None # no map for this tag -> nothing
def test_t5577_block_roles(self):
from pm3py.cli.rawcli.memory import page_role
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)
def test_mfc_block_roles(self):
from pm3py.cli.rawcli.memory import page_role, landmark_pages
# 1K: block 0 manufacturer, every 4th block a sector trailer, rest data
assert "manufacturer" in page_role("MIFARE Classic 1K", 0)
assert page_role("MIFARE Classic 1K", 1) == "data block (sector 0)"
assert page_role("MIFARE Classic 1K", 3).startswith("sector 0 trailer")
assert "Key A" in page_role("MIFARE Classic 1K", 3) and "Key B" in page_role("MIFARE Classic 1K", 3)
assert page_role("MIFARE Classic 1K", 63).startswith("sector 15 trailer")
assert page_role("MIFARE Classic 1K", 64) is None # out of range
# 4K large sectors 32-39 are 16 blocks each (trailer = last block)
assert page_role("MIFARE Classic 4K", 143).startswith("sector 32 trailer")
assert page_role("MIFARE Classic 4K", 255).startswith("sector 39 trailer")
assert page_role("MIFARE Classic 4K", 254) == "data block (sector 39)"
# Mini is a 5-sector Classic
assert page_role("MIFARE Mini", 19).startswith("sector 4 trailer")
assert page_role("MIFARE Mini", 20) is None
# landmarks = block 0 + every trailer
assert [b for b, _ in landmark_pages("MIFARE Classic 1K")] == [0, 3, 7, 11, 15, 19, 23,
27, 31, 35, 39, 43, 47, 51, 55, 59, 63]
assert len(landmark_pages("MIFARE Classic 4K")) == 41 # block 0 + 40 sectors
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)
assert L["fam"] == "ntag"
assert L.get("cnt", False) == cls._has_nfc_counter # CFG1 NFC-counter bits
def test_ultralight_layouts_match_models(self):
from pm3py.cli.rawcli.memory import _LAYOUTS
from pm3py.sim import MF0UL11, MF0UL21
for cls, name in [(MF0UL11, "MIFARE Ultralight EV1 (MF0UL11)"),
(MF0UL21, "MIFARE Ultralight EV1 (MF0UL21)")]:
L = _LAYOUTS[name]
assert (L["cfg0"], L["cfg1"], L["pwd"], L["pack"]) == \
(cls._cfg0_page, cls._cfg1_page, cls._pwd_page, cls._pack_page)
assert L["fam"] == "ul" # page 3 is OTP, no ASCII mirror
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)
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_toggle_updates_breadcrumb_prefix(self):
# the toggle flips the breadcrumb entry prefix 0x <-> 0b and requests a redraw
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-_"))
redraws = []
ev = SimpleNamespace(current_buffer=Buffer(), data="",
app=SimpleNamespace(invalidate=lambda: redraws.append(1)))
assert "0x" in s.breadcrumb() and "0b" not in s.breadcrumb()
toggle.handler(ev)
assert "0b" in s.breadcrumb() and s.entry_prefix == "0b" and redraws # flipped + redraw
toggle.handler(ev)
assert "0x" in s.breadcrumb() and "0b" not in s.breadcrumb() # and back
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"]
def test_digit_binding_fires_completion_trigger_for_raw_bytes(self):
# regression: raw byte entry must append via insert_text (which fires completion), not
# replace buf.document (which resets complete_state and silently killed the live
# autocomplete menu during hex/binary byte entry). on_text_insert fires from insert_text,
# never from a document= swap, so it is a faithful proxy for "the menu would refresh".
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()
s.toggle_entry_mode() # binary: regroups every 8 digits (worst case)
kb = KeyBindings()
install_key_bindings(kb, s)
digit = {getattr(b.keys[0], "value", b.keys[0]): b for b in kb.bindings}
buf = Buffer()
fired = []
buf.on_text_insert += lambda _b: fired.append(1)
app = SimpleNamespace(invalidate=lambda: None)
for ch in "00000100":
digit[ch].handler(SimpleNamespace(current_buffer=buf, app=app, data=ch))
assert buf.text == "00000100"
assert len(fired) == 8 # one insert_text per digit (0 with document=)
class TestRawcliLiveSession:
"""End-to-end through the REAL PromptSession run() builds (real key bindings + RawCompleter +
complete_while_typing), driven by a prompt_toolkit pipe input that simulates TTY keystrokes.
Exercises the actual rawcli input stack; no device needed for the input layer."""
@staticmethod
def _run_keys(keys):
import asyncio
from prompt_toolkit import PromptSession
from prompt_toolkit.input import create_pipe_input
from prompt_toolkit.output import DummyOutput
from prompt_toolkit.key_binding import KeyBindings
from pm3py.cli.rawcli.entry import install_key_bindings
from pm3py.cli.rawcli.completer import RawCompleter
state = RawSession()
comp = RawCompleter(state)
queried = []
_orig = comp.get_completions
def _rec(document, event):
queried.append(document.text)
yield from _orig(document, event)
comp.get_completions = _rec
kb = KeyBindings()
install_key_bindings(kb, state)
# PromptSession.prompt() runs asyncio.run() internally, which nulls the current-loop
# pointer; save/restore it so the shared-loop `_run` convention in other test files isn't
# poisoned (see tests/test_pyws_plugin.py).
try:
prev_loop = asyncio.get_event_loop()
except RuntimeError:
prev_loop = None
try:
with create_pipe_input() as inp:
session = PromptSession(key_bindings=kb, completer=comp, complete_while_typing=True,
input=inp, output=DummyOutput())
inp.send_text(keys + "\r")
line = session.prompt("> ")
finally:
if prev_loop is not None and not prev_loop.is_closed():
asyncio.set_event_loop(prev_loop)
return line, queried
def test_accepted_line_across_input_paths(self):
# the change touched the digit handler; confirm every input path still yields the right line
toggle = "\x14" # Ctrl-t entry-mode toggle
for keys, expect in [("3004", "30 04"),
(toggle + "00000100", "00000100"),
("30" + toggle + "00000100", "30 00000100"),
("read(4)", "read(4)"),
("identify", "identify")]:
line, _ = self._run_keys(keys)
assert line == expect, f"{keys!r} -> {line!r} != {expect!r}"
def test_live_completion_fires_during_binary_entry(self):
# the fix, in the real session: the completer is queried with the byte-line text while
# typing binary (0 queries before the fix, since buf.document= reset complete_state)
line, queried = self._run_keys("\x14" + "00000100")
byte_q = [q for q in queried if q and q.replace(" ", "").strip("01") == ""]
assert line == "00000100"
assert byte_q, "completer never queried for byte-line text — live menu not firing"