diff --git a/AGENTS.md b/AGENTS.md index 82db650..6a7d858 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `.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 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 -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. ### Testing after decoder changes diff --git a/pd.py b/pd.py index 06e3a5e..bc2730c 100644 --- a/pd.py +++ b/pd.py @@ -28,7 +28,7 @@ from .gsmtap_stream import (GsmtapStreamSender, GSMTAP_SIM_RST_EVENT, GSMTAP_SIM_VCC_EVENT, 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. if plausible_cla(byte) and byte != 0x00: 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.hasT0 = True self.hasT1 = False diff --git a/tests/baseline.txt b/tests/baseline.txt index 6a7f09a..b4821d1 100644 --- a/tests/baseline.txt +++ b/tests/baseline.txt @@ -1,7 +1,7 @@ -=== Baseline captured 2026-10-17T23:51:01+03:00 === +=== Baseline captured 2026-10-18T02:13:20+03:00 === === test_8 === -('=== iso7816 decoder v1.9.3 ===',) +('=== iso7816 decoder v1.9.4 ===',) ('etu recovered (samples):', 1237) iso7816-1: RST deasserted ('RST deasserted',) @@ -36,7 +36,7 @@ Degradation metrics: RESULT: OK - capture fully explained (reader + CAT + valid retries) === test_7 === -('=== iso7816 decoder v1.9.3 ===',) +('=== iso7816 decoder v1.9.4 ===',) ('etu recovered (samples):', 1236) iso7816-1: RST deasserted ('RST deasserted',) @@ -77,7 +77,7 @@ Degradation metrics: RESULT: OK - capture fully explained (reader + CAT + valid retries) === xiaomi_phone_sample === -('=== iso7816 decoder v1.9.3 ===',) +('=== iso7816 decoder v1.9.4 ===',) ('etu recovered (samples):', 53) (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] @@ -102,7 +102,7 @@ Degradation metrics: RESULT: OK - capture fully explained (reader + CAT + valid retries) === xiaomi_mi_a1_coldboot === -('=== iso7816 decoder v1.9.3 ===',) +('=== iso7816 decoder v1.9.4 ===',) ('RST low at start: expecting cold-boot ATR',) iso7816-1: RST deasserted ('RST deasserted',) @@ -129,7 +129,7 @@ Degradation metrics: RESULT: OK - capture fully explained (reader + CAT + valid retries) === 4gmodem_coldboot === -('=== iso7816 decoder v1.9.3 ===',) +('=== iso7816 decoder v1.9.4 ===',) ('RST low at start: expecting cold-boot ATR',) iso7816-1: RST deasserted ('RST deasserted',) @@ -156,7 +156,7 @@ Degradation metrics: RESULT: OK - capture fully explained (reader + CAT + valid retries) === alcor_reader_sample === -('=== iso7816 decoder v1.9.3 ===',) +('=== iso7816 decoder v1.9.4 ===',) ('RST low at start: expecting cold-boot ATR',) iso7816-1: RST deasserted ('RST deasserted',) @@ -189,7 +189,7 @@ Degradation metrics: RESULT: OK - capture fully explained (reader + CAT + valid retries) === samsung_A55_coldboot_new_cable === -('=== iso7816 decoder v1.9.3 ===',) +('=== iso7816 decoder v1.9.4 ===',) ('RST low at start: expecting cold-boot ATR',) iso7816-1: RST deasserted ('RST deasserted',) @@ -216,7 +216,7 @@ Degradation metrics: RESULT: OK - capture fully explained (reader + CAT + valid retries) === samsung_A55_coldboot_new_cable_sim2 === -('=== iso7816 decoder v1.9.3 ===',) +('=== iso7816 decoder v1.9.4 ===',) ('RST low at start: expecting cold-boot ATR',) iso7816-1: RST deasserted ('RST deasserted',) @@ -243,7 +243,7 @@ Degradation metrics: RESULT: OK - capture fully explained (reader + CAT + valid retries) === samsung_S21p_coldboot_new_cable === -('=== iso7816 decoder v1.9.3 ===',) +('=== iso7816 decoder v1.9.4 ===',) ('RST low at start: expecting cold-boot ATR',) iso7816-1: RST deasserted ('RST deasserted',) @@ -270,7 +270,7 @@ Degradation metrics: RESULT: OK - capture fully explained (reader + CAT + valid retries) === samsung_S21p_coldboot_new_cable_sim2 === -('=== iso7816 decoder v1.9.3 ===',) +('=== iso7816 decoder v1.9.4 ===',) ('RST low at start: expecting cold-boot ATR',) iso7816-1: 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_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_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_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 @@ -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_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_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_no_edges (__main__.TestDeglitchEdges.test_no_edges) ... 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 ---------------------------------------------------------------------- -Ran 84 tests in 0.025s +Ran 95 tests in 0.028s OK diff --git a/tests/test_gsmtap.py b/tests/test_gsmtap.py index aab3ed6..27a3aab 100644 --- a/tests/test_gsmtap.py +++ b/tests/test_gsmtap.py @@ -752,5 +752,216 @@ class TestLineEventPayload(unittest.TestCase): 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__': unittest.main()