Drive the real PromptSession that run() builds (real key bindings + RawCompleter + complete_while_typing) via a prompt_toolkit pipe input simulating TTY keystrokes, so the actual rawcli input stack is exercised, not just the handler in isolation. Asserts every input path yields the correct accepted line (hex byte line, binary via toggle, mixed hex/binary, function call, command name) and that the completer is queried live during binary entry (0 queries before the autocomplete fix, since buf.document= reset complete_state). prompt()'s internal asyncio.run() nulls the current-loop pointer; the helper saves and restores it so the shared-loop `_run` convention in other test files is not poisoned. Full suite stays green (1383). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
996 lines
49 KiB
Python
996 lines
49 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,
|
|
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_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"
|
|
|
|
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)
|
|
|
|
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"
|
|
# 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_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_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"
|