v1.9.4: decode_step/hunt byte-replay tests + hunt EOF guard

- tests: TestDecodeStep drives decode_step() with a scripted byte queue
  (read/peek/consume stubbed over _replay + the queue; the re-align/PPS/
  ATR/annotation sinks stubbed): 'CX'/'EX' exchanges frame and emit clean,
  0x5F frames as a command (it was always eaten by the idle-noise gate
  before v1.9.3), the 0x61 text-byte boundary is a one-byte suspicious-CLA
  discard, the invalid-INS re-frame recovers a shifted SELECT, 0xFE still
  takes the idle-noise path.
- tests: TestAtrHuntDetect drives handle_atr(): 0xC4 0xA4 / 0xC4 0x89
  hand the header to DATA via _replay, CLA 0x00 stays excluded, an invalid
  INS does not hand off.
- pd.py: the hunt command-detect guards peek_byte() returning None (a
  capture ending right after a plausible CLA byte raised TypeError, seen
  only by decode()'s crash protection);
  test_eof_after_plausible_cla_is_clean pins the clean return.
- AGENTS v1.9.4 section; baseline regenerated (version + test listing;
  compare_baseline: IDENTICAL); 95/95 unit tests.
This commit is contained in:
catarrh committed 2026-10-18 02:22:15 +03:00
1 parent 54dfc6586d
commit 883033e712
4 files changed
+271 -15

No files matched your search

+32 -1
View File
@@ -333,6 +333,37 @@ Known limitation: no on-disk fixture reproduces the exact live failure (all
real captures contain a 372 ATR), so end-to-end proof needs a re-captured real captures contain a 372 ATR), so end-to-end proof needs a re-captured
`.sr` from the failing phone; the unit tests cover the loop-escape logic. `.sr` from the failing phone; the unit tests cover the loop-escape logic.
### v1.9.4: decode_step/hunt unit coverage + hunt EOF guard
The v1.9.3 review's residual risk -- the new acceptance classes were only
proven by "no regression" on the example traces -- is closed with a
byte-replay harness, and the new coverage surfaced one latent defect:
- **`TestDecodeStep`** drives the real `decode_step()` DATA-state gate chain
with a scripted byte queue (`read_byte`/`peek_byte`/`_consume_first`
stubbed over `_replay` + the queue; the re-align/PPS/ATR/annotation sinks
stubbed; `reset()` provides the state). Pinned: `'CX'`/`'EX'` exchanges
frame and emit clean; `0x5F` (always eaten by the idle-noise gate before
v1.9.3) frames as a command; the `0x61` menu-text boundary byte is a
suspicious CLA with exactly one byte consumed; the invalid-INS one-byte
re-frame recovers a shifted SELECT into the intended packet; `0xFE` still
takes the idle-noise path.
- **`TestAtrHuntDetect`** drives `handle_atr()` the same way: `0xC4 0xA4`
and `0xC4 0x89` (the odd-INS acceptance) hand the header to DATA via
`_replay`; CLA `0x00` stays excluded; an invalid INS does not hand off.
- **Bug found and fixed:** the hunt's command-detect called
`plausible_ins(nxt)` with no None check -- a capture ending right after a
plausible CLA byte made `peek_byte()` return None and raised TypeError
(previously seen only by `decode()`'s crash protection).
`test_eof_after_plausible_cla_is_clean` pins the clean end-of-capture
return.
Validation: 95/95 unit tests; the new framing/hunt tests fail against
v1.9.2 for the right reasons (the acceptance is missing) and the EOF test
fails against v1.9.3 (the TypeError); full trace suite unchanged
(`compare_baseline.sh` IDENTICAL, baseline regenerated for the version +
test listing). VERSION -> 1.9.4.
### v1.9.3: higher logical channels in the CLA gate + defined odd INS codes ### v1.9.3: higher logical channels in the CLA gate + defined odd INS codes
A trace with higher logical channels (4-19) or an odd-INS command could not A trace with higher logical channels (4-19) or an odd-INS command could not
@@ -852,7 +883,7 @@ append the measured CLK frequency when known
### Versioning ### Versioning
The decoder version is defined in `pd.py` as `VERSION = '1.9.3'`. The decoder version is defined in `pd.py` as `VERSION = '1.9.4'`.
The version is printed to the log on decoder startup. The version is printed to the log on decoder startup.
### Testing after decoder changes ### Testing after decoder changes
+5 -2
View File
@@ -28,7 +28,7 @@ from .gsmtap_stream import (GsmtapStreamSender,
GSMTAP_SIM_RST_EVENT, GSMTAP_SIM_VCC_EVENT, GSMTAP_SIM_RST_EVENT, GSMTAP_SIM_VCC_EVENT,
GSMTAP_FLAG_BAD_FCS) GSMTAP_FLAG_BAD_FCS)
VERSION = '1.9.3' VERSION = '1.9.4'
@@ -1766,7 +1766,10 @@ class Decoder(srd.Decoder):
# on noise like the 0x00 0x1E seen in the live test. # on noise like the 0x00 0x1E seen in the live test.
if plausible_cla(byte) and byte != 0x00: if plausible_cla(byte) and byte != 0x00:
nxt = self.peek_byte() nxt = self.peek_byte()
if plausible_ins(nxt): # nxt is None at end-of-capture: keep hunting (the read
# below returns None and the hunt ends cleanly) instead of
# calling plausible_ins(None), which raises TypeError.
if nxt is not None and plausible_ins(nxt):
self._replay.appendleft(byte) self._replay.appendleft(byte)
self.hasT0 = True self.hasT0 = True
self.hasT1 = False self.hasT1 = False
+23 -12
View File
@@ -1,7 +1,7 @@
=== Baseline captured 2026-10-17T23:51:01+03:00 === === Baseline captured 2026-10-18T02:13:20+03:00 ===
=== test_8 === === test_8 ===
('=== iso7816 decoder v1.9.3 ===',) ('=== iso7816 decoder v1.9.4 ===',)
('etu recovered (samples):', 1237) ('etu recovered (samples):', 1237)
iso7816-1: RST deasserted iso7816-1: RST deasserted
('RST deasserted',) ('RST deasserted',)
@@ -36,7 +36,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries) RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== test_7 === === test_7 ===
('=== iso7816 decoder v1.9.3 ===',) ('=== iso7816 decoder v1.9.4 ===',)
('etu recovered (samples):', 1236) ('etu recovered (samples):', 1236)
iso7816-1: RST deasserted iso7816-1: RST deasserted
('RST deasserted',) ('RST deasserted',)
@@ -77,7 +77,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries) RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== xiaomi_phone_sample === === xiaomi_phone_sample ===
('=== iso7816 decoder v1.9.3 ===',) ('=== iso7816 decoder v1.9.4 ===',)
('etu recovered (samples):', 53) ('etu recovered (samples):', 53)
(11674007, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 0, 1, 0, 0, 1, 1, 1, 1]) (11674007, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 0, 1, 0, 0, 1, 1, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 0, 0, 1, 0, 0, 1, 1, 1, 1] iso7816-1: CHKSUM ERROR bits=[0, 0, 0, 1, 0, 0, 1, 1, 1, 1]
@@ -102,7 +102,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries) RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== xiaomi_mi_a1_coldboot === === xiaomi_mi_a1_coldboot ===
('=== iso7816 decoder v1.9.3 ===',) ('=== iso7816 decoder v1.9.4 ===',)
('RST low at start: expecting cold-boot ATR',) ('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted iso7816-1: RST deasserted
('RST deasserted',) ('RST deasserted',)
@@ -129,7 +129,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries) RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== 4gmodem_coldboot === === 4gmodem_coldboot ===
('=== iso7816 decoder v1.9.3 ===',) ('=== iso7816 decoder v1.9.4 ===',)
('RST low at start: expecting cold-boot ATR',) ('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted iso7816-1: RST deasserted
('RST deasserted',) ('RST deasserted',)
@@ -156,7 +156,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries) RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== alcor_reader_sample === === alcor_reader_sample ===
('=== iso7816 decoder v1.9.3 ===',) ('=== iso7816 decoder v1.9.4 ===',)
('RST low at start: expecting cold-boot ATR',) ('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted iso7816-1: RST deasserted
('RST deasserted',) ('RST deasserted',)
@@ -189,7 +189,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries) RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== samsung_A55_coldboot_new_cable === === samsung_A55_coldboot_new_cable ===
('=== iso7816 decoder v1.9.3 ===',) ('=== iso7816 decoder v1.9.4 ===',)
('RST low at start: expecting cold-boot ATR',) ('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted iso7816-1: RST deasserted
('RST deasserted',) ('RST deasserted',)
@@ -216,7 +216,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries) RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== samsung_A55_coldboot_new_cable_sim2 === === samsung_A55_coldboot_new_cable_sim2 ===
('=== iso7816 decoder v1.9.3 ===',) ('=== iso7816 decoder v1.9.4 ===',)
('RST low at start: expecting cold-boot ATR',) ('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted iso7816-1: RST deasserted
('RST deasserted',) ('RST deasserted',)
@@ -243,7 +243,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries) RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== samsung_S21p_coldboot_new_cable === === samsung_S21p_coldboot_new_cable ===
('=== iso7816 decoder v1.9.3 ===',) ('=== iso7816 decoder v1.9.4 ===',)
('RST low at start: expecting cold-boot ATR',) ('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted iso7816-1: RST deasserted
('RST deasserted',) ('RST deasserted',)
@@ -270,7 +270,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries) RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== samsung_S21p_coldboot_new_cable_sim2 === === samsung_S21p_coldboot_new_cable_sim2 ===
('=== iso7816 decoder v1.9.3 ===',) ('=== iso7816 decoder v1.9.4 ===',)
('RST low at start: expecting cold-boot ATR',) ('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted iso7816-1: RST deasserted
('RST deasserted',) ('RST deasserted',)
@@ -342,6 +342,11 @@ test_explicit_eof_wins (__main__.TestAtEof.test_explicit_eof_wins) ... ok
test_pending_replay_is_bounded (__main__.TestAtEof.test_pending_replay_is_bounded) ... ok test_pending_replay_is_bounded (__main__.TestAtEof.test_pending_replay_is_bounded) ... ok
test_pending_replay_is_not_eof (__main__.TestAtEof.test_pending_replay_is_not_eof) ... ok test_pending_replay_is_not_eof (__main__.TestAtEof.test_pending_replay_is_not_eof) ... ok
test_stuck_with_nothing_queued_is_eof (__main__.TestAtEof.test_stuck_with_nothing_queued_is_eof) ... ok test_stuck_with_nothing_queued_is_eof (__main__.TestAtEof.test_stuck_with_nothing_queued_is_eof) ... ok
test_eof_after_plausible_cla_is_clean (__main__.TestAtrHuntDetect.test_eof_after_plausible_cla_is_clean) ... ok
test_handoff_on_extended_channel_cla (__main__.TestAtrHuntDetect.test_handoff_on_extended_channel_cla) ... ok
test_handoff_on_odd_ins_89 (__main__.TestAtrHuntDetect.test_handoff_on_odd_ins_89) ... ok
test_no_handoff_on_basic_channel_cla (__main__.TestAtrHuntDetect.test_no_handoff_on_basic_channel_cla) ... ok
test_no_handoff_on_invalid_ins (__main__.TestAtrHuntDetect.test_no_handoff_on_invalid_ins) ... ok
test_all_ff_idle_loop_is_bounded (__main__.TestAtrHuntEscape.test_all_ff_idle_loop_is_bounded) ... ok test_all_ff_idle_loop_is_bounded (__main__.TestAtrHuntEscape.test_all_ff_idle_loop_is_bounded) ... ok
test_idle_ff_retries_before_cap (__main__.TestAtrHuntEscape.test_idle_ff_retries_before_cap) ... ok test_idle_ff_retries_before_cap (__main__.TestAtrHuntEscape.test_idle_ff_retries_before_cap) ... ok
test_idle_resets_capped_then_resume (__main__.TestAtrHuntEscape.test_idle_resets_capped_then_resume) ... ok test_idle_resets_capped_then_resume (__main__.TestAtrHuntEscape.test_idle_resets_capped_then_resume) ... ok
@@ -350,6 +355,12 @@ test_non_ff_resumes_when_count_high (__main__.TestAtrHuntEscape.test_non_ff_resu
test_recover_etu_in_hunt_runs_measurement (__main__.TestAtrHuntEscape.test_recover_etu_in_hunt_runs_measurement) ... ok test_recover_etu_in_hunt_runs_measurement (__main__.TestAtrHuntEscape.test_recover_etu_in_hunt_runs_measurement) ... ok
test_recovery_attempts_capped_then_hard_limit (__main__.TestAtrHuntEscape.test_recovery_attempts_capped_then_hard_limit) ... ok test_recovery_attempts_capped_then_hard_limit (__main__.TestAtrHuntEscape.test_recovery_attempts_capped_then_hard_limit) ... ok
test_retry_then_recover_after_two_failures (__main__.TestAtrHuntEscape.test_retry_then_recover_after_two_failures) ... ok test_retry_then_recover_after_two_failures (__main__.TestAtrHuntEscape.test_retry_then_recover_after_two_failures) ... ok
test_bit_dense_valid_cla_is_not_idle_noise (__main__.TestDecodeStep.test_bit_dense_valid_cla_is_not_idle_noise) ... ok
test_ex_class_exchange_frames (__main__.TestDecodeStep.test_ex_class_exchange_frames) ... ok
test_extended_channel_exchange_frames (__main__.TestDecodeStep.test_extended_channel_exchange_frames) ... ok
test_idle_noise_still_discarded (__main__.TestDecodeStep.test_idle_noise_still_discarded) ... ok
test_invalid_ins_re_frame_recovers_command (__main__.TestDecodeStep.test_invalid_ins_re_frame_recovers_command) ... ok
test_text_byte_boundary_is_discarded_not_framed (__main__.TestDecodeStep.test_text_byte_boundary_is_discarded_not_framed) ... ok
test_glitch_low_pulse_removed (__main__.TestDeglitchEdges.test_glitch_low_pulse_removed) ... ok test_glitch_low_pulse_removed (__main__.TestDeglitchEdges.test_glitch_low_pulse_removed) ... ok
test_no_edges (__main__.TestDeglitchEdges.test_no_edges) ... ok test_no_edges (__main__.TestDeglitchEdges.test_no_edges) ... ok
test_real_pulses_preserved (__main__.TestDeglitchEdges.test_real_pulses_preserved) ... ok test_real_pulses_preserved (__main__.TestDeglitchEdges.test_real_pulses_preserved) ... ok
@@ -424,6 +435,6 @@ test_valid_ins_odd_variants (__main__.TestValidateT0Apdu.test_valid_ins_odd_vari
test_vs_reader_mirror (__main__.TestValidateT0Apdu.test_vs_reader_mirror) ... ok test_vs_reader_mirror (__main__.TestValidateT0Apdu.test_vs_reader_mirror) ... ok
---------------------------------------------------------------------- ----------------------------------------------------------------------
Ran 84 tests in 0.025s Ran 95 tests in 0.028s
OK OK
+211
View File
@@ -752,5 +752,216 @@ class TestLineEventPayload(unittest.TestCase):
self.assertEqual(self.pd.LINE_EVENT_FLAG_CLK_HZ, 0x01) self.assertEqual(self.pd.LINE_EVENT_FLAG_CLK_HZ, 0x01)
class TestDecodeStep(unittest.TestCase):
"""Byte-replay harness for the DATA-state gate chain in decode_step().
The rest of the suite exercises only pure functions and leaf methods;
these tests drive the real state machine with a scripted byte queue so
the CLA/INS acceptance rules are pinned on the framing path (gate
ordering, header read, one-byte re-frame, exchange assembly), not just
in the predicates.
"""
@classmethod
def setUpClass(cls):
cls.gs, cls.pd = _load_iso7816()
def _make(self, byte_seq):
inst = object.__new__(self.pd.Decoder)
inst.reset()
inst.samplenum = 0
inst._inverse_convention = False # normally set by start()
inst._atr_parsed = True # keep 0x3B on the DATA path
inst._primed = True # skip the first-sample RST/VCC priming
inst._at_eof = lambda: False
inst.state = 'DATA'
inst.hasT0 = True
inst.hasT1 = False
inst.bit_samples = 100 # ETU known: skip _measure_etu()
inst._pps_speed_changed = False
inst._resync = False
inst.out_ann = 0
inst.emitted = []
inst.discarded = []
inst._bytes = collections.deque(byte_seq)
def pop_byte():
if inst._replay:
return inst._replay.popleft()
return inst._bytes.popleft() if inst._bytes else None
def peek_byte():
b = inst._replay[0] if inst._replay else (
inst._bytes[0] if inst._bytes else None)
inst.peeked_samplenum = inst.samplenum
return b
inst.read_byte = pop_byte
inst.read_first_byte = pop_byte
inst._consume_first = pop_byte
inst.peek_byte = peek_byte
# The re-align / PPS / ATR / annotation paths are not under test.
inst.resync_idle = lambda: None
inst.handle_pps = lambda: None
inst.handle_atr = lambda pins: None
inst.put = lambda *a, **k: None
inst.log = lambda *a, **k: None
inst._note_discard = lambda value, reason, idle=False: \
inst.discarded.append((value, reason, idle))
inst._note_pre_sw = lambda value, reason: \
inst.discarded.append((value, reason, True))
inst.emit_packet = lambda sub_type, payload, ss, es, flags=0: \
inst.emitted.append((bytes(payload), flags))
# The vectors never depend on sample positions; a benign wait()
# keeps an unrelated wait call from crashing the harness.
inst.wait = lambda cond=None: [1] * 8
return inst
def test_extended_channel_exchange_frames(self):
# 'CX' STATUS on channel 4 (v1.9.3): the whole exchange must be
# read and emitted clean.
inst = self._make(bytes.fromhex('c0f20000009000'))
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted,
[(bytes.fromhex('c0f20000009000'), 0)])
self.assertEqual(inst.discarded, [])
def test_ex_class_exchange_frames(self):
# 'EX' STATUS on channel 6 with SM (b6 = 1): same.
inst = self._make(bytes.fromhex('e2f20000009000'))
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted,
[(bytes.fromhex('e2f20000009000'), 0)])
self.assertEqual(inst.discarded, [])
def test_bit_dense_valid_cla_is_not_idle_noise(self):
# 0x5F was accepted by plausible_cla() all along but always
# discarded by the idle-noise gate before v1.9.3 (dead code): the
# framing path must consume it as a command byte.
inst = self._make(bytes.fromhex('5fa40004009000'))
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted,
[(bytes.fromhex('5fa40004009000'), 0)])
self.assertEqual(inst.discarded, [])
def test_text_byte_boundary_is_discarded_not_framed(self):
# xiaomi_mi_a1_coldboot regression byte: the menu-text 'a' (0x61)
# at a mis-framed boundary is a suspicious CLA (not a frame start
# and not idle noise) -- exactly one byte is consumed.
inst = self._make(bytes.fromhex('612000c000621e'))
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted, [])
self.assertEqual(len(inst.discarded), 1)
self.assertEqual(inst.discarded[0][0], 0x61)
self.assertIn('suspicious CLA', inst.discarded[0][1])
self.assertEqual(bytes(inst._bytes), bytes.fromhex('2000c000621e'))
def test_invalid_ins_re_frame_recovers_command(self):
# A stray leading byte (0x1C) shifts the header; the invalid-INS
# window (INS 0x00) drops only that byte and re-frames the real
# command, which is emitted intact on the next step.
inst = self._make(bytes.fromhex('1c00a40004009000'))
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted, [])
self.assertEqual(inst.discarded[0][0], 0x1c)
self.assertIn('invalid INS', inst.discarded[0][1])
self.assertEqual(list(inst._replay), [0x00, 0xA4, 0x00, 0x04])
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted,
[(bytes.fromhex('00a40004009000'), 0)])
def test_idle_noise_still_discarded(self):
# 0xFE (all-1s minus one bit) is not a valid class: unchanged
# idle-noise discard.
inst = self._make(bytes.fromhex('fea40004009000'))
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted, [])
self.assertEqual(len(inst.discarded), 1)
self.assertEqual(inst.discarded[0][0], 0xfe)
self.assertIn('idle-line noise', inst.discarded[0][1])
class TestAtrHuntDetect(unittest.TestCase):
"""The ATR-hunt command-detect handoff in handle_atr(), driven by a
scripted byte queue (the hunt logic itself is not otherwise covered)."""
@classmethod
def setUpClass(cls):
cls.gs, cls.pd = _load_iso7816()
def _make(self, byte_seq):
inst = object.__new__(self.pd.Decoder)
inst.reset()
inst.samplenum = 0
inst.ss = 0
inst._inverse_convention = False # normally set by start()
inst._samples_per_clock = 4.0 # skip _measure_clock_period()
inst.bit_samples = 100 # skip _compute_bit_samples()
inst.out_ann = 0
inst.hasT0 = False # start() defaults; a handoff flips it
inst.hasT1 = False
inst._bytes = collections.deque(byte_seq)
def pop_byte():
if inst._replay:
return inst._replay.popleft()
return inst._bytes.popleft() if inst._bytes else None
def peek_byte():
b = inst._replay[0] if inst._replay else (
inst._bytes[0] if inst._bytes else None)
inst.peeked_samplenum = inst.samplenum
return b
inst.read_byte = pop_byte
inst.read_first_byte = pop_byte
inst._consume_first = pop_byte
inst.peek_byte = peek_byte
inst.put = lambda *a, **k: None
inst.log = lambda *a, **k: None
inst.wait = lambda cond=None: [1] * 8
return inst
def test_handoff_on_extended_channel_cla(self):
# 0xC4 (channel 8, no SM) + 0xA4 (SELECT): a command header, not a
# non-TS byte; it is replayed to the DATA state.
inst = self._make([0xC4, 0xA4])
inst.handle_atr(None)
self.assertEqual(inst.state, 'DATA')
self.assertEqual(list(inst._replay), [0xC4])
self.assertTrue(inst.hasT0)
def test_handoff_on_odd_ins_89(self):
# The TS 102 221 AUTHENTICATE '89' form (v1.9.3): the second byte
# only qualifies because ODD_INS accepts it.
inst = self._make([0xC4, 0x89])
inst.handle_atr(None)
self.assertEqual(inst.state, 'DATA')
self.assertEqual(list(inst._replay), [0xC4])
def test_no_handoff_on_basic_channel_cla(self):
# CLA 0x00 is excluded by design (pre-activation noise).
inst = self._make([0x00, 0xA4])
inst.handle_atr(None)
self.assertEqual(inst.state, 'ATR')
self.assertEqual(list(inst._replay), [])
def test_no_handoff_on_invalid_ins(self):
# 0x61 is not a valid INS: the pair stays on the hunt path.
inst = self._make([0xC4, 0x61])
inst.handle_atr(None)
self.assertEqual(inst.state, 'ATR')
self.assertEqual(list(inst._replay), [])
def test_eof_after_plausible_cla_is_clean(self):
# Capture ends right after a plausible CLA byte: the hunt must
# return cleanly. It used to call plausible_ins(None) -> TypeError
# (seen only by decode()'s crash protection).
inst = self._make([0xC4])
inst.handle_atr(None) # must not raise
self.assertEqual(inst.state, 'ATR')
self.assertEqual(list(inst._replay), [])
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()