From b6537bd54be88e3f2467405b62c448546e58dfff Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?=
=?UTF-8?q?=D0=B8=D0=BD?=
Date: Sat, 10 Oct 2026 00:30:45 +0300
Subject: [PATCH] v3.24.0: the EF.ARR access rules in the FCP view
The file manager's decoded FCI shows the '8B' security attribute
reference (EF_ARR 2F06, record 23). The referenced record's access
rules are now rendered as indented muted lines under that row, decoded
with the existing EF.ARR record decoder (AM_DO/SC_DO conditions).
The server reads the card's ARR copies (MF/2F06 and ADF.USIM/6F06,
best effort) once per card session in _apply_equipped_card and keeps
them as server.arr; GET /api/arr serves the cache without the card
lock, a disconnect clears it, and a write to an ARR copy through
/api/write refreshes it. The PWA fetches it lazily per card session
(pysimArrEnsure, keyed by card_session) and resolves the reference with
the selected file's DF context: an ADF.USIM file uses the ADF's 6F06
copy, the implicit reference form too (ISO 7816-4 5.4.3.3 lookup
order). Unresolvable references stay silent.
The '8B' decode now covers all three ISO 7816-4 Table 25 forms:
implicit ARR byte / FID + record / FID + (SEID, ARR) pairs - the pairs
form was mis-decoded as a record before. A snapshot resolves the same
references offline from its own captured EF.ARR copy.
Tests: the three forms, the resolution (DF preference, out-of-range
silence), the snapshot path, the server read/cache/route/write-refresh.
868 frontend + 856 Python; sw cache simple-v423.
---
docs/api.md | 22 ++++
frontend/help-ru.html | 2 +-
frontend/help.html | 2 +-
frontend/index.html | 132 ++++++++++++++++++++--
frontend/sw.js | 2 +-
frontend/tests/html.test.js | 2 +-
frontend/tests/profiler.test.js | 89 +++++++++++++++
pyproject.toml | 2 +-
pysim_simple_server/server.py | 92 +++++++++++++++-
tests/test_arr.py | 190 ++++++++++++++++++++++++++++++++
10 files changed, 521 insertions(+), 14 deletions(-)
create mode 100644 tests/test_arr.py
diff --git a/docs/api.md b/docs/api.md
index 8919987..9b44219 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -74,6 +74,7 @@ a 3.x PWA).
| `/api/net-sim` | POST | Run a network-condition scenario (attach, service loss, roaming, churn, 2G, SMS, CB, AUTHENTICATE) |
| `/api/net-state` | GET | Cached network-state monitor (service, location, per-file state) |
| `/api/net-state-refresh` | POST | Re-read the monitored files (optional `files` list) and return the updated monitor state |
+| `/api/arr` | GET | Cached EF.ARR copies (the FCP '8B' security attribute references): records per ARR file |
| `/api/mcc-mnc` | GET | Search the optional MCC/MNC operator list (`?q=`; `?random=1&exclude=`) |
| `/api/proactive-log` | GET | Last 50 proactive commands |
| `/api/status-poll` | POST | Manual STATUS poll + FETCH if 91XX |
@@ -1398,6 +1399,27 @@ optional body filters the read:
Without a body all monitored files are re-read. Answers `503` when the reader
is not initialized.
+### `GET /api/arr`
+
+Returns the card's cached **EF.ARR copies** — the access-rule files the FCP
+security attribute DO `'8B'` references (ISO 7816-4 Table 25). The copies
+are read once per card session when a card is equipped (`MF/2F06` and
+`ADF.USIM/6F06`, best effort) and served from memory, so this endpoint
+performs no card I/O; a disconnect clears the cache, and an `EF.ARR` write
+through `/api/write` refreshes it.
+
+```json
+{"ok": true, "session": 12, "arrs": [
+ {"fid": "2F06", "path": "MF/2F06", "record_len": 10, "num_records": 23,
+ "records": ["FFFF...", "..."]}]}
+```
+
+`ok` is `false` (with an empty `arrs`) when the card has no readable ARR.
+The PWA's file manager resolves the `'8B'` reference of the selected file's
+FCI against this cache and shows the referenced record's access rules as
+indented lines; snapshots resolve the same references from their own
+captured `EF.ARR` copy.
+
### `GET /api/proactive-log`
Returns the last 50 proactive commands fetched during CAT sessions, newest
diff --git a/frontend/help-ru.html b/frontend/help-ru.html
index 4f0b148..72267ad 100644
--- a/frontend/help-ru.html
+++ b/frontend/help-ru.html
@@ -397,7 +397,7 @@
Подключение к локальному pysim-simple-server для работы с картой: введите URL сервера (по умолчанию http://127.0.0.1:8080) и нажмите Подключиться. Область статуса показывает состояние ридера/карты, а Подключить карту (пере)инициализирует карту после вставки. Подвкладки: Файловый менеджер, Командная строка pySim и Отправка APDU. «Профайлер» и «Симулятор» — отдельные вкладки верхнего уровня.
6.1 Файловый менеджер
- Дерево файловой системы отображается слева; выбор файла открывает панель деталей справа. Элементы сгруппированы: DF выше EF, сортировка по FID или символьному имени (пиллы и кнопка «Проверить все файлы» закреплены над прокручиваемым деревом; выбор сохраняется в localStorage). При выборе файла над содержимым также показываются FID, тип файла, размер / структура записей и декодированный FCI — в рамке с символьным именем файла.
+ Дерево файловой системы отображается слева; выбор файла открывает панель деталей справа. Элементы сгруппированы: DF выше EF, сортировка по FID или символьному имени (пиллы и кнопка «Проверить все файлы» закреплены над прокручиваемым деревом; выбор сохраняется в localStorage). При выборе файла над содержимым также показываются FID, тип файла, размер / структура записей и декодированный FCI — в рамке с символьным именем файла. Если FCI ссылается на свои правила доступа через запись EF.ARR (атрибут безопасности 8B), правила указанной записи показываются с отступом под этой строкой: они берутся из копий EF.ARR карты (читаются один раз за сессию при equip, обновляются при сбросе/equip и после записи EF.ARR); снимок разрешает те же ссылки из собственной захваченной копии.
- Читать сырые данные / Декодировать — чтение выбранного файла в виде hex-дампа или таблицы декодированных полей (клиентские декодеры EF: IMSI, ICCID, SPN, списки PLMN, LOCI/PSLOCI/EPSLOCI, ADN/MSISDN, таблицы сервисов, SUME, …); серверный pySim JSON того же чтения остаётся доступен в свёрнутом блоке pySim JSON (сервер). Подсвеченная кнопка — текущий вид; нажатие любой из них (пере)читывает файл. «Декодировать» недоступна, если для файла нет клиентского декодера (проверка по имени/FID до чтения).
- Редактировать сырые данные — изменение hex-данных, Сохранить для записи (или Отмена); transparent-файлы редактируются в textarea, record-файлы — по одному полю на запись с флажками выбора. Декодированный вид доступен только для чтения — «Редактировать сырые данные» сначала переключает на hex-вид и при необходимости читает файл.
diff --git a/frontend/help.html b/frontend/help.html
index 1c4804d..a7f3b28 100644
--- a/frontend/help.html
+++ b/frontend/help.html
@@ -396,7 +396,7 @@
Connects to a local pysim-simple-server for live card operations: enter the server URL (default http://127.0.0.1:8080) and press Connect. The status area shows the reader/card state, and Equip card (re)initializes the card after insertion. Sub-tabs: File manager, pySim command line, and Raw APDU. The Profiler and Simulator are separate top-level tabs.
6.1 File manager
- The file system tree is displayed on the left; selecting a file opens its detail pane on the right. Entries are grouped with DFs above EFs and sorted by FID or symbolic Name (pills pinned above the scrolling tree together with Probe all files; the choice is remembered in localStorage). Selecting a file also shows its FID, file type, size / record layout and the decoded FCI — in a bordered block labelled with the file’s symbolic name — above the content pane.
+ The file system tree is displayed on the left; selecting a file opens its detail pane on the right. Entries are grouped with DFs above EFs and sorted by FID or symbolic Name (pills pinned above the scrolling tree together with Probe all files; the choice is remembered in localStorage). Selecting a file also shows its FID, file type, size / record layout and the decoded FCI — in a bordered block labelled with the file’s symbolic name — above the content pane. When the FCI references its access rules through an EF.ARR record (security attribute 8B), the rules of the referenced record are shown as indented lines under that row: they are resolved from the card’s EF.ARR copies (read once per card session at equip, refreshed on reset/equip and after an EF.ARR write); a snapshot resolves the same references from its own captured copy.
- Read raw / Read decoded — read the selected file as a hex dump or as a decoded field table (client-side EF decoders: IMSI, ICCID, SPN, PLMN lists, LOCI/PSLOCI/EPSLOCI, ADN/MSISDN, service tables, SUME, …); the server-side pySim JSON of the same read stays available in the collapsed pySim JSON (server) disclosure. The highlighted pill is the current view; clicking either pill (re-)reads the file. Read decoded is disabled when the client-side decoders do not cover the file (name/FID check, before reading).
- Edit raw — edit the raw hex data, Save to write back (or Cancel); transparent files use a textarea, record files one input per record with selection checkboxes. The decoded view is read-only — Edit raw switches to the hex view first and reads the file if needed.
diff --git a/frontend/index.html b/frontend/index.html
index 28a9c81..96172b7 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1905,7 +1905,7 @@
// ===== Version =====
// Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect().
-const SIMPLE_VERSION = '3.23.9';
+const SIMPLE_VERSION = '3.24.0';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== view history (the browser Back/Forward buttons) =====
@@ -15060,8 +15060,9 @@ async function pysimFsToggleDir(name) {
let pysimFsSelected = null;
-function pysimFsInfoHtml(sel, name) {
+function pysimFsInfoHtml(sel, name, opts) {
if (!sel) return '';
+ opts = opts || {};
const num = v => (v === null || v === undefined) ? null : String(v);
const attrs = [];
if (sel.fid) attrs.push('FID: ' + esc(String(sel.fid).toUpperCase()));
@@ -15079,10 +15080,26 @@ function pysimFsInfoHtml(sel, name) {
(name ? '' : '') +
(attrs.length ? '' + attrs.join(' · ') + '
' : '') +
'' +
- profilerFciPreviewItems(sel.fci_hex, { hideFileSize: num(sel.file_size) !== null }) +
+ profilerFciPreviewItems(sel.fci_hex, { hideFileSize: num(sel.file_size) !== null, arr: opts.arr, path: opts.path }) +
'
';
}
+// EF.ARR cache for the file manager's FCP view (v3.24.0): the server reads
+// the card's ARR copies once per session (GET /api/arr is itself cache-only);
+// a card-session change drops this copy.
+let _pysimArrCache = null;
+
+async function pysimArrEnsure() {
+ if (_pysimArrCache && _pysimArrCache.session === _pysimCardSession) return _pysimArrCache.arrs;
+ try {
+ const data = await pysimFetch('/api/arr');
+ _pysimArrCache = { session: _pysimCardSession, arrs: (data && data.arrs) || [] };
+ } catch (e) {
+ _pysimArrCache = { session: _pysimCardSession, arrs: [] };
+ }
+ return _pysimArrCache.arrs;
+}
+
async function pysimFsClickFile(name) {
pysimFsResetEdit();
pysimFsSetMode('raw');
@@ -15095,7 +15112,8 @@ async function pysimFsClickFile(name) {
pysimFsRenderTree();
}
pysimFsSelected = name;
- document.getElementById('pysim-fs-info').innerHTML = exists ? pysimFsInfoHtml(sel, name) : '';
+ const arrs = await pysimArrEnsure();
+ document.getElementById('pysim-fs-info').innerHTML = exists ? pysimFsInfoHtml(sel, name, { arr: arrs, path: pysimFsNodePath(node) }) : '';
document.getElementById('pysim-fs-content').innerHTML = '';
pysimFsSyncContentVisibility();
document.getElementById('pysim-fs-status').textContent = exists ? '' : '✗ File not found on card';
@@ -15257,6 +15275,10 @@ async function pysimFsSave() {
const statusEl = document.getElementById('pysim-fs-status');
const out = document.getElementById('pysim-fs-content');
const node = pysimFsFindNode(rawName, pysimFsTreeRoot);
+ // An EF.ARR write changes the referenced access rules: drop the client
+ // ARR cache so the next FCP view refetches the server's refreshed copy.
+ const wroteFid = String((node && node.fid) || (/^[0-9a-fA-F]{4}$/.test(rawName || '') ? rawName : '')).toUpperCase();
+ const arrTouched = wroteFid === '2F06' || wroteFid === '6F06';
statusEl.textContent = 'Saving...';
try {
const textareas = out.querySelectorAll('textarea');
@@ -15267,6 +15289,7 @@ async function pysimFsSave() {
const data = await pysimFetch('/api/write', body);
if (data.success) {
statusEl.textContent = 'SW: ' + data.sw + ' OK';
+ if (arrTouched) _pysimArrCache = null;
pysimFsCancel();
pysimFsRead();
} else {
@@ -15298,6 +15321,7 @@ async function pysimFsSave() {
return;
}
statusEl.textContent = 'SW: 9000 OK';
+ if (arrTouched) _pysimArrCache = null;
pysimFsCancel();
pysimFsRead();
}
@@ -19616,6 +19640,8 @@ function snapshotSaveName() {
function profilerRenderSnapshotFiles(s) {
if (!s.files.length) return '' + t('No files captured.') + '';
+ // the snapshot's own EF.ARR copies resolve the FCP '8B' references offline
+ const snapArr = profilerSnapshotArr(s);
let html = '';
for (const f of s.files) {
const name = profilerCustomNameForPath(f.path) || f.name;
@@ -19644,7 +19670,7 @@ function profilerRenderSnapshotFiles(s) {
html += '' + esc(t('FCI hex (raw SELECT response)')) + '
' +
'
';
html += '' + esc(t('Decoded FCI')) + '
' +
- '
' + profilerFciPreviewItems(f.fciHex) + '
';
+ '' + profilerFciPreviewItems(f.fciHex, { arr: snapArr, path: f.path }) + '
';
html += '';
}
const dec = f.content ? efDecodeFile(f.name, efFidFromPath(f.path), f.content) : null;
@@ -20357,6 +20383,65 @@ function fcpPsTemplate(v) {
return pins.length ? pins.join('; ') : (v || '');
}
+// FCP '8B' security attribute referencing (ISO 7816-4 5.4.3.3, Table 25):
+// one ARR byte (a record in the implicitly known EF.ARR), a file identifier +
+// ARR byte (the record number), or a file identifier followed by (SEID, ARR)
+// pairs. Returns null for a malformed length (the raw value is shown then).
+function fcpArrRef(value) {
+ const v = (value || '').toUpperCase();
+ if (!/^[0-9A-F]*$/.test(v)) return null;
+ if (v.length === 2) return { implicit: true, record: parseInt(v, 16) };
+ if (v.length === 6) return { fid: v.substr(0, 4), record: parseInt(v.substr(4, 2), 16) };
+ if (v.length >= 8 && v.length % 2 === 0) {
+ const pairs = [];
+ for (let i = 4; i + 4 <= v.length; i += 4) {
+ pairs.push({ seid: parseInt(v.substr(i, 2), 16), record: parseInt(v.substr(i + 2, 2), 16) });
+ }
+ return { fid: v.substr(0, 4), pairs: pairs };
+ }
+ return null;
+}
+
+// SEID label (TS 102 221 9.3.1 / Table 9.0d: SE00 and SE01 are defined; the
+// same pattern names any other value).
+function fcpSeidName(seid) {
+ return 'SE' + (Number(seid) || 0).toString(16).toUpperCase().padStart(2, '0');
+}
+
+// Access rules of the record(s) an FCP '8B' reference points at, resolved
+// against a set of EF.ARR copies ({fid, path, records[]} - the server's
+// /api/arr cache or a snapshot's own EF.ARR files). `path` is the tree path
+// of the file whose FCI is shown: it picks the ARR copy of that DF (the
+// ISO 7816-4 5.4.3.3 lookup order - ADF files use the ADF's 6F06 copy).
+// Returns [] when nothing resolves - the caller stays silent (v3.24.0).
+function fcpArrRules(ref, arrs, path) {
+ if (!ref || !arrs || !arrs.length) return [];
+ const df = (((path || '').split('/')[0]) || 'MF').toUpperCase();
+ const pick = fid => {
+ const hits = arrs.filter(a => String(a.fid || '').toUpperCase() === fid);
+ if (!hits.length) return null;
+ return hits.find(a => String(a.path || '').split('/')[0].toUpperCase() === df) || hits[0];
+ };
+ const out = [];
+ const add = (label, fid, record) => {
+ const arr = pick(fid);
+ const recs = (arr && arr.records) || [];
+ if (!(record >= 1) || record > recs.length) return;
+ const dec = efDecArr(efBytes(recs[record - 1] || ''));
+ for (const rule of ((dec && dec.rules) || [])) {
+ out.push((label ? label + ': ' : '') + rule);
+ }
+ };
+ if (ref.implicit) {
+ add('', df.startsWith('ADF.') ? '6F06' : '2F06', ref.record);
+ } else if (ref.pairs) {
+ for (const p of ref.pairs) add(fcpSeidName(p.seid), ref.fid, p.record);
+ } else {
+ add('', ref.fid, ref.record);
+ }
+ return out;
+}
+
function fcpDo(key, value) {
if (/^[0-9A-F]+$/.test(value) === false) value = value || '';
switch (key) {
@@ -20370,7 +20455,13 @@ function fcpDo(key, value) {
case '87': return { name: 'FCI extension EF', decoded: value };
case '88': return { name: 'Short file identifier', decoded: fcpSfi(value) };
case '8A': return { name: 'Life cycle status', decoded: fcpLifeCycle(value) };
- case '8B': return { name: 'Security attributes (referenced)', decoded: value.length >= 6 ? 'EF_ARR ' + value.substr(0, 4) + ', record ' + fcpInt(value.substr(4, 2)) : value };
+ case '8B': {
+ const ref = fcpArrRef(value);
+ if (!ref) return { name: 'Security attributes (referenced)', decoded: value };
+ if (ref.implicit) return { name: 'Security attributes (referenced)', decoded: 'record ' + ref.record + ' (implicit EF_ARR)' };
+ if (ref.pairs) return { name: 'Security attributes (referenced)', decoded: 'EF_ARR ' + ref.fid + ', ' + ref.pairs.map(p => fcpSeidName(p.seid) + ' \u2192 record ' + p.record).join(', ') };
+ return { name: 'Security attributes (referenced)', decoded: 'EF_ARR ' + ref.fid + ', record ' + ref.record };
+ }
case '8C': return { name: 'Security attributes (compact)', decoded: value };
case '8D': return { name: 'Security env. template EF', decoded: value };
case '8E': return { name: 'Channel security attribute', decoded: value };
@@ -20562,6 +20653,26 @@ function fcpDiffHtml(expectedHex, actualHex, labels) {
return html;
}
+// EF.ARR copies captured inside a snapshot (the scan stores every readable
+// file): the snapshot's FCI view resolves '8B' references offline from them,
+// shaped like the server's /api/arr entries (v3.24.0).
+function profilerSnapshotArr(snapshot) {
+ const out = [];
+ for (const f of ((snapshot && snapshot.files) || [])) {
+ const fid = efFidFromPath(f.path);
+ if (!fid || (fid !== '2F06' && fid !== '6F06')) continue;
+ if (!f.content || f.content.kind !== 'record') continue;
+ out.push({
+ fid: fid,
+ path: f.path,
+ record_len: f.recordLen,
+ num_records: f.numRecords,
+ records: (f.content.records || []).map(r => r.data || ''),
+ });
+ }
+ return out;
+}
+
// Decoded FCI rows. The short file identifier belongs to the file
// identifier, so it is merged into that row. `opts.hideFileSize` drops the
// File size DO where the caller already shows the size (file manager header).
@@ -20573,7 +20684,11 @@ function profilerFciPreviewItems(hex, opts) {
for (const it of d.items) {
if (it.decoded === null) continue;
if (opts.hideFileSize && it.key === '80') continue;
- rows.push({ key: it.key, name: it.name, text: it.decoded || it.value });
+ // the referenced EF.ARR record's access rules, when the caller has the
+ // ARR content (the file manager's live cache, or a snapshot's copy)
+ const ref = it.key === '8B' ? fcpArrRef(it.value) : null;
+ rows.push({ key: it.key, name: it.name, text: it.decoded || it.value,
+ sub: ref ? fcpArrRules(ref, opts.arr, opts.path) : [] });
}
const fi = rows.find(r => r.key === '83');
const sfi = rows.find(r => r.key === '88');
@@ -20584,6 +20699,9 @@ function profilerFciPreviewItems(hex, opts) {
let html = '';
for (const r of rows) {
html += '' + esc(r.name) + ': ' + esc(r.text) + '
';
+ for (const s of (r.sub || [])) {
+ html += '\u21b3 ' + esc(s) + '
';
+ }
}
if (d.error) {
html += '' + esc(t('Decode failed')) + ': ' + esc(d.error) + '
';
diff --git a/frontend/sw.js b/frontend/sw.js
index 7efc1c6..d13ae0e 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'simple-v422';
+const CACHE = 'simple-v423';
const URLS = [
'index.html',
'help.html',
diff --git a/frontend/tests/html.test.js b/frontend/tests/html.test.js
index 8b0abb8..3b03bad 100644
--- a/frontend/tests/html.test.js
+++ b/frontend/tests/html.test.js
@@ -443,7 +443,7 @@ test('file manager shows FCI info and keeps the selection in state, not the DOM'
const content = html.indexOf('id="pysim-fs-content"');
assert.ok(detail !== -1 && info > detail && info < content, 'pysim-fs-info must sit above the content');
assert.ok(html.includes('function pysimFsInfoHtml'));
- assert.ok(html.includes('pysimFsInfoHtml(sel, name)'));
+ assert.ok(html.includes('pysimFsInfoHtml(sel, name, { arr: arrs, path: pysimFsNodePath(node) })'));
assert.ok(!html.includes('pysim-fs-filename'));
assert.ok(html.includes('let pysimFsSelected = null;'));
assert.ok(html.includes('pysimFsSelected = name;'));
diff --git a/frontend/tests/profiler.test.js b/frontend/tests/profiler.test.js
index be08dd9..a537ea5 100644
--- a/frontend/tests/profiler.test.js
+++ b/frontend/tests/profiler.test.js
@@ -22,6 +22,7 @@ function extractFunc(src, name, asyncFn) {
}
const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'pysimCustomNormPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'profilerMatchedRecordsText', 'profilerCloneName', 'profilerClone', 'profilerNewId', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode',
+ 'fcpArrRef', 'fcpSeidName', 'fcpArrRules', 'profilerSnapshotArr', 'efBytes', 'efHex', 'efTlv', 'efDecArr', 'efArrAmLabel', 'efArrSc', 'efArrAmDo', 'efFidFromPath',
'pinKeyRefName', 'fcpPsTemplate', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput', 'profilerScanToggleAll', 'profilerScanIgnoreAllState', 'swapNibbles', 'decIccid', 'profilerSnapshotIccid', 'profilerValidateSnapshot', 'profilerListSwitch', 'profilerScanRefreshOptions', 'profilerLiveSource', 'profilerSnapshotSource', 'profilerVisibleResults', 'profilerRulesFromSnapshot', 'profilerExtraFileResults', 'profilerScanNameKeydown', 'profilerTimingStats', 'profilerTimingAccumulator', 'profilerFormatMs', 'profilerRenderSnapshotSummary', 'profilerSnapshotCountLabel', 'pysimFsInfoHtml', 'profilerLabelText', 'profilerResultsHeaderText', 'profilerRenderResultsView', 'profilerBuildFileRuleFromSnapshot', 'profilerSnapshotPickListHtml', 'profilerScanSetTarget'];
let code = '';
for (const f of FNS) code += extractFunc(html, f) + '\n';
@@ -813,6 +814,94 @@ test('pysimFsInfoHtml keeps the FCI File size when the header has none', () => {
delete global.t;
});
+test('fcpArrRef parses the three 8B referencing forms', () => {
+ assert.deepStrictEqual(fcpArrRef('17'), { implicit: true, record: 23 });
+ assert.deepStrictEqual(fcpArrRef('2F0617'), { fid: '2F06', record: 23 });
+ assert.deepStrictEqual(fcpArrRef('2f0600170101'), {
+ fid: '2F06', pairs: [{ seid: 0, record: 23 }, { seid: 1, record: 1 }] });
+ assert.strictEqual(fcpArrRef('2F060'), null); // odd length
+ assert.strictEqual(fcpArrRef('ZZZZZZ'), null); // not hex
+});
+
+test('fcpDo decodes every 8B referencing form', () => {
+ global.t = s => s;
+ assert.strictEqual(fcpDo('8B', '2F0617').decoded, 'EF_ARR 2F06, record 23');
+ assert.strictEqual(fcpDo('8B', '17').decoded, 'record 23 (implicit EF_ARR)');
+ assert.strictEqual(fcpDo('8B', '2F0600170101').decoded,
+ 'EF_ARR 2F06, SE00 \u2192 record 23, SE01 \u2192 record 1');
+ assert.strictEqual(fcpDo('8B', '2F06').decoded, '2F06');
+ delete global.t;
+});
+
+test('the FCI view shows the referenced EF.ARR rules when the ARR is known', () => {
+ global.t = s => s;
+ // the stk_params fixture FCI with 8B 03 2F06 02 (record 2)
+ const hex = '621A82054221000F0583026F4F8A01058B032F06028802004B8801B0';
+ const arrs = [{ fid: '2F06', path: 'MF/2F06', record_len: 6, num_records: 2,
+ records: ['FFFFFFFFFFFFFFFFFFFFFFFF', '800101A40683010A950108'] }];
+ const out = profilerFciPreviewItems(hex, { arr: arrs, path: 'MF/7F20/6F07' });
+ assert.ok(out.includes('EF_ARR 2F06, record 2'), out);
+ assert.ok(out.includes('AM 0x01 (READ/SEARCH (EF) / DELETE FILE child (DF)): ADM1 (verify)'), out);
+ assert.ok(out.includes('\u21b3'), out);
+ // without ARR data the row is unchanged
+ const plain = profilerFciPreviewItems(hex);
+ assert.ok(plain.includes('EF_ARR 2F06, record 2'), plain);
+ assert.ok(!plain.includes('ADM1'), plain);
+ // an out-of-range record stays silent
+ const hex9 = '621A82054221000F0583026F4F8A01058B032F06098802004B8801B0';
+ const oor = profilerFciPreviewItems(hex9, { arr: arrs, path: 'MF/2F06' });
+ assert.ok(oor.includes('EF_ARR 2F06, record 9'), oor);
+ assert.ok(!oor.includes('ADM1'), oor);
+ delete global.t;
+});
+
+test('the implicit 8B form uses the ARR of the file\'s DF', () => {
+ global.t = s => s;
+ // 8B 01 02: implicit EF_ARR, record 2
+ const hex = '621882054221000F0583026F4F8A01058B01028002004B8801B0';
+ const arrs = [
+ { fid: '2F06', path: 'MF/2F06', records: ['FFFFFFFF', '800101A40683010A950108'] },
+ { fid: '6F06', path: 'ADF.USIM/6F06', records: ['FFFFFFFF', '8001019000'] },
+ ];
+ // an ADF.USIM file resolves against the ADF's 6F06 copy
+ const adf = profilerFciPreviewItems(hex, { arr: arrs, path: 'ADF.USIM/6F07' });
+ assert.ok(adf.includes('record 2 (implicit EF_ARR)'), adf);
+ assert.ok(adf.includes('AM 0x01 (READ/SEARCH (EF) / DELETE FILE child (DF)): always'), adf);
+ assert.ok(!adf.includes('ADM1'), adf);
+ // an MF-tree file resolves against MF/2F06
+ const mf = profilerFciPreviewItems(hex, { arr: arrs, path: 'MF/7F20/6F07' });
+ assert.ok(mf.includes('AM 0x01 (READ/SEARCH (EF) / DELETE FILE child (DF)): ADM1 (verify)'), mf);
+ delete global.t;
+});
+
+test('the snapshot FCI view resolves references from the snapshot ARR', () => {
+ global.t = s => s;
+ const snap = { files: [
+ { path: 'MF/2F06', name: 'EF.ARR', fileType: 'linear_fixed', recordLen: 6, numRecords: 2,
+ content: { kind: 'record', records: [{ num: 1, data: 'FFFFFFFFFFFFFFFFFFFFFFFF' },
+ { num: 2, data: '800101A40683010A950108' }] } },
+ { path: 'MF/7F20/6F07', name: 'EF.IMSI',
+ fciHex: '621A82054221000F0583026F4F8A01058B032F06028802004B8801B0' },
+ ]};
+ const arrs = profilerSnapshotArr(snap);
+ assert.deepStrictEqual(arrs.map(a => a.fid), ['2F06']);
+ const out = profilerFciPreviewItems(snap.files[1].fciHex, { arr: arrs, path: snap.files[1].path });
+ assert.ok(out.includes('AM 0x01 (READ/SEARCH (EF) / DELETE FILE child (DF)): ADM1 (verify)'), out);
+ delete global.t;
+});
+
+test('pysimFsInfoHtml shows the referenced ARR rules when given the cache', () => {
+ global.t = s => s;
+ const hex = '621A82054221000F0583026F4F8A01058B032F06028802004B8801B0';
+ const arrs = [{ fid: '2F06', path: 'MF/2F06', records: ['', '800101a40683010a950108'] }];
+ const out = pysimFsInfoHtml({ fid: '6f4f', file_type: 'linear_fixed', file_size: 75,
+ record_len: 15, num_of_rec: 5, fci_hex: hex }, 'EF.SAMPLE',
+ { arr: arrs, path: 'MF/7F20/6F4F' });
+ assert.ok(out.includes('EF_ARR 2F06, record 2'), out);
+ assert.ok(out.includes('ADM1 (verify)'), out);
+ delete global.t;
+});
+
test('pysimFsInfoHtml embeds the symbolic name in the FCI border', () => {
global.t = s => s;
const hex = '6212800200098202412183026F078A0105880110';
diff --git a/pyproject.toml b/pyproject.toml
index d33431f..c291c9a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
-version = "3.23.9"
+version = "3.24.0"
description = "HTTP REST server wrapping pysim for the SIMple PWA"
requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py
index fbeaaf4..dad4957 100644
--- a/pysim_simple_server/server.py
+++ b/pysim_simple_server/server.py
@@ -36,7 +36,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
-VERSION = '3.23.9'
+VERSION = '3.24.0'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -485,6 +485,74 @@ def _read_iccid(app):
cleanup()
+# EF.ARR copies referenced by the FCP security attribute DO '8B' (ISO 7816-4
+# Table 25): the MF copy and the ADF.USIM copy (TS 102 221 13.4). The read
+# runs once per card session (_refresh_arr_cache); the PWA's file manager
+# resolves the referenced record's access rules against the cache.
+ARR_READ_PATHS = ('MF/2F06', 'ADF.USIM/6F06')
+ARR_RECORD_LIMIT = 64
+
+
+def _arr_fid(fid):
+ """True if the FID is an EF.ARR copy referenced by FCP '8B'."""
+ return str(fid or '').upper() in ('2F06', '6F06')
+
+
+def _read_arr(app):
+ """Best-effort EF.ARR reads (ARR_READ_PATHS): the access rule records the
+ FCP '8B' security attribute references point at. Returns a list of
+ {'fid','path','record_len','num_records','records'} dicts (records as hex,
+ 1-based) or None; never raises."""
+ if not app or not getattr(app, 'rs', None):
+ return None
+ lchan = app.rs.lchan[0]
+ out = []
+ for path in ARR_READ_PATHS:
+ cleanup = None
+ try:
+ _, cleanup = _select_path(lchan, path, app)
+ if _get_file_type(lchan, lchan.selected_file) not in ('linear_fixed', 'cyclic'):
+ continue
+ n = min(lchan.selected_file_num_of_rec() or 1, ARR_RECORD_LIMIT)
+ records = []
+ for i in range(1, n + 1):
+ rv = lchan.read_record(i)
+ data = rv[0] if isinstance(rv, tuple) else rv
+ records.append((data or '').upper())
+ out.append({'fid': path.rsplit('/', 1)[-1].upper(),
+ 'path': path,
+ 'record_len': _fcp_value(lchan, 'selected_file_record_len'),
+ 'num_records': len(records),
+ 'records': records})
+ except Exception:
+ continue
+ finally:
+ if cleanup:
+ try:
+ cleanup()
+ except Exception:
+ pass
+ return out or None
+
+
+def _refresh_arr_cache(server):
+ """Read the EF.ARR copies into the session cache (best effort); a failed
+ read clears the previous session's cache."""
+ server.arr = _read_arr(getattr(server, 'app', None))
+ if server.arr:
+ _tlog('equip: EF.ARR ' + ', '.join(
+ '%s (%d rec)' % (a['fid'], a['num_records']) for a in server.arr))
+
+
+def _arr_refresh_after_write(server, app, fid):
+ """Re-read the cache after a write to an EF.ARR copy: the referenced
+ access rules may have changed. Returns True when a refresh happened."""
+ if not _arr_fid(fid):
+ return False
+ server.arr = _read_arr(app)
+ return True
+
+
_MCC_MNC_CACHE = {'path': None, 'data': None}
@@ -4361,6 +4429,7 @@ def _handle_card_disconnect(stale=False):
_server_ref.event_list = None
_server_ref.sim_menu = None
_server_ref.iccid = None
+ _server_ref.arr = None
_server_ref.net_state = None
_server_ref.equipping = False
_server_ref.card_session = getattr(_server_ref, 'card_session', 0) + 1
@@ -4435,6 +4504,7 @@ def _apply_equipped_card(server):
server.card_present = True
server.card_session = getattr(server, 'card_session', 0) + 1
server.iccid = None
+ server.arr = None
# Read the ICCID before the TERMINAL PROFILE starts a CAT session: the
# PWA auto-selects the matching card preset (SCP80 views) from it.
server.iccid = _read_iccid(server.app)
@@ -4444,6 +4514,9 @@ def _apply_equipped_card(server):
# ICCID (still before the TERMINAL PROFILE opens a CAT session). A
# card without a readable ICCID is considered unusable - give up.
_netstate_init(server)
+ # EF.ARR (FCP '8B' references): read once per card session - the
+ # file manager resolves the referenced record's rules from it.
+ _refresh_arr_cache(server)
else:
server.net_state = None
_tlog('equip: ICCID not readable - network state skipped')
@@ -5923,7 +5996,7 @@ _CARD_FREE_GET = frozenset([
'/api/stk-status', '/api/poll-status', '/api/proactive-log',
'/api/menu', '/api/events', '/api/terminal-profile',
'/api/pli-qualifiers', '/api/pli-dict', '/api/commands',
- '/api/net-state', '/api/mcc-mnc', '/api/presets', '/api/test/scripts',
+ '/api/net-state', '/api/arr', '/api/mcc-mnc', '/api/presets', '/api/test/scripts',
'/api/test/suites',
'/api/scp81/status', '/api/bip/status', '/api/scp81/script',
'/api/scp81/log', '/api/bip/log',
@@ -7956,6 +8029,17 @@ class PysimHandler(BaseHTTPRequestHandler):
_netstate_compute(self.server)
self._send_json({'available': True, 'state': state})
self._log_resp({'available': True})
+ elif self.path == '/api/arr':
+ # EF.ARR session cache (v3.24.0): read once per card session in
+ # _refresh_arr_cache; the file manager resolves the FCP '8B'
+ # security attribute references against it. Cache-only - served
+ # without the card lock (see _CARD_FREE_GET).
+ self._log_req()
+ arrs = getattr(self.server, 'arr', None)
+ self._send_json({'ok': bool(arrs),
+ 'session': getattr(self.server, 'card_session', 0),
+ 'arrs': arrs or []})
+ self._log_resp({'ok': bool(arrs), 'arrs': len(arrs or [])})
elif self.path == '/api/status':
self._log_req()
app = self.server.app
@@ -8723,6 +8807,10 @@ class PysimHandler(BaseHTTPRequestHandler):
else:
sw = sw_match.group(1) if sw_match else '9000'
resp = {'success': True, 'sw': sw}
+ # An EF.ARR write changes the referenced access rules:
+ # refresh the session cache the file manager reads.
+ _arr_refresh_after_write(self.server, app,
+ getattr(lchan.selected_file, 'fid', None))
self._send_json(resp)
self._log_resp(resp)
except Exception as e:
diff --git a/tests/test_arr.py b/tests/test_arr.py
new file mode 100644
index 0000000..80c0aed
--- /dev/null
+++ b/tests/test_arr.py
@@ -0,0 +1,190 @@
+# coding=utf-8
+"""EF.ARR session cache (v3.24.0): the equip-time read, the FCP '8B' backing
+store, the /api/arr route and the write refresh."""
+
+import json
+import pathlib
+import threading
+import unittest
+import urllib.request
+from unittest import mock
+
+from pysim_simple_server.__main__ import _build_http_server
+from pysim_simple_server.server import (PysimHandler, _CARD_FREE_GET, _arr_fid,
+ _arr_refresh_after_write, _read_arr,
+ _refresh_arr_cache)
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+SRC = (ROOT / 'pysim_simple_server' / 'server.py').read_text(encoding='utf-8')
+
+
+class FakeFile:
+ def __init__(self, fid):
+ self.fid = fid
+
+
+class FakeLchan:
+ """One selected ARR file: read_record serves its records."""
+
+ def __init__(self, files):
+ self.files = files # path -> [record hex]
+ self._cur = None
+ self.selected_file = None
+
+ def selected_file_num_of_rec(self):
+ return len(self.files.get(self._cur) or [])
+
+ def selected_file_record_len(self):
+ return 6
+
+ def read_record(self, n):
+ return (self.files[self._cur][n - 1], '9000')
+
+
+def _select(lchan, path, app):
+ if path not in lchan.files:
+ raise RuntimeError('file not found')
+ lchan._cur = path
+ lchan.selected_file = FakeFile(path.rsplit('/', 1)[-1])
+ return lchan.selected_file, None
+
+
+def _app(files):
+ lchan = FakeLchan(files)
+ app = mock.Mock()
+ app.rs.lchan = [lchan]
+ return app, lchan
+
+
+def _read(app, file_type='linear_fixed'):
+ with mock.patch('pysim_simple_server.server._select_path', _select), \
+ mock.patch('pysim_simple_server.server._get_file_type', return_value=file_type):
+ return _read_arr(app)
+
+
+class ReadArrTests(unittest.TestCase):
+ def test_reads_both_copies(self):
+ app, _ = _app({'MF/2F06': ['800101A40683010A950108', '9000'],
+ 'ADF.USIM/6F06': ['8001019000']})
+ arrs = _read(app)
+ self.assertEqual([a['fid'] for a in arrs], ['2F06', '6F06'])
+ self.assertEqual(arrs[0]['path'], 'MF/2F06')
+ self.assertEqual(arrs[0]['records'], ['800101A40683010A950108', '9000'])
+ self.assertEqual(arrs[0]['num_records'], 2)
+ self.assertEqual(arrs[0]['record_len'], 6)
+ self.assertEqual(arrs[1]['path'], 'ADF.USIM/6F06')
+
+ def test_a_missing_copy_is_skipped(self):
+ app, _ = _app({'ADF.USIM/6F06': ['9000']})
+ arrs = _read(app)
+ self.assertEqual([a['fid'] for a in arrs], ['6F06'])
+
+ def test_no_arr_returns_none(self):
+ app, _ = _app({})
+ self.assertIsNone(_read(app))
+
+ def test_a_transparent_file_is_skipped(self):
+ app, _ = _app({'MF/2F06': ['9000']})
+ self.assertIsNone(_read(app, file_type='transparent'))
+
+ def test_a_failing_read_skips_the_file(self):
+ app, lchan = _app({'MF/2F06': ['9000'], 'ADF.USIM/6F06': ['9000']})
+ orig = lchan.read_record
+
+ def flaky(n):
+ if lchan._cur == 'MF/2F06':
+ raise RuntimeError('6A82')
+ return orig(n)
+ lchan.read_record = flaky
+ arrs = _read(app)
+ self.assertEqual([a['fid'] for a in arrs], ['6F06'])
+
+ def test_the_record_count_is_capped(self):
+ app, _ = _app({'MF/2F06': ['9000'] * 200})
+ arrs = _read(app)
+ self.assertEqual(arrs[0]['num_records'], 64)
+
+ def test_no_app_returns_none(self):
+ self.assertIsNone(_read_arr(None))
+ self.assertIsNone(_read_arr(mock.Mock(rs=None)))
+
+
+class CacheTests(unittest.TestCase):
+ def test_arr_fid(self):
+ self.assertTrue(_arr_fid('2F06'))
+ self.assertTrue(_arr_fid('6f06'))
+ self.assertFalse(_arr_fid('6F07'))
+ self.assertFalse(_arr_fid(None))
+
+ def test_refresh_stores_and_clears(self):
+ s = mock.Mock()
+ with mock.patch('pysim_simple_server.server._read_arr',
+ return_value=[{'fid': '2F06', 'num_records': 2}]), \
+ mock.patch('pysim_simple_server.server._tlog'):
+ _refresh_arr_cache(s)
+ self.assertEqual(s.arr, [{'fid': '2F06', 'num_records': 2}])
+ with mock.patch('pysim_simple_server.server._read_arr', return_value=None):
+ _refresh_arr_cache(s)
+ self.assertIsNone(s.arr)
+
+ def test_write_refresh_only_for_arr_fids(self):
+ s = mock.Mock()
+ with mock.patch('pysim_simple_server.server._read_arr',
+ return_value=[{'fid': '2F06', 'num_records': 2}]) as m:
+ self.assertFalse(_arr_refresh_after_write(s, None, '6F07'))
+ m.assert_not_called()
+ self.assertTrue(_arr_refresh_after_write(s, None, '2f06'))
+ m.assert_called_once()
+ self.assertEqual(s.arr, [{'fid': '2F06', 'num_records': 2}])
+
+ def test_equip_and_disconnect_wiring(self):
+ # the equip hook reads the cache; a disconnect clears it; an EF.ARR
+ # write refreshes it
+ seg = SRC[SRC.index('def _apply_equipped_card'):SRC.index('def _card_reset_reinit')]
+ self.assertIn('_refresh_arr_cache(server)', seg)
+ seg = SRC[SRC.index('def _handle_card_disconnect'):SRC.index('def _ensure_transport')]
+ self.assertIn('_server_ref.arr = None', seg)
+ seg = SRC[SRC.index("elif self.path == '/api/write':"):SRC.index("elif self.path == '/api/tree':")]
+ self.assertIn('_arr_refresh_after_write', seg)
+
+
+class ArrRouteTests(unittest.TestCase):
+ """GET /api/arr serves the session cache without touching the card."""
+
+ def setUp(self):
+ self.server = _build_http_server('127.0.0.1', 0, PysimHandler)
+ self.server.log_requests = False
+ self.server.app = None
+ threading.Thread(target=self.server.serve_forever, daemon=True).start()
+ self.port = self.server.server_address[1]
+
+ def tearDown(self):
+ self.server.shutdown()
+ self.server.server_close()
+
+ def _get(self, path):
+ with urllib.request.urlopen('http://127.0.0.1:%d%s' % (self.port, path),
+ timeout=5) as res:
+ return res.status, json.loads(res.read() or b'{}')
+
+ def test_arr_is_a_card_free_get(self):
+ self.assertIn('/api/arr', _CARD_FREE_GET)
+
+ def test_empty_cache_answers_not_found(self):
+ status, resp = self._get('/api/arr')
+ self.assertEqual(status, 200, resp)
+ self.assertFalse(resp['ok'])
+ self.assertEqual(resp['arrs'], [])
+
+ def test_the_cache_is_served_with_the_session(self):
+ self.server.arr = [{'fid': '2F06', 'path': 'MF/2F06', 'records': ['9000']}]
+ self.server.card_session = 7
+ status, resp = self._get('/api/arr')
+ self.assertEqual(status, 200, resp)
+ self.assertTrue(resp['ok'])
+ self.assertEqual(resp['session'], 7)
+ self.assertEqual(resp['arrs'][0]['fid'], '2F06')
+
+
+if __name__ == '__main__':
+ unittest.main()