|
| 1 | +"""Exercise Type 2 memory boundaries through the real reader's READ command.""" |
| 2 | +import logging |
| 3 | +from pathlib import Path |
| 4 | +import sys |
| 5 | +from types import ModuleType |
| 6 | +from unittest.mock import Mock |
| 7 | + |
| 8 | +import pytest |
| 9 | +import yaml |
| 10 | + |
| 11 | +from test_tags import ( |
| 12 | + PROCESSOR_FIXTURES, |
| 13 | + _assert_expected_matches_actual, |
| 14 | + _collect_fixture_cases, |
| 15 | +) |
| 16 | + |
| 17 | +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) |
| 18 | + |
| 19 | +from reader.scan_result import ScanResult |
| 20 | +from tag.tag_types import TagType |
| 21 | + |
| 22 | + |
| 23 | +@pytest.fixture |
| 24 | +def reader(monkeypatch): |
| 25 | + # The RF command is simulated; no Linux GPIO/SPI hardware is accessed. |
| 26 | + monkeypatch.setitem(sys.modules, "gpiod", ModuleType("gpiod")) |
| 27 | + monkeypatch.setitem(sys.modules, "spidev", ModuleType("spidev")) |
| 28 | + from reader.fm175xx.rfid import Fm175xx |
| 29 | + |
| 30 | + instance = Fm175xx.__new__(Fm175xx) |
| 31 | + instance.logger = logging.getLogger("test.ultralight") |
| 32 | + return instance |
| 33 | + |
| 34 | + |
| 35 | +@pytest.fixture |
| 36 | +def scan(): |
| 37 | + return ScanResult(TagType.MifareUltralight, b"\x04\x01\x02\x03\x04\x05\x06", b"\x00\x44", b"\x00", b"\x04\x00") |
| 38 | + |
| 39 | + |
| 40 | +def memory(size_byte): |
| 41 | + physical_sizes = {0x06: 64, 0x12: 180, 0x3E: 540, 0x6D: 924} |
| 42 | + data = bytearray(i % 251 for i in range(physical_sizes[size_byte])) |
| 43 | + data[12:16] = bytes([0xE1, 0x10, size_byte, 0x00]) |
| 44 | + return bytes(data) |
| 45 | + |
| 46 | + |
| 47 | +def mock_tag(reader, monkeypatch, data, failures=None, short_page=None): |
| 48 | + from reader.fm175xx import constants |
| 49 | + from reader.fm175xx.rfid import Fm175xxReturnVal |
| 50 | + |
| 51 | + failures = dict(failures or {}) |
| 52 | + |
| 53 | + def execute(cmd): |
| 54 | + assert cmd.send_buff[0] == 0x30 |
| 55 | + assert cmd.bytes_to_recv == 16 |
| 56 | + page = cmd.send_buff[1] |
| 57 | + assert 0 <= page <= 255 |
| 58 | + offset = page * 4 |
| 59 | + assert offset + 16 <= len(data), "READ crossed the physical memory boundary" |
| 60 | + result = Fm175xxReturnVal() |
| 61 | + if failures.get(page, 0): |
| 62 | + failures[page] -= 1 |
| 63 | + result.err_code = constants.FM175XX_CARD_TIMER_ERR |
| 64 | + else: |
| 65 | + result.err_code = constants.FM175XX_OK |
| 66 | + result.out_data = list(data[offset:offset + 16]) |
| 67 | + if page == short_page: |
| 68 | + result.out_data.pop() |
| 69 | + return result |
| 70 | + |
| 71 | + command = Mock(side_effect=execute) |
| 72 | + monkeypatch.setattr(reader, "_Fm175xx__command_exe", command) |
| 73 | + return command |
| 74 | + |
| 75 | + |
| 76 | +@pytest.mark.parametrize("size_byte,total_size,last_page", [ |
| 77 | + pytest.param(0x06, 64, 12, id="ultralight"), |
| 78 | + pytest.param(0x12, 180, 41, id="ntag213"), |
| 79 | + pytest.param(0x3E, 540, 131, id="ntag215"), |
| 80 | + pytest.param(0x6D, 924, 227, id="ntag216"), |
| 81 | +]) |
| 82 | +def test_reads_all_physical_pages(reader, scan, monkeypatch, size_byte, total_size, last_page): |
| 83 | + data = memory(size_byte) |
| 84 | + command = mock_tag(reader, monkeypatch, data) |
| 85 | + |
| 86 | + result = reader.read_mifare_ultralight(scan) |
| 87 | + |
| 88 | + assert result == data |
| 89 | + assert len(result) == total_size |
| 90 | + assert command.call_args_list[0].args[0].send_buff == [0x30, 0] |
| 91 | + assert command.call_args_list[-1].args[0].send_buff == [0x30, last_page] |
| 92 | + assert command.call_count == (total_size + 15) // 16 |
| 93 | + assert sum(call.args[0].send_buff[1] == 0 for call in command.call_args_list) == 1 |
| 94 | + |
| 95 | + |
| 96 | +@pytest.mark.parametrize("cc", [ |
| 97 | + bytes.fromhex("00 00 00 00"), |
| 98 | + bytes.fromhex("00 10 12 00"), |
| 99 | + bytes.fromhex("e1 10 00 00"), |
| 100 | + bytes.fromhex("e1 10 01 00"), |
| 101 | + bytes.fromhex("e1 10 7f 00"), |
| 102 | + bytes.fromhex("e1 10 ff 00"), |
| 103 | +]) |
| 104 | +def test_invalid_cc_fails_before_reading_data(reader, scan, monkeypatch, cc): |
| 105 | + data = bytearray(memory(0x12)) |
| 106 | + data[12:16] = cc |
| 107 | + command = mock_tag(reader, monkeypatch, data) |
| 108 | + |
| 109 | + assert reader.read_mifare_ultralight(scan) is None |
| 110 | + assert command.call_count == 1 |
| 111 | + |
| 112 | + |
| 113 | +@pytest.mark.parametrize("size_byte,page", [ |
| 114 | + (0x6D, 0), (0x6D, 4), (0x6D, 48), (0x6D, 132), |
| 115 | + (0x06, 12), (0x12, 41), (0x3E, 131), (0x6D, 227), |
| 116 | +]) |
| 117 | +def test_persistent_read_failure_never_returns_partial_success(reader, scan, monkeypatch, size_byte, page): |
| 118 | + # Page 48 used to be accepted as a successful end after three failed reads. |
| 119 | + command = mock_tag(reader, monkeypatch, memory(size_byte), failures={page: 3}) |
| 120 | + |
| 121 | + assert reader.read_mifare_ultralight(scan) is None |
| 122 | + assert [call.args[0].send_buff[1] for call in command.call_args_list[-3:]] == [page] * 3 |
| 123 | + |
| 124 | + |
| 125 | +@pytest.mark.parametrize("page", [0, 4, 227]) |
| 126 | +def test_transient_failure_retries_and_returns_complete_data(reader, scan, monkeypatch, page): |
| 127 | + data = memory(0x6D) |
| 128 | + command = mock_tag(reader, monkeypatch, data, failures={page: 2}) |
| 129 | + |
| 130 | + assert reader.read_mifare_ultralight(scan) == data |
| 131 | + assert sum(call.args[0].send_buff[1] == page for call in command.call_args_list) == 3 |
| 132 | + |
| 133 | + |
| 134 | +@pytest.mark.parametrize("page", [0, 4]) |
| 135 | +def test_short_response_is_a_read_failure(reader, scan, monkeypatch, page): |
| 136 | + mock_tag(reader, monkeypatch, memory(0x12), short_page=page) |
| 137 | + |
| 138 | + assert reader.read_mifare_ultralight(scan) is None |
| 139 | + |
| 140 | + |
| 141 | +@pytest.mark.parametrize("fixture_path", [ |
| 142 | + path for path in _collect_fixture_cases() |
| 143 | + if PROCESSOR_FIXTURES[path.parent.name]["tag_type"] == TagType.MifareUltralight |
| 144 | +], ids=lambda path: path.stem) |
| 145 | +def test_physical_dump_preserves_processor_output(reader, scan, monkeypatch, fixture_path): |
| 146 | + data = fixture_path.read_bytes() |
| 147 | + # Some older NTAG213 dumps include rollover bytes. Simulate only physical |
| 148 | + # memory and pass the real reader's exact-length output to each processor. |
| 149 | + physical_sizes = {0x06: 64, 0x12: 180, 0x3E: 540, 0x6D: 924} |
| 150 | + physical_size = physical_sizes[data[14]] |
| 151 | + assert len(data) >= physical_size |
| 152 | + mock_tag(reader, monkeypatch, data[:physical_size]) |
| 153 | + |
| 154 | + dump = reader.read_mifare_ultralight(scan) |
| 155 | + assert dump == data[:physical_size] |
| 156 | + processor = PROCESSOR_FIXTURES[fixture_path.parent.name]["build_processor"]() |
| 157 | + filament = processor.process_tag(scan, dump) |
| 158 | + |
| 159 | + assert filament is not None |
| 160 | + expected = yaml.safe_load(fixture_path.with_suffix(".yml").read_text(encoding="utf-8")) |
| 161 | + _assert_expected_matches_actual(expected, filament.to_dict()) |
0 commit comments