diff --git a/frontend/index.html b/frontend/index.html index 014579a..f8bf728 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1925,7 +1925,7 @@ function showDetail(msg, entry) { INS0x${(decoded.ins_hex || '').toUpperCase()} — ${decoded.ins_name} ${catRow}${respRow}${trRow} ${p1p2}${p1}${p2} - P30x${hx(decoded.p3, 2)} — ${decoded.p3} bytes${lmNote} + P30x${hx(decoded.p3, 2)} — ${decoded.p3} bytes${decoded.p3_kind === 'le' ? ' · ' + t('Le (expected response length)') : decoded.p3_kind === 'lc' ? ' · ' + t('Lc (command data length)') : ''}${lmNote} ${body} ${cmdRows(decoded.cmd)} ${responseRows(decoded.response)} @@ -3386,7 +3386,7 @@ function onSearchChange() { } // ──────────────────── Versioning ──────────────────── -const PWA_VERSION = '2.7.8'; +const PWA_VERSION = '2.7.9'; const MIN_SERVER_VERSION = '2.3.0'; function parseVersion(v) { @@ -3453,6 +3453,8 @@ const LANG_RU = { 'Close': 'Закрыть', 'Offset': 'Смещение', 'Record': 'Запись №', + 'Lc (command data length)': 'Lc (длина данных команды)', + 'Le (expected response length)': 'Le (ожидаемая длина ответа)', 'Before': 'Было', 'After': 'Стало', 'Show the change': 'Показать изменение', diff --git a/frontend/sw.js b/frontend/sw.js index 519fc62..fb65e99 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simtrace-analyser-v88'; +const CACHE = 'simtrace-analyser-v89'; const PRECACHE = [ 'index.html', diff --git a/frontend/tests/style.test.js b/frontend/tests/style.test.js index 503e350..033d214 100644 --- a/frontend/tests/style.test.js +++ b/frontend/tests/style.test.js @@ -22,6 +22,11 @@ test('no per-element scrollbar overrides or thin default remain', () => { assert.doesNotMatch(src, /::-webkit-scrollbar \{ width: 6px/); }); +test('the P3 row labels Lc and Le', () => { + assert.match(src, /decoded\.p3_kind === 'le' \? ' · ' \+ t\('Le \(expected response length\)'\)/); + assert.match(src, /t\('Lc \(command data length\)'\)/); +}); + // ── trace topbar layout ──────────────────────────────────────────────── test('the view selector sits over the side panel at the panel width', () => { diff --git a/pyproject.toml b/pyproject.toml index b3b2d36..a2d31a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend" [project] name = "simtrace-analyser" -version = "2.7.8" +version = "2.7.9" description = "SIMtrace Analyser — APDU trace analyser for GSMTAP/PCAP captures (bundles the simtrace2-pysniff utility)" readme = {file = "README.md", content-type = "text/markdown"} license = {text = "GPL-2.0-or-later"} diff --git a/simtrace_analyser/decode.py b/simtrace_analyser/decode.py index 2fe8a51..9aa5329 100644 --- a/simtrace_analyser/decode.py +++ b/simtrace_analyser/decode.py @@ -3942,6 +3942,14 @@ def decode_message(raw_data, prev=None): elif cmd_body_len < p3 and not (spec and spec.get('le')): result['length_mismatch'] = {'kind': 'truncated', 'expected': p3, 'actual': cmd_body_len} + # P3 is the length of the command data (Lc) when the command carries data + # and the expected response length (Le) for a case-2 command — the two are + # otherwise easy to confuse in the detailed view. + if spec and spec.get('le'): + result['p3_kind'] = 'le' + elif cmd_body_len > 0: + result['p3_kind'] = 'lc' + if cmd_body_len > 0: body = remaining[:cmd_body_len] result['body'] = {'hex': body.hex(), 'size': cmd_body_len} @@ -4020,14 +4028,23 @@ def decode_message(raw_data, prev=None): 'raw': body.hex().upper()} else: result['file'] = {'sfi': rec_sfi, 'unknown': True, 'ef': f'SFI {rec_sfi}'} - elif ins == 0xA2 and prev and rec_sfi: - # SEARCH RECORD with SFI → attach the target file name (the body - # is a search pattern, not file data). - fid = (prev.get('sfi_map') or {}).get(rec_sfi) + elif ins == 0xA2 and prev and (rec_sfi or prev.get('sel')): + # SEARCH RECORD: the body is a search pattern, not file data. An + # SFI addresses the file it names; without one the search runs in + # the current EF (TS 102 221 §11.1.7.1) — show the target like + # READ/UPDATE do. + sel = prev.get('sel') or {} + fid = (prev.get('sfi_map') or {}).get(rec_sfi) if rec_sfi \ + else sel.get('fid') if fid: - result['file'] = {'fid': fid, 'ef': _file_name(fid, prev.get('df'))} - else: - result['file'] = {'sfi': rec_sfi, 'unknown': True, 'ef': f'SFI {rec_sfi}'} + if not rec_sfi and not _is_record_file(sel): + result['file'] = _stale_file_note(sel) + else: + result['file'] = {'fid': fid, + 'ef': _file_name(fid, prev.get('df'))} + elif rec_sfi: + result['file'] = {'sfi': rec_sfi, 'unknown': True, + 'ef': f'SFI {rec_sfi}'} elif ins in (0xB0, 0xB2, 0xD6, 0xDC) and prev and prev.get('sel'): offset = p1p2.get('offset', 0) if ins in (0xB2, 0xDC) and not _is_record_file(prev['sel']): diff --git a/simtrace_analyser/version.py b/simtrace_analyser/version.py index 47467f4..cd3138d 100644 --- a/simtrace_analyser/version.py +++ b/simtrace_analyser/version.py @@ -1,3 +1,3 @@ """Single source of truth for the SIMtrace Analyser version.""" -__version__ = '2.7.8' +__version__ = '2.7.9' diff --git a/tests/test_decode.py b/tests/test_decode.py index 0e4310e..2dce4cd 100644 --- a/tests/test_decode.py +++ b/tests/test_decode.py @@ -1967,6 +1967,42 @@ class TestP1P2(unittest.TestCase): r = decode_message(bytes.fromhex('0020008108' + '12' * 8)) self.assertEqual(r['p2']['name'], 'Second PIN Appl 1') + def test_p3_kind_lc_le(self): + # P3 is the command-data length (Lc) when the command carries data and + # the expected response length (Le) for a case-2 command. + from simtrace_analyser.decode import decode_message + self.assertEqual(decode_message(bytes.fromhex('00b000000a'))['p3_kind'], + 'le') # READ BINARY: Le + self.assertEqual(decode_message( + bytes.fromhex('00a40000023f00'))['p3_kind'], 'lc') # SELECT: Lc + self.assertEqual(decode_message( + bytes.fromhex('00a201040200006164'))['p3_kind'], 'lc') # pattern + # A zero-length SELECT carries neither (no data, no Le). + self.assertNotIn('p3_kind', + decode_message(bytes.fromhex('00a4000000'))) + + def test_search_record_target_file(self): + # A SEARCH RECORD without an SFI searches the current EF + # (TS 102 221 §11.1.7.1) — show the target like READ RECORD does. + from simtrace_analyser.decode import decode_message + prev = {'sel': {'fid': '4fa1', 'structure': 'linear fixed'}, + 'df': '5f3a'} + r = decode_message(bytes.fromhex('00a201040200006164'), prev=prev) + self.assertEqual(r['file'], {'fid': '4fa1', 'ef': '4FA1'}) + # An SFI-addressed search resolves through the SFI map… + prev['sfi_map'] = {2: '4f3a'} + r = decode_message(bytes.fromhex('00a201142c' + 'ff' * 44), prev=prev) + self.assertEqual(r['file'], {'fid': '4f3a', 'ef': '4F3A'}) + # …and an unknown SFI stays marked as such. + prev['sfi_map'] = {} + r = decode_message(bytes.fromhex('00a201142c' + 'ff' * 44), prev=prev) + self.assertTrue(r['file']['unknown']) + self.assertEqual(r['file']['sfi'], 2) + # A record search on a transparent selection is a stale-selection note. + prev2 = {'sel': {'fid': '2fe2', 'structure': 'transparent'}, 'df': '3f00'} + r = decode_message(bytes.fromhex('00a201040200006164'), prev=prev2) + self.assertTrue(r['file'].get('stale')) + def test_manage_channel_p2(self): r = decode_message(bytes.fromhex('0070000300')) self.assertEqual(r['p1']['name'], 'Open channel') diff --git a/tests/test_pcap_import.py b/tests/test_pcap_import.py index 47914a8..bddf362 100644 --- a/tests/test_pcap_import.py +++ b/tests/test_pcap_import.py @@ -224,7 +224,7 @@ class TestImportEndpoint(unittest.TestCase): class TestVersion(unittest.TestCase): def test_package_version(self): from simtrace2_pysniff import __version__ - self.assertEqual(__version__, '2.7.8') + self.assertEqual(__version__, '2.7.9') def test_status_includes_version(self): import os