diff --git a/frontend/index.html b/frontend/index.html
index 7beeef4..014579a 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -3386,7 +3386,7 @@ function onSearchChange() {
}
// ──────────────────── Versioning ────────────────────
-const PWA_VERSION = '2.7.7';
+const PWA_VERSION = '2.7.8';
const MIN_SERVER_VERSION = '2.3.0';
function parseVersion(v) {
diff --git a/frontend/sw.js b/frontend/sw.js
index 54b48ec..519fc62 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'simtrace-analyser-v87';
+const CACHE = 'simtrace-analyser-v88';
const PRECACHE = [
'index.html',
diff --git a/pyproject.toml b/pyproject.toml
index 304f3b5..b3b2d36 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend"
[project]
name = "simtrace-analyser"
-version = "2.7.7"
+version = "2.7.8"
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 bf8e761..7bffdc9 100644
--- a/simtrace_analyser/database.py
+++ b/simtrace_analyser/database.py
@@ -296,6 +296,7 @@ class Database:
"ORDER BY id DESC LIMIT 500",
(session_id, before_id)).fetchall()
states = {}
+ learned = {} # df -> {sfi: fid} learned this session
clk_hz = None
for data, typ in reversed(rows):
if typ in ('atr', 'change', 'gap'):
@@ -320,13 +321,19 @@ class Database:
continue
(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['sfi_map'], st['df_path'],
+ learned)
st['prev'] = self._context_from_decoded(d)
return states, clk_hz
- def _advance_state(self, d, sel, df, sfi_map, df_path=None):
- """Update (sel, df, sfi_map, df_path) for one decoded TPDU."""
+ def _advance_state(self, d, sel, df, sfi_map, df_path=None, learned=None):
+ """Update (sel, df, sfi_map, df_path) for one decoded TPDU.
+
+ *learned* caches the session's SFI→FID relations per DF (from the
+ FCPs' '88' DO and the card's EF_PBR), so leaving the DF and coming
+ back does not forget them."""
from .decode import selected_df_state, sfi_table, KNOWN_FIDS
+ from .pbr import parse_pbr_record
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.
@@ -334,12 +341,34 @@ class Database:
df = new_df
df_path = new_path
sfi_map = dict(sfi_table(new_df))
+ sfi_map.update((learned or {}).get(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:
sfi = (d.get('response') or {}).get('sfi')
if sfi is not None:
sfi_map = dict(sfi_map)
sfi_map[sfi] = sel['fid']
+ if learned is not None:
+ learned.setdefault(df, {})[sfi] = sel['fid']
+ # Learn the phonebook file references from an EF_PBR record
+ # (TS 31.102 §4.4.2.1): the card declares each file's FID and SFI, so
+ # an SFI-addressed SEARCH/READ after the PBR read resolves even
+ # without the file's own FCP.
+ f = d.get('file') or {}
+ hx = (d.get('body') or {}).get('hex') if f.get('fid') == '4f30' else None
+ if hx:
+ try:
+ parsed = parse_pbr_record(bytes.fromhex(hx))
+ except ValueError:
+ parsed = None
+ if parsed:
+ for group in parsed['groups']:
+ for item in group['files']:
+ if item.get('sfi') and 'fid' in item:
+ sfi_map = dict(sfi_map)
+ sfi_map[item['sfi']] = item['fid']
+ if learned is not None:
+ learned.setdefault(df, {})[item['sfi']] = item['fid']
sel = self._selection_after(d, sel)
# A record/SEARCH command with a valid SFI sets the file as current EF
# (TS 102 221 §11.1.2).
@@ -395,6 +424,7 @@ class Database:
from .decode import atr_rate_fields, cla_channel
msgs = []
states = dict(initial_states or {})
+ learned = {} # df -> {sfi: fid} learned this session
last_atr_i = None # index of the most recent ATR in *msgs*
last_atr_rated = False # whether that ATR already carries a data rate
for row in rows:
@@ -416,7 +446,8 @@ class Database:
if msg['type'] == 'tpdu' and d and d.get('ins_hex'):
(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['sfi_map'], st['df_path'],
+ learned)
st['prev'] = self._context_from_decoded(d)
elif msg['type'] == 'atr':
states = {}
diff --git a/simtrace_analyser/version.py b/simtrace_analyser/version.py
index 17ab6e5..47467f4 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.7'
+__version__ = '2.7.8'
diff --git a/tests/test_decode.py b/tests/test_decode.py
index 65eca52..0e4310e 100644
--- a/tests/test_decode.py
+++ b/tests/test_decode.py
@@ -1782,6 +1782,53 @@ class TestSelectionTracking(unittest.TestCase):
self.assertEqual(msgs[3]['decoded']['cla']['channel'], 8)
self.assertEqual(msgs[5]['decoded']['cla']['channel'], 12)
+ def test_pbr_sfi_resolves_after_the_pbr_read(self):
+ # The card declares the phonebook SFIs in EF_PBR (TS 31.102 §4.4.2.1:
+ # FID + SFI per primitive tag). A SEARCH RECORD addressed by SFI 2
+ # after the PBR read must resolve to the declared file (EF_ADN 4F3A)
+ # even though nothing selected it and no FCP carried its '88' DO.
+ import tempfile
+ from simtrace_analyser.database import Database
+ with tempfile.NamedTemporaryFile(suffix='.db') as tmp:
+ db = Database(tmp.name)
+ sid = db.create_session('capture')
+ db.insert_message(sid, 0.0, 'tpdu',
+ bytes.fromhex('00a40804047f105f3a6102'))
+ db.insert_message(sid, 0.1, 'tpdu',
+ bytes.fromhex('00c0000008' +
+ '62088202412183025f3a' + '9000'))
+ db.insert_message(sid, 0.2, 'tpdu',
+ bytes.fromhex('00a40000024f306102'))
+ db.insert_message(sid, 0.3, 'tpdu',
+ bytes.fromhex('00c000000b' +
+ '620b8205422100180183024f30' +
+ '9000'))
+ db.insert_message(sid, 0.4, 'tpdu',
+ bytes.fromhex('00b2010418' +
+ 'a80ac0034f3a02c5034fa104'
+ 'aa0ac2034f4a06cb034f4f07' +
+ '9000'))
+ # Leave DF_PHONEBOOK and come back (the live trace does this):
+ # the learned SFI relations must survive the excursion.
+ db.insert_message(sid, 0.5, 'tpdu',
+ bytes.fromhex('00a40804047f106f066102'))
+ db.insert_message(sid, 0.6, 'tpdu',
+ bytes.fromhex('00c000000b' +
+ '620b82054221004b0183026f06' +
+ '9000'))
+ db.insert_message(sid, 0.7, 'tpdu',
+ bytes.fromhex('00a40804047f105f3a6120'))
+ db.insert_message(sid, 0.8, 'tpdu',
+ bytes.fromhex('00c0000008' +
+ '620882013883025f3a' +
+ '9000'))
+ db.insert_message(sid, 0.9, 'tpdu',
+ bytes.fromhex('00a2011427' + 'ff' * 39 +
+ '9000'))
+ search = db.get_messages(sid)[9]['decoded']
+ self.assertEqual(search['p2']['sfi'], 2)
+ self.assertEqual(search['file'], {'fid': '4f3a', 'ef': '4F3A'})
+
def test_manage_channel_opens_use_their_channel(self):
# The MANAGE CHANNEL open/close exchanges themselves run on the
# basic channel; a later extended-channel command must not disturb it.
diff --git a/tests/test_pcap_import.py b/tests/test_pcap_import.py
index 1132873..47914a8 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.7')
+ self.assertEqual(__version__, '2.7.8')
def test_status_includes_version(self):
import os