diff --git a/README.md b/README.md index 34119ba..19e074f 100644 --- a/README.md +++ b/README.md @@ -216,8 +216,10 @@ new messages pin the view to the bottom only while the reader is already there, so a jump is never pulled away or un-highlighted by the live poll). Rows that carry a recorded change (a card-side change, or an update that wrote or first observed bytes) also offer **Show diff**: a modal showing the full file path (`MF/EF_ICCID (2FE2)`, -`ADF/EF_IMSI (6F07)`, `DF_GSM_ACCESS/EF_KcGPRS (4F52)`, `AID …/4F09` — the DF -named from the EF.DIR labels when known) and the affected unit — the whole +`ADF/EF_IMSI (6F07)`, `DF_TELECOM/DF_PHONEBOOK/4FA1`, `AID …/4F09` — the whole +DF chain the trace's selection path spells out, each DF named from the +specification dictionary and the AID from the EF.DIR labels when known) and +the affected unit — the whole file, or the single record that changed — **before and after** the change, as hex views (up to 16 bytes per row — a shorter file/record ends its row there instead of padding with `--`, and the offset column only appears when a view diff --git a/README_RUS.md b/README_RUS.md index 51d3a5a..4df5fff 100644 --- a/README_RUS.md +++ b/README_RUS.md @@ -221,8 +221,10 @@ Files — это таймлайн файловых операций по все поэтому переход не срывается живым опросом и не снимает выделение). У строк с записанным изменением (изменение со стороны карты либо запись, изменившая или впервые показавшая байты) есть кнопка **Show diff** («Изменения»): окно показывает полный путь файла (`MF/EF_ICCID (2FE2)`, -`ADF/EF_IMSI (6F07)`, `DF_GSM_ACCESS/EF_KcGPRS (4F52)`, `AID …/4F09` — DF -называется по меткам из EF.DIR, если они есть) и затронутую единицу — файл +`ADF/EF_IMSI (6F07)`, `DF_TELECOM/DF_PHONEBOOK/4FA1`, `AID …/4F09` — вся +цепочка DF из пути выбора в трассе, каждый DF назван по словарю +спецификаций, а AID — по меткам из EF.DIR, если они есть) и затронутую +единицу — файл целиком или изменившуюся запись — **до и после** изменения в виде hex-вида (до 16 байт в строке: короткий файл/запись заканчивает строку, а не дополняется `--`, и столбец смещений появляется, только когда представление diff --git a/frontend/index.html b/frontend/index.html index 427cc65..8d3fc4c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3386,7 +3386,7 @@ function onSearchChange() { } // ──────────────────── Versioning ──────────────────── -const PWA_VERSION = '2.7.5'; +const PWA_VERSION = '2.7.6'; const MIN_SERVER_VERSION = '2.3.0'; function parseVersion(v) { diff --git a/frontend/sw.js b/frontend/sw.js index 73bbf29..bf05f20 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simtrace-analyser-v85'; +const CACHE = 'simtrace-analyser-v86'; const PRECACHE = [ 'index.html', diff --git a/pyproject.toml b/pyproject.toml index 5d086be..fdcc085 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend" [project] name = "simtrace-analyser" -version = "2.7.5" +version = "2.7.6" 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/database.py b/simtrace_analyser/database.py index 6433c42..bf8e761 100644 --- a/simtrace_analyser/database.py +++ b/simtrace_analyser/database.py @@ -274,7 +274,8 @@ class Database: def _new_channel_state(self): from .decode import sfi_table - return {'prev': None, 'sel': None, 'df': '3f00', 'sfi_map': dict(sfi_table('3f00'))} + return {'prev': None, 'sel': None, 'df': '3f00', 'df_path': ['3f00'], + 'sfi_map': dict(sfi_table('3f00'))} def _channel_state(self, states, channel): st = states.get(channel) @@ -312,22 +313,26 @@ class Database: ctx = dict(st['prev']) if st['prev'] else {} ctx['sel'] = st['sel'] ctx['df'] = st['df'] + ctx['df_path'] = st['df_path'] ctx['sfi_map'] = st['sfi_map'] d = decode_sniff_msg(data, 'tpdu', 0, prev=ctx) if not d or not d.get('ins_hex'): continue - st['sel'], st['df'], st['sfi_map'] = self._advance_state(d, st['sel'], st['df'], st['sfi_map']) + (st['sel'], st['df'], st['sfi_map'], + st['df_path']) = self._advance_state(d, st['sel'], st['df'], + st['sfi_map'], st['df_path']) st['prev'] = self._context_from_decoded(d) return states, clk_hz - def _advance_state(self, d, sel, df, sfi_map): - """Update (sel, df, sfi_map) for one decoded TPDU.""" - from .decode import selected_df_fid, sfi_table, KNOWN_FIDS - new_df = selected_df_fid(d, df) + def _advance_state(self, d, sel, df, sfi_map, df_path=None): + """Update (sel, df, sfi_map, df_path) for one decoded TPDU.""" + from .decode import selected_df_state, sfi_table, KNOWN_FIDS + new_df, new_path = selected_df_state(d, df, df_path) if new_df is not None and new_df != df: # Only a successful SELECT changes the current DF. if (d.get('sw') or {}).get('sw1') in _SUCCESS_SW1: df = new_df + df_path = new_path sfi_map = dict(sfi_table(new_df)) # Learn SFI from the FCP of the last SELECT (GET RESPONSE). if d.get('ins_hex') == 'c0' and d.get('response_for') == 'SELECT' and sel: @@ -343,7 +348,7 @@ class Database: fid = sfi_map.get(sfi) if fid: sel = {'fid': fid, 'name': KNOWN_FIDS.get(fid), 'structure': None} - return sel, df, sfi_map + return sel, df, sfi_map, (df_path or [df]) def _context_from_decoded(self, d): ins_hex = d.get('ins_hex') @@ -400,6 +405,7 @@ class Database: ctx = dict(st['prev']) if st['prev'] else {} ctx['sel'] = st['sel'] ctx['df'] = st['df'] + ctx['df_path'] = st['df_path'] ctx['sfi_map'] = st['sfi_map'] msg = self._row_to_message(row, prev=ctx, clk_hz=clk_hz) d = msg.get('decoded') @@ -408,7 +414,9 @@ class Database: # channel state as it was *before* this message. on_message(msg, st, channel) if msg['type'] == 'tpdu' and d and d.get('ins_hex'): - st['sel'], st['df'], st['sfi_map'] = self._advance_state(d, st['sel'], st['df'], st['sfi_map']) + (st['sel'], st['df'], st['sfi_map'], + st['df_path']) = self._advance_state(d, st['sel'], st['df'], + st['sfi_map'], st['df_path']) st['prev'] = self._context_from_decoded(d) elif msg['type'] == 'atr': states = {} diff --git a/simtrace_analyser/decode.py b/simtrace_analyser/decode.py index 7269324..770ca01 100644 --- a/simtrace_analyser/decode.py +++ b/simtrace_analyser/decode.py @@ -298,6 +298,44 @@ def selected_df_fid(d, current_df=None): return None +def selected_df_state(d, current_df=None, current_path=None): + """(current DF, DF path) after a SELECT, or ``(None, None)``. + + The path is the chain of DFs from the root (MF) or from the running + application's ADF to the current DF: selecting ``7F10/5F3A/4FA1`` leaves + ``('5f3a', ['7f10', '5f3a'])``. It lets the Files view show a file's full + path (``DF_TELECOM/DF_PHONEBOOK/4FA1``) instead of its parent alone. + """ + df = selected_df_fid(d, current_df) + if df is None: + return None, None + p1 = (d.get('p1') or {}).get('raw') + h = (d.get('body') or {}).get('hex') or '' + base = list(current_path or ([current_df] if current_df else [])) + if p1 in ('00', '01'): + # A child DF of the current one; MF and an ADF start a fresh path. + if df == '3f00' or df == '7fff' or df.startswith('aid:'): + return df, [df] + return df, base + [df] if base else ['3f00', df] + if p1 in ('08', '09'): + comps = [h[i:i + 4] for i in range(0, len(h) - 3, 4)] + chain = [] if p1 == '08' else list(base) + for comp in comps: + if comp == '7fff': + # The ADF: the path restarts there (an AID identity stays). + chain = [df] if df.startswith('aid:') else ['7fff'] + elif _is_df_fid(comp): + chain.append(comp) + if not chain: + chain = ['3f00'] if p1 == '08' else (list(base) or ['3f00']) + if chain[-1] != df: + chain.append(df) + return df, chain + if p1 == '04': # by AID: the ADF is the path root + return df, [df] + return df, (base or [df]) + + def sfi_table(df): """Return the SFI→FID table for a DF FID, or an empty dict.""" return _SFI_BY_DF.get(df or '', {}) diff --git a/simtrace_analyser/filetrack.py b/simtrace_analyser/filetrack.py index 11fb385..d92a209 100644 --- a/simtrace_analyser/filetrack.py +++ b/simtrace_analyser/filetrack.py @@ -40,7 +40,8 @@ keeps counting). from .database import _SUCCESS_SW1 from .decode import (DF_CHILDREN, KNOWN_FIDS, _file_name, _is_df_fid, - select_target_fid, selected_df_fid, sfi_table) + select_target_fid, selected_df_fid, selected_df_state, + sfi_table) IMAGE_CAP = 64 * 1024 # observed bytes kept per file EVENTS_CAP = 1000 # change events kept per file @@ -73,20 +74,35 @@ def _file_label(d): return None -def _file_path(df, fid, name, aid_labels=None): - """A human-readable path for a tracked file: the DF (named when known), - the EF and the FID — e.g. ``MF/EF_ICCID (2FE2)``, - ``ADF/EF_IMSI (6F07)``, ``DF_GSM_ACCESS/EF_KcGPRS (4F52)``, - ``AID A00000015141434C00/4F09``.""" +def _file_path(df, fid, name, aid_labels=None, df_path=None): + """A human-readable path for a tracked file: the DF chain (named when + known), the EF and the FID — e.g. ``MF/EF_ICCID (2FE2)``, + ``ADF/EF_IMSI (6F07)``, ``DF_TELECOM/DF_PHONEBOOK/4FA1``, + ``AID A00000015141434C00/4F09``. Without *df_path* the current DF is + named alone (the previous behaviour).""" ef = (DF_CHILDREN.get(df, {}) or {}).get(fid) or name or fid.upper() - if df.startswith('aid:'): - aid = df[4:].upper() - label = (aid_labels or {}).get(aid) - df_label = f'AID {aid}' + (f' ({label})' if label else '') - elif df == '7fff': - df_label = 'ADF' - else: - df_label = KNOWN_FIDS.get(df) or df.upper() + labels = [] + for comp in df_path or []: + if comp == '3f00' and len(df_path) > 1: + continue # MF is implicit in a longer path + if comp.startswith('aid:'): + aid = comp[4:].upper() + label = (aid_labels or {}).get(aid) + labels.append(f'AID {aid}' + (f' ({label})' if label else '')) + elif comp == '7fff': + labels.append('ADF') + else: + labels.append(KNOWN_FIDS.get(comp) or comp.upper()) + if not labels: + if df.startswith('aid:'): + aid = df[4:].upper() + label = (aid_labels or {}).get(aid) + labels = [f'AID {aid}' + (f' ({label})' if label else '')] + elif df == '7fff': + labels = ['ADF'] + else: + labels = [KNOWN_FIDS.get(df) or df.upper()] + df_label = '/'.join(labels) if ef.upper() == fid.upper(): return f'{df_label}/{ef.upper()}' return f'{df_label}/{ef} ({fid.upper()})' @@ -131,11 +147,12 @@ def _segments(buf, mask): class _FileEntry: """One tracked file: counters, sparse image, change log.""" - def __init__(self, key, df, fid, name, image_cap=IMAGE_CAP): + def __init__(self, key, df, fid, name, image_cap=IMAGE_CAP, df_path=None): self.key = key self.df = df self.fid = fid self.name = name + self.df_path = list(df_path or []) self.image_cap = image_cap self.sfi = None self.structure = None @@ -248,21 +265,22 @@ class FileTracker: return ins_hex = d['ins_hex'] df = state.get('df') or '3f00' + df_path = state.get('df_path') or [df] if ins_hex == 'c0': self._observe_get_response(d, state, channel, index, msg) return self._pending_read.pop(channel, None) ins = int(ins_hex, 16) if ins == 0xA4: - self._observe_select(d, df) + self._observe_select(d, df, df_path) elif ins in _UPDATE_INS: - self._observe_write(d, df, index, msg) + self._observe_write(d, df, index, msg, df_path) elif ins in _READ_INS: - self._observe_read(d, df, channel, index, msg) + self._observe_read(d, df, channel, index, msg, df_path) # -- file registry --------------------------------------------------- - def _entry(self, df, fid, name=None): + def _entry(self, df, fid, name=None, df_path=None): key = (df, fid) entry = self.files.get(key) if entry is None: @@ -270,9 +288,14 @@ class FileTracker: # no name) still gets its DF-scoped dictionary name. entry = _FileEntry(key, df, fid, (name or _file_name(fid, df)) if fid else '?', - image_cap=self._image_cap) + image_cap=self._image_cap, df_path=df_path) self.files[key] = entry - elif name: + elif df_path and len(df_path) > len(entry.df_path) and \ + list(df_path[:len(entry.df_path)]) == entry.df_path: + # A fuller path learned later (e.g. the DF was entered by a path + # selection after the first FCP-only sighting). + entry.df_path = list(df_path) + if name: placeholders = {entry.fid, entry.fid.upper(), '', '?'} if entry.sfi is not None: placeholders.add(f'SFI {entry.sfi}') @@ -371,15 +394,18 @@ class FileTracker: # -- operations ------------------------------------------------------ - def _observe_select(self, d, df): + def _observe_select(self, d, df, df_path=None): fid = select_target_fid(d) if not fid or _is_df_fid(fid) or not _success(d): return # A path selection of an EF also enters the EF's parent DF # (TS 102 221 §8.4.1) — key the file by that DF, not the one the - # channel was in before the SELECT. + # channel was in before the SELECT, and keep the DF chain the + # selection's own path spells out (DF_TELECOM/DF_PHONEBOOK/…). effective = selected_df_fid(d, df) or df - self._entry(effective, fid).counters['select'] += 1 + _new_df, new_path = selected_df_state(d, df, df_path) + self._entry(effective, fid, + df_path=new_path or df_path).counters['select'] += 1 def _observe_get_response(self, d, state, channel, index, msg): response = d.get('response') or {} @@ -390,7 +416,7 @@ class FileTracker: if not fid or _is_df_fid(fid): return df = state.get('df') or '3f00' - entry = self._entry(df, fid) + entry = self._entry(df, fid, df_path=state.get('df_path')) fd = response.get('file_descriptor') if isinstance(fd, dict): entry.structure = fd.get('structure', entry.structure) @@ -409,28 +435,31 @@ class FileTracker: # SFI-addressed command keeps its context (the response's # attribution resolves against the current selection, not the SFI). src, src_df = d, state.get('df') or '3f00' + src_path = state.get('df_path') if pending: _fid, sfi = self._file_ref(pending['d'], pending['df']) if sfi is not None: src, src_df = pending['d'], pending['df'] - self._apply_read(src, src_df, index, msg, _body_bytes(d)) + src_path = pending.get('df_path') + self._apply_read(src, src_df, index, msg, _body_bytes(d), src_path) - def _observe_read(self, d, df, channel, index, msg): + def _observe_read(self, d, df, channel, index, msg, df_path=None): if not _success(d): return if not _body_bytes(d): # Data pending (T=0): the GET RESPONSE will carry it. - self._pending_read[channel] = {'d': d, 'df': df} + self._pending_read[channel] = {'d': d, 'df': df, + 'df_path': df_path} return - self._apply_read(d, df, index, msg, _body_bytes(d)) + self._apply_read(d, df, index, msg, _body_bytes(d), df_path) - def _apply_read(self, d, df, index, msg, data): + def _apply_read(self, d, df, index, msg, data, df_path=None): if not data: return fid, sfi = self._file_ref(d, df) if fid is None: return - entry = self._entry(df, fid, _file_label(d)) + entry = self._entry(df, fid, _file_label(d), df_path) if sfi is not None and entry.sfi is None: entry.sfi = sfi entry.counters['read'] += 1 @@ -449,14 +478,14 @@ class FileTracker: self._push('read', entry, index, msg, record=record) self._note_dir_content(d) - def _observe_write(self, d, df, index, msg): + def _observe_write(self, d, df, index, msg, df_path=None): data = _body_bytes(d) if not data or not _success(d): return fid, sfi = self._file_ref(d, df) if fid is None: return - entry = self._entry(df, fid, _file_label(d)) + entry = self._entry(df, fid, _file_label(d), df_path) if sfi is not None and entry.sfi is None: entry.sfi = sfi entry.counters['update'] += 1 @@ -540,7 +569,7 @@ class FileTracker: 'key': _key_str(entry.key), 'name': entry.name, 'path': _file_path(entry.df, entry.fid, entry.name, - self.aid_labels), + self.aid_labels, entry.df_path), 'df': entry.df, 'fid': entry.fid, 'structure': entry.structure, @@ -567,7 +596,7 @@ class FileTracker: files[_key_str(key)] = { 'name': entry.name, 'path': _file_path(entry.df, entry.fid, entry.name, - self.aid_labels), + self.aid_labels, entry.df_path), 'df': entry.df, 'fid': entry.fid, 'sfi': entry.sfi, diff --git a/simtrace_analyser/version.py b/simtrace_analyser/version.py index a22159c..ae1349a 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.5' +__version__ = '2.7.6' diff --git a/tests/test_decode.py b/tests/test_decode.py index c7eb48b..bbea472 100644 --- a/tests/test_decode.py +++ b/tests/test_decode.py @@ -1814,6 +1814,33 @@ class TestSfiResolution(unittest.TestCase): self.assertEqual(r['file']['ef'], 'EF_LOCI') self.assertEqual(r['file']['tmsi'], '1E731E57') + def test_selected_df_state(self): + from simtrace_analyser.decode import selected_df_state + + def sel(p1, body): + return {'ins_hex': 'a4', 'p1': {'raw': p1}, 'body': {'hex': body}} + + # A path selection spells out the DF chain: 7F10/5F3A/4FA1. + self.assertEqual(selected_df_state(sel('08', '7f105f3a4fa1')), + ('5f3a', ['7f10', '5f3a'])) + # A child DF selected by FID appends to the current chain. + self.assertEqual(selected_df_state(sel('00', '5f3a'), '7f10', ['7f10']), + ('5f3a', ['7f10', '5f3a'])) + # MF and the ADF start a fresh chain; an AID is the root. + self.assertEqual(selected_df_state(sel('00', '3f00'), '7f10', + ['7f10', '5f3a']), + ('3f00', ['3f00'])) + self.assertEqual(selected_df_state(sel('08', '7fff6f07'), '3f00', + ['3f00']), ('7fff', ['7fff'])) + aid = 'aid:a00000015141434c00' + self.assertEqual(selected_df_state(sel('04', 'A00000015141434C00'), + '3f00', ['3f00']), (aid, [aid])) + # An EF selection leaves the chain in place; non-SELECTs never move it. + self.assertEqual(selected_df_state(sel('09', '4fa1'), '5f3a', + ['7f10', '5f3a']), + ('5f3a', ['7f10', '5f3a'])) + self.assertEqual(selected_df_state({'ins_hex': 'b0'}), (None, None)) + def test_selected_df_fid(self): from simtrace_analyser.decode import selected_df_fid, sfi_table self.assertEqual(selected_df_fid({'ins_hex': 'a4', 'p1': {'raw': '08'}, diff --git a/tests/test_filetrack.py b/tests/test_filetrack.py index 1f6e466..a50c96b 100644 --- a/tests/test_filetrack.py +++ b/tests/test_filetrack.py @@ -13,6 +13,11 @@ def sel_fid(fid, le='02'): return f'00a4000002{fid}61{le}' +def sel_path(path_hex, le='02'): + """SELECT a path from the MF (P1=08).""" + return f'00a40804{len(path_hex) // 2:02x}{path_hex}61{le}' + + def gr(data_hex, total=None): """GET RESPONSE carrying *data_hex* (the FCP or the read data).""" n = len(data_hex) // 2 if total is None else total @@ -541,6 +546,20 @@ class TestNaming(TrackerCase): 'DF_GSM/EF_SST (6F38)') +class TestDfChain(TrackerCase): + def test_path_selection_keeps_the_full_df_chain(self): + # The trace's own path selection spells out DF_TELECOM/DF_PHONEBOOK; + # the Files view used to show only the parent DF. + data = self.track( + sel_path('7f105f3a4fa1'), + gr(fcp('4fa1', rec_len=44, n_rec=100)), + ) + entry = data['files']['5f3a/4fa1'] + self.assertEqual(entry['path'], 'DF_TELECOM/DF_PHONEBOOK/4FA1') + self.assertEqual(entry['record_len'], 44) + self.assertEqual(entry['n_records'], 100) + + class TestFilePath(unittest.TestCase): def test_paths(self): from simtrace_analyser.filetrack import _file_path @@ -556,6 +575,13 @@ class TestFilePath(unittest.TestCase): # The DF_TELECOM files are named from TS 51.011 10.5 / TS 31.102 4.5. self.assertEqual(_file_path('7f10', '6f3a', '6F3A'), 'DF_TELECOM/EF_ADN (6F3A)') + # The full DF chain (learned from the selection path) is part of the + # path — the immediate DF alone hid DF_TELECOM for the phonebook. + self.assertEqual(_file_path('5f3a', '4fa1', '4FA1', None, + ['7f10', '5f3a']), + 'DF_TELECOM/DF_PHONEBOOK/4FA1') + self.assertEqual(_file_path('7fff', '6f07', 'EF_IMSI', None, ['7fff']), + 'ADF/EF_IMSI (6F07)') # An application selected by an unknown AID: the AID is the DF name, # with the EF.DIR label when the trace surfaced it. self.assertEqual(_file_path('aid:a00000015141434c00', '4f09', '4F09'), diff --git a/tests/test_pcap_import.py b/tests/test_pcap_import.py index 46d77d0..7a0c6c0 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.5') + self.assertEqual(__version__, '2.7.6') def test_status_includes_version(self): import os