From eb83238ae77035f7130cdd4a22917f5ffdcfa2bb 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, 19 Sep 2026 21:07:30 +0300 Subject: [PATCH] EF content decoders: decoded fields in the file manager, snapshots, checks and comparisons (v2.4.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 8 +- README_RUS.md | 8 +- frontend/help-ru.html | 6 +- frontend/help.html | 6 +- frontend/index.html | 923 ++++++++++++++++++++++++++++++- frontend/sw.js | 2 +- frontend/tests/ef_decode.test.js | 232 ++++++++ pyproject.toml | 2 +- pysim_simple_server/server.py | 2 +- 9 files changed, 1161 insertions(+), 28 deletions(-) create mode 100644 frontend/tests/ef_decode.test.js diff --git a/README.md b/README.md index a8ab216..a4cf1c0 100644 --- a/README.md +++ b/README.md @@ -500,7 +500,7 @@ Browse the UICC filesystem in a tree view. Files are shown with names, FIDs, and - Entries are grouped with DFs above EFs and sorted by **FID** or symbolic **Name** (pills above the tree, remembered in `localStorage`) - **Read** — reads the selected file (auto-detects transparent vs record files) - **Edit** — switch to edit mode, modify hex data, click **Save** to write back -- **Raw / Decoded** — toggle between hex dump and pysim-decoded JSON view +- **Raw / Decoded** — toggle between the hex dump and a decoded field table (client-side decoders for IMSI, ICCID, SPN, PLMN lists, LOCI/EPSLOCI, ADN/MSISDN, service tables, SUME, …); the server-side pySim JSON of the same read stays behind a collapsed disclosure - Selecting a file shows its FID, file type, size / record layout and the decoded FCI above the contents - Missing files are shown in red (✗); a present but empty DF shows `(empty)` - **Probe all files** — walks the whole tree (incl. custom files), marks every entry present/absent with *N / total* progress, stoppable, and ends with a summary; browsing itself stays lazy @@ -525,7 +525,7 @@ A filesystem rule is defined by: - **File attributes** — file type, size, record length and record count, taken from the FCP template (any may be left unset). - **Check contents** (optional) — **Exact** hex equality, or **Mask** where `?` is a per-nibble wildcard (a mask with no `?` is a prefix match, e.g. `0891` for the IMSI MCC/MNC). Record files store a per-record list. -The check report marks each verified aspect (e.g. *filetype ✓, size ✗, contents ✓*), lists mismatches as read-only monospace expected/actual fields aligned in one column, and shows a decoded per-parameter FCI comparison for FCI mismatches. Corrupt FCI data shows whatever decoded before the faulty part plus an explicit decode-failure note; record mismatches list the *matching records*. In the report the mismatch fields and FCI comparison columns are labelled `expected (profile name)` and `actual (card ICCID)` for a live check, or `actual (snapshot name)` for a snapshot check; the results header reads `Profile verification results for: ` (or `… → `; snapshot comparison: `Snapshot comparison results: `). **Only mismatches** in the results header hides all passing files and keeps failures and errors only. +The check report marks each verified aspect (e.g. *filetype ✓, size ✗, contents ✓*), lists mismatches as read-only monospace expected/actual fields aligned in one column, shows content mismatches of files with a decoder as a decoded per-field comparison (only the differing fields, expected and actual side by side; the raw pair is kept when the decoded values match), and shows a decoded per-parameter FCI comparison for FCI mismatches. Corrupt FCI data shows whatever decoded before the faulty part plus an explicit decode-failure note; record mismatches list the *matching records*. In the report the mismatch fields and FCI comparison columns are labelled `expected (profile name)` and `actual (card ICCID)` for a live check, or `actual (snapshot name)` for a snapshot check; the results header reads `Profile verification results for: ` (or `… → `; snapshot comparison: `Snapshot comparison results: `). **Only mismatches** in the results header hides all passing files and keeps failures and errors only. #### “Profile from card” scan options @@ -533,12 +533,12 @@ The scan dialog asks for a profile name and offers the FCP/FCI mode described ab #### Card snapshots -The list view has two tabs — **Profiles** and **Card snapshots**. A 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 every card command (SELECT / READ BINARY / READ RECORD) from command to response and stores min/avg/max per type plus the total scan time; the snapshot view shows these in the summary and the select/read time per file (read time per record). Timings are display-only and ignored by checks/comparisons. +The list view has two tabs — **Profiles** and **Card snapshots**. A 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 every card command (SELECT / READ BINARY / READ RECORD) from command to response and stores min/avg/max per type plus the total scan time; the snapshot view shows these in the summary and the select/read time per file (read time per record). Timings are display-only and ignored by checks/comparisons. - **New snapshot** scans the card; **Import snapshot** loads JSON. - Each snapshot row has **Open** (all captured data read-only, raw FCI with decoded FCI and contents; only the name is editable), **Export**, and **Delete**. - **Check card snapshot** on a profile row runs the profile rules against a snapshot picked from the list, without a card reader. Files whose contents were not captured are reported as unverifiable errors. -- **Compare snapshots** compares two snapshots offline exactly like a profile check: pick the *master* snapshot and the *snapshot to check*, optionally masking the first 4 bytes of EF.IMSI/EF.ICCID (on by default), and get the same report. Every file must match exactly (exact FCI, contents); files present only in the checked snapshot are reported as extra files. In the comparison report the mismatch fields and FCI comparison columns are labeled with the master/checked snapshot names instead of expected/actual. +- **Compare snapshots** compares two snapshots offline exactly like a profile check: pick the *master* snapshot and the *snapshot to check* and get the same report — snapshot comparison is always exact (no content masking). Files present only in the checked snapshot are reported as extra files. In the comparison report the mismatch fields and FCI comparison columns are labeled with the master/checked snapshot names instead of expected/actual. --- diff --git a/README_RUS.md b/README_RUS.md index c7855e2..2e800e6 100644 --- a/README_RUS.md +++ b/README_RUS.md @@ -474,7 +474,7 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на - Элементы сгруппированы (DF выше EF) и отсортированы по **FID** или символьному **имени** (пиллы над деревом, выбор сохраняется в `localStorage`) - **Read** — чтение файла (автоопределение transparent/record) - **Edit** — режим редактирования, измените hex-данные и нажмите **Save** для записи -- **Raw / Decoded** — переключение между hex-дампом и декодированным JSON +- **Raw / Decoded** — переключение между hex-дампом и таблицей декодированных полей (клиентские декодеры: IMSI, ICCID, SPN, списки PLMN, LOCI/EPSLOCI, ADN/MSISDN, таблицы сервисов, SUME, …); серверный pySim JSON того же чтения остаётся в свёрнутом блоке - При выборе файла над содержимым показываются FID, тип файла, размер / структура записей и декодированный FCI - Отсутствующие файлы показаны красным (✗); существующий пустой DF — `(пусто)` - **Проверить все файлы** — обход всего дерева (включая пользовательские) с пометкой «есть/нет», прогрессом *N / всего*, возможностью остановки и сводкой в конце; сам просмотр остаётся ленивым @@ -499,7 +499,7 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на - **Атрибуты файла** — тип, размер, длина и число записей из шаблона FCP (любое можно не задавать). - **Проверка содержимого** (опционально) — **Exact** (точное равенство hex) или **Mask**, где `?` — пониббловый джокер (маска без `?` — префиксное совпадение, напр. `0891` для MCC/MNC IMSI). Для record-файлов хранится список записей. -Отчёт проверки помечает каждый аспект (напр. *тип файла ✓, размер ✗, содержимое ✓*), показывает расхождения как поля только для чтения (ожидаемое/фактическое в одной колонке) и декодированное сравнение параметров FCI для расхождений FCI. Повреждённые FCI показывают всё, что удалось декодировать, плюс явное сообщение об ошибке; для записей указываются *совпадающие записи*. В отчёте поля расхождений и колонки сравнения FCI подписаны `ожидалось (имя профиля)` и `фактически (ICCID карты)` для проверки карты либо `фактически (имя снимка)` для проверки снимка; в заголовке отчёта — `Результаты проверки профиля: <профиль> → ` (или `… → <имя снимка>`; для сравнения снимков — `Результаты сравнения снимков: <эталон> → <проверяемый>`). Опция **«Только расхождения»** скрывает все совпавшие файлы, оставляя несовпадения и ошибки. +Отчёт проверки помечает каждый аспект (напр. *тип файла ✓, размер ✗, содержимое ✓*), показывает расхождения как поля только для чтения (ожидаемое/фактическое в одной колонке), показывает расхождения содержимого файлов с декодером декодированным сравнением по полям (только отличающиеся поля, ожидаемое и фактическое рядом; сырая пара остаётся, когда декодированные значения совпадают) и декодированное сравнение параметров FCI для расхождений FCI. Повреждённые FCI показывают всё, что удалось декодировать, плюс явное сообщение об ошибке; для записей указываются *совпадающие записи*. В отчёте поля расхождений и колонки сравнения FCI подписаны `ожидалось (имя профиля)` и `фактически (ICCID карты)` для проверки карты либо `фактически (имя снимка)` для проверки снимка; в заголовке отчёта — `Результаты проверки профиля: <профиль> → ` (или `… → <имя снимка>`; для сравнения снимков — `Результаты сравнения снимков: <эталон> → <проверяемый>`). Опция **«Только расхождения»** скрывает все совпавшие файлы, оставляя несовпадения и ошибки. #### Опции сканирования «Профиль с карты» @@ -507,12 +507,12 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на #### Снимки карт -Представление списка имеет две вкладки — **«Профили»** и **«Снимки карт»**. Снимок — неизменяемая фиксация файловой системы: путь, символьное имя, тип, размер (или длина/число записей), сырой FCI и содержимое (если читается) каждого существующего файла. ICCID декодируется из EF.ICCID и показывается рядом с именем. При сканировании также измеряется время каждой команды карты (SELECT / READ BINARY / READ RECORD) от отправки до ответа; сохраняются min/сред/max по типам и общее время сканирования — они показываются в сводке снимка и по файлам/записям. Время носит информационный характер и не используется при проверках и сравнении. +Представление списка имеет две вкладки — **«Профили»** и **«Снимки карт»**. Снимок — неизменяемая фиксация файловой системы: путь, символьное имя, тип, размер (или длина/число записей), сырой FCI и содержимое (если читается) каждого существующего файла. ICCID декодируется из EF.ICCID и показывается рядом с именем. Захваченное содержимое показывается вместе с декодированным представлением, где есть декодер — таблица полей для transparent-файлов и однострочная сводка по каждой записи для record-файлов. При сканировании также измеряется время каждой команды карты (SELECT / READ BINARY / READ RECORD) от отправки до ответа; сохраняются min/сред/max по типам и общее время сканирования — они показываются в сводке снимка и по файлам/записям. Время носит информационный характер и не используется при проверках и сравнении. - **Новый снимок** сканирует карту; **Импорт снимка** загружает JSON. - В строке снимка: **Открыть** (все данные только для чтения, сырой FCI с декодированным и содержимое; редактируется только имя), **Экспорт**, **Удалить**. - **Проверить снимок карты** в строке профиля выполняет правила профиля на выбранном снимке без картридера. Файлы без захваченного содержимого помечаются как непроверяемые ошибки. -- **Сравнить снимки** сравнивает два снимка offline так же, как проверка профиля: выберите *эталонный* снимок и *снимок для проверки*, при необходимости включите маску первых 4 байт EF.IMSI/EF.ICCID (включена по умолчанию). Всё должно совпадать точно (FCI, содержимое); файлы только в проверяемом снимке помечаются как лишние. В отчёте поля расхождений и колонки сравнения FCI подписаны именами эталонного и проверяемого снимков вместо expected/actual. +- **Сравнить снимки** сравнивает два снимка offline так же, как проверка профиля: выберите *эталонный* снимок и *снимок для проверки* и получите тот же отчёт — сравнение снимков всегда точное (без масок содержимого). Файлы только в проверяемом снимке помечаются как лишние. В отчёте поля расхождений и колонки сравнения FCI подписаны именами эталонного и проверяемого снимков вместо expected/actual. --- diff --git a/frontend/help-ru.html b/frontend/help-ru.html index 35c6d0f..1ac607e 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -350,7 +350,7 @@
  • Прочитать — чтение файла (автоопределение transparent/record)
  • Редактировать — изменение hex-данных, Сохранить для записи (или Отмена)
  • -
  • Данные как на карте / Декодированные данные — переключение между hex-дампом и декодированным JSON
  • +
  • Данные как на карте / Декодированные данные — переключение между hex-дампом и таблицей декодированных полей (клиентские декодеры EF: IMSI, ICCID, SPN, списки PLMN, LOCI/PSLOCI/EPSLOCI, ADN/MSISDN, таблицы сервисов, SUME, …); серверный pySim JSON того же чтения остаётся доступен в свёрнутом блоке pySim JSON (сервер)
  • Проверить все файлы — обход всего дерева (включая пользовательские файлы) с пометкой каждого элемента: есть (обычный вид) или нет (красный ✗, без стрелки разворачивания); существующие пустые DF показывают (пусто). Отображается прогресс N / всего, обход можно остановить; в конце — сводка «есть/нет». Файлы проверяются только при разворачивании или проверке — просмотр остаётся ленивым.
@@ -417,12 +417,12 @@
  • Атрибуты файла — тип файла, размер, длина записи и число записей из FCP-шаблона (любой можно оставить незаданным).
  • Проверить содержимое (опционально) — Точное (точное совпадение hex) или Маска, где ? — шаблон на один полубайт (маска без ? — совпадение префикса, например 0891 для MCC/MNC из IMSI). Для record-файлов хранится список по записям.
  • -

    Проверить карту выполняет каждое правило на подключённой карте и показывает строку прогресса и отчёт прохождения. Рядом с путём файла указывается, что именно проверялось (например, тип файла и размер, содержимое или полный FCI); если часть проверок прошла, а часть нет — каждый аспект помечается (тип файла ✓, размер ✗, содержимое ✓), а расхождения расписываются ниже. Несовпавшие сырые данные (FCI, содержимое, данные записей) показываются как поля только для чтения с моноширинным шрифтом — ожидаемое над фактическим, в одной и той же колонке — для удобного сравнения; для расхождений FCI дополнительно показывается декодированное сравнение по параметрам (размер файла, дескриптор/структура, жизненный цикл, FID, SFI, проприетарные параметры…). Декодированный просмотр FCI также отображается рядом с полем FCI hex при редактировании правила. Если данные FCI повреждены, показывается всё, что удалось декодировать до места ошибки, вместе с явным сообщением об ошибке декодирования. Для record-файлов при расхождении содержимого добавляется пометка совпадающие записи: 1-5, 7-10 со списком записей, которые совпали. В отчёте поля расхождений и колонки сравнения FCI подписаны ожидалось (имя профиля) и фактически (ICCID карты), а в заголовке отчёта выводится Результаты проверки профиля: <профиль> → <ICCID карты>. Опция «Только расхождения» в заголовке отчёта скрывает все совпавшие файлы и оставляет только несовпадения и ошибки.

    +

    Проверить карту выполняет каждое правило на подключённой карте и показывает строку прогресса и отчёт прохождения. Рядом с путём файла указывается, что именно проверялось (например, тип файла и размер, содержимое или полный FCI); если часть проверок прошла, а часть нет — каждый аспект помечается (тип файла ✓, размер ✗, содержимое ✓), а расхождения расписываются ниже. Несовпавшие сырые данные (FCI, содержимое, данные записей) показываются как поля только для чтения с моноширинным шрифтом — ожидаемое над фактическим, в одной и той же колонке — для удобного сравнения; расхождения содержимого файлов с декодером показываются декодированным сравнением по полям (только отличающиеся поля, ожидаемое и фактическое рядом; сырая пара остаётся, когда декодированные значения совпадают), а для расхождений FCI дополнительно показывается декодированное сравнение по параметрам (размер файла, дескриптор/структура, жизненный цикл, FID, SFI, проприетарные параметры…). Декодированный просмотр FCI также отображается рядом с полем FCI hex при редактировании правила. Если данные FCI повреждены, показывается всё, что удалось декодировать до места ошибки, вместе с явным сообщением об ошибке декодирования. Для record-файлов при расхождении содержимого добавляется пометка совпадающие записи: 1-5, 7-10 со списком записей, которые совпали. В отчёте поля расхождений и колонки сравнения FCI подписаны ожидалось (имя профиля) и фактически (ICCID карты), а в заголовке отчёта выводится Результаты проверки профиля: <профиль> → <ICCID карты>. Опция «Только расхождения» в заголовке отчёта скрывает все совпавшие файлы и оставляет только несовпадения и ошибки.

    Опции сканирования «Профиль с карты»

    Диалог сканирования запрашивает имя профиля и предлагает селектор «Проверка FCP/FCI» (те же три режима, по умолчанию Тип файла + размер (FCP)), применяемый ко всем создаваемым правилам, а также список «Игнорировать содержимое файлов» (все отмечены по умолчанию, кроме EF.ARR; флажок в заголовке отмечает или снимает весь список) часто перезаписываемых файлов, содержимое которых пропускается: EF.LOCI, EF.PSLOCI, EF.EPSLOCI, EF.5GS3GPPLOCI, EF.Keys, EF.KeysPS, EF.SMS, EF.Kc, EF.KcGPRS, EF.LOCIGPRS, EF.CBMID, EF.SMSS, EF.ACC, EF.EPSNSC, EF.START-HFN, EF.ARR. Ещё две отмеченные по умолчанию опции «Сравнивать первые 4 байта для» EF.IMSI и EF.ICCID захватывают содержимое этих файлов как маску только первых 4 байт (снимите для точного сравнения). Строка прогресса показывает N / всего файлов с текущим путём файла во время сканирования; при сканировании опции скрываются, а кнопки блокируются. Правила создаются только для файлов, которые реально существуют на карте (возвращён FCP-шаблон); отсутствующие файлы пропускаются. Пользовательские файлы из подвкладки «Пользовательские файлы» включаются с той же проверкой существования. Тот же диалог и опции использует «Профиль из снимка»: вместо карты обходятся захваченные файлы выбранного снимка; для файлов, содержимое которых не было захвачено, правило создаётся без проверки содержимого (при последующей проверке профиля они помечаются как непроверяемые).

    Снимки карт

    -

    Представление списка имеет две вкладки — «Профили» и «Снимки карт». Снимок карты — неизменяемая фиксация файловой системы карты: для каждого существующего файла сохраняются путь, символьное имя, тип, размер (или длина/число записей), сырой FCI из ответа SELECT и содержимое, если файл читается (без списка игнорирования и без масок). ICCID декодируется из EF.ICCID и показывается рядом с именем снимка. При сканировании измеряется время каждой команды карты (SELECT, READ BINARY, READ RECORD) от отправки до ответа; снимок хранит min/сред/max по каждому типу команд и общее время сканирования, а в представлении эти значения показываются в сводке под заголовком, время select/read — для каждого файла и время чтения — для каждой записи. Время носит информационный характер и не используется при проверках и сравнении.

    +

    Представление списка имеет две вкладки — «Профили» и «Снимки карт». Снимок карты — неизменяемая фиксация файловой системы карты: для каждого существующего файла сохраняются путь, символьное имя, тип, размер (или длина/число записей), сырой FCI из ответа SELECT и содержимое, если файл читается (без списка игнорирования и без масок). ICCID декодируется из EF.ICCID и показывается рядом с именем снимка. Захваченное содержимое показывается вместе с декодированным представлением, где есть декодер — таблица полей для transparent-файлов и однострочная сводка по каждой записи для record-файлов. При сканировании измеряется время каждой команды карты (SELECT, READ BINARY, READ RECORD) от отправки до ответа; снимок хранит min/сред/max по каждому типу команд и общее время сканирования, а в представлении эти значения показываются в сводке под заголовком, время select/read — для каждого файла и время чтения — для каждой записи. Время носит информационный характер и не используется при проверках и сравнении.

    • Новый снимок — запрашивает имя и сканирует карту, затем возвращает к списку.
    • Импорт снимка — загружает снимок из JSON-файла.
    • diff --git a/frontend/help.html b/frontend/help.html index 3c60511..690970c 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -350,7 +350,7 @@
      • Read — reads the selected file (auto-detects transparent vs record files)
      • Edit — modify hex data, Save to write back (or Cancel)
      • -
      • Raw / Decoded — toggle between hex dump and pySim-decoded JSON
      • +
      • Raw / Decoded — 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 pySim JSON (server) disclosure
      • Probe all files — walks the whole tree (including custom files) and marks every entry present (normal) or absent (red ✗, no expand arrow); empty-but-present DFs show (empty). Shows progress N / total, can be stopped, and finishes with a present/absent summary. Files are only verified when expanded or probed — browsing stays lazy.
      @@ -417,12 +417,12 @@
    • File attributes — file type, size, record length and record count, taken from the FCP template (any may be left unset).
    • Check contents (optional) — Exact hex equality, or Mask where ? is a per-nibble wildcard (a mask with no ? is a prefix match, e.g. 0891 for the IMSI MCC/MNC). Record files store a per-record list.
    -

    Check card 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. filetype and size, contents or exact FCI); when some checks pass and others fail, each aspect is marked (filetype ✓, size ✗, contents ✓) 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 matching records: 1-5, 7-10 note lists the records that did match. In the report the mismatch fields and FCI comparison columns are labelled expected (profile name) and actual (card ICCID), and the results header reads Profile verification results for: <profile> → <card ICCID>. An Only mismatches option in the results header hides all passing files and keeps only failures and errors.

    +

    Check card 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. filetype and size, contents or exact FCI); when some checks pass and others fail, each aspect is marked (filetype ✓, size ✗, contents ✓) 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 matching records: 1-5, 7-10 note lists the records that did match. In the report the mismatch fields and FCI comparison columns are labelled expected (profile name) and actual (card ICCID), and the results header reads Profile verification results for: <profile> → <card ICCID>. An Only mismatches option in the results header hides all passing files and keeps only failures and errors.

    “Profile from card” scan options

    The scan dialog asks for a profile name and offers a “FCP/FCI check” selector (the same three modes above, default Filetype + size) applied to every generated rule, plus an “Ignore contents of files” checklist (all checked by default except EF.ARR; the header checkbox checks or unchecks the whole list) of frequently-overwritten files whose contents are skipped: EF.LOCI, EF.PSLOCI, EF.EPSLOCI, EF.5GS3GPPLOCI, EF.Keys, EF.KeysPS, EF.SMS, EF.Kc, EF.KcGPRS, EF.LOCIGPRS, EF.CBMID, EF.SMSS, EF.ACC, EF.EPSNSC, EF.START-HFN, EF.ARR. Two further checked-by-default options “Match first 4 bytes for” EF.IMSI and EF.ICCID capture those files’ contents as a mask of only the first 4 bytes (uncheck for exact matching). A progress line shows N / total files 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 Custom files sub-tab are included under the same existence check. The same dialog and options are used by Profile from snapshot, which walks the selected snapshot’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).

    Card snapshots

    -

    The list view has two tabs — Profiles and Card snapshots. 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.

    +

    The list view has two tabs — Profiles and Card snapshots. 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.

    • New snapshot — asks for a name and scans the card, then returns to the list.
    • Import snapshot — loads a snapshot from a JSON file.
    • diff --git a/frontend/index.html b/frontend/index.html index 08f7e06..a28b27f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -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 = '
      ' + esc(JSON.stringify(data.decoded, null, 2)) + '
      '; + 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 += '
      ' + esc(t('No decoder for this file')) + ' — ' + esc(t('showing raw data')) + '
      '; + if (data.records) { + for (const r of data.records) { + html += '
      ' + r.num + '' + + '
      '; + } + } else { + html += ''; + } + return html; + } + if (dec.records) { + for (const r of dec.records) { + html += '
      ' + + '
      ' + esc(t('Record')) + ' ' + r.num + (r.empty ? ' — ' + esc(t('empty')) : '') + '
      ' + + efRenderFieldsHtml(efFlatten(r.data)) + '
      '; + } + } else { + if (dec.empty) html += '
      ' + esc(t('empty')) + '
      '; + html += efRenderFieldsHtml(efFlatten(dec.data)); + } + html += '
      ' + esc(t('pySim JSON (server)')) + '' + + '
      '; + 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) { '
      ' + profilerFciPreviewItems(f.fciHex) + '
      '; html += ''; } + const dec = f.content ? efDecodeFile(f.name, efFidFromPath(f.path), f.content) : null; html += '
      ' + esc(t('Contents')) + '
      '; if (!f.content) { html += '
      ' + esc(t('Not captured')) + '
      '; @@ -10397,10 +10454,16 @@ function profilerRenderSnapshotFiles(s) { '' + (rec.ms !== undefined ? '' + esc(profilerFormatMs(rec.ms)) + '' : '') + ''; + 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 += '
      ' + esc(sum) + '
      '; } html += ''; } else { html += ''; + if (dec && dec.data && !dec.empty) { + html += '
      ' + esc(t('Decoded')) + '
      ' + efRenderFieldsHtml(efFlatten(dec.data)) + '
      '; + } } html += ''; } @@ -11309,14 +11372,29 @@ function profilerRenderReport(results, labels) { if (c.label === 'extra file') { html += '
      ' + esc(t('extra file')) + '
      '; } 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 += '
      '; html += '
      ' + esc(c.label) + '
      '; - html += '
      ' + - '' + esc(expLabel) + '' + - '
      '; - html += '
      ' + - '' + esc(actLabel) + '' + - '
      '; + if (dd && dd.rows.length) { + html += '
      ' + esc(t('Decoded comparison')) + ' — ' + esc(expLabel) + ' | ' + esc(actLabel) + '
      '; + for (const [label, ev, av] of dd.rows) { + html += '
      ' + + '' + esc(label) + '' + + '' + + '' + + '
      '; + } + } else { + if (dd && dd.sameDecoded) html += '
      ' + esc(t('Decoded values match; raw bytes differ')) + '
      '; + html += '
      ' + + '' + esc(expLabel) + '' + + '
      '; + html += '
      ' + + '' + esc(actLabel) + '' + + '
      '; + } if (c.label === 'fci') html += fcpDiffHtml(String(c.expected), String(c.actual), labels); html += '
      '; } 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: "Support for Trusted non-3GPP access networks by USIM", 136: "Support for multiple records of NAS security context storage for multiple registration", 137: "Pre-configured CAG information list", 138: "SOR-CMCI storage in USIM", 139: "5G ProSe", 140: "Storage of disaster roaming information in USIM", 141: "Pre-configured eDRX parameters", 142: "5G NSWO support", 143: "PWS configuration for SNPN in USIM", 144: "Multiplier Coefficient for Higher Priority PLMN search via NG-RAN satellite access", 145: "K_AUSF derivation configuration", 146: "Network Identifier for SNPN (NID)"}; +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 '' + esc(t('No decodable fields')) + ''; + let html = '
      '; + for (const [k, v] of rows) { + html += '
      ' + + '' + esc(k) + '' + + '' + esc(v) + '
      '; + } + return html + '
      '; +} + +// 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': 'Сканировать', diff --git a/frontend/sw.js b/frontend/sw.js index 8b55e68..f6e84f5 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v190'; +const CACHE = 'simple-v191'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/ef_decode.test.js b/frontend/tests/ef_decode.test.js new file mode 100644 index 0000000..db35b56 --- /dev/null +++ b/frontend/tests/ef_decode.test.js @@ -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/); +}); diff --git a/pyproject.toml b/pyproject.toml index 45875e2..c54bfd6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "2.3.1" +version = "2.4.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 e254a34..9ad6230 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -21,7 +21,7 @@ from osmocom.construct import GsmOrUcs2Adapter from osmocom.tlv import BER_TLV_IE -VERSION = '2.3.1' +VERSION = '2.4.0' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE