EF content decoders: decoded fields in the file manager, snapshots, checks and comparisons (v2.4.0)

Client-side, offline decoder layer (ported from simtrace-analyser, verified
against the pinned specs and pySim test vectors):
- file manager Decoded pill: decoded field table instead of the raw pySim
  JSON dump (per-record fields for record files); the server's
  read_*_decoded JSON stays behind a 'pySim JSON (server)' disclosure;
  switching Raw/Decoded re-reads the open file
- snapshot view: decoded field block for transparent files and a per-record
  decoded summary for record files
- profile check / snapshot comparison report: content mismatches of files
  with a decoder render as a per-field decoded diff (only the differing
  fields, expected | actual); the raw expected/actual pair is kept when the
  decoded values match
- coverage: ICCID, IMSI/IST, UST/SST, LI/PL, SPN, PLMNsel/PLMNwAcT/
  OPLMNwAcT/HPLMNwAcT, OPL, SPDI, PNN, LOCI/PSLOCI/EPSLOCI/EPSNSC/LOCIGPRS,
  Kc/KcGPRS, ACC, PHASE, CBMI/CBMID/CBMIR, ECC, AD, ACL, SMSP, SMS, ARR, DIR,
  IMPI/DOMAIN/IMPU, ADN/FDN/SDN/MSISDN/LND/BDN, EXT1-5, SUME (TS 51.011
  §10.3.34); unknown files fall back to raw
- new frontend/tests/ef_decode.test.js (14 tests); help EN/RU + READMEs
  document the decoded views; version 2.4.0, SW cache simple-v191
- also fix the README/RUS snapshot-comparison text: comparison is always
  exact (no content masking)
This commit is contained in:
catarrh committed 2026-09-19 21:07:30 +03:00
1 parent 4f6e48f0e0
commit eb83238ae7
9 files changed
+1161 -28

No files matched your search

+3 -3
View File
@@ -350,7 +350,7 @@
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Прочитать</strong> — чтение файла (автоопределение transparent/record)</li>
<li><strong>Редактировать</strong> — изменение hex-данных, <strong>Сохранить</strong> для записи (или <strong>Отмена</strong>)</li>
<li><strong>Данные как на карте / Декодированные данные</strong> — переключение между hex-дампом и декодированным JSON</li>
<li><strong>Данные как на карте / Декодированные данные</strong> — переключение между hex-дампом и таблицей декодированных полей (клиентские декодеры EF: IMSI, ICCID, SPN, списки PLMN, LOCI/PSLOCI/EPSLOCI, ADN/MSISDN, таблицы сервисов, SUME, …); серверный pySim JSON того же чтения остаётся доступен в свёрнутом блоке <em>pySim JSON (сервер)</em></li>
<li><strong>Проверить все файлы</strong> — обход всего дерева (включая пользовательские файлы) с пометкой каждого элемента: есть (обычный вид) или нет (красный ✗, без стрелки разворачивания); существующие пустые DF показывают <code class="font-mono text-sm">(пусто)</code>. Отображается прогресс <em>N / всего</em>, обход можно остановить; в конце — сводка «есть/нет». Файлы проверяются только при разворачивании или проверке — просмотр остаётся ленивым.</li>
</ul>
@@ -417,12 +417,12 @@
<li><strong>Атрибуты файла</strong> — тип файла, размер, длина записи и число записей из FCP-шаблона (любой можно оставить незаданным).</li>
<li><strong>Проверить содержимое</strong> (опционально) — <strong>Точное</strong> (точное совпадение hex) или <strong>Маска</strong>, где <code class="font-mono text-sm">?</code> — шаблон на один полубайт (маска без <code class="font-mono text-sm">?</code> — совпадение префикса, например <code class="font-mono text-sm">0891</code> для MCC/MNC из IMSI). Для record-файлов хранится список по записям.</li>
</ul>
<p class="text-sm mb-3"><strong>Проверить карту</strong> выполняет каждое правило на подключённой карте и показывает строку прогресса и отчёт прохождения. Рядом с путём файла указывается, что именно проверялось (например, <em>тип файла и размер, содержимое</em> или <em>полный FCI</em>); если часть проверок прошла, а часть нет — каждый аспект помечается (<em>тип файла ✓, размер ✗, содержимое ✓</em>), а расхождения расписываются ниже. Несовпавшие сырые данные (FCI, содержимое, данные записей) показываются как поля только для чтения с моноширинным шрифтом — ожидаемое над фактическим, в одной и той же колонке — для удобного сравнения; для расхождений FCI дополнительно показывается декодированное сравнение по параметрам (размер файла, дескриптор/структура, жизненный цикл, FID, SFI, проприетарные параметры…). Декодированный просмотр FCI также отображается рядом с полем FCI hex при редактировании правила. Если данные FCI повреждены, показывается всё, что удалось декодировать до места ошибки, вместе с явным сообщением об ошибке декодирования. Для record-файлов при расхождении содержимого добавляется пометка <em>совпадающие записи: 1-5, 7-10</em> со списком записей, которые совпали. В отчёте поля расхождений и колонки сравнения FCI подписаны <em>ожидалось (имя профиля)</em> и <em>фактически (ICCID карты)</em>, а в заголовке отчёта выводится <em>Результаты проверки профиля: &lt;профиль&gt; &rarr; &lt;ICCID карты&gt;</em>. Опция <strong>«Только расхождения»</strong> в заголовке отчёта скрывает все совпавшие файлы и оставляет только несовпадения и ошибки.</p>
<p class="text-sm mb-3"><strong>Проверить карту</strong> выполняет каждое правило на подключённой карте и показывает строку прогресса и отчёт прохождения. Рядом с путём файла указывается, что именно проверялось (например, <em>тип файла и размер, содержимое</em> или <em>полный FCI</em>); если часть проверок прошла, а часть нет — каждый аспект помечается (<em>тип файла ✓, размер ✗, содержимое ✓</em>), а расхождения расписываются ниже. Несовпавшие сырые данные (FCI, содержимое, данные записей) показываются как поля только для чтения с моноширинным шрифтом — ожидаемое над фактическим, в одной и той же колонке — для удобного сравнения; расхождения содержимого файлов с декодером показываются декодированным сравнением по полям (только отличающиеся поля, ожидаемое и фактическое рядом; сырая пара остаётся, когда декодированные значения совпадают), а для расхождений FCI дополнительно показывается декодированное сравнение по параметрам (размер файла, дескриптор/структура, жизненный цикл, FID, SFI, проприетарные параметры…). Декодированный просмотр FCI также отображается рядом с полем FCI hex при редактировании правила. Если данные FCI повреждены, показывается всё, что удалось декодировать до места ошибки, вместе с явным сообщением об ошибке декодирования. Для record-файлов при расхождении содержимого добавляется пометка <em>совпадающие записи: 1-5, 7-10</em> со списком записей, которые совпали. В отчёте поля расхождений и колонки сравнения FCI подписаны <em>ожидалось (имя профиля)</em> и <em>фактически (ICCID карты)</em>, а в заголовке отчёта выводится <em>Результаты проверки профиля: &lt;профиль&gt; &rarr; &lt;ICCID карты&gt;</em>. Опция <strong>«Только расхождения»</strong> в заголовке отчёта скрывает все совпавшие файлы и оставляет только несовпадения и ошибки.</p>
<h4 class="font-medium mb-1">Опции сканирования &laquo;Профиль с карты&raquo;</h4>
<p class="text-sm mb-2">Диалог сканирования запрашивает имя профиля и предлагает селектор <strong>&laquo;Проверка FCP/FCI&raquo;</strong> (те же три режима, по умолчанию <strong>Тип файла + размер (FCP)</strong>), применяемый ко всем создаваемым правилам, а также список <strong>&laquo;Игнорировать содержимое файлов&raquo;</strong> (все отмечены по умолчанию, кроме <code class="font-mono text-sm">EF.ARR</code>; флажок в заголовке отмечает или снимает весь список) часто перезаписываемых файлов, содержимое которых пропускается: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Ещё две отмеченные по умолчанию опции <strong>&laquo;Сравнивать первые 4 байта для&raquo;</strong> <code class="font-mono text-sm">EF.IMSI</code> и <code class="font-mono text-sm">EF.ICCID</code> захватывают содержимое этих файлов как маску только первых 4 байт (снимите для точного сравнения). Строка прогресса показывает <em>N / всего файлов</em> с текущим путём файла во время сканирования; при сканировании опции скрываются, а кнопки блокируются. Правила создаются только для файлов, которые реально существуют на карте (возвращён FCP-шаблон); отсутствующие файлы пропускаются. Пользовательские файлы из подвкладки <strong>&laquo;Пользовательские файлы&raquo;</strong> включаются с той же проверкой существования. Тот же диалог и опции использует <strong>&laquo;Профиль из снимка&raquo;</strong>: вместо карты обходятся захваченные файлы выбранного снимка; для файлов, содержимое которых не было захвачено, правило создаётся без проверки содержимого (при последующей проверке профиля они помечаются как непроверяемые).</p>
<h4 id="card-snapshots" class="font-medium mb-1">Снимки карт</h4>
<p class="text-sm mb-2">Представление списка имеет две вкладки &mdash; <strong>&laquo;Профили&raquo;</strong> и <strong>&laquo;Снимки карт&raquo;</strong>. Снимок карты — неизменяемая фиксация файловой системы карты: для каждого существующего файла сохраняются путь, символьное имя, тип, размер (или длина/число записей), сырой FCI из ответа SELECT и содержимое, если файл читается (без списка игнорирования и без масок). ICCID декодируется из EF.ICCID и показывается рядом с именем снимка. При сканировании измеряется время каждой команды карты (SELECT, READ BINARY, READ RECORD) от отправки до ответа; снимок хранит min/сред/max по каждому типу команд и общее время сканирования, а в представлении эти значения показываются в сводке под заголовком, время select/read — для каждого файла и время чтения — для каждой записи. Время носит информационный характер и не используется при проверках и сравнении.</p>
<p class="text-sm mb-2">Представление списка имеет две вкладки &mdash; <strong>&laquo;Профили&raquo;</strong> и <strong>&laquo;Снимки карт&raquo;</strong>. Снимок карты — неизменяемая фиксация файловой системы карты: для каждого существующего файла сохраняются путь, символьное имя, тип, размер (или длина/число записей), сырой FCI из ответа SELECT и содержимое, если файл читается (без списка игнорирования и без масок). ICCID декодируется из EF.ICCID и показывается рядом с именем снимка. Захваченное содержимое показывается вместе с декодированным представлением, где есть декодер — таблица полей для transparent-файлов и однострочная сводка по каждой записи для record-файлов. При сканировании измеряется время каждой команды карты (SELECT, READ BINARY, READ RECORD) от отправки до ответа; снимок хранит min/сред/max по каждому типу команд и общее время сканирования, а в представлении эти значения показываются в сводке под заголовком, время select/read — для каждого файла и время чтения — для каждой записи. Время носит информационный характер и не используется при проверках и сравнении.</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Новый снимок</strong> &mdash; запрашивает имя и сканирует карту, затем возвращает к списку.</li>
<li><strong>Импорт снимка</strong> &mdash; загружает снимок из JSON-файла.</li>
+3 -3
View File
@@ -350,7 +350,7 @@
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Read</strong> — reads the selected file (auto-detects transparent vs record files)</li>
<li><strong>Edit</strong> — modify hex data, <strong>Save</strong> to write back (or <strong>Cancel</strong>)</li>
<li><strong>Raw / Decoded</strong> — toggle between hex dump and pySim-decoded JSON</li>
<li><strong>Raw / Decoded</strong> — toggle between the hex dump and 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 <em>pySim JSON (server)</em> disclosure</li>
<li><strong>Probe all files</strong> — walks the whole tree (including custom files) and marks every entry present (normal) or absent (red ✗, no expand arrow); empty-but-present DFs show <code class="font-mono text-sm">(empty)</code>. Shows progress <em>N / total</em>, can be stopped, and finishes with a present/absent summary. Files are only verified when expanded or probed — browsing stays lazy.</li>
</ul>
@@ -417,12 +417,12 @@
<li><strong>File attributes</strong> — file type, size, record length and record count, taken from the FCP template (any may be left unset).</li>
<li><strong>Check contents</strong> (optional) — <strong>Exact</strong> hex equality, or <strong>Mask</strong> where <code class="font-mono text-sm">?</code> is a per-nibble wildcard (a mask with no <code class="font-mono text-sm">?</code> is a prefix match, e.g. <code class="font-mono text-sm">0891</code> for the IMSI MCC/MNC). Record files store a per-record list.</li>
</ul>
<p class="text-sm mb-3"><strong>Check card</strong> runs every rule against the equipped card and shows a live progress line plus a pass/fail report. Each row states exactly what was verified next to the file path (e.g. <em>filetype and size, contents</em> or <em>exact FCI</em>); when some checks pass and others fail, each aspect is marked (<em>filetype ✓, size ✗, contents ✓</em>) with the mismatches detailed below. Mismatched raw data (FCI, contents, record data) is shown as read-only monospace fields — expected above actual, aligned in the same column — for easy comparison; FCI mismatches additionally show a decoded per-parameter comparison (file size, file descriptor/structure, life cycle, FID, SFI, proprietary parameters…). A decoded FCI preview is also shown beside the FCI hex field while editing a rule. If the FCI data is corrupt, whatever was decoded before the faulty part is shown together with an explicit decode-failure note. For record files with a contents mismatch, a <em>matching records: 1-5, 7-10</em> note lists the records that did match. In the report the mismatch fields and FCI comparison columns are labelled <em>expected (profile name)</em> and <em>actual (card ICCID)</em>, and the results header reads <em>Profile verification results for: &lt;profile&gt; &rarr; &lt;card ICCID&gt;</em>. An <strong>Only mismatches</strong> option in the results header hides all passing files and keeps only failures and errors.</p>
<p class="text-sm mb-3"><strong>Check card</strong> runs every rule against the equipped card and shows a live progress line plus a pass/fail report. Each row states exactly what was verified next to the file path (e.g. <em>filetype and size, contents</em> or <em>exact FCI</em>); when some checks pass and others fail, each aspect is marked (<em>filetype ✓, size ✗, contents ✓</em>) with the mismatches detailed below. Mismatched raw data (FCI, contents, record data) is shown as read-only monospace fields — expected above actual, aligned in the same column — for easy comparison; content mismatches of files with a decoder are shown as a decoded per-field comparison instead (only the differing fields, expected and actual side by side; the raw pair is kept when the decoded values match), and FCI mismatches additionally show a decoded per-parameter comparison (file size, file descriptor/structure, life cycle, FID, SFI, proprietary parameters…). A decoded FCI preview is also shown beside the FCI hex field while editing a rule. If the FCI data is corrupt, whatever was decoded before the faulty part is shown together with an explicit decode-failure note. For record files with a contents mismatch, a <em>matching records: 1-5, 7-10</em> note lists the records that did match. In the report the mismatch fields and FCI comparison columns are labelled <em>expected (profile name)</em> and <em>actual (card ICCID)</em>, and the results header reads <em>Profile verification results for: &lt;profile&gt; &rarr; &lt;card ICCID&gt;</em>. An <strong>Only mismatches</strong> option in the results header hides all passing files and keeps only failures and errors.</p>
<h4 class="font-medium mb-1">&ldquo;Profile from card&rdquo; scan options</h4>
<p class="text-sm mb-2">The scan dialog asks for a profile name and offers a <strong>&ldquo;FCP/FCI check&rdquo;</strong> selector (the same three modes above, default <strong>Filetype + size</strong>) applied to every generated rule, plus an <strong>&ldquo;Ignore contents of files&rdquo;</strong> checklist (all checked by default except <code class="font-mono text-sm">EF.ARR</code>; the header checkbox checks or unchecks the whole list) of frequently-overwritten files whose contents are skipped: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Two further checked-by-default options <strong>&ldquo;Match first 4 bytes for&rdquo;</strong> <code class="font-mono text-sm">EF.IMSI</code> and <code class="font-mono text-sm">EF.ICCID</code> capture those files&rsquo; contents as a mask of only the first 4 bytes (uncheck for exact matching). A progress line shows <em>N / total files</em> with the current file path while scanning; during the scan the options are hidden and the buttons are locked. Rules are created only for files that actually exist on the card (a FCP template is returned); missing files are skipped. Custom files from the <strong>Custom files</strong> sub-tab are included under the same existence check. The same dialog and options are used by <strong>Profile from snapshot</strong>, which walks the selected snapshot&rsquo;s captured files instead of the card; rules for files whose contents were not captured during the scan get no content check (they are reported as unverifiable when the profile is later checked).</p>
<h4 id="card-snapshots" class="font-medium mb-1">Card snapshots</h4>
<p class="text-sm mb-2">The list view has two tabs &mdash; <strong>Profiles</strong> and <strong>Card snapshots</strong>. A card snapshot is an immutable capture of the card filesystem: for every existing file it stores the path, symbolic name, file type, size (or record length/count), the raw FCI from the SELECT response, and the contents whenever the file is readable (no ignore list, no masking). The ICCID is decoded from EF.ICCID and shown next to the snapshot name. The scan also measures each card command (SELECT, READ BINARY, READ RECORD) from command to response; the snapshot stores min/avg/max per command type and the total scan time, and the view shows these in the summary under the title plus the select/read times per file and the read time per record. Timings are informational only and are not used by checks or comparisons.</p>
<p class="text-sm mb-2">The list view has two tabs &mdash; <strong>Profiles</strong> and <strong>Card snapshots</strong>. A card snapshot is an immutable capture of the card filesystem: for every existing file it stores the path, symbolic name, file type, size (or record length/count), the raw FCI from the SELECT response, and the contents whenever the file is readable (no ignore list, no masking). The ICCID is decoded from EF.ICCID and shown next to the snapshot name. Captured contents are shown with their decoded form where a decoder exists — a field table for transparent files and a one-line summary per record for record files. The scan also measures each card command (SELECT, READ BINARY, READ RECORD) from command to response; the snapshot stores min/avg/max per command type and the total scan time, and the view shows these in the summary under the title plus the select/read times per file and the read time per record. Timings are informational only and are not used by checks or comparisons.</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>New snapshot</strong> &mdash; asks for a name and scans the card, then returns to the list.</li>
<li><strong>Import snapshot</strong> &mdash; loads a snapshot from a JSON file.</li>
+912 -11
View File
@@ -1305,7 +1305,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 = '2.3.1';
const SIMPLE_VERSION = '2.4.0';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching =====
@@ -6838,19 +6838,19 @@ async function pysimFsRead() {
const out = document.getElementById('pysim-fs-content');
const statusEl = document.getElementById('pysim-fs-status');
out.innerHTML = '';
statusEl.textContent = 'Loading...';
statusEl.textContent = t('Loading...');
try {
const node = pysimFsFindNode(rawName, pysimFsTreeRoot);
const body = pysimFsSelectBody(node || { name: rawName, fid: null });
body.mode = pysimFsDecodedMode ? 'decoded' : 'raw';
body.mode = 'raw'; // decoding is client-side (works offline in snapshots too)
const data = await pysimFetch('/api/read', body);
if (!data.success) {
statusEl.textContent = 'SW: ' + (data.sw || '?') + ' — ' + (data.error || 'Error');
return;
}
statusEl.textContent = 'SW: ' + data.sw + ' OK';
if (data.decoded) {
out.innerHTML = '<pre class="text-xs leading-relaxed">' + esc(JSON.stringify(data.decoded, null, 2)) + '</pre>';
if (pysimFsDecodedMode) {
out.innerHTML = pysimFsDecodedHtml(node || { name: rawName, fid: null }, data);
} else if (data.records) {
let html = '';
for (const r of data.records) {
@@ -6869,6 +6869,59 @@ async function pysimFsRead() {
}
}
// Decoded view of a read result: label/value fields from the client-side EF
// decoders, with the server-side pySim JSON behind a disclosure.
function pysimFsDecodedHtml(node, data) {
const name = (node && node.name) || null;
const fid = (node && node.fid) || (/^[0-9a-fA-F]{4}$/.test(pysimFsSelected || '') ? pysimFsSelected : null);
const content = data.records ? { kind: 'record', records: data.records } : { kind: 'transparent', data: data.data };
const dec = efDecodeFile(node && node.name, fid, content);
let html = '';
if (!dec || !dec.fn) {
html += '<div class="text-xs text-yellow-600 dark:text-yellow-500 mb-1">' + esc(t('No decoder for this file')) + ' — ' + esc(t('showing raw data')) + '</div>';
if (data.records) {
for (const r of data.records) {
html += '<div class="flex items-center gap-1 mb-1"><span class="text-xs w-8 text-gray-500 dark:text-slate-400 shrink-0">' + r.num + '</span>' +
'<input class="flex-1 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-white dark:bg-slate-800" value="' + esc(r.data) + '" readonly></div>';
}
} else {
html += '<textarea rows="8" readonly class="w-full font-mono text-xs border-0 bg-transparent resize-none">' + esc(data.data || '') + '</textarea>';
}
return html;
}
if (dec.records) {
for (const r of dec.records) {
html += '<div class="mb-2">' +
'<div class="text-xs text-gray-500 dark:text-slate-400">' + esc(t('Record')) + ' ' + r.num + (r.empty ? ' — ' + esc(t('empty')) : '') + '</div>' +
efRenderFieldsHtml(efFlatten(r.data)) + '</div>';
}
} else {
if (dec.empty) html += '<div class="text-xs text-gray-500 dark:text-slate-400 mb-1">' + esc(t('empty')) + '</div>';
html += efRenderFieldsHtml(efFlatten(dec.data));
}
html += '<details class="mt-2" ontoggle="if (this.open) pysimFsLoadPySimJson()"><summary class="text-xs text-gray-400 dark:text-slate-500 cursor-pointer">' + esc(t('pySim JSON (server)')) + '</summary>' +
'<div id="pysim-fs-pysim-json" class="text-xs font-mono mt-1 text-gray-600 dark:text-slate-400 break-all"></div></details>';
return html;
}
async function pysimFsLoadPySimJson() {
const el = document.getElementById('pysim-fs-pysim-json');
if (!el) return;
el.textContent = t('Loading...');
try {
const rawName = pysimFsSelected;
const node = pysimFsFindNode(rawName, pysimFsTreeRoot);
const body = pysimFsSelectBody(node || { name: rawName, fid: null });
body.mode = 'decoded';
const data = await pysimFetch('/api/read', body);
if (data && data.decoded) el.textContent = JSON.stringify(data.decoded, null, 1);
else if (data && data.data) el.textContent = data.data;
else el.textContent = (data && data.error) || t('No response yet');
} catch (e) {
el.textContent = 'Error: ' + e.message;
}
}
let pysimFsEditMode = false;
let pysimFsEditData = null;
@@ -6966,6 +7019,9 @@ function pysimFsSetMode(mode) {
el.classList.toggle('text-gray-700', !active);
el.classList.toggle('dark:text-slate-300', !active);
});
// Re-render the open file in the new mode (not while editing: that would
// discard unsaved changes).
if (pysimFsSelected && !pysimFsEditMode) pysimFsRead();
}
function pysimFsResetEdit() {
@@ -10387,6 +10443,7 @@ function profilerRenderSnapshotFiles(s) {
'<div class="text-xs font-mono leading-relaxed text-gray-600 dark:text-slate-400 break-all">' + profilerFciPreviewItems(f.fciHex) + '</div></div>';
html += '</div>';
}
const dec = f.content ? efDecodeFile(f.name, efFidFromPath(f.path), f.content) : null;
html += '<div class="text-xs text-gray-500 dark:text-slate-400 mt-1 mb-0.5">' + esc(t('Contents')) + '</div>';
if (!f.content) {
html += '<div class="text-xs text-gray-400 dark:text-slate-500">' + esc(t('Not captured')) + '</div>';
@@ -10397,10 +10454,16 @@ function profilerRenderSnapshotFiles(s) {
'<input readonly value="' + escHtml(rec.data) + '" class="flex-1 min-w-0 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-gray-100 dark:bg-slate-800">' +
(rec.ms !== undefined ? '<span class="text-xs text-gray-400 w-16 text-right shrink-0">' + esc(profilerFormatMs(rec.ms)) + '</span>' : '') +
'</div>';
const dr = dec && dec.records ? dec.records.find(x => x.num === rec.num) : null;
const sum = dr && !dr.empty ? efDataSummary(dr.data) : '';
if (sum) html += '<div class="text-xs text-gray-500 dark:text-slate-400 ml-10">' + esc(sum) + '</div>';
}
html += '</div>';
} else {
html += '<input readonly value="' + escHtml(f.content.data || '') + '" class="w-full min-w-0 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-gray-100 dark:bg-slate-800">';
if (dec && dec.data && !dec.empty) {
html += '<div class="mt-1"><div class="text-xs text-gray-500 dark:text-slate-400 mb-0.5">' + esc(t('Decoded')) + '</div>' + efRenderFieldsHtml(efFlatten(dec.data)) + '</div>';
}
}
html += '</div>';
}
@@ -11309,14 +11372,29 @@ function profilerRenderReport(results, labels) {
if (c.label === 'extra file') {
html += '<div class="text-xs text-red-600 mt-1">' + esc(t('extra file')) + '</div>';
} else if (profilerRawDataCheck(c)) {
const isContent = c.label === 'content' || /^content\.rec\d+$/.test(c.label);
const dd = isContent && typeof efContentDiff === 'function'
? efContentDiff(r.name, efFidFromPath(r.path), String(c.expected), String(c.actual)) : null;
html += '<div class="mt-1">';
html += '<div class="text-xs text-red-600 font-mono pl-2">' + esc(c.label) + '</div>';
html += '<div class="flex items-center gap-2 mt-0.5 pl-2">' +
'<span class="' + labelCls + '">' + esc(expLabel) + '</span>' +
'<input readonly title="' + escHtml(String(c.expected)) + '" value="' + escHtml(String(c.expected)) + '" class="flex-1 min-w-0 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-gray-100 dark:bg-slate-800"></div>';
html += '<div class="flex items-center gap-2 mt-0.5 pl-2">' +
'<span class="' + labelCls + '">' + esc(actLabel) + '</span>' +
'<input readonly title="' + escHtml(String(c.actual)) + '" value="' + escHtml(String(c.actual)) + '" class="flex-1 min-w-0 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-gray-100 dark:bg-slate-800"></div>';
if (dd && dd.rows.length) {
html += '<div class="text-xs text-gray-500 dark:text-slate-400 pl-2 mt-0.5">' + esc(t('Decoded comparison')) + ' — ' + esc(expLabel) + ' | ' + esc(actLabel) + '</div>';
for (const [label, ev, av] of dd.rows) {
html += '<div class="flex items-center gap-2 mt-0.5 pl-2">' +
'<span class="' + labelCls + '">' + esc(label) + '</span>' +
'<input readonly title="' + escHtml(expLabel) + '" value="' + escHtml(ev) + '" class="flex-1 min-w-0 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-gray-100 dark:bg-slate-800">' +
'<input readonly title="' + escHtml(actLabel) + '" value="' + escHtml(av) + '" class="flex-1 min-w-0 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-gray-100 dark:bg-slate-800">' +
'</div>';
}
} else {
if (dd && dd.sameDecoded) html += '<div class="text-xs text-gray-500 dark:text-slate-400 pl-2">' + esc(t('Decoded values match; raw bytes differ')) + '</div>';
html += '<div class="flex items-center gap-2 mt-0.5 pl-2">' +
'<span class="' + labelCls + '">' + esc(expLabel) + '</span>' +
'<input readonly title="' + escHtml(String(c.expected)) + '" value="' + escHtml(String(c.expected)) + '" class="flex-1 min-w-0 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-gray-100 dark:bg-slate-800"></div>';
html += '<div class="flex items-center gap-2 mt-0.5 pl-2">' +
'<span class="' + labelCls + '">' + esc(actLabel) + '</span>' +
'<input readonly title="' + escHtml(String(c.actual)) + '" value="' + escHtml(String(c.actual)) + '" class="flex-1 min-w-0 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-gray-100 dark:bg-slate-800"></div>';
}
if (c.label === 'fci') html += fcpDiffHtml(String(c.expected), String(c.actual), labels);
html += '</div>';
} else {
@@ -11343,6 +11421,820 @@ document.querySelectorAll('.pysim-subtab').forEach(btn => {
});
window.addEventListener('resize', pysimFsFitTree);
const EF_UST_SERVICES = {1: "Local Phone Book", 2: "Fixed Dialling Numbers (FDN)", 3: "Extension 2", 4: "Service Dialling Numbers (SDN)", 5: "Extension3", 6: "Barred Dialling Numbers (BDN)", 7: "Extension4", 8: "Outgoing Call Information (OCI and OCT)", 9: "Incoming Call Information (ICI and ICT)", 10: "Short Message Storage (SMS)", 11: "Short Message Status Reports (SMSR)", 12: "Short Message Service Parameters (SMSP)", 13: "Advice of Charge (AoC)", 14: "Capability Configuration Parameters 2 (CCP2)", 15: "Cell Broadcast Message Identifier", 16: "Cell Broadcast Message Identifier Ranges", 17: "Group Identifier Level 1", 18: "Group Identifier Level 2", 19: "Service Provider Name", 20: "User controlled PLMN selector with Access Technology", 21: "MSISDN", 22: "Image (IMG)", 23: "Support of Localised Service Areas (SoLSA)", 24: "Enhanced Multi-Level Precedence and Pre-emption Service", 25: "Automatic Answer for eMLPP", 26: "RFU", 27: "GSM Access", 28: "Data download via SMS-PP", 29: "Data download via SMS-CB", 30: "Call Control by USIM", 31: "MO-SMS Control by USIM", 32: "RUN AT COMMAND command", 33: "shall be set to 1", 34: "Enabled Services Table", 35: "APN Control List (ACL)", 36: "Depersonalisation Control Keys", 37: "Co-operative Network List", 38: "GSM security context", 39: "CPBCCH Information", 40: "Investigation Scan", 41: "MexE", 42: "Operator controlled PLMN selector with Access Technology", 43: "HPLMN selector with Access Technology", 44: "Extension 5", 45: "PLMN Network Name", 46: "Operator PLMN List", 47: "Mailbox Dialling Numbers", 48: "Message Waiting Indication Status", 49: "Call Forwarding Indication Status", 50: "Reserved and shall be ignored", 51: "Service Provider Display Information", 52: "Multimedia Messaging Service (MMS)", 53: "Extension 8", 54: "Call control on GPRS by USIM", 55: "MMS User Connectivity Parameters", 56: "Network's indication of alerting in the MS (NIA)", 57: "VGCS Group Identifier List (EFVGCS and EFVGCSS)", 58: "VBS Group Identifier List (EFVBS and EFVBSS)", 59: "Pseudonym", 60: "User Controlled PLMN selector for I-WLAN access", 61: "Operator Controlled PLMN selector for I-WLAN access", 62: "User controlled WSID list", 63: "Operator controlled WSID list", 64: "VGCS security", 65: "VBS security", 66: "WLAN Reauthentication Identity", 67: "Multimedia Messages Storage", 68: "Generic Bootstrapping Architecture (GBA)", 69: "MBMS security", 70: "Data download via USSD and USSD application mode", 71: "Equivalent HPLMN", 72: "Additional TERMINAL PROFILE after UICC activation", 73: "Equivalent HPLMN Presentation Indication", 74: "Last RPLMN Selection Indication", 75: "OMA BCAST Smart Card Profile", 76: "GBA-based Local Key Establishment Mechanism", 77: "Terminal Applications", 78: "Service Provider Name Icon", 79: "PLMN Network Name Icon", 80: "Connectivity Parameters for USIM IP connections", 81: "Home I-WLAN Specific Identifier List", 82: "I-WLAN Equivalent HPLMN Presentation Indication", 83: "I-WLAN HPLMN Priority Indication", 84: "I-WLAN Last Registered PLMN", 85: "EPS Mobility Management Information", 86: "Allowed CSG Lists and corresponding indications", 87: "Call control on EPS PDN connection by USIM", 88: "HPLMN Direct Access", 89: "eCall Data", 90: "Operator CSG Lists and corresponding indications", 91: "Support for SM-over-IP", 92: "Support of CSG Display Control", 93: "Communication Control for IMS by USIM", 94: "Extended Terminal Applications", 95: "Support of UICC access to IMS", 96: "Non-Access Stratum configuration by USIM", 97: "PWS configuration by USIM", 98: "RFU", 99: "URI support by UICC", 100: "Extended EARFCN support", 101: "ProSe", 102: "USAT Application Pairing", 103: "Media Type support", 104: "IMS call disconnection cause", 105: "URI support for MO SHORT MESSAGE CONTROL", 106: "ePDG configuration Information support", 107: "ePDG configuration Information configured", 108: "ACDC support", 109: "MCPTT", 110: "ePDG configuration Information for Emergency Service support", 111: "ePDG configuration Information for Emergency Service configured", 112: "eCall Data over IMS", 113: "URI support for SMS-PP DOWNLOAD as defined in 3GPP TS 31.111 [12]", 114: "From Preferred", 115: "IMS configuration data", 116: "TV configuration", 117: "3GPP PS Data Off", 118: "3GPP PS Data Off Service List", 119: "V2X", 120: "XCAP Configuration Data", 121: "EARFCN list for MTC/NB-IOT UEs", 122: "5GS Mobility Management Information", 123: "5G Security Parameters", 124: "Subscription identifier privacy support", 125: "SUCI calculation by the USIM", 126: "UAC Access Identities support", 127: "Expect control plane-based Steering of Roaming information during initial registration in VPLMN", 128: "Call control on PDU Session by USIM", 129: "5GS Operator PLMN List", 130: "Support for SUPI of type NSI or GLI or GCI", 131: "3GPP PS Data Off separate Home and Roaming lists", 132: "Support for URSP by USIM", 133: "5G Security Parameters extended", 134: "MuD and MiD configuration data", 135: Line truncated
const EF_SST_SERVICES = {1: "CHV1 disable function", 2: "Abbreviated Dialling Numbers (ADN)", 3: "Fixed Dialling Numbers (FDN)", 4: "Short Message Storage (SMS)", 5: "Advice of Charge (AoC)", 6: "Capability Configuration Parameters (CCP)", 7: "PLMN selector", 8: "RFU", 9: "MSISDN", 10: "Extension1", 11: "Extension2", 12: "SMS Parameters", 13: "Last Number Dialled (LND)", 14: "Cell Broadcast Message Identifier", 15: "Group Identifier Level 1", 16: "Group Identifier Level 2", 17: "Service Provider Name", 18: "Service Dialling Numbers (SDN)", 19: "Extension3", 20: "RFU", 21: "VGCS Group Identifier List (EFVGCS and EFVGCSS)", 22: "VBS Group Identifier List (EFVBS and EFVBSS)", 23: "enhanced Multi-Level Precedence and Pre-emption Service", 24: "Automatic Answer for eMLPP", 25: "Data download via SMS-CB", 26: "Data download via SMS-PP", 27: "Menu selection", 28: "Call control", 29: "Proactive SIM", 30: "Cell Broadcast Message Identifier Ranges", 31: "Barred Dialling Numbers (BDN)", 32: "Extension4", 33: "De-personalization Control Keys", 34: "Co-operative Network List", 35: "Short Message Status Reports", 36: "Network's indication of alerting in the MS", 37: "Mobile Originated Short Message control by SIM", 38: "GPRS", 39: "Image (IMG)", 40: "SoLSA (Support of Local Service Area)", 41: "USSD string data object supported in Call Control", 42: "RUN AT COMMAND command", 43: "User controlled PLMN Selector with Access Technology", 44: "Operator controlled PLMN Selector with Access Technology", 45: "HPLMN Selector with Access Technology", 46: "CPBCCH Information", 47: "Investigation Scan", 48: "Extended Capability Configuration Parameters", 49: "MExE", 50: "Reserved and shall be ignored", 51: "PLMN Network Name", 52: "Operator PLMN List", 53: "Mailbox Dialling Numbers", 54: "Message Waiting Indication Status", 55: "Call Forwarding Indication Status", 56: "Service Provider Display Information", 57: "Multimedia Messaging Service (MMS)", 58: "Extension 8", 59: "MMS User Connectivity Parameters"};
const EF_IST_SERVICES = {1: "P-CSCF address", 2: "Generic Bootstrapping Architecture (GBA)", 3: "HTTP Digest", 4: "GBA-based Local Key Establishment Mechanism", 5: "Support of P-CSCF discovery for IMS Local Break Out", 6: "Short Message Storage (SMS)", 7: "Short Message Status Reports (SMSR)", 8: "Support for SM-over-IP including data download via SMS-PP as defined in TS 31.111 [31]", 9: "Communication Control for IMS by ISIM", 10: "Support of UICC access to IMS", 11: "URI support by UICC", 12: "Media Type support", 13: "IMS call disconnection cause", 14: "URI support for MO SHORT MESSAGE CONTROL", 15: "MCPTT", 16: "URI support for SMS-PP DOWNLOAD as defined in 3GPP TS 31.111 [31]", 17: "From Preferred", 18: "IMS configuration data", 19: "XCAP Configuration Data", 20: "WebRTC URI", 21: "MuD and MiD configuration data", 22: "IMS Data Channel indication"};
const EF_PHASE_NAMES = {0: "phase 1", 2: "phase 2", 3: "phase 2 and higher"};
// ===== EF content decoders (client-side, offline) =====
// Decode the contents of common EFs for the file manager, the snapshot view,
// profile-check results and snapshot comparison. Ported/adapted from
// simtrace-analyser, which sourced the formats from the pinned specs
// (TS 51.011, TS 31.102, TS 31.103, TS 102 221 Annex A, TS 24.008) and pySim;
// pySim's server-side read_*_decoded stays available as a cross-check.
// Decoders are pure functions over byte arrays returning plain objects;
// efFlatten() turns a decoded object into [label, value] rows for display and
// efDiffData() into per-field diff rows for the report.
const EF_FIELD_LABELS = {
imsi: 'IMSI', iccid: 'ICCID', languages: 'Languages', name: 'Name', number: 'Number',
text: 'Text', title: 'Title', icon: 'Icon', mcc: 'MCC', mnc: 'MNC', lac: 'LAC',
tac: 'TAC', rac: 'RAC', tmsi: 'TMSI', p_tmsi: 'P-TMSI', guti: 'GUTI', ksi: 'KSI',
kc: 'Kc', acm_max: 'ACM max', apns: 'APNs', access_classes: 'Access classes',
op_mode: 'Operation mode', test_mode: 'Test mode', additional_info: 'Additional info',
mnc_length: 'MNC length', services: 'Services', plmns: 'PLMNs', applications: 'Applications',
aid: 'AID', label: 'Label', eap: 'EAP', message_ids: 'Message IDs', ranges: 'Ranges',
esc: 'Emergency category', param_indicators: 'Parameter indicators', tp_da: 'TP-DA',
sca: 'SCA', tp_pid: 'TP-PID', tp_dcs: 'TP-DCS', tp_vp: 'TP-VP', alpha: 'Alpha',
direction: 'Direction', status: 'Status', tpdu: 'TPDU', display_condition: 'Display condition',
rfu: 'RFU', ccp: 'CCP', ext: 'Extension', tag: 'Tag', hex: 'Hex', lac_range: 'LAC range',
pnn_record: 'PNN record', pnn_record_id: 'PNN record', mcc_mnc: 'MCC/MNC', full: 'Full name',
short: 'Short name', eap_types: 'EAP types', dfs: 'DFs', uplink_nas_count: 'Uplink NAS count',
downlink_nas_count: 'Downlink NAS count', nas_algorithms: 'NAS algorithms', k_asme: 'K-ASME',
ksi_asme: 'KSI-ASME', tmsi_time: 'TMSI time', lac_range_low: 'LAC range low',
lac_range_high: 'LAC range high', show_in_hplmn: 'Show in HPLMN', hide_in_oplmn: 'Hide in OPLMN',
location_update_status: 'Location update status', eps_update_status: 'EPS update status',
update_status: 'Update status', access_tech: 'Access technology', p_tmsi_signature: 'P-TMSI signature',
};
const EF_GSM7_EXT = { 0x0A: '\n', 0x14: '^', 0x28: '{', 0x29: '}', 0x2F: '\\', 0x3C: '[', 0x3D: '~', 0x3E: ']', 0x40: '|', 0x65: '\u20ac' };
function efBytes(hex) {
const n = (hex || '').length >> 1;
const out = new Uint8Array(n);
for (let i = 0; i < n; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16);
return out;
}
function efHex(b) {
let s = '';
for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, '0').toUpperCase();
return s;
}
function efSwapNibbles(hex) {
let s = '';
for (let i = 0; i + 1 < hex.length; i += 2) s += hex[i + 1] + hex[i];
return s;
}
function efAllFf(b) {
if (!b.length) return true;
for (let i = 0; i < b.length; i++) if (b[i] !== 0xFF) return false;
return true;
}
function efRstripFf(b) {
let end = b.length;
while (end > 0 && b[end - 1] === 0xFF) end--;
return b.subarray(0, end);
}
function efTlv(data) {
const out = [];
let i = 0;
while (i + 2 <= data.length) {
const tag = data[i]; i += 1;
let len = data[i]; i += 1;
if (len & 0x80) {
const n = len & 0x7F;
if (n === 0 || i + n > data.length) break;
len = 0;
for (let k = 0; k < n; k++) len = (len << 8) | data[i++];
}
if (i + len > data.length) break;
out.push({ tag: tag, value: data.slice(i, i + len) });
i += len;
}
return out;
}
function efUcs2Be(b) {
let end = b.length - (b.length % 2);
let s = '';
for (let i = 0; i + 1 < end; i += 2) {
const u = (b[i] << 8) | b[i + 1];
if (u === 0) break;
s += String.fromCharCode(u);
}
return s;
}
function efGsm7Octets(b) {
let s = '';
for (let i = 0; i < b.length; i++) {
const v = b[i];
if (v === 0xFF) continue;
s += v < GSM7_ALPHABET.length ? GSM7_ALPHABET[v] : '?';
}
return s;
}
// TS 102 221 Annex A / TS 24.008 text string (GsmOrUcs2 variants).
function efAnnexA(b) {
if (!b.length || efAllFf(b)) return '';
const first = b[0];
if (first === 0x80) return efUcs2Be(b.subarray(1));
if (first === 0x90) {
let d = b.subarray(1);
for (let i = 0; i + 1 < d.length; i += 2) {
if (d[i] === 0 && d[i + 1] === 0) { d = d.subarray(0, i); break; }
}
return efUcs2Be(d);
}
if (first === 0x81 && b.length >= 3) {
const num = b[1], base = b[2] << 7;
let s = '';
for (let i = 0; i < num && 3 + i < b.length; i++) {
const ch = b[3 + i];
s += (ch & 0x80) ? String.fromCharCode((ch & 0x7F) + base)
: (ch < GSM7_ALPHABET.length ? GSM7_ALPHABET[ch] : '?');
}
return s;
}
if (first === 0x82 && b.length >= 4) {
const num = b[1], base = (b[2] << 8) | b[3];
let s = '';
for (let i = 0; i < num && 4 + i < b.length; i++) {
const ch = b[4 + i];
s += (ch & 0x80) ? String.fromCharCode((ch & 0x7F) + base)
: (ch < GSM7_ALPHABET.length ? GSM7_ALPHABET[ch] : '?');
}
return s;
}
if (first >= 0x83 && first <= 0x87) {
const d = efRstripFf(b.subarray(1));
return d.length ? gsm7Decode(d) : '';
}
if (first & 0x80) {
const u = b.slice();
u[0] &= 0x7F;
let end = u.length;
while (end >= 2 && u[end - 1] === 0xFF && u[end - 2] === 0xFF) end -= 2;
return efUcs2Be(u.subarray(0, end));
}
const d = efRstripFf(b);
let allLow = d.length > 0;
for (let i = 0; i < d.length; i++) if (d[i] >= 0x80) { allLow = false; break; }
return allLow ? efGsm7Octets(d) : gsm7Decode(d);
}
// [TON/NPI byte + BCD digits] -> dialling string.
function efBcdAddress(data) {
if (!data.length) return '';
const ton = (data[0] >> 4) & 0x07;
const digits = [];
for (let i = 1; i < data.length; i++) {
const lo = data[i] & 0x0F, hi = (data[i] >> 4) & 0x0F;
if (lo <= 9) digits.push(String(lo)); else if (lo === 0x0F) break;
if (hi <= 9) digits.push(String(hi)); else if (hi === 0x0F) break;
}
const num = digits.join('');
return ton === 1 ? '+' + num : num;
}
// [length octet (counts the following octets) + TON/NPI + BCD digits],
// as used by the SMSP TP-DA/SCA fields and TPDU addresses (TS 24.008).
function efBcdAddressLen(data) {
if (data.length < 2) return '';
const n = data[0];
const ton = (data[1] >> 4) & 0x07;
const bcd = data.subarray(2, Math.min(data.length, 1 + n));
const digits = efSwapNibbles(efHex(bcd)).replace(/[fF]+$/, '');
return ton === 1 ? '+' + digits : digits;
}
// 3-byte PLMN (TS 24.008) -> {mcc, mnc}
function efPlmn(b) {
if (b.length < 3) return null;
const h = efHex(b.subarray(0, 3));
const mcc = (h[1] + h[0] + h[3]).replace(/F+$/, '') || '0';
const mnc = (h[5] + h[4] + h[2]).replace(/F+$/, '') || '0';
return { mcc: mcc, mnc: mnc };
}
function efPlmnAct(u16) {
const techs = new Set();
if (u16 & 0x8000) techs.add('UTRAN');
if (u16 & 0x0800) techs.add('NG-RAN');
if (u16 & 0x0040) techs.add('GSM COMPACT');
if (u16 & 0x0020) techs.add('cdma2000 HRPD');
if (u16 & 0x0010) techs.add('cdma2000 1xRTT');
const e = u16 & 0x7000;
if (e === 0x4000 || e === 0x7000) { techs.add('E-UTRAN WB-S1'); techs.add('E-UTRAN NB-S1'); }
else if (e === 0x5000) techs.add('E-UTRAN NB-S1');
else if (e === 0x6000) techs.add('E-UTRAN WB-S1');
const g = u16 & 0x008C;
if (g === 0x0080 || g === 0x008C) { techs.add('GSM'); techs.add('EC-GSM-IoT'); }
else if (g === 0x0084) techs.add('GSM');
else if (g === 0x0086) techs.add('EC-GSM-IoT');
return [...techs].sort();
}
// TS 23.038 Network Name (DCS byte + text) from EF_PNN.
function efPnnText(value) {
if (value.length < 2) return '';
const dcs = value[0];
const coding = (dcs >> 4) & 0x07;
let data = efRstripFf(value.subarray(1));
if (coding === 1) {
for (let i = 0; i + 1 < data.length; i += 2) {
if (data[i] === 0 && data[i + 1] === 0) { data = data.subarray(0, i); break; }
}
return efUcs2Be(data);
}
if (coding === 0) {
let allLow = data.length > 0;
for (let i = 0; i < data.length; i++) if (data[i] >= 0x80) { allLow = false; break; }
return allLow ? efGsm7Octets(data) : gsm7Decode(data);
}
return '';
}
// ---- decoders ----
function efDecIccid(b) {
if (efAllFf(b)) return { empty: true };
const digits = efSwapNibbles(efHex(b)).replace(/[fF]+$/, '');
return { iccid: digits || null };
}
function efDecImsi(b) {
if (efAllFf(b)) return { empty: true };
if (b.length <= 2) return efDecServiceTable(b, EF_IST_SERVICES); // 6F07 is also EF.IST
const digits = efSwapNibbles(efHex(b.subarray(1))).replace(/[fF]+$/, '');
// First nibble is the mobile-identity type + parity indicator (TS 24.008).
return { imsi: digits.slice(1) || null };
}
function efDecLi(b) {
const codes = [];
for (let i = 0; i + 1 < b.length; i += 2) {
if (b[i] === 0xFF && b[i + 1] === 0xFF) continue;
try {
const c = String.fromCharCode(b[i]) + String.fromCharCode(b[i + 1]);
if (/^[A-Za-z]{2}$/.test(c)) codes.push(c);
} catch (e) {}
}
return { languages: codes };
}
function efDecServiceTable(b, table) {
const services = [];
for (let i = 0; i < b.length; i++) {
for (let bit = 0; bit < 8; bit++) {
if (b[i] & (1 << bit)) {
const n = i * 8 + bit + 1;
services.push({ n: n, name: table[n] || 'RFU' });
}
}
}
return { services: services };
}
function efDecPlmnList(b) {
const plmns = [];
for (let i = 0; i + 2 < b.length; i += 3) {
const chunk = b.subarray(i, i + 3);
if (efAllFf(chunk)) break;
const p = efPlmn(chunk);
if (p) plmns.push(p);
}
return { plmns: plmns };
}
function efDecPlmnWact(b) {
const plmns = [];
for (let i = 0; i + 4 < b.length; i += 5) {
const chunk = b.subarray(i, i + 5);
if (efAllFf(chunk.subarray(0, 3))) break;
const p = efPlmn(chunk.subarray(0, 3));
if (!p) continue;
const act = (chunk[3] << 8) | chunk[4];
const techs = efPlmnAct(act);
p.access_tech = techs.length ? techs.join(', ') : '0x' + act.toString(16).toUpperCase().padStart(4, '0');
plmns.push(p);
}
return { plmns: plmns };
}
function efDecSpn(b) {
if (b.length < 2) return { raw: efHex(b) };
const cond = b[0];
const out = { show_in_hplmn: !!(cond & 0x01), hide_in_oplmn: !!(cond & 0x02) };
const text = efAnnexA(efRstripFf(b.subarray(1)));
if (text) out.name = text;
return out;
}
function efDecLoci(b) {
if (b.length < 11) return { raw: efHex(b) };
const tmsi = b.subarray(0, 4), lai = b.subarray(4, 9);
const p = efPlmn(lai.subarray(0, 3)) || {};
return {
tmsi: efAllFf(tmsi) ? null : efHex(tmsi),
mcc: p.mcc, mnc: p.mnc,
lac: '0x' + efHex(lai.subarray(3, 5)),
tmsi_time: '0x' + efHex(b.subarray(9, 10)),
location_update_status: '0x' + efHex(b.subarray(10, 11)),
};
}
function efDecPsLoci(b) {
if (efAllFf(b)) return { empty: true };
if (b.length < 14) return { raw: efHex(b) };
const rai = b.subarray(7, 13);
const p = efPlmn(rai.subarray(0, 3)) || {};
return {
p_tmsi: efAllFf(b.subarray(0, 4)) ? null : efHex(b.subarray(0, 4)),
p_tmsi_signature: efHex(b.subarray(4, 7)),
mcc: p.mcc, mnc: p.mnc,
lac: '0x' + efHex(rai.subarray(3, 5)),
rac: '0x' + efHex(rai.subarray(5, 6)),
update_status: '0x' + efHex(b.subarray(13, 14)),
};
}
function efDecEpsLoci(b) {
if (efAllFf(b)) return { empty: true };
if (b.length < 18) return { raw: efHex(b) };
const tai = b.subarray(12, 17);
const p = efPlmn(tai.subarray(0, 3)) || {};
return {
guti: efAllFf(b.subarray(0, 12)) ? null : efHex(b.subarray(0, 12)),
mcc: p.mcc, mnc: p.mnc,
tac: '0x' + efHex(tai.subarray(3, 5)),
eps_update_status: '0x' + efHex(b.subarray(17, 18)),
};
}
function efDecEpsNsc(b) {
if (!b.length || efAllFf(b)) return { empty: true };
let inner = b;
for (const t of efTlv(b)) if (t.tag === 0xA0) { inner = t.value; break; }
const out = {};
for (const t of efTlv(inner)) {
if (t.tag === 0x80) out.ksi_asme = efHex(t.value);
else if (t.tag === 0x81) out.k_asme = efHex(t.value);
else if (t.tag === 0x82) out.uplink_nas_count = parseInt(efHex(t.value) || '0', 16);
else if (t.tag === 0x83) out.downlink_nas_count = parseInt(efHex(t.value) || '0', 16);
else if (t.tag === 0x84) out.nas_algorithms = efHex(t.value);
}
return Object.keys(out).length ? out : { raw: efHex(b) };
}
function efDecKc(b) {
if (efAllFf(b)) return { empty: true };
const out = {};
if (b.length >= 8) out.kc = efHex(b.subarray(0, 8));
if (b.length >= 9) out.ksi = (b[8] >> 4) & 0x0F;
return Object.keys(out).length ? out : { raw: efHex(b) };
}
function efDecAcc(b) {
if (b.length < 2) return { raw: efHex(b) };
const val = (b[0] << 8) | b[1];
const classes = [];
for (let i = 0; i < 16; i++) if (val & (0x8000 >> i)) classes.push(i);
return { access_classes: classes };
}
function efDecPhase(b) {
const v = b.length ? b[0] : 0;
return { phase: EF_PHASE_NAMES[v] || '0x' + v.toString(16).toUpperCase().padStart(2, '0') };
}
function efDecCbmi(b) {
const ids = [];
for (let i = 0; i + 1 < b.length; i += 2) {
const v = (b[i] << 8) | b[i + 1];
if (v !== 0xFFFF) ids.push(v);
}
return { message_ids: ids };
}
function efDecCbmir(b) {
const ranges = [];
for (let i = 0; i + 3 < b.length; i += 4) {
const rec = b.subarray(i, i + 4);
if (efAllFf(rec)) continue;
ranges.push([efHex(rec.subarray(0, 2)), efHex(rec.subarray(2, 4))]);
}
return ranges.length ? { ranges: ranges } : { raw: efHex(b) };
}
function efDecEcc(b) {
if (efAllFf(b)) return { empty: true };
const recs = [];
for (let i = 0; i + 2 < b.length; i += 3) {
const rec = b.subarray(i, i + 3);
if (efAllFf(rec)) continue;
const digits = efSwapNibbles(efHex(rec.subarray(0, 2))).replace(/[fF]+$/, '');
recs.push({ number: digits || null, esc: '0x' + efHex(rec.subarray(2, 3)) });
}
if (!recs.length) return { raw: efHex(b) };
return recs.length === 1 ? recs[0] : { numbers: recs };
}
function efDecOpl(b) {
if (efAllFf(b)) return { empty: true };
const recs = [];
for (let i = 0; i + 7 < b.length; i += 8) {
const rec = b.subarray(i, i + 8);
if (efAllFf(rec)) continue;
const p = efPlmn(rec.subarray(0, 3)) || {};
recs.push({
mcc: p.mcc, mnc: p.mnc,
lac_range: [efHex(rec.subarray(3, 5)), efHex(rec.subarray(5, 7))],
pnn_record: rec[7],
});
}
if (!recs.length) return { raw: efHex(b) };
return recs.length === 1 ? recs[0] : { records: recs };
}
function efDecAd(b) {
if (efAllFf(b)) return { empty: true };
const op = b[0] & 0x03;
const out = {
op_mode: { 0: 'normal operation', 1: 'specific features' }[op] || '0x' + op.toString(16).padStart(2, '0'),
test_mode: !!(b[0] & 0x04),
};
if (b.length >= 3) out.additional_info = efHex(b.subarray(1, 3));
if (b.length >= 4 && b[3] !== 0xFF) out.mnc_length = b[3];
return out;
}
function efDecAcl(b) {
if (efAllFf(b)) return { empty: true };
const apns = [];
for (const t of efTlv(b.subarray(1))) {
if (t.tag === 0xDD) apns.push(t.value.length ? String.fromCharCode.apply(null, t.value) : '(network provided)');
}
return apns.length ? { apns: apns } : { raw: efHex(b) };
}
function efDecSmsp(b) {
if (efAllFf(b)) return { empty: true };
if (b.length < 28) return { raw: efHex(b) };
const y = b.length - 28;
const out = {};
const alpha = efAnnexA(efRstripFf(b.subarray(0, y)));
if (alpha) out.alpha = alpha;
const pi = b[y];
const flags = [];
out.param_indicators = '0x' + pi.toString(16).toUpperCase().padStart(2, '0');
if (!(pi & 0x01)) out.tp_da = efBcdAddressLen(b.subarray(y + 1, y + 13));
if (!(pi & 0x10)) out.sca = efBcdAddressLen(b.subarray(y + 13, y + 25));
if (!(pi & 0x02)) out.tp_pid = '0x' + b[y + 25].toString(16).toUpperCase().padStart(2, '0');
if (!(pi & 0x04)) out.tp_dcs = '0x' + b[y + 26].toString(16).toUpperCase().padStart(2, '0');
if (!(pi & 0x08)) out.tp_vp = '0x' + b[y + 27].toString(16).toUpperCase().padStart(2, '0');
return out;
}
function efDecSpdi(b) {
if (efAllFf(b)) return { empty: true };
const plmns = [];
const collect = value => {
for (let i = 0; i + 2 < value.length; i += 3) {
const p = efPlmn(value.subarray(i, i + 3));
if (p && !plmns.some(x => x.mcc === p.mcc && x.mnc === p.mnc)) plmns.push(p);
}
};
for (const t of efTlv(b)) {
if (t.tag === 0xA3) {
let got = false;
for (const t2 of efTlv(t.value)) if (t2.tag === 0x80) { collect(t2.value); got = true; }
if (!got) collect(t.value);
} else if (t.tag === 0x80) collect(t.value);
}
return plmns.length ? { plmns: plmns } : { raw: efHex(b) };
}
function efDecNai(b) {
if (!b.length || efAllFf(b)) return { empty: true };
const data = efRstripFf(b);
if (!data.length || ((data[0] === 0x80 || data[0] === 0x81) && data.length <= 2)) return { empty: true };
const texts = [];
for (const t of efTlv(data)) {
if ((t.tag === 0x80 || t.tag === 0x81) && t.value.length) {
try { texts.push(new TextDecoder('utf-8').decode(t.value)); } catch (e) {}
}
}
return texts.length ? { text: texts.length === 1 ? texts[0] : texts.join(', ') } : { raw: efHex(b) };
}
function efDecDir(b) {
const apps = [];
for (const t of efTlv(b)) {
if (t.tag !== 0x61) continue;
const app = {};
for (const t2 of efTlv(t.value)) {
if (t2.tag === 0x4F) app.aid = efHex(t2.value);
else if (t2.tag === 0x50) { const l = efAnnexA(t2.value); if (l) app.label = l; }
else if (t2.tag === 0x73) {
for (const t3 of efTlv(t2.value)) {
if (t3.tag !== 0xA0) continue;
const eap = {};
for (const t4 of efTlv(t3.value)) {
if (t4.tag === 0x80) eap.eap_types = Array.prototype.map.call(t4.value, x => ({ 18: 'EAP-SIM', 23: 'EAP-AKA', 50: 'EAP-AKA\'' }[x] || x));
else if (t4.tag === 0x81) { eap.dfs = []; for (let i = 0; i + 1 < t4.value.length; i += 2) eap.dfs.push(efHex(t4.value.subarray(i, i + 2))); }
else if (t4.tag === 0x82) eap.label = String.fromCharCode.apply(null, t4.value);
}
app.eap = eap;
}
}
}
if (Object.keys(app).length) apps.push(app);
}
return apps.length ? { applications: apps } : { raw: efHex(b) };
}
function efDecArr(b) {
const rules = [];
for (const t of efTlv(b)) rules.push({ tag: '0x' + t.tag.toString(16).toUpperCase().padStart(2, '0'), hex: efHex(t.value) });
return rules.length ? { rules: rules } : { raw: efHex(b) };
}
function efDecPnn(b) {
if (efAllFf(b)) return { empty: true };
const out = {};
for (const t of efTlv(b)) {
if (t.tag === 0x43) out.full = efPnnText(t.value);
else if (t.tag === 0x45) out.short = efPnnText(t.value);
}
return Object.keys(out).length ? out : { raw: efHex(b) };
}
// ADN-format record: alpha + len(1) + TON/NPI(1) + BCD(10) + CCP(1) + EXT1(1).
function efDecAdn(b) {
if (efAllFf(b)) return { empty: true };
if (b.length < 14) return { raw: efHex(b) };
const alpha = efRstripFf(b.subarray(0, b.length - 14));
const name = efAnnexA(alpha);
const number = efBcdAddress(b.subarray(b.length - 13, b.length - 2));
const out = { name: name, number: number };
if (b[b.length - 2] !== 0xFF) out.ccp = b[b.length - 2];
if (b[b.length - 1] !== 0xFF) out.ext1 = b[b.length - 1];
return out;
}
// TS 51.011 §10.5.10 EF_EXT1: record type(1) + extension data(11) + identifier(1).
// Additional data (type bit 2): count byte + BCD remainder; called party
// subaddress (type bit 1): TS 24.008 subaddress data.
function efDecExt1(b) {
if (efAllFf(b)) return { empty: true };
if (b.length < 13) return { raw: efHex(b) };
const rt = b[0];
const out = {};
if (rt & 0x02) {
const n = b[1];
if (n > 0) {
const digits = efSwapNibbles(efHex(b.subarray(2, 2 + Math.min(n, 10)))).replace(/[fF]+$/, '');
out.number = digits || null;
}
}
if (rt & 0x01) out.subaddress = efHex(b.subarray(1, 12));
if (b[12] !== 0xFF) out.next_record = b[12];
return Object.keys(out).length ? out : { raw: efHex(b) };
}
function efDecSms(b) {
if (efAllFf(b)) return { empty: true };
const s = b[0];
let direction = null, status;
if ((s & 0x01) === 0x00) { direction = null; status = 'free space'; }
else if ((s & 0x07) === 0x01) { direction = 'MT'; status = 'message read'; }
else if ((s & 0x07) === 0x03) { direction = 'MT'; status = 'message to be read'; }
else if ((s & 0x07) === 0x07) { direction = 'MO'; status = 'message to be sent'; }
else if ((s & 0x1F) === 0x05) { direction = 'MO'; status = 'sent (status not requested)'; }
else if ((s & 0x1F) === 0x0D) { direction = 'MO'; status = 'sent (status requested, not received)'; }
else if ((s & 0x1F) === 0x15) { direction = 'MO'; status = 'sent (status received, not stored)'; }
else if ((s & 0x1F) === 0x1D) { direction = 'MO'; status = 'sent (status received, stored)'; }
else { direction = null; status = 'RFU'; }
const out = { status: status };
if (direction) out.direction = direction;
const rem = b.subarray(1);
if (rem.length) {
const scLen = rem[0];
const tpdu = efRstripFf(rem.subarray(1 + scLen));
if (tpdu.length) out.tpdu = efHex(tpdu);
}
return out;
}
// TS 51.011 §10.3.34 EF_SUME: Simple TLVs for the SET UP MENU title.
function efDecSume(b) {
if (efAllFf(b)) return { empty: true };
const out = {};
for (const t of efTlv(b)) {
if (t.tag === 0x05) { const s = efAnnexA(t.value); if (s) out.title = s; }
else if (t.tag === 0x1E) out.icon = efHex(t.value);
}
return Object.keys(out).length ? out : { raw: efHex(b) };
}
// ---- registry ----
const EF_DECODERS = [
{ name: 'EF.ICCID', fid: '2fe2', fn: efDecIccid },
{ name: 'EF.IMSI', fid: '6f07', fn: efDecImsi },
{ name: 'EF.IST', fid: '6f07', fn: b => efDecServiceTable(b, EF_IST_SERVICES) },
{ name: 'EF.UST', fid: '6f38', fn: b => efDecServiceTable(b, EF_UST_SERVICES) },
{ name: 'EF.SST', fid: '6f38', fn: b => efDecServiceTable(b, EF_SST_SERVICES) },
{ name: 'EF.LI', fid: '6f05', fn: efDecLi },
{ name: 'EF.LP', fid: '6f05', fn: efDecLi },
{ name: 'EF.PL', fid: '2f05', fn: efDecLi },
{ name: 'EF.SPN', fid: '6f46', fn: efDecSpn },
{ name: 'EF.PLMNsel', fid: '6f30', fn: efDecPlmnList },
{ name: 'EF.PLMNwAcT', fid: '6f60', fn: efDecPlmnWact },
{ name: 'EF.OPLMNwAcT', fid: '6f61', fn: efDecPlmnWact },
{ name: 'EF.HPLMNwAcT', fid: '6f62', fn: efDecPlmnWact },
{ name: 'EF.OPL', fid: '6fc6', fn: efDecOpl },
{ name: 'EF.SPDI', fid: '6fcd', fn: efDecSpdi },
{ name: 'EF.PNN', fid: '6fc5', fn: efDecPnn },
{ name: 'EF.LOCI', fid: '6f7e', fn: efDecLoci },
{ name: 'EF.PSLOCI', fid: '6f73', fn: efDecPsLoci },
{ name: 'EF.EPSLOCI', fid: '6fe3', fn: efDecEpsLoci },
{ name: 'EF.EPSNSC', fid: '6fe4', fn: efDecEpsNsc },
{ name: 'EF.LOCIGPRS', fid: '6f53', fn: efDecPsLoci },
{ name: 'EF.Kc', fid: '6f20', fn: efDecKc },
{ name: 'EF.KcGPRS', fid: '6f52', fn: efDecKc },
{ name: 'EF.ACC', fid: '6f78', fn: efDecAcc },
{ name: 'EF.PHASE', fid: '6fae', fn: efDecPhase },
{ name: 'EF.CBMI', fid: '6f45', fn: efDecCbmi },
{ name: 'EF.CBMID', fid: '6f48', fn: efDecCbmi },
{ name: 'EF.CBMIR', fid: '6f50', fn: efDecCbmir },
{ name: 'EF.ECC', fid: '6fb7', fn: efDecEcc },
{ name: 'EF.AD', fid: '6fad', fn: efDecAd },
{ name: 'EF.ACL', fid: '6f57', fn: efDecAcl },
{ name: 'EF.SMSP', fid: '6f42', fn: efDecSmsp },
{ name: 'EF.SMS', fid: '6f3c', fn: efDecSms },
{ name: 'EF.ARR', fid: '2f06', fn: efDecArr },
{ name: 'EF.ARR', fid: '6f06', fn: efDecArr },
{ name: 'EF.DIR', fid: '2f00', fn: efDecDir },
{ name: 'EF.IMPI', fid: '6f02', fn: efDecNai },
{ name: 'EF.DOMAIN', fid: '6f03', fn: efDecNai },
{ name: 'EF.IMPU', fid: '6f04', fn: efDecNai },
{ name: 'EF.ADN', fid: '6f3a', fn: efDecAdn },
{ name: 'EF.FDN', fid: '6f3b', fn: efDecAdn },
{ name: 'EF.SDN', fid: '6f49', fn: efDecAdn },
{ name: 'EF.MSISDN', fid: '6f40', fn: efDecAdn },
{ name: 'EF.LND', fid: '6f44', fn: efDecAdn },
{ name: 'EF.BDN', fid: '6f4d', fn: efDecAdn },
{ name: 'EF.EXT1', fid: '6f4a', fn: efDecExt1 },
{ name: 'EF.EXT2', fid: '6f4b', fn: efDecExt1 },
{ name: 'EF.EXT3', fid: '6f4c', fn: efDecExt1 },
{ name: 'EF.EXT4', fid: '6f4e', fn: efDecExt1 },
{ name: 'EF.EXT5', fid: '6f55', fn: efDecExt1 },
{ name: 'EF.SUME', fid: '6f54', fn: efDecSume },
];
function efFindDecoder(name, fid) {
let n = (name || '').toUpperCase();
if (n.includes('/')) n = n.split('/').pop();
for (const d of EF_DECODERS) if (d.name === n) return d;
const f = (fid || '').toLowerCase();
if (f) for (const d of EF_DECODERS) if (d.fid === f) return d;
return null;
}
function efFidFromPath(path) {
const seg = (path || '').split('/').pop();
return /^[0-9a-fA-F]{4}$/.test(seg) ? seg.toUpperCase() : null;
}
// ---- content decoding, flattening and diffs ----
function efDecodeBytes(fn, bytes) {
try { return fn(bytes); } catch (e) { return { raw: efHex(bytes) }; }
}
// content: {kind:'transparent', data:'HEX'} | {kind:'record', records:[{num,data}]}
function efDecodeFile(name, fid, content) {
const dec = efFindDecoder(name, fid);
if (!dec) return null;
const out = { ef: dec.name, fn: dec.fn, data: null, records: null, empty: false };
if (!content) return out;
if (content.kind === 'record') {
out.records = (content.records || []).map(r => {
const d = efDecodeBytes(dec.fn, efBytes(r.data || ''));
return { num: r.num, data: d, empty: !!(d && d.empty) };
});
} else {
out.data = efDecodeBytes(dec.fn, efBytes(content.data || ''));
out.empty = !!(out.data && out.data.empty);
}
return out;
}
function efDecodeHex(name, fid, hex) {
const dec = efFindDecoder(name, fid);
if (!dec || !dec.fn) return null;
return efDecodeBytes(dec.fn, efBytes(hex || ''));
}
function efIsRawOnly(data) {
if (!data || typeof data !== 'object') return true;
const keys = Object.keys(data);
return keys.length === 0 || (keys.length === 1 && keys[0] === 'raw');
}
function efFieldLabel(k) {
if (EF_FIELD_LABELS[k]) return EF_FIELD_LABELS[k];
return k.replace(/_/g, ' ').replace(/^./, c => c.toUpperCase());
}
function efPrimitive(v) {
return typeof v === 'boolean' ? (v ? 'yes' : 'no') : String(v);
}
// Flatten a decoded object into [label, value] rows (display and diff).
function efFlatten(data) {
const rows = [];
if (data === null || data === undefined) return rows;
if (typeof data !== 'object') { rows.push(['', efPrimitive(data)]); return rows; }
const arrayRows = (label, arr) => {
if (!arr.length) return;
if (typeof arr[0] !== 'object' || arr[0] === null) {
rows.push([label, arr.map(efPrimitive).join(', ')]);
return;
}
arr.forEach((it, i) => {
if (Array.isArray(it)) rows.push([label + ' ' + (i + 1), it.map(efPrimitive).join('\u2013')]);
else if (it && it.n !== undefined && it.name !== undefined) rows.push(['Service ' + it.n, String(it.name)]);
else if (it && it.mcc !== undefined) rows.push(['PLMN ' + (i + 1), it.mcc + '-' + it.mnc + (it.access_tech ? ' (' + it.access_tech + ')' : '')]);
else if (it && it.aid !== undefined) rows.push(['Application ' + (i + 1), (it.label ? it.label + ' \u2014 ' : '') + it.aid + (it.eap ? ' [' + (it.eap.eap_types || []).join(', ') + ']' : '')]);
else rows.push([label + ' ' + (i + 1), JSON.stringify(it)]);
});
};
for (const k of Object.keys(data)) {
const v = data[k];
if (v === null || v === undefined) continue;
if (Array.isArray(v)) arrayRows(efFieldLabel(k), v);
else if (typeof v === 'object') {
for (const [k2, v2] of efFlatten(v)) rows.push([efFieldLabel(k) + (k2 ? ' / ' + k2 : ''), v2]);
} else {
rows.push([efFieldLabel(k), efPrimitive(v)]);
}
}
return rows;
}
// Per-field diff of two decoded objects: only differing fields.
function efDiffData(expData, actData) {
const e = {}, a = {};
for (const [k, v] of efFlatten(expData)) e[k] = v;
for (const [k, v] of efFlatten(actData)) a[k] = v;
const labels = [...new Set([...Object.keys(e), ...Object.keys(a)])];
const rows = [];
for (const l of labels) {
const ev = e[l], av = a[l];
if (ev !== av) rows.push([l, ev === undefined ? '' : ev, av === undefined ? '' : av]);
}
return rows;
}
function efDataSummary(data) {
if (!data || typeof data !== 'object') return '';
if (data.imsi) return 'IMSI ' + data.imsi;
if (data.iccid) return 'ICCID ' + data.iccid;
if (data.title) return data.title;
if (data.name !== undefined && data.number !== undefined) return (data.name + ' ' + data.number).trim();
if (data.number) return data.number;
if (data.text) return data.text;
if (data.languages && data.languages.length) return data.languages.join(' ');
if (data.services) return data.services.length + ' services';
if (data.plmns) return data.plmns.map(p => p.mcc + '-' + p.mnc).join(', ');
if (data.applications) return data.applications.map(x => x.label || x.aid || '').join('; ');
if (data.status) return data.status + (data.direction ? ' (' + data.direction + ')' : '');
if (data.full) return data.full;
if (data.mcc !== undefined && data.mnc !== undefined) return data.mcc + '-' + data.mnc;
if (data.kc) return 'Kc ' + data.kc;
return '';
}
function efRenderFieldsHtml(rows) {
if (!rows.length) return '<span class="text-gray-400">' + esc(t('No decodable fields')) + '</span>';
let html = '<div class="text-xs">';
for (const [k, v] of rows) {
html += '<div class="flex gap-2 mb-0.5">' +
'<span class="w-40 shrink-0 text-right text-gray-500 dark:text-slate-400 break-all">' + esc(k) + '</span>' +
'<span class="font-mono break-all">' + esc(v) + '</span></div>';
}
return html + '</div>';
}
// Mismatching decoded fields for a content check; null when the file has no
// decoder or either side fails to decode.
function efContentDiff(name, fid, expectedHex, actualHex) {
const exp = efDecodeHex(name, fid, expectedHex);
const act = efDecodeHex(name, fid, actualHex);
if (exp === null || act === null || efIsRawOnly(exp) || efIsRawOnly(act)) return null;
const rows = efDiffData(exp, act);
return { rows: rows, sameDecoded: rows.length === 0 };
}
// ===== i18n =====
const LANG_RU = {
'Generate APDU': 'Сгенерировать APDU',
@@ -11663,6 +12555,15 @@ const LANG_RU = {
'Open': 'Открыть',
'Contents': 'Содержимое',
'Not captured': 'Не прочитано',
'Decoded': 'Декодировано',
'Record': 'Запись',
'empty': 'пусто',
'No decoder for this file': 'Нет декодера для этого файла',
'showing raw data': 'показаны сырые данные',
'No decodable fields': 'Нет декодируемых полей',
'pySim JSON (server)': 'pySim JSON (сервер)',
'Decoded comparison': 'Декодированное сравнение',
'Decoded values match; raw bytes differ': 'Декодированные значения совпадают; отличаются сырые байты',
'Back to list': 'Назад к списку',
'Add rule': 'Добавить правило',
'Scan': 'Сканировать',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v190';
const CACHE = 'simple-v191';
const URLS = [
'index.html',
'help.html',
+232
View File
@@ -0,0 +1,232 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
const FNS = [
'efBytes', 'efHex', 'efSwapNibbles', 'efAllFf', 'efRstripFf', 'efTlv', 'efUcs2Be',
'efGsm7Octets', 'efAnnexA', 'efBcdAddress', 'efBcdAddressLen', 'efPlmn', 'efPlmnAct', 'efPnnText',
'efDecIccid', 'efDecImsi', 'efDecLi', 'efDecServiceTable', 'efDecPlmnList', 'efDecPlmnWact',
'efDecSpn', 'efDecLoci', 'efDecPsLoci', 'efDecEpsLoci', 'efDecEpsNsc', 'efDecKc', 'efDecAcc',
'efDecPhase', 'efDecCbmi', 'efDecCbmir', 'efDecEcc', 'efDecOpl', 'efDecAd', 'efDecAcl',
'efDecSmsp', 'efDecSpdi', 'efDecNai', 'efDecDir', 'efDecArr', 'efDecPnn', 'efDecAdn',
'efDecExt1', 'efDecSms', 'efDecSume', 'efFindDecoder', 'efFidFromPath', 'efDecodeBytes',
'efDecodeFile', 'efDecodeHex', 'efIsRawOnly', 'efFieldLabel', 'efPrimitive', 'efFlatten',
'efDiffData', 'efDataSummary', 'efContentDiff', 'gsm7Decode', 'efRenderFieldsHtml',
'pysimFsDecodedHtml', 'profilerRenderReport', 'profilerLabelText', 'profilerResultAspects',
'profilerAspectSummary', 'profilerMatchedRecordsText', 'profilerRawDataCheck', 'profilerCustomNameForPath',
'pysimCustomNormPath',
];
let code = '';
for (const f of FNS) code += extractFunc(html, f) + '\n';
for (const c of ['GSM7_ALPHABET', 'GSM7_EXT_MAP']) {
code += html.match(new RegExp('const ' + c + ' = [\\s\\S]*?\\n\\];'))[0].replace('const ', 'var ') + '\n';
}
for (const c of ['EF_UST_SERVICES', 'EF_SST_SERVICES', 'EF_IST_SERVICES', 'EF_PHASE_NAMES']) {
code += html.match(new RegExp('const ' + c + ' = \\{.*\\};'))[0].replace('const ', 'var ') + '\n';
}
code += html.match(/const EF_FIELD_LABELS = \{[\s\S]*?\n\};/)[0].replace('const ', 'var ') + '\n';
code += html.match(/const EF_DECODERS = \[[\s\S]*?\n\];/)[0].replace('const ', 'var ') + '\n';
code += 'var pysimCustomFiles = [];\nvar pysimFsSelected = \'EF.IMSI\';\n';
code += 'function esc(s){return String(s);}\nfunction escHtml(s){return String(s);}\nfunction t(k){return k;}\n';
eval(code);
test('ef helpers: bytes, hex, swap, TLV, PLMN', () => {
assert.strictEqual(efHex(efBytes('a0ff01')), 'A0FF01');
assert.strictEqual(efSwapNibbles('1234'), '2143');
assert.strictEqual(efPlmn(efBytes('22F860')).mcc, '228');
assert.strictEqual(efPlmn(efBytes('22F860')).mnc, '06');
assert.ok(efAllFf(efBytes('FFFF')));
assert.ok(!efAllFf(efBytes('FF00')));
// 05 03 AA BB CC + 1E 01 DD
const tlvs = efTlv(efBytes('0503AABBCC1E01DD'));
assert.deepStrictEqual(tlvs.map(t => t.tag), [0x05, 0x1E]);
assert.strictEqual(efHex(tlvs[0].value), 'AABBCC');
assert.strictEqual(efHex(tlvs[1].value), 'DD');
// long-form length
assert.strictEqual(efTlv(efBytes('058105' + '00'.repeat(5)))[0].value.length, 5);
});
test('efAnnexA decodes GSM7 octets, UCS2 and packed variants', () => {
assert.strictEqual(efAnnexA(efBytes('47534D2D52204348')), 'GSM-R CH'); // one octet per char
assert.strictEqual(efAnnexA(efBytes('8000470053004D')), 'GSM'); // UCS2 prefix
assert.strictEqual(efAnnexA(efBytes('')), '');
assert.strictEqual(efAnnexA(efBytes('FFFFFFFF')), '');
});
test('EF decoders match the pinned spec / pySim test vectors', () => {
assert.deepStrictEqual(efDecIccid(efBytes('988812010000400310f0')), { iccid: '8988211000000430010' });
assert.deepStrictEqual(efDecImsi(efBytes('082982608200002080')), { imsi: '228062800000208' });
assert.deepStrictEqual(efDecLi(efBytes('6465')), { languages: ['de'] });
assert.deepStrictEqual(efDecLi(efBytes('FFFF')), { languages: [] });
assert.deepStrictEqual(efDecSpn(efBytes('0147534d2d52204348ffffffffffffffff')), { show_in_hplmn: true, hide_in_oplmn: false, name: 'GSM-R CH' });
assert.deepStrictEqual(efDecAcc(efBytes('0000')), { access_classes: [] });
assert.deepStrictEqual(efDecAcc(efBytes('8000')), { access_classes: [0] });
assert.deepStrictEqual(efDecEcc(efBytes('19f101')), { number: '911', esc: '0x01' });
assert.deepStrictEqual(efDecKc(efBytes('837d783609a3858f05')), { kc: '837D783609A3858F', ksi: 0 });
assert.deepStrictEqual(efDecPlmnList(efBytes('22F860')), { plmns: [{ mcc: '228', mnc: '06' }] });
assert.deepStrictEqual(efDecCbmi(efBytes('0010FFFF0020')), { message_ids: [16, 32] });
assert.deepStrictEqual(efDecCbmir(efBytes('0000FFFEFFFF')), { ranges: [['0000', 'FFFE']] });
assert.deepStrictEqual(efDecPhase(efBytes('03')), { phase: 'phase 2 and higher' });
assert.deepStrictEqual(efDecNai(efBytes('8021696d732e6d6e633030302e6d63633733382e336770706e6574776f726b2e6f7267')),
{ text: 'ims.mnc000.mcc738.3gppnetwork.org' });
assert.deepStrictEqual(efDecImsi(efBytes('FFFF')), { empty: true });
assert.deepStrictEqual(efDecIccid(efBytes('FFFFFFFFFFFF')), { empty: true });
});
test('EF.SUME decodes the SET UP MENU title and icon TLVs (TS 51.011 10.3.34)', () => {
// 05 05 "Menu!" + 1E 02 01 23 + FF padding
const d = efDecSume(efBytes('05054D656E75211E020123FFFFFFFF'));
assert.deepStrictEqual(d, { title: 'Menu!', icon: '0123' });
assert.deepStrictEqual(efDecSume(efBytes('FFFFFFFF')), { empty: true });
// UCS2 title: 05 09 80 00 4D 00 65 00 6E 00 75
assert.deepStrictEqual(efDecSume(efBytes('050980004D0065006E0075FFFFFFFF')),
{ title: 'Menu' });
});
test('ADN-format records: alpha + BCD number + CCP/EXT1', () => {
const d = efDecAdn(efBytes('42204841203120536963FFFFFFFFFFFF06810628560810FFFFFFFFFFFFFF'));
assert.strictEqual(d.name, 'B HA 1 Sic');
assert.strictEqual(d.number, '6082658001');
assert.strictEqual(d.ccp, undefined);
assert.strictEqual(d.ext1, undefined);
// international number in EF.MSISDN
const m = efDecAdn(efBytes('ffffffffffffffffffffffffffffffffffffffff04b12143f5ffffffffffffffffff'));
assert.strictEqual(m.name, '');
assert.strictEqual(m.number, '12345');
// EXT1 per TS 51.011 10.5.10: type 02 (additional data) + count 03 + BCD
assert.deepStrictEqual(efDecExt1(efBytes('02032143F5' + 'FF'.repeat(8))), { number: '12345' });
});
test('EF record decoders: SMS, OPL, DIR, ARR, IMPI/SMSP', () => {
const opl = efDecOpl(efBytes('62f2100000fffe01'));
assert.deepStrictEqual(opl, { mcc: '262', mnc: '01', lac_range: ['0000', 'FFFE'], pnn_record: 1 });
const dir = efDecDir(efBytes('61294f10a0000000871002ffffffff890709000050055553696d31730ea00c80011781025f608203454150'));
assert.strictEqual(dir.applications.length, 1);
assert.strictEqual(dir.applications[0].aid, 'A0000000871002FFFFFFFF8907090000');
assert.strictEqual(dir.applications[0].label, 'USim1');
const arr = efDecArr(efBytes('800101a40683010a950108'));
assert.deepStrictEqual(arr.rules, [{ tag: '0x80', hex: '01' }, { tag: '0xA4', hex: '83010A950108' }]);
const impi = efDecNai(efBytes('803137333830303630303030303031303140696d732e6d6e633030302e6d63633733382e336770706e6574776f726b2e6f7267'));
assert.strictEqual(impi.text, '738006000000101@ims.mnc000.mcc738.3gppnetwork.org');
const smsp = efDecSmsp(efBytes('534d5343ffffffffffffffffffffffffe1ffffffffffffffffffffffff0891945197109099f9ffffff0000a9'));
assert.strictEqual(smsp.alpha, 'SMSC');
assert.strictEqual(smsp.sca, '+4915790109999');
assert.strictEqual(smsp.tp_pid, '0x00');
assert.strictEqual(smsp.tp_dcs, '0x00');
// EF.SMS status byte + skip SMSC + TPDU
const sms = efDecSms(efBytes('030891945197109099f90000'));
assert.strictEqual(sms.direction, 'MT');
assert.strictEqual(sms.status, 'message to be read');
assert.strictEqual(sms.tpdu, '0000');
});
test('service tables: UST service bits and IST fallback for 6F07', () => {
const ust = efDecServiceTable(efBytes('00000010'), EF_UST_SERVICES);
assert.deepStrictEqual(ust.services, [{ n: 29, name: 'Data download via SMS-CB' }]);
const ist = efDecImsi(efBytes('01'));
assert.deepStrictEqual(ist.services, [{ n: 1, name: 'P-CSCF address' }]);
});
test('decoder registry resolves by name first, then FID; unknown returns null', () => {
assert.strictEqual(efFindDecoder('EF.IMSI', null).fid, '6f07');
assert.strictEqual(efFindDecoder(null, '2fe2').name, 'EF.ICCID');
assert.strictEqual(efFindDecoder('ADF.USIM/EF.LOCI', null).name, 'EF.LOCI');
assert.strictEqual(efFindDecoder('EF.SUME', null).fid, '6f54');
assert.strictEqual(efFindDecoder('EF.NOPE', 'ABCD'), null);
assert.strictEqual(efFidFromPath('ADF.USIM/6F07'), '6F07');
assert.strictEqual(efFidFromPath('MF/7F10/6F3A'), '6F3A');
assert.strictEqual(efFidFromPath('ADF.USIM'), null);
});
test('efDecodeFile handles transparent and record content', () => {
const t = efDecodeFile('EF.IMSI', null, { kind: 'transparent', data: '082982608200002080' });
assert.strictEqual(typeof t.fn, 'function');
assert.strictEqual(t.data.imsi, '228062800000208');
const r = efDecodeFile('EF.ADN', '6f3a', { kind: 'record', records: [
{ num: 1, data: '42204841203120536963FFFFFFFFFFFF06810628560810FFFFFFFFFFFFFF' },
{ num: 2, data: 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF' },
] });
assert.strictEqual(r.records.length, 2);
assert.strictEqual(r.records[0].data.name, 'B HA 1 Sic');
assert.strictEqual(r.records[0].empty, false);
assert.strictEqual(r.records[1].empty, true);
assert.strictEqual(efDecodeFile('EF.NOPE', null, { kind: 'transparent', data: '00' }), null);
});
test('efFlatten renders fields, services and PLMNs for display', () => {
assert.deepStrictEqual(efFlatten({ imsi: '123' }), [['IMSI', '123']]);
assert.deepStrictEqual(efFlatten({ services: [{ n: 5, name: 'Call Control by USIM' }] }),
[['Service 5', 'Call Control by USIM']]);
assert.deepStrictEqual(efFlatten({ plmns: [{ mcc: '262', mnc: '01', access_tech: 'GSM' }] }),
[['PLMN 1', '262-01 (GSM)']]);
assert.deepStrictEqual(efFlatten({ show_in_hplmn: true }), [['Show in HPLMN', 'yes']]);
assert.deepStrictEqual(efFlatten({ title: 'Menu!', icon: '0123' }), [['Title', 'Menu!'], ['Icon', '0123']]);
});
test('efDiffData reports only differing fields; efContentDiff decodes both sides', () => {
assert.deepStrictEqual(efDiffData({ imsi: '1', kc: 'AA' }, { imsi: '2', kc: 'AA' }), [['IMSI', '1', '2']]);
assert.deepStrictEqual(efDiffData({ imsi: '1' }, { imsi: '1' }), []);
const dd = efContentDiff('EF.IMSI', null, '082982608200002080', '082982608200002081');
assert.ok(dd && dd.rows.length === 1);
assert.strictEqual(dd.rows[0][0], 'IMSI');
assert.strictEqual(dd.rows[0][1], '228062800000208');
const same = efContentDiff('EF.IMSI', null, '082982608200002080', '092982608200002080');
assert.ok(same && same.sameDecoded);
assert.strictEqual(efContentDiff('EF.NOPE', null, '00', '01'), null);
});
test('efDataSummary produces one-line summaries', () => {
assert.strictEqual(efDataSummary(efDecImsi(efBytes('082982608200002080'))), 'IMSI 228062800000208');
assert.strictEqual(efDataSummary(efDecIccid(efBytes('988812010000400310f0'))), 'ICCID 8988211000000430010');
assert.strictEqual(efDataSummary(efDecSume(efBytes('05054D656E7521FFFFFFFF'))), 'Menu!');
assert.strictEqual(efDataSummary({ plmns: [{ mcc: '262', mnc: '01' }] }), '262-01');
});
test('file manager decoded view renders fields, not JSON', () => {
const out = pysimFsDecodedHtml({ name: 'EF.IMSI', fid: null }, { success: true, data: '082982608200002080' });
assert.match(out, /IMSI/);
assert.match(out, /228062800000208/);
assert.match(out, /pySim JSON \(server\)/);
assert.doesNotMatch(out, /\{"imsi"/);
// no decoder -> raw fallback with a note
const raw = pysimFsDecodedHtml({ name: 'EF.NOPE', fid: 'abcd' }, { success: true, data: '0011' });
assert.match(raw, /No decoder for this file/);
assert.match(raw, /0011/);
});
test('profilerRenderReport shows decoded field diffs and the raw-only note', () => {
const res = {
path: 'ADF.USIM/6F07', name: 'EF.IMSI', status: 'fail',
checks: [{ label: 'content', expected: '082982608200002080', actual: '082982608200002081', ok: false }],
};
const out = profilerRenderReport([res], null);
assert.match(out, /Decoded comparison/);
assert.match(out, /IMSI/);
assert.match(out, /228062800000208/);
assert.match(out, /228062800000218/);
const same = profilerRenderReport([{
path: 'ADF.USIM/6F07', name: 'EF.IMSI', status: 'fail',
checks: [{ label: 'content', expected: '082982608200002080', actual: '092982608200002080', ok: false }],
}], null);
assert.match(same, /Decoded values match/);
});