"""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, 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): s = RawSession(device=_mock_device(scan15={"found": True, "uid": "e004010203040506"})) r = identify(s) assert r["found"] and s.field == "hf" and s.protocol == "hf15" assert s.transponder == "ISO15693" def test_15693_rejects_bogus_uid(self): # a flaky 14a miss can leave a non-E0 "uid" — must NOT be reported as a 15693 tag s = RawSession(device=_mock_device(scan15={"found": True, "uid": "0000000000003d42"})) r = identify(s) assert r["found"] is False and s.transponder is None def test_no_flat_lf(self): # nothing on HF and a flat LF envelope must report not-found, not a bogus "LF tag" assert identify(RawSession(device=_mock_device()))["found"] is False def test_lf_wiring_sets_label(self, monkeypatch): # the LF path folds pm3py.lf.identify_lf's result into the session (demod itself is # covered in test_lf_demod.py); here we assert the rawcli wiring/label/summary. import pm3py.lf monkeypatch.setattr(pm3py.lf, "identify_lf", lambda dev, emit=None: { "found": True, "field": "lf", "protocol": "lf", "chip": "T5577", "config": "00148040", "emulating": "EM4100 / EM4102", "emitted": {"protocol": "EM4100", "id_hex": "0102030405"}, "label": "T5577 (EM4100 / EM4102)"}) s = RawSession(device=_mock_device()) # HF finds nothing -> LF path r = identify(s) assert r["found"] and s.field == "lf" and s.protocol == "lf" assert s.transponder == "T5577 (EM4100 / EM4102)" assert r["config"] == "00148040" and r["id"] == "0102030405" assert "config 0x00148040" in format_summary(r) def test_lf_native_credential_label(self, monkeypatch): import pm3py.lf monkeypatch.setattr(pm3py.lf, "identify_lf", lambda dev, emit=None: { "found": True, "field": "lf", "protocol": "lf", "chip": None, "config": None, "emulating": None, "emitted": {"protocol": "EM4100", "id_hex": "1122334455"}, "label": "EM4100 1122334455"}) s = RawSession(device=_mock_device()) r = identify(s) assert s.transponder == "EM4100 1122334455" assert "id 1122334455" in format_summary(r) def test_identify_clears_stale_result(self): # a second identify that finds nothing must clear the previous tag from the session dev = _mock_device(scan14={"found": True, "sak": 0x08, "atqa": "0400", "uid": "b978423d"}) s = RawSession(device=dev) identify(s) assert s.transponder == "MIFARE Classic 1K" dev.hf.iso14a.scan.return_value = {"found": False} # tag removed identify(s) assert s.transponder is None and s.field is None # no stale data def test_sak_names_from_an10833(self): assert name_14a({"sak": 0x18}) == "MIFARE Classic 4K" assert name_14a({"sak": 0x20}).startswith("ISO14443-4") assert name_14a({"sak": 0x99}) == "ISO14443-A (SAK 99)" def test_clear_forgets_tag(self): s = RawSession(device=_mock_device(scan14={"found": True, "sak": 0x08, "uid": "01020304"})) identify(s) clear(s) assert s.field is None and s.transponder is None and s.protocol == "hf14a" class TestVersionId: def test_exact_models(self): from pm3py.cli.rawcli.identify import decode_version assert decode_version(bytes.fromhex("0004040201001303")) == "NTAG216" assert decode_version(bytes.fromhex("0004040201000f03")) == "NTAG213" assert "Ultralight EV1" in decode_version(bytes.fromhex("0004030101000b03")) def test_structural_range_for_unknown(self): from pm3py.cli.rawcli.identify import decode_version # unknown NTAG-family version -> product + honest size range (AN10833) out = decode_version(bytes.fromhex("0004040201001503")) assert out.startswith("NTAG (") and "B)" in out def test_static_map_matches_models(self): # guard against drift between the static version map and the transponder models from pm3py.cli.rawcli.identify import _VERSION_MODELS from pm3py.sim import (NTAG210, NTAG212, NTAG213, NTAG215, NTAG216, MF0UL11, MF0UL21, NT3H2111, NT3H2211) model_vbytes = {c._version_bytes.hex() for c in (NTAG210, NTAG212, NTAG213, NTAG215, NTAG216, MF0UL11, MF0UL21, NT3H2111, NT3H2211)} assert set(_VERSION_MODELS) == model_vbytes class TestControlCommands: def test_identify_command(self, capsys): s = RawSession(device=_mock_device( scan14={"found": True, "sak": 0x00, "atqa": "4400", "uid": "04a1b2c3d4e5f6"}, version=NTAG216_VERSION)) dispatch(s, "identify") out = _plain(capsys.readouterr().out) assert "NTAG216" in out # exact model in the summary assert "WUPA" in out and "GET_VERSION" in out # probe exchanges streamed to the trace assert s.breadcrumb() == "[raw / hf / NTAG216 / 0x]" def test_transponder_when_none(self, capsys): dispatch(RawSession(), "transponder") assert "run 'identify'" in capsys.readouterr().out def test_close(self, capsys): s = RawSession(device=_mock_device(scan14={"found": True, "sak": 0x08})) dispatch(s, "identify") dispatch(s, "close") assert "closed" in capsys.readouterr().out assert s.transponder is None class TestCatalog: def test_type2_builds(self): assert TYPE2.get("READ").build("4") == b"\x30\x04" assert TYPE2.get("read").build("0x04") == b"\x30\x04" # case + hex arg assert TYPE2.get("FAST_READ").build("0", "3") == b"\x3A\x00\x03" assert TYPE2.get("GET_VERSION").build() == b"\x60" assert TYPE2.get("WRITE").build("4", "01020304") == b"\xA2\x04\x01\x02\x03\x04" assert TYPE2.get("PWD_AUTH").build("FFFFFFFF") == b"\x1B\xFF\xFF\xFF\xFF" # two-phase write builds a frame list: command frame + 16-byte data frame assert TYPE2.get("COMPAT_WRITE").build("4", "0102") == [b"\xA0\x04", b"\x01\x02" + b"\x00" * 14] def test_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_iso15_builds(self): assert ISO15.get("READ_BLOCK").build("4") == b"\x02\x20\x04" assert ISO15.get("INVENTORY").build() == b"\x26\x01\x00" def test_resolver(self): s = RawSession() assert catalog_for(s) is None # nothing identified s.protocol, s.transponder = "hf14a", "NTAG215" assert catalog_for(s) is TYPE2 s.transponder = "MIFARE Classic 1K" assert catalog_for(s).name == "MIFARE Classic" s.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_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"]