v1.6.0: ATR-hunt deadlock + idle-loop trap fixes

Add hunt-side live ETU recovery so a card already at a non-default F/D
(e.g. Samsung F=512/D=32 -> 16 CLK/bit) can be decoded without a 372-rate
ATR, and bound the all-0xFF hunt so an idle/unsyncable line can never loop
forever.

- _recover_etu_in_hunt(): measure the live ETU from DATA edges while still
  in FIND START, then re-derive clock_skip; reuses _confirm_etu validation.
- _atr_hunt_failure_action(): pure recover/retry/resume_data decision, with
  ATR_HUNT_ETU_ATTEMPTS=3 / ATR_HUNT_IDLE_RESETS=3 / ATR_HUNT_FAIL_LIMIT=12.
- Persistent counters reset only on a successful ATR parse (not RST re-arm),
  so RST-cycling phones cannot starve the escape.
- 8 new unit tests (55 total).

Validated on the full trace suite (test_8 56/0/OK, test_7 65/0/OK, xiaomi
640, xiaomi_cold 473, 4gmodem 327, A55 407/395, S21p 399/375, target S21p
369; 0 CONCAT, RESULT: OK) with no regressions.
This commit is contained in:
Трошин Антон Валерьевич committed 2026-09-11 17:45:54 +03:00
1 parent b7a6e50fc8
commit bca1b85b94
3 files changed
+237 -13

No files matched your search

+87
View File
@@ -248,6 +248,93 @@ class TestDeglitchEdges(unittest.TestCase):
self.assertEqual(result, [(0, 0), (100, 0)])
class TestAtrHuntEscape(unittest.TestCase):
"""Regression: the ATR hunt must never loop forever on an idle/unsyncable
line, and must recover the live ETU when the card is already at a
non-default F/D (Samsung 16 CLK/bit) with no 372-rate ATR coming."""
def setUp(self):
_, pd = _load_iso7816()
self.pd = pd
self.inst = object.__new__(pd.Decoder)
self.inst.bit_samples = None
self.inst._atr_hunt_count = 0
self.inst._atr_fail_total = 0
self.inst._hunt_etu_attempts = 0
self.inst._atr_idle_resets = 0
def _hunt_round(self, byte):
"""Mirror handle_atr's bookkeeping around _atr_hunt_failure_action."""
self.inst._atr_hunt_count += 1
action = self.inst._atr_hunt_failure_action(byte)
if action == 'recover':
self.inst._atr_hunt_count = 0
elif action == 'retry' and byte == 0xFF:
self.inst._atr_idle_resets += 1
self.inst._atr_hunt_count = 0
return action
def test_retry_then_recover_after_two_failures(self):
self.assertEqual(self._hunt_round(0x00), 'retry')
self.assertEqual(self._hunt_round(0x00), 'recover')
self.assertEqual(self.inst._hunt_etu_attempts, 1)
def test_recovery_attempts_capped_then_hard_limit(self):
actions = [self._hunt_round(0x00) for _ in range(200)]
actions = actions[:actions.index('resume_data') + 1]
self.assertEqual(actions.count('recover'),
self.pd.ATR_HUNT_ETU_ATTEMPTS)
self.assertEqual(actions[-1], 'resume_data')
def test_all_ff_idle_loop_is_bounded(self):
seen = []
for _ in range(200):
seen.append(self._hunt_round(0xFF))
if seen[-1] == 'resume_data':
break
self.assertEqual(seen[-1], 'resume_data')
self.assertLess(len(seen), 20)
def test_idle_ff_retries_before_cap(self):
self.inst.bit_samples = 100 # ETU known -> recovery disabled
self.inst._hunt_etu_attempts = self.pd.ATR_HUNT_ETU_ATTEMPTS
self.inst._atr_idle_resets = self.pd.ATR_HUNT_IDLE_RESETS - 1
self.assertEqual(self.inst._atr_hunt_failure_action(0xFF), 'retry')
def test_idle_resets_capped_then_resume(self):
self.inst.bit_samples = 100
self.inst._hunt_etu_attempts = self.pd.ATR_HUNT_ETU_ATTEMPTS
self.inst._atr_idle_resets = self.pd.ATR_HUNT_IDLE_RESETS
self.assertEqual(self.inst._atr_hunt_failure_action(0xFF),
'resume_data')
def test_non_ff_resumes_when_count_high(self):
self.inst.bit_samples = 100
self.inst._hunt_etu_attempts = self.pd.ATR_HUNT_ETU_ATTEMPTS
self.inst._atr_hunt_count = 8
self.assertEqual(self.inst._atr_hunt_failure_action(0x00),
'resume_data')
def test_no_recovery_when_etu_known(self):
self.inst.bit_samples = 100
for _ in range(6):
self.assertEqual(self._hunt_round(0x00), 'retry')
self.assertEqual(self.inst._hunt_etu_attempts, 0)
def test_recover_etu_in_hunt_runs_measurement(self):
calls = []
self.inst._samples_per_clock = 4.0 # skip CLK-period measurement
self.inst._measure_etu = lambda: calls.append('measure')
self.inst._derive_clock_skip_from_etu = lambda: calls.append('derive')
self.inst._measure_clock_period = lambda: calls.append('period')
self.inst.log = lambda *a, **k: None
self.inst.clock_skip = 372
self.inst._atr_hunt_count = 5
self.inst._recover_etu_in_hunt()
self.assertEqual(calls, ['measure', 'derive'])
self.assertEqual(self.inst._atr_hunt_count, 0)
class TestEmbeddedExchanges(unittest.TestCase):
"""CONCAT detection: structurally valid packets that embed complete T=0
exchanges (a byte-drop mis-frame swallowed several real exchanges into