Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 101ef21e9f | |||
| d036ef79a1 | |||
| 9411fd775d | |||
| ded63c94e4 | |||
| 1174a340db |
+54
-11
@@ -9,6 +9,55 @@ every change carries a descriptive commit message.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [3.33.0] — 2026-10-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Edit decoded** in the file manager: a file is read raw, decoded into its own editable
|
||||||
|
form, re-encoded in the browser and written through the normal update path. The first
|
||||||
|
wave covers **EF.SPN**, **EF.PLMNwAcT / EF.OPLMNwAcT / EF.HPLMNwAcT**, the dialling-number
|
||||||
|
files **EF.ADN / EF.FDN / EF.SDN / EF.MSISDN / EF.LND / EF.BDN** and **EF.PNN**. Shapes:
|
||||||
|
a flat transparent form (SPN), a transparent list editor with add/remove rows (PLMN+ACT),
|
||||||
|
per-record forms with a selection checkbox that is auto-checked when a field changes
|
||||||
|
(dialling numbers, PNN) and per-record TLV item rows with add/remove/edit (PNN's full and
|
||||||
|
short names plus the PLMN additional information). All checked records are encoded and
|
||||||
|
validated before anything is written; unchanged fields, record lengths, extension
|
||||||
|
pointers (EF.EXT1–EXT5), the BDN comparison-method byte and unknown PNN TLVs are
|
||||||
|
preserved, and an edit that would break a reference (a number continued in an extension
|
||||||
|
file, a dropped mandatory PNN full name) is refused.
|
||||||
|
- `docs/decoded-editing.md`: the file-format survey behind the design (every known EF
|
||||||
|
classified by structure/coding into the editor's shapes S0–S10, with the release waves).
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- The file manager's decoded view no longer carries the collapsed **pySim JSON (server)**
|
||||||
|
disclosure — the view is the client-side EF decoder's table only. The disclosure was an
|
||||||
|
unrelated *second* decode: opening it issued another `/api/read` with `mode: 'decoded'`
|
||||||
|
to run pySim's `read_binary_decoded`/`read_records_decoded`, and it was reachable only
|
||||||
|
when our decoder covered the file (`Read decoded` is decoder-gated), so the one case
|
||||||
|
where a second opinion would matter — a file our decoders do not cover — never offered
|
||||||
|
it. `/api/read` keeps `mode: 'decoded'` for API callers; the help, READMEs and project
|
||||||
|
notes say the decoded table is built in the browser from the same raw bytes.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **EF.BDN** decodes and encodes with its own `X+15` layout (the trailing Comparison Method
|
||||||
|
Pointer); the shared ADN codec had treated it as `X+14`.
|
||||||
|
- `efPlmnAct` decoded the EC-GSM-IoT-only ACT (`b8=1,b4=1,b3=0`, `0x0088`) as nothing while
|
||||||
|
checking the impossible `0x0086` (TS 31.102 Table 4.2.5.2).
|
||||||
|
- PNN network names: the decoder now trusts a non-zero spare-bit count before the
|
||||||
|
"all bytes < 0x80 → uncompressed" heuristic, so a valid packed name can no longer be
|
||||||
|
misread (and later written back wrong).
|
||||||
|
- `/api/write` and `/api/read` report a cmd2 exception in the captured output as a failure
|
||||||
|
instead of `success: true` with `SW: 9000`.
|
||||||
|
- The empty amber **preset-mismatch banner** no longer flashes on the SCP80/RAM view.
|
||||||
|
`ramOpChanged()` (v3.28.0) un-hid `#ram-preset-warning` by the operation kind, but the
|
||||||
|
banner's text *and* visibility belong to `spPresetWarningRender()` (which also handles
|
||||||
|
the batch suppression): with no warning text the empty bordered box stayed visible until
|
||||||
|
the next status poll re-hid it — on every RAM view and operation switch. The visibility
|
||||||
|
is content-driven again; `frontend/tests/ram.test.js` pins it (the pre-fix code fails
|
||||||
|
the test).
|
||||||
|
|
||||||
## [3.32.5] — 2026-10-21
|
## [3.32.5] — 2026-10-21
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -25,6 +74,11 @@ every change carries a descriptive commit message.
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- The auto-equip's captured traceback now always includes the **exception line**: cmd2
|
||||||
|
renders a rich-box traceback longer than the logged head, so the actual error was cut
|
||||||
|
off. The tail is appended after a `...` marker; the "releasing the old link failed"
|
||||||
|
message now says it continues with a fresh link (a removed card's dead PC/SC handle
|
||||||
|
always fails to disconnect).
|
||||||
- A card insertion now marks the PC/SC link for a rebuild, so the **first** auto-equip
|
- A card insertion now marks the PC/SC link for a rebuild, so the **first** auto-equip
|
||||||
attempt already uses a fresh link. On Windows the old link cannot connect after a
|
attempt already uses a fresh link. On Windows the old link cannot connect after a
|
||||||
swap — v3.32.2 recovered it, but only on the second attempt, after a failed init, a
|
swap — v3.32.2 recovered it, but only on the second attempt, after a failed init, a
|
||||||
@@ -33,17 +87,6 @@ every change carries a descriptive commit message.
|
|||||||
equip worker (never in the pyscard monitor thread) and `_ensure_transport` releases
|
equip worker (never in the pyscard monitor thread) and `_ensure_transport` releases
|
||||||
the old link first.
|
the old link first.
|
||||||
|
|
||||||
## [3.32.3] — 2026-10-21
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- The auto-equip's captured traceback now always includes the **exception line**: cmd2
|
|
||||||
renders a rich-box traceback longer than the logged head, so the actual error was cut
|
|
||||||
off (seen in the first Windows confirmation of v3.32.2). The tail is appended after a
|
|
||||||
`...` marker.
|
|
||||||
- The "releasing the old link failed" message now says it continues with a fresh link —
|
|
||||||
a removed card's dead PC/SC handle always fails to disconnect.
|
|
||||||
|
|
||||||
## [3.32.2] — 2026-10-21
|
## [3.32.2] — 2026-10-21
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -593,8 +593,9 @@ Connects to the bundled [`pysim-simple-server`](pysim_simple_server/) for live c
|
|||||||
Browse the UICC filesystem in a tree view. Files are shown with names, FIDs, and AIDs (for ADFs). Selecting a file shows its detail pane: FID, file type, size / record layout and the decoded FCI (in a bordered block labelled with the file's symbolic name) above the content pane. When the FCI references its access rules through an EF.ARR record (security attribute `8B`), the referenced record's decoded rules are shown as indented lines under that row — resolved from the card's cached EF.ARR copies (MF, DF.TELECOM/DF.GSM and ADF.USIM, following the file's DF chain; a snapshot resolves them from its own captured copy).
|
Browse the UICC filesystem in a tree view. Files are shown with names, FIDs, and AIDs (for ADFs). Selecting a file shows its detail pane: FID, file type, size / record layout and the decoded FCI (in a bordered block labelled with the file's symbolic name) above the content pane. When the FCI references its access rules through an EF.ARR record (security attribute `8B`), the referenced record's decoded rules are shown as indented lines under that row — resolved from the card's cached EF.ARR copies (MF, DF.TELECOM/DF.GSM and ADF.USIM, following the file's DF chain; a snapshot resolves them from its own captured copy).
|
||||||
|
|
||||||
- Entries are grouped with DFs above EFs and sorted by **FID** or symbolic **Name** (pills pinned above the tree together with **Probe all files**, remembered in `localStorage`)
|
- Entries are grouped with DFs above EFs and sorted by **FID** or symbolic **Name** (pills pinned above the tree together with **Probe all files**, remembered in `localStorage`)
|
||||||
- **Read raw** / **Read decoded** — read the selected file as a hex dump or as a decoded field table (client-side decoders for IMSI, ICCID, SPN, PLMN lists, LOCI/EPSLOCI, ADN/MSISDN, service tables, SUME, …); the highlighted pill is the current view and clicking either pill (re-)reads the file. The server-side pySim JSON of the same read stays behind a collapsed *pySim JSON (server)* disclosure; **Read decoded** is disabled when the client-side decoders do not cover the file (resolved by name/FID before reading)
|
- **Read raw** / **Read decoded** — read the selected file as a hex dump or as a decoded field table (client-side decoders for IMSI, ICCID, SPN, PLMN lists, LOCI/EPSLOCI, ADN/MSISDN, service tables, SUME, …); the highlighted pill is the current view and clicking either pill (re-)reads the file. The decoded table is built in the browser from the same raw bytes (no server-side decode); **Read decoded** is disabled when the client-side decoders do not cover the file (resolved by name/FID before reading)
|
||||||
- **Edit raw** — edit the raw hex data and **Save** to write back (or **Cancel**); transparent files use a textarea, record files one input per record with selection checkboxes. The decoded view is read-only — **Edit raw** switches to the hex view first and reads the file if needed
|
- **Edit raw** — edit the raw hex data and **Save** to write back (or **Cancel**); transparent files use a textarea, record files one input per record with selection checkboxes. **Edit raw** switches to the hex view first and reads the file if needed
|
||||||
|
- **Edit decoded** — edit through the file's own decoded form (client-side encoders; written via the normal update path): **EF.SPN**, **PLMNwAcT / OPLMNwAcT / HPLMNwAcT**, the dialling-number files **ADN / FDN / SDN / MSISDN / LND / BDN**, and **EF.PNN**. Transparent files get one form (PLMN rows and PNN items with add/remove); record files get one form per record with a checkbox that is auto-checked when a field changes, so only edited records are written. **Save** validates every selected record before writing; extension pointers (EF.EXT1–EF.EXT5) and the BDN comparison-method byte are preserved, and editing a number continued in an extension file is refused. The shape survey and the planned coverage are in `docs/decoded-editing.md`
|
||||||
- **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. 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. Missing files are shown in red (✗); a present but empty DF shows `(empty)`
|
||||||
- **ADM** — files that need the administrator PIN fail with `6982`/`9804`; when the matching card preset (same ICCID) carries an ADM key, a **Verify ADM** button appears next to the error and the header badge (`ADM ✓/✗ ⚿`) becomes clickable. Every wrong key consumes an attempt (the remaining attempts are shown and a retry asks for confirmation); a blocked ADM needs the card's unblock key
|
- **ADM** — files that need the administrator PIN fail with `6982`/`9804`; when the matching card preset (same ICCID) carries an ADM key, a **Verify ADM** button appears next to the error and the header badge (`ADM ✓/✗ ⚿`) becomes clickable. Every wrong key consumes an attempt (the remaining attempts are shown and a retry asks for confirmation); a blocked ADM needs the card's unblock key
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -568,8 +568,9 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
|
|||||||
Дерево файлов UICC. Отображаются имена, FID и AID (для ADF). При выборе файла открывается панель деталей: FID, тип файла, размер / структура записей и декодированный FCI (в блоке с рамкой и именем файла) над панелью содержимого. Когда FCI ссылается на свои правила доступа через запись EF.ARR (атрибут безопасности `8B`), правила указанной записи показываются отступом под этой строкой — они берутся из кэшированных копий EF.ARR карты (MF, DF.TELECOM/DF.GSM и ADF.USIM, по цепочке DF файла; снимок разрешает их из собственной захваченной копии).
|
Дерево файлов UICC. Отображаются имена, FID и AID (для ADF). При выборе файла открывается панель деталей: FID, тип файла, размер / структура записей и декодированный FCI (в блоке с рамкой и именем файла) над панелью содержимого. Когда FCI ссылается на свои правила доступа через запись EF.ARR (атрибут безопасности `8B`), правила указанной записи показываются отступом под этой строкой — они берутся из кэшированных копий EF.ARR карты (MF, DF.TELECOM/DF.GSM и ADF.USIM, по цепочке DF файла; снимок разрешает их из собственной захваченной копии).
|
||||||
|
|
||||||
- Элементы сгруппированы (DF выше EF) и отсортированы по **FID** или символьному **имени** (пиллы закреплены над деревом вместе с **«Проверить все файлы»**, выбор сохраняется в `localStorage`)
|
- Элементы сгруппированы (DF выше EF) и отсортированы по **FID** или символьному **имени** (пиллы закреплены над деревом вместе с **«Проверить все файлы»**, выбор сохраняется в `localStorage`)
|
||||||
- **Read raw** / **Read decoded** — чтение выбранного файла как hex-дампа или как таблицы декодированных полей (клиентские декодеры: IMSI, ICCID, SPN, списки PLMN, LOCI/EPSLOCI, ADN/MSISDN, таблицы сервисов, SUME, …); подсвеченная пилла — текущий вид, клик по любой из них перечитывает файл. Серверный pySim JSON того же чтения остаётся в свёрнутом блоке *pySim JSON (server)*; **Read decoded** отключается, когда клиентские декодеры не покрывают файл (определяется по имени/FID до чтения)
|
- **Read raw** / **Read decoded** — чтение выбранного файла как hex-дампа или как таблицы декодированных полей (клиентские декодеры: IMSI, ICCID, SPN, списки PLMN, LOCI/EPSLOCI, ADN/MSISDN, таблицы сервисов, SUME, …); подсвеченная пилла — текущий вид, клик по любой из них перечитывает файл. таблица декодируется в браузере из тех же сырых байт (без серверного декодирования); **Read decoded** отключается, когда клиентские декодеры не покрывают файл (определяется по имени/FID до чтения)
|
||||||
- **Edit raw** — редактирование сырых hex-данных и **Save** для записи (или **Cancel**); для transparent-файлов — textarea, для record-файлов — по полю на запись с флажками выбора. Декодированный вид только для чтения — **Edit raw** сначала переключается на hex и при необходимости читает файл
|
- **Edit raw** — редактирование сырых hex-данных и **Save** для записи (или **Cancel**); для transparent-файлов — textarea, для record-файлов — по полю на запись с флажками выбора. **Edit raw** сначала переключается на hex и при необходимости читает файл
|
||||||
|
- **Edit decoded** — редактирование в собственной декодированной форме файла (клиентские кодировщики; запись обычным путём): **EF.SPN**, **PLMNwAcT / OPLMNwAcT / HPLMNwAcT**, файлы номеров **ADN / FDN / SDN / MSISDN / LND / BDN** и **EF.PNN**. Для transparent-файла — одна форма (строки PLMN и элементы PNN с добавлением/удалением); для record-файла — форма на запись с флажком, который ставится автоматически при изменении поля, поэтому записываются только изменённые записи. **Save** проверяет все выбранные записи до записи; указатели расширений (EF.EXT1–EF.EXT5) и байт метода сравнения BDN сохраняются; редактирование номера, продолжение которого лежит в файле расширения, отклоняется. Обзор форматов и план охвата — в `docs/decoded-editing.md`
|
||||||
- **Проверить все файлы** — обход всего дерева (включая пользовательские) с пометкой «есть/нет», прогрессом *N / всего*, возможностью остановки и сводкой в конце; сам просмотр остаётся ленивым. Отсутствующие файлы показаны красным (✗); существующий пустой DF — `(пусто)`
|
- **Проверить все файлы** — обход всего дерева (включая пользовательские) с пометкой «есть/нет», прогрессом *N / всего*, возможностью остановки и сводкой в конце; сам просмотр остаётся ленивым. Отсутствующие файлы показаны красным (✗); существующий пустой DF — `(пусто)`
|
||||||
- **ADM** — файлы, требующие администраторский PIN, возвращают `6982`/`9804`; если в подходящем пресете карты (тот же ICCID) есть ключ ADM, рядом с ошибкой появляется кнопка **«Проверить ADM»**, а значок в заголовке (`ADM ✓/✗ ⚿`) становится кликабельным. Каждый неверный ключ расходует попытку (остаток показывается, повторная попытка требует подтверждения); заблокированный ADM требует ключа разблокировки карты
|
- **ADM** — файлы, требующие администраторский PIN, возвращают `6982`/`9804`; если в подходящем пресете карты (тот же ICCID) есть ключ ADM, рядом с ошибкой появляется кнопка **«Проверить ADM»**, а значок в заголовке (`ADM ✓/✗ ⚿`) становится кликабельным. Каждый неверный ключ расходует попытку (остаток показывается, повторная попытка требует подтверждения); заблокированный ADM требует ключа разблокировки карты
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
# Decoded editing — file-format survey and design
|
||||||
|
|
||||||
|
Status: design note for the **Edit decoded** feature (v3.33.0). It classifies
|
||||||
|
the EFs the workbench knows (the client-side decoder registry) by the spec's
|
||||||
|
own structure and content coding, assigns each an editing *shape*, and sets the
|
||||||
|
release waves. The reference is the pinned specs (`projects/docs/uicc/`):
|
||||||
|
3GPP TS 31.102 v18.10 (USIM), TS 51.011 v4.15 (SIM), TS 24.008 v18.8 (network
|
||||||
|
name / PLMN / address coding), TS 102 221 (file structure / Annex A text).
|
||||||
|
pySim is a cross-check only.
|
||||||
|
|
||||||
|
The editor is client-side, like the decoders: the file is read raw, decoded
|
||||||
|
into its schema's values, edited, re-encoded and written with `/api/write`.
|
||||||
|
Only the files listed with a shape have an encoder; everything else keeps the
|
||||||
|
raw editor.
|
||||||
|
|
||||||
|
## 1. Editing shapes
|
||||||
|
|
||||||
|
Each shape is one renderer + one encoder contract. `fields` are the flat form
|
||||||
|
values, `list` is a transparent file holding N items, `items` is a record
|
||||||
|
holding a TLV list; the record-scoped variants reuse the same field/item
|
||||||
|
renderers inside a per-record block with a "write this record" checkbox
|
||||||
|
(auto-checked when an input changes).
|
||||||
|
|
||||||
|
| Shape | File / record content | Editor |
|
||||||
|
|---|---|---|
|
||||||
|
| **S0** read-only | card- or network-managed values (location, keys, counters) | no editor; the button says why |
|
||||||
|
| **S1** scalar/flag TR | one or a few bytes / flags | one form |
|
||||||
|
| **S2** text TR | Annex A (TS 102 221) or NAI text | one form, text field |
|
||||||
|
| **S3** BCD TR | ICCID / IMSI (nibble-swapped BCD, parity/type nibble) | one form, digits |
|
||||||
|
| **S4** PLMN list TR | n × 3-byte PLMN (TS 24.008) | item rows, add/remove |
|
||||||
|
| **S5** PLMN+ACT list TR | n × (3-byte PLMN + 2-byte ACT) | item rows, add/remove, tech multi-select |
|
||||||
|
| **S6** bit/service list TR | service or identifier bit lists | item list with labels (checkbox per service/id) |
|
||||||
|
| **S7** fixed-record fields | alpha + BCD address + ids (ADN family), LAI+id (OPL), code+alpha+category (ECC), TP parameters (SMSP) | per-record form + checkbox |
|
||||||
|
| **S8** record TLV list | PNN (name TLVs), DIR (application templates), ARR (access rules), PBR (A8/A9/AA containers) | per-record item rows, add/remove/edit; unknown TLVs preserved |
|
||||||
|
| **S9** nested structures | PBR containers, DIR templates, EXT chains | later: a tree, or read-only with a structured view |
|
||||||
|
| **S10** dedicated builders | SMS (TPDU), SMSP (TPDU parameter set), key material | later: a dedicated form, not a generic encoder |
|
||||||
|
|
||||||
|
Rules that apply to every encoder (and are what the tests pin):
|
||||||
|
|
||||||
|
- encode **only the edited values**, from the original record/file bytes:
|
||||||
|
length, padding and anything the schema does not expose are preserved;
|
||||||
|
- never change the file/record length; refuse when the new value does not fit;
|
||||||
|
- preserve the original text coding when the text is unchanged, and choose a
|
||||||
|
spec-legal coding when it changed (Annex A: unpacked GSM 7-bit / UCS-2; PNN
|
||||||
|
network name: packed GSM 7-bit with the spare-bit count / UCS-2);
|
||||||
|
- refuse (not silently truncate) when an edit would invalidate a cross-file
|
||||||
|
reference (`EF.EXT1` chain, PBR/IAP, OPL→PNN record id, CMI pointer).
|
||||||
|
|
||||||
|
## 2. Survey of the known files
|
||||||
|
|
||||||
|
`Wave` is the release that adds the encoder. Files we do not decode yet are
|
||||||
|
listed only where they matter for the plan; the spec's ~120 USIM EFs include
|
||||||
|
many specialized ones (MMS, VGCS/VBS, ePDG, IMS configuration, …) that are not
|
||||||
|
editing candidates.
|
||||||
|
|
||||||
|
### 2.1 Transparent files
|
||||||
|
|
||||||
|
| EF | FID | Spec | Size | Content | Shape | Wave |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| EF.ICCID | 2FE2 | TS 51.011 10.3.1 | 10 B | BCD (nibble-swapped), F padding | S3 | 3.34 |
|
||||||
|
| EF.IMSI | 6F07 | TS 31.102 4.2.2 | 9 B | length byte + '9'-prefixed BCD | S3 | 3.34 |
|
||||||
|
| EF.IST | 6F07 | TS 31.102 4.2.2 (SAIP) | 1–2 B | service bits | S6 | 3.34 |
|
||||||
|
| EF.UST | 6F38 | TS 31.102 4.2.8 | X ≥ 1 | service bits | S6 | 3.34 |
|
||||||
|
| EF.SST | 6F38 | TS 51.011 10.3.7 | X ≥ 1 | service bits | S6 | 3.34 |
|
||||||
|
| EF.EST | 6F56 | TS 31.102 4.2.47 | X ≥ 1 | enabled-service bits | S6 | 3.34 |
|
||||||
|
| EF.LI | 6F05 | TS 31.102 4.2.1 | 2n | ISO 639 language codes | S6 | 3.34 |
|
||||||
|
| EF.LP / EF.PL | 6F05 / 2F05 | TS 51.011 10.3.1/10.3.2 | 1–n | language codes | S6 | 3.34 |
|
||||||
|
| EF.SPN | 6F46 | TS 31.102 4.2.12 | 17 B | condition byte + Annex A text | S2 | **3.33** |
|
||||||
|
| EF.PLMNsel | 6F30 | TS 51.011 10.3.4 | 3n (n ≥ 8) | PLMN list | S4 | 3.34 |
|
||||||
|
| EF.FPLMN | 6F7B | TS 31.102 4.2.16 | 3n (n ≥ 4) | PLMN list | S4 | 3.34 |
|
||||||
|
| EF.EHPLMN | 6FD9 | TS 31.102 4.2.84 | 3n | PLMN list | S4 | 3.34 |
|
||||||
|
| EF.PLMNwAcT | 6F60 | TS 31.102 4.2.5 | 5n (n ≥ 8) | PLMN + ACT | S5 | **3.33** |
|
||||||
|
| EF.OPLMNwAcT | 6F61 | TS 31.102 4.2.53 | 5n (n ≥ 8) | PLMN + ACT | S5 | **3.33** |
|
||||||
|
| EF.HPLMNwAcT | 6F62 | TS 31.102 4.2.54 | 5n (n ≥ 1) | PLMN + ACT | S5 | **3.33** |
|
||||||
|
| EF.SPDI | 6FCD | TS 31.102 4.2.66 | x | TLV: A3 > 80 > PLMN list | S4 | 3.34 |
|
||||||
|
| EF.GID1 / GID2 | 6F3E / 6F3F | TS 31.102 4.2.10/4.2.11 | n | text (Annex A) | S2 | 3.34 |
|
||||||
|
| EF.SUUME | 6F54 | TS 51.011 10.3.34 | X+Y | alpha (Annex A) | S2 | 3.35 |
|
||||||
|
| EF.IMPI/DOMAIN/IMPU | 6F02/6F03/6F04 | TS 31.102 (ISIM) | X | NAI text | S2 | 3.35 |
|
||||||
|
| EF.ACC | 6F78 | TS 31.102 4.2.15 | 2 B | class bits | S1/S6 | 3.34 |
|
||||||
|
| EF.PHASE | 6FAE | TS 51.011 10.3.19 | 1 B | phase id | S1 | 3.34 |
|
||||||
|
| EF.HPPLMN | 6F31 | TS 31.102 4.2.6 | 1 B | search period | S1 | 3.34 |
|
||||||
|
| EF.AD | 6FAD | TS 31.102 4.2.18 | 4+X | op mode, info, MNC length | S1 | 3.34 |
|
||||||
|
| EF.CBMI | 6F45 | TS 31.102 4.2.14 | 2n | CB message ids | S6 | 3.34 |
|
||||||
|
| EF.CBMID | 6F48 | TS 31.102 4.2.20 | 2n | CB message ids | S6 | 3.34 |
|
||||||
|
| EF.CBMIR | 6F50 | TS 31.102 4.2.22 | 4n | CB id ranges | S6 | 3.34 |
|
||||||
|
| EF.ACL | 6F57 | TS 31.102 4.2.48 | X | version byte + TLV list (DD APNs) | S8-TR | 3.35 |
|
||||||
|
| EF.LOCI | 6F7E | TS 31.102 4.2.17 | 11 B | TMSI/LAI/status | **S0** | — |
|
||||||
|
| EF.PSLOCI | 6F73 | TS 31.102 4.2.23 | 14 B | P-TMSI/RAI/status | **S0** | — |
|
||||||
|
| EF.EPSLOCI | 6FE3 | TS 31.102 4.2.91 | 18 B | GUTI/TAI/status | **S0** | — |
|
||||||
|
| EF.LOCIGPRS | 6F53 | TS 51.011 10.3.33 | 14 B | GPRS location | **S0** | — |
|
||||||
|
| EF.Kc / KcGPRS | 6F20 / 6F52 | TS 31.102 4.4.3.1/4.4.3.2 | 9 B | ciphering keys | **S0** | — |
|
||||||
|
| EF.Keys / KeysPS | 6F08 / 6F09 | TS 31.102 4.2.3/4.2.4 | 33 B | keys + status | **S0** | — |
|
||||||
|
|
||||||
|
### 2.2 Linear fixed / cyclic files
|
||||||
|
|
||||||
|
| EF | FID | Spec | Record | Content | Shape | Wave |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| EF.ADN | 6F3A | TS 31.102 4.4.2.3 | X+14 | alpha + len + TON/NPI + 10 B BCD + CCP + EXT1 | S7 | **3.33** |
|
||||||
|
| EF.FDN | 6F3B | TS 31.102 4.2.24 | X+14 | ADN layout (EXT2) | S7 | **3.33** |
|
||||||
|
| EF.SDN | 6F49 | TS 31.102 4.2.29 | X+14 | ADN layout (EXT3) | S7 | **3.33** |
|
||||||
|
| EF.MSISDN | 6F40 | TS 31.102 4.2.26 | X+14 | ADN layout (EXT5) | S7 | **3.33** |
|
||||||
|
| EF.LND | 6F44 | TS 51.011 10.5.8 | X+14 | ADN layout, **cyclic** | S7 | **3.33** |
|
||||||
|
| EF.BDN | 6F4D | TS 31.102 4.2.44 | **X+15** | ADN layout + **Comparison Method Pointer** | S7 | **3.33** (fix) |
|
||||||
|
| EF.PNN | 6FC5 | TS 31.102 4.2.58 | X ≥ 3 | TLVs: 43 full / 45 short / 80 PLMN info | S8 | **3.33** |
|
||||||
|
| EF.OPL | 6FC6 | TS 31.102 4.2.59 | 8 | LAI (PLMN+LAC) + PNN record id | S7 | 3.35 |
|
||||||
|
| EF.ECC (SIM) | 6FB7 | TS 51.011 10.3.27 | 3n | emergency codes (TR) | S6 | 3.34 |
|
||||||
|
| EF.ECC (USIM) | 6FB7 | TS 31.102 4.2.21 | X+4 | code(3) + alpha(X) + category(1) | S7 | 3.35 |
|
||||||
|
| EF.EXT1–EXT5 | 6F4A–6F55 | TS 31.102 4.4.2.4 / 4.2.30/31/45/37 | 13 | type-dependent: additional data / subaddress | S9 | 3.36 |
|
||||||
|
| EF.SMSP | 6F42 | TS 31.102 4.2.27 | 28+Y | alpha + parameter indicators + TP-DA/SCA/PID/DCS/VP | S10 | 3.36 |
|
||||||
|
| EF.SMS | 6F3C | TS 31.102 4.2.25 | 176 | status byte + TPDU | S10 | 3.36 |
|
||||||
|
| EF.ARR | 2F06/6F06 | TS 31.102 4.2.55 | X | access-rule TLVs (ISO 7816-4) | S8/S9 | 3.36 |
|
||||||
|
| EF.DIR | 2F00 | TS 51.011 10.3.3 | X | application template TLVs (61 > 4F/50/73…) | S8/S9 | 3.36 |
|
||||||
|
| EF.PBR | 4F30/4FXX | TS 31.102 4.4.2.1 | X | BER-TLV: A8/A9/AA containers, C0–CC links | S9 | later |
|
||||||
|
| EF.EPSNSC | 6FE4 | TS 31.102 4.2.92 | — | NAS security context | **S0** | — |
|
||||||
|
| EF.ICI/OCI (call logs) | 6F80/6F81 | TS 31.102 4.2.33/4.2.34 | X+28/X+27 | ADN-like + time/date → cyclic, ME-written | **S0** (plan) | — |
|
||||||
|
|
||||||
|
### 2.3 Coverage vs the spec
|
||||||
|
|
||||||
|
The decoder registry covers ~45 EFs. The spec defines many more; the
|
||||||
|
remaining ones are either specialized protocols (MMS, VGCS/VBS, ePDG, IMS
|
||||||
|
configuration, GBA, 5G slice configuration) or administrative/key files that
|
||||||
|
must not be edited from a workbench without a dedicated flow (Keys, DCK,
|
||||||
|
network parameters, START-HFN). Nothing in the survey forces a new shape
|
||||||
|
beyond S9/S10.
|
||||||
|
|
||||||
|
## 3. Findings (defects and constraints the survey exposed)
|
||||||
|
|
||||||
|
1. **EF.BDN is X+15, not X+14.** The shared ADN codec assumes the last two
|
||||||
|
bytes are CCP1/EXT1; BDN adds a trailing **Comparison Method Pointer**
|
||||||
|
(`X+15`, TS 31.102 4.2.44). The read-only decoded view currently
|
||||||
|
mis-parses BDN records, and the v3.33.0 encoder would corrupt them. The
|
||||||
|
codec must take the layout (ADN vs BDN) and preserve the CMI pointer.
|
||||||
|
2. **EF.ECC has two layouts.** SIM: transparent 3n (TS 51.011 10.3.27);
|
||||||
|
USIM: linear fixed X+4 (TS 31.102 4.2.21). The decoder implements the SIM
|
||||||
|
layout only; on a USIM the alpha bytes are read as more codes. A
|
||||||
|
structure-aware decode is needed before an ECC editor.
|
||||||
|
3. **S8 is not one shape.** PNN (flat tag+length+value TLVs) is the simple
|
||||||
|
case. DIR/ARR/PBR nest TLVs and cross-reference other files; they need
|
||||||
|
either a tree editor or a structured read-only view first.
|
||||||
|
4. **References are part of the model.** EXT1–5 chains (ADN/MSISDN/BDN),
|
||||||
|
OPL→PNN record ids, PBR→(ADN/PBC/EXT/IAP) by FID/SFI. An editor that
|
||||||
|
ignores them can leave a card in a contradictory state: encoders must
|
||||||
|
preserve untouched references and refuse edits that would break one.
|
||||||
|
5. **Cyclic files edit like linear ones** for our purposes (write by record
|
||||||
|
number), but the card owns the order (LND/ICI/OCI/ACM); the editor notes
|
||||||
|
it and the write is still a plain UPDATE.
|
||||||
|
6. **FID collisions across DFs** (e.g. 6F02 = EF.IMPI in ISIM and EF.OCST in
|
||||||
|
USIM) make the fid-only decoder fallback unsafe; the name-based lookup is
|
||||||
|
the primary one and encoders should not be fid-only where a collision is
|
||||||
|
possible.
|
||||||
|
7. **Text coding is per file.** Annex A (TS 102 221) for SPN/ADN/SUUME;
|
||||||
|
the Network Name IE (TS 24.008 10.5.3.5a) for PNN, where the first byte is
|
||||||
|
`0x80 | spare` for packed GSM 7-bit and `0x90` for UCS-2 — the real-card
|
||||||
|
vector `43 0A 82 D4 32 BB BC 7E B7 5D E4 32 …` confirms it.
|
||||||
|
|
||||||
|
## 4. Release waves
|
||||||
|
|
||||||
|
- **v3.33.0 (current)** — framework: encoder registry, field/list/items
|
||||||
|
schemas, record checkboxes + auto-check, save/validation; shapes S2, S5,
|
||||||
|
S7, S8 for SPN, PLMNwAcT/OPLMNwAcT/HPLMNwAcT, ADN/FDN/SDN/MSISDN/LND,
|
||||||
|
PNN; the BDN layout fix; the `efPlmnAct` EC-GSM-IoT-only (`0x88`) decode fix.
|
||||||
|
- **v3.34.0** — S1/S3/S6 wave: UST/SST/IST/EST service toggles, LI/LP/PL,
|
||||||
|
ACC, PHASE, HPPLMN, AD, CBMI/CBMID/CBMIR, ECC (SIM), ICCID, IMSI,
|
||||||
|
PLMNsel/FPLMN/EHPLMN, SPDI, GID1/GID2.
|
||||||
|
- **v3.35.0** — remaining S7 records: OPL (PNN record select), ECC (USIM),
|
||||||
|
SUUME, IMPI/DOMAIN/IMPU; EXT1–5 as a read-only structured view.
|
||||||
|
- **v3.36.0+** — S10/complex: SMSP (parameter form), SMS (TPDU editor,
|
||||||
|
reusing the existing PDU builders), ARR (rule editor — the decoder already
|
||||||
|
renders rule lines), DIR (application templates), PBR (nested tree).
|
||||||
|
- **Read-only forever (S0)** unless a dedicated flow appears: LOCI/PSLOCI/
|
||||||
|
EPSLOCI/EPSNSC/LOCIGPRS, Kc/KcGPRS, Keys/KeysPS, ACM, START-HFN/THRESHOLD,
|
||||||
|
call logs (ME-written), DCK/NETPAR.
|
||||||
|
|
||||||
|
## 5. Testing strategy
|
||||||
|
|
||||||
|
- **Round-trip**: for every encoder, `encode(decode(original), original)`
|
||||||
|
must reproduce the original bytes for spec-canonical records (the vectors
|
||||||
|
come from the spec's own examples, pySim's test data and real-card dumps).
|
||||||
|
- **Edit-one-field**: changing a field changes only that field's bytes
|
||||||
|
(padding, coding of untouched fields, ids and extension pointers stay).
|
||||||
|
- **Refusals**: over-capacity values, broken references, unsupported layouts
|
||||||
|
(BDN with the CMI pointer, EXT1 chains) throw an actionable error.
|
||||||
|
- **No capability by accident**: a file without an encoder must never show
|
||||||
|
the editor (gating test), and a schema/label drift guard keeps the editors
|
||||||
|
and decoders aligned.
|
||||||
@@ -407,8 +407,9 @@
|
|||||||
<h3 id="file-manager" class="text-lg font-medium mb-2">6.1 Файловый менеджер</h3>
|
<h3 id="file-manager" class="text-lg font-medium mb-2">6.1 Файловый менеджер</h3>
|
||||||
<p class="text-sm mb-2">Дерево файловой системы отображается слева; выбор файла открывает панель деталей справа. Элементы сгруппированы: DF выше EF, сортировка по <strong>FID</strong> или символьному <strong>имени</strong> (пиллы и кнопка <strong>«Проверить все файлы»</strong> закреплены над прокручиваемым деревом; выбор сохраняется в <code class="font-mono text-sm">localStorage</code>). При выборе файла над содержимым также показываются FID, тип файла, размер / структура записей и декодированный FCI — в рамке с символьным именем файла. Если FCI ссылается на свои правила доступа через запись <code class="font-mono text-sm">EF.ARR</code> (атрибут безопасности <code class="font-mono text-sm">8B</code>), правила указанной записи показываются с отступом под этой строкой: они берутся из копий <code class="font-mono text-sm">EF.ARR</code> карты — копии MF (<code class="font-mono text-sm">2F06</code>), копий DF.TELECOM/DF.GSM (<code class="font-mono text-sm">6F06</code>) и копии ADF.USIM (<code class="font-mono text-sm">6F06</code>): читаются один раз за сессию при equip, обновляются при сбросе/equip и после записи <code class="font-mono text-sm">EF.ARR</code>; снимок разрешает те же ссылки из собственной захваченной копии. Побеждает копия собственного DF файла, с подъёмом до MF; ссылка, для которой в этой цепочке копии нет, остаётся неразрешённой, если только кандидат не единственный.</p>
|
<p class="text-sm mb-2">Дерево файловой системы отображается слева; выбор файла открывает панель деталей справа. Элементы сгруппированы: DF выше EF, сортировка по <strong>FID</strong> или символьному <strong>имени</strong> (пиллы и кнопка <strong>«Проверить все файлы»</strong> закреплены над прокручиваемым деревом; выбор сохраняется в <code class="font-mono text-sm">localStorage</code>). При выборе файла над содержимым также показываются FID, тип файла, размер / структура записей и декодированный FCI — в рамке с символьным именем файла. Если FCI ссылается на свои правила доступа через запись <code class="font-mono text-sm">EF.ARR</code> (атрибут безопасности <code class="font-mono text-sm">8B</code>), правила указанной записи показываются с отступом под этой строкой: они берутся из копий <code class="font-mono text-sm">EF.ARR</code> карты — копии MF (<code class="font-mono text-sm">2F06</code>), копий DF.TELECOM/DF.GSM (<code class="font-mono text-sm">6F06</code>) и копии ADF.USIM (<code class="font-mono text-sm">6F06</code>): читаются один раз за сессию при equip, обновляются при сбросе/equip и после записи <code class="font-mono text-sm">EF.ARR</code>; снимок разрешает те же ссылки из собственной захваченной копии. Побеждает копия собственного DF файла, с подъёмом до MF; ссылка, для которой в этой цепочке копии нет, остаётся неразрешённой, если только кандидат не единственный.</p>
|
||||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||||
<li><strong>Читать сырые данные / Декодировать</strong> — чтение выбранного файла в виде hex-дампа или таблицы декодированных полей (клиентские декодеры EF: IMSI, ICCID, SPN, списки PLMN, LOCI/PSLOCI/EPSLOCI, ADN/MSISDN, таблицы сервисов, SUME, …); серверный pySim JSON того же чтения остаётся доступен в свёрнутом блоке <em>pySim JSON (сервер)</em>. Подсвеченная кнопка — текущий вид; нажатие любой из них (пере)читывает файл. «Декодировать» недоступна, если для файла нет клиентского декодера (проверка по имени/FID до чтения).</li>
|
<li><strong>Читать сырые данные / Декодировать</strong> — чтение выбранного файла в виде hex-дампа или таблицы декодированных полей (клиентские декодеры EF: IMSI, ICCID, SPN, списки PLMN, LOCI/PSLOCI/EPSLOCI, ADN/MSISDN, таблицы сервисов, SUME, …); таблица декодируется в браузере из тех же сырых байт (без серверного декодирования). Подсвеченная кнопка — текущий вид; нажатие любой из них (пере)читывает файл. «Декодировать» недоступна, если для файла нет клиентского декодера (проверка по имени/FID до чтения).</li>
|
||||||
<li><strong>Редактировать сырые данные</strong> — изменение hex-данных, <strong>Сохранить</strong> для записи (или <strong>Отмена</strong>); transparent-файлы редактируются в textarea, record-файлы — по одному полю на запись с флажками выбора. Декодированный вид доступен только для чтения — «Редактировать сырые данные» сначала переключает на hex-вид и при необходимости читает файл.</li>
|
<li><strong>Редактировать сырые данные</strong> — изменение hex-данных, <strong>Сохранить</strong> для записи (или <strong>Отмена</strong>); transparent-файлы редактируются в textarea, record-файлы — по одному полю на запись с флажками выбора. «Редактировать сырые данные» сначала переключает на hex-вид и при необходимости читает файл.</li>
|
||||||
|
<li><strong>Редактировать декодированные</strong> — редактирование файла в его собственной декодированной форме; кодирование выполняется в браузере, запись идёт обычным путём обновления. Кодировщики покрывают <strong>EF.SPN</strong>, списки PLMN с технологиями доступа (<strong>PLMNwAcT / OPLMNwAcT / HPLMNwAcT</strong>), файлы номеров (<strong>ADN / FDN / SDN / MSISDN / LND / BDN</strong>) и <strong>EF.PNN</strong>. Для transparent-файла — одна форма (поля SPN, строки PLMN с добавлением/удалением, элементы PNN с добавлением/удалением); для record-файла — по форме на запись с флажком выбора, который ставится автоматически при изменении поля, поэтому записываются только изменённые записи. <strong>Сохранить</strong> проверяет все выбранные записи до записи; для файлов без кодировщика кнопка недоступна с подсказкой. Указатели расширений (EF.EXT1–EF.EXT5) и байт метода сравнения BDN сохраняются; редактирование номера, продолжение которого лежит в файле расширения, отклоняется.</li>
|
||||||
<li><strong>Проверить все файлы</strong> — обход всего дерева (включая пользовательские файлы) с пометкой каждого элемента: есть (обычный вид) или нет (красный ✗, без стрелки разворачивания); существующие пустые DF показывают <code class="font-mono text-sm">(пусто)</code>. Отображается прогресс <em>N / всего</em>, обход можно остановить; в конце — сводка «есть/нет». Файлы проверяются только при разворачивании или проверке — просмотр остаётся ленивым.</li>
|
<li><strong>Проверить все файлы</strong> — обход всего дерева (включая пользовательские файлы) с пометкой каждого элемента: есть (обычный вид) или нет (красный ✗, без стрелки разворачивания); существующие пустые DF показывают <code class="font-mono text-sm">(пусто)</code>. Отображается прогресс <em>N / всего</em>, обход можно остановить; в конце — сводка «есть/нет». Файлы проверяются только при разворачивании или проверке — просмотр остаётся ленивым.</li>
|
||||||
<li><strong>ADM</strong> — файлы, требующие администраторский PIN, возвращают <code class="font-mono text-sm">6982</code>/<code class="font-mono text-sm">9804</code>; если в подходящем пресете карты (тот же ICCID) есть ключ ADM, рядом с ошибкой появляется кнопка <strong>«Проверить ADM»</strong>, а значок в заголовке (<code class="font-mono text-sm">ADM ✓/✗ ⚿</code>) становится кликабельным. Каждый неверный ключ расходует попытку: остаток показывается, а повторная попытка требует подтверждения. Заблокированный ADM здесь не восстановить — нужен ключ разблокировки карты.</li>
|
<li><strong>ADM</strong> — файлы, требующие администраторский PIN, возвращают <code class="font-mono text-sm">6982</code>/<code class="font-mono text-sm">9804</code>; если в подходящем пресете карты (тот же ICCID) есть ключ ADM, рядом с ошибкой появляется кнопка <strong>«Проверить ADM»</strong>, а значок в заголовке (<code class="font-mono text-sm">ADM ✓/✗ ⚿</code>) становится кликабельным. Каждый неверный ключ расходует попытку: остаток показывается, а повторная попытка требует подтверждения. Заблокированный ADM здесь не восстановить — нужен ключ разблокировки карты.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
+3
-2
@@ -406,8 +406,9 @@
|
|||||||
<h3 id="file-manager" class="text-lg font-medium mb-2">6.1 File manager</h3>
|
<h3 id="file-manager" class="text-lg font-medium mb-2">6.1 File manager</h3>
|
||||||
<p class="text-sm mb-2">The file system tree is displayed on the left; selecting a file opens its detail pane on the right. Entries are grouped with DFs above EFs and sorted by <strong>FID</strong> or symbolic <strong>Name</strong> (pills pinned above the scrolling tree together with <strong>Probe all files</strong>; the choice is remembered in <code class="font-mono text-sm">localStorage</code>). Selecting a file also shows its FID, file type, size / record layout and the decoded FCI — in a bordered block labelled with the file’s symbolic name — above the content pane. When the FCI references its access rules through an <code class="font-mono text-sm">EF.ARR</code> record (security attribute <code class="font-mono text-sm">8B</code>), the rules of the referenced record are shown as indented lines under that row: they are resolved from the card’s <code class="font-mono text-sm">EF.ARR</code> copies — the MF copy (<code class="font-mono text-sm">2F06</code>), the DF.TELECOM/DF.GSM copies (<code class="font-mono text-sm">6F06</code>) and the ADF.USIM copy (<code class="font-mono text-sm">6F06</code>): read once per card session at equip, refreshed on reset/equip and after an <code class="font-mono text-sm">EF.ARR</code> write; a snapshot resolves the same references from its own captured copy. The copy of the file’s own DF wins, walking up to MF; a reference with no copy in that chain stays unresolved unless a single candidate exists.</p>
|
<p class="text-sm mb-2">The file system tree is displayed on the left; selecting a file opens its detail pane on the right. Entries are grouped with DFs above EFs and sorted by <strong>FID</strong> or symbolic <strong>Name</strong> (pills pinned above the scrolling tree together with <strong>Probe all files</strong>; the choice is remembered in <code class="font-mono text-sm">localStorage</code>). Selecting a file also shows its FID, file type, size / record layout and the decoded FCI — in a bordered block labelled with the file’s symbolic name — above the content pane. When the FCI references its access rules through an <code class="font-mono text-sm">EF.ARR</code> record (security attribute <code class="font-mono text-sm">8B</code>), the rules of the referenced record are shown as indented lines under that row: they are resolved from the card’s <code class="font-mono text-sm">EF.ARR</code> copies — the MF copy (<code class="font-mono text-sm">2F06</code>), the DF.TELECOM/DF.GSM copies (<code class="font-mono text-sm">6F06</code>) and the ADF.USIM copy (<code class="font-mono text-sm">6F06</code>): read once per card session at equip, refreshed on reset/equip and after an <code class="font-mono text-sm">EF.ARR</code> write; a snapshot resolves the same references from its own captured copy. The copy of the file’s own DF wins, walking up to MF; a reference with no copy in that chain stays unresolved unless a single candidate exists.</p>
|
||||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||||
<li><strong>Read raw / Read decoded</strong> — read the selected file as a hex dump or as a decoded field table (client-side EF decoders: IMSI, ICCID, SPN, PLMN lists, LOCI/PSLOCI/EPSLOCI, ADN/MSISDN, service tables, SUME, …); the server-side pySim JSON of the same read stays available in the collapsed <em>pySim JSON (server)</em> disclosure. The highlighted pill is the current view; clicking either pill (re-)reads the file. <strong>Read decoded</strong> is disabled when the client-side decoders do not cover the file (name/FID check, before reading).</li>
|
<li><strong>Read raw / Read decoded</strong> — read the selected file as a hex dump or as a decoded field table (client-side EF decoders: IMSI, ICCID, SPN, PLMN lists, LOCI/PSLOCI/EPSLOCI, ADN/MSISDN, service tables, SUME, …); the decoded table is built in the browser from the same raw bytes (no server-side decode). The highlighted pill is the current view; clicking either pill (re-)reads the file. <strong>Read decoded</strong> is disabled when the client-side decoders do not cover the file (name/FID check, before reading).</li>
|
||||||
<li><strong>Edit raw</strong> — edit the raw hex data, <strong>Save</strong> to write back (or <strong>Cancel</strong>); transparent files use a textarea, record files one input per record with selection checkboxes. The decoded view is read-only — <strong>Edit raw</strong> switches to the hex view first and reads the file if needed.</li>
|
<li><strong>Edit raw</strong> — edit the raw hex data, <strong>Save</strong> to write back (or <strong>Cancel</strong>); transparent files use a textarea, record files one input per record with selection checkboxes. <strong>Edit raw</strong> switches to the hex view first and reads the file if needed.</li>
|
||||||
|
<li><strong>Edit decoded</strong> — edit the file through its own decoded form, encoded in the browser and written with the normal update path. The encoders cover <strong>EF.SPN</strong>, the PLMN lists with access technology (<strong>PLMNwAcT / OPLMNwAcT / HPLMNwAcT</strong>), the dialling-number files (<strong>ADN / FDN / SDN / MSISDN / LND / BDN</strong>) and <strong>EF.PNN</strong>. A transparent file gets one form (SPN fields, PLMN rows with add/remove, PNN items with add/remove); a record file gets one form per record with a selection checkbox that is auto-checked when a field changes, so only the edited records are written. <strong>Save</strong> validates every selected record before writing anything; files without an encoder keep the button disabled with a tooltip. Extension pointers (EF.EXT1–EF.EXT5) and the BDN comparison-method byte are preserved, and editing a number that continues in an extension file is refused.</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>
|
<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>
|
||||||
<li><strong>ADM</strong> — files that need the administrator PIN fail with <code class="font-mono text-sm">6982</code>/<code class="font-mono text-sm">9804</code>; if the matching card preset (same ICCID) carries an ADM key, a <strong>Verify ADM</strong> button appears next to the error and the header badge (<code class="font-mono text-sm">ADM ✓/✗ ⚿</code>) becomes clickable. Every wrong key consumes an attempt: the remaining attempts are shown and a retry asks for confirmation. A blocked ADM cannot be recovered here — it needs the card’s unblock key.</li>
|
<li><strong>ADM</strong> — files that need the administrator PIN fail with <code class="font-mono text-sm">6982</code>/<code class="font-mono text-sm">9804</code>; if the matching card preset (same ICCID) carries an ADM key, a <strong>Verify ADM</strong> button appears next to the error and the header badge (<code class="font-mono text-sm">ADM ✓/✗ ⚿</code>) becomes clickable. Every wrong key consumes an attempt: the remaining attempts are shown and a retry asks for confirmation. A blocked ADM cannot be recovered here — it needs the card’s unblock key.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
+942
-40
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'simple-v463';
|
const CACHE = 'simple-v466';
|
||||||
const URLS = [
|
const URLS = [
|
||||||
'index.html',
|
'index.html',
|
||||||
'help.html',
|
'help.html',
|
||||||
|
|||||||
@@ -57,9 +57,10 @@ test('pysimAdmSecuritySw flags only the access-condition SWs', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('file-manager security failures route through the ADM hint', () => {
|
test('file-manager security failures route through the ADM hint', () => {
|
||||||
// no raw SW error rendering is left (all three sites use the hint helper)
|
// no raw SW error rendering is left (every read/write site, the raw and
|
||||||
|
// the decoded edit paths, uses the hint helper)
|
||||||
assert.ok(!/statusEl\.textContent = 'SW: ' \+ \(data\.sw/.test(html), 'raw SW error rendering is gone');
|
assert.ok(!/statusEl\.textContent = 'SW: ' \+ \(data\.sw/.test(html), 'raw SW error rendering is gone');
|
||||||
assert.strictEqual((html.match(/pysimFsShowError\(statusEl, data\.sw/g) || []).length, 3);
|
assert.strictEqual((html.match(/pysimFsShowError\(statusEl, data\.sw/g) || []).length, 6);
|
||||||
assert.ok(html.includes("btn.textContent = t('Verify ADM')"));
|
assert.ok(html.includes("btn.textContent = t('Verify ADM')"));
|
||||||
assert.ok(html.includes("el.setAttribute('onclick', 'pysimVerifyAdm()')"));
|
assert.ok(html.includes("el.setAttribute('onclick', 'pysimVerifyAdm()')"));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -325,7 +325,7 @@ test('efFlatten renders fields, services and PLMNs for display', () => {
|
|||||||
[['Service 5', 'Call Control by USIM']]);
|
[['Service 5', 'Call Control by USIM']]);
|
||||||
assert.deepStrictEqual(efFlatten({ plmns: [{ mcc: '262', mnc: '01', access_tech: 'GSM' }] }),
|
assert.deepStrictEqual(efFlatten({ plmns: [{ mcc: '262', mnc: '01', access_tech: 'GSM' }] }),
|
||||||
[['PLMN 1', '262-01 (GSM)']]);
|
[['PLMN 1', '262-01 (GSM)']]);
|
||||||
assert.deepStrictEqual(efFlatten({ show_in_hplmn: true }), [['Show in HPLMN', 'yes']]);
|
assert.deepStrictEqual(efFlatten({ show_in_hplmn: true }), [['Display the network name in HPLMN / an SPDI PLMN', 'yes']]);
|
||||||
assert.deepStrictEqual(efFlatten({ title: 'Menu!', icon: '0123' }), [['Title', 'Menu!'], ['Icon', '0123']]);
|
assert.deepStrictEqual(efFlatten({ title: 'Menu!', icon: '0123' }), [['Title', 'Menu!'], ['Icon', '0123']]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -376,7 +376,10 @@ test('file manager decoded view renders fields, not JSON', () => {
|
|||||||
const out = pysimFsDecodedHtml({ name: 'EF.IMSI', fid: null }, { success: true, data: '082982608200002080' });
|
const out = pysimFsDecodedHtml({ name: 'EF.IMSI', fid: null }, { success: true, data: '082982608200002080' });
|
||||||
assert.match(out, /IMSI/);
|
assert.match(out, /IMSI/);
|
||||||
assert.match(out, /228062800000208/);
|
assert.match(out, /228062800000208/);
|
||||||
assert.match(out, /pySim JSON \(server\)/);
|
// The view is the client-side decoder's table only (v3.32.7): the
|
||||||
|
// server-side pySim JSON disclosure is gone - it was an unrelated second
|
||||||
|
// read of the card, not an input to this decode.
|
||||||
|
assert.doesNotMatch(out, /pySim JSON/);
|
||||||
assert.doesNotMatch(out, /\{"imsi"/);
|
assert.doesNotMatch(out, /\{"imsi"/);
|
||||||
// the label column is content-sized, not a fixed 10rem block
|
// the label column is content-sized, not a fixed 10rem block
|
||||||
assert.match(out, /grid-template-columns:max-content 1fr/);
|
assert.match(out, /grid-template-columns:max-content 1fr/);
|
||||||
@@ -387,6 +390,15 @@ test('file manager decoded view renders fields, not JSON', () => {
|
|||||||
assert.match(raw, /0011/);
|
assert.match(raw, /0011/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the file manager never requests the server-side decode', () => {
|
||||||
|
// The decoded table is client-side (the raw read's bytes); the pySim JSON
|
||||||
|
// lookup - a second /api/read with mode 'decoded' - is gone (v3.32.7).
|
||||||
|
assert.ok(!html.includes("mode = 'decoded'"));
|
||||||
|
assert.ok(!html.includes('pysimFsLoadPySimJson'));
|
||||||
|
assert.ok(!html.includes('pysim-fs-pysim-json'));
|
||||||
|
assert.ok(!html.includes('pySim JSON (server)'));
|
||||||
|
});
|
||||||
|
|
||||||
test('efRenderFieldsHtml sizes the label column to its content', () => {
|
test('efRenderFieldsHtml sizes the label column to its content', () => {
|
||||||
const out = efRenderFieldsHtml([['ICCID', '8988'], ['Very long label', 'x']]);
|
const out = efRenderFieldsHtml([['ICCID', '8988'], ['Very long label', 'x']]);
|
||||||
assert.match(out, /style="display:grid;grid-template-columns:max-content 1fr/);
|
assert.match(out, /style="display:grid;grid-template-columns:max-content 1fr/);
|
||||||
@@ -426,3 +438,28 @@ test('profilerRenderReport shows raw and decoded comparisons for content mismatc
|
|||||||
assert.match(same, /Decoded values match/);
|
assert.match(same, /Decoded values match/);
|
||||||
assert.doesNotMatch(same, /Decoded comparison/);
|
assert.doesNotMatch(same, /Decoded comparison/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('EF.BDN uses the 15-byte ADN layout (TS 31.102 4.2.44)', () => {
|
||||||
|
// alpha 'TEST' | len 06 | TON/NPI 91 | BCD 21 43 65 87 09 + FF padding |
|
||||||
|
// CCP FF | EXT4 FF | EF.CMI comparison-method pointer 03
|
||||||
|
const hex = '54455354' + '06' + '91' + '2143658709' + 'FF'.repeat(7) + '03';
|
||||||
|
const bdn = efBytes(hex);
|
||||||
|
const want = { name: 'TEST', number: '+1234567890', cmi: 3 };
|
||||||
|
assert.strictEqual(bdn.length, 19);
|
||||||
|
assert.deepStrictEqual(efDecAdn(bdn, 15), want);
|
||||||
|
assert.deepStrictEqual(efFindDecoder('EF.BDN').fn(bdn), want);
|
||||||
|
// the 14-byte default and the EF.ADN registry entry are unchanged
|
||||||
|
const adn = efBytes('42204841203120536963FFFFFFFFFFFF06810628560810FFFFFFFFFFFFFF');
|
||||||
|
const adnWant = { name: 'B HA 1 Sic', number: '6082658001' };
|
||||||
|
assert.deepStrictEqual(efDecAdn(adn), adnWant);
|
||||||
|
assert.deepStrictEqual(efFindDecoder('EF.ADN').fn(adn), adnWant);
|
||||||
|
assert.notDeepStrictEqual(efDecAdn(adn, 15), adnWant);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the BDN extension and CMI fields carry readable labels', () => {
|
||||||
|
assert.deepStrictEqual(efFlatten({ cmi: 3 }), [['Comparison method pointer', '3']]);
|
||||||
|
assert.deepStrictEqual(efFlatten({ ext1: 2 }), [['Extension record', '2']]);
|
||||||
|
const bdn = efDecAdn(efBytes('54455354' + '06' + '91' + '2143658709' +
|
||||||
|
'FF'.repeat(7) + '03'), 15);
|
||||||
|
assert.ok(efFlatten(bdn).some(r => r[0] === 'Comparison method pointer' && r[1] === '3'));
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,513 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull a const's initializer by bracket depth (the schemas/registries are
|
||||||
|
// nested objects and arrays, so the line-based extraction used elsewhere does
|
||||||
|
// not work for them).
|
||||||
|
function extractConst(src, name) {
|
||||||
|
const m = new RegExp('const ' + name + ' = ').exec(src);
|
||||||
|
if (!m) throw new Error('const ' + name + ' not found');
|
||||||
|
const begin = m.index + m[0].length;
|
||||||
|
const opener = src[begin];
|
||||||
|
const closer = opener === '[' ? ']' : (opener === '{' ? '}' : null);
|
||||||
|
if (!closer) throw new Error('unsupported const ' + name);
|
||||||
|
let depth = 0, i = begin;
|
||||||
|
for (; i < src.length; i++) {
|
||||||
|
if (src[i] === opener) depth++;
|
||||||
|
else if (src[i] === closer) {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) { i++; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'var ' + name + ' = ' + src.slice(begin, i) + ';\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
const FNS = [
|
||||||
|
'efBytes', 'efHex', 'efSwapNibbles', 'efAllFf', 'efRstripFf', 'efUcs2Be',
|
||||||
|
'efGsm7Octets', 'efAnnexA', 'efBcdAddress', 'efPlmn', 'efPlmnAct',
|
||||||
|
'efDecSpn', 'efDecPlmnWact', 'efDecAdn', 'efDecPnn', 'efFindDecoder',
|
||||||
|
'efDecodeBytes', 'efTlv', 'efPnnText', 'efDecItemsFromHex',
|
||||||
|
'efGsm7PlainText', 'efEncTextBytes', 'efPlmnEnc', 'efActEnc', 'efEncSpn',
|
||||||
|
'efEncPlmnList', 'efPlmnRowOrig', 'efEncAdn', 'efEncAdnExt', 'efPnnNameBytes', 'efPnnHexBytes', 'efPnnOrigTlv', 'efEncPnn',
|
||||||
|
'efFindEncoder', 'efHasEncoder', 'gsm7Decode', 'gsm7TextToSeptets',
|
||||||
|
'gsm7Encode', 'efEncError',
|
||||||
|
];
|
||||||
|
|
||||||
|
let code = '';
|
||||||
|
for (const f of FNS) code += extractFunc(html, f) + '\n';
|
||||||
|
for (const c of ['GSM7_ALPHABET', 'GSM7_EXT_MAP', 'EF_ACT_TECHS', 'EF_SPN_FIELDS',
|
||||||
|
'EF_ADN_FIELDS', 'EF_PLMN_LIST', 'EF_PNN_ITEMS', 'EF_ENCODERS']) {
|
||||||
|
code += extractConst(html, c);
|
||||||
|
}
|
||||||
|
eval(code);
|
||||||
|
|
||||||
|
test('efPlmnEnc builds the TS 24.008 PLMN and round-trips through efPlmn', () => {
|
||||||
|
assert.strictEqual(efPlmnEnc('262', '01'), '62F210');
|
||||||
|
assert.strictEqual(efPlmnEnc('228', '06'), '22F860');
|
||||||
|
assert.deepStrictEqual(efPlmn(efBytes('22F860')), { mcc: '228', mnc: '06' });
|
||||||
|
assert.deepStrictEqual(efPlmn(efBytes(efPlmnEnc('310', '260'))), { mcc: '310', mnc: '260' });
|
||||||
|
assert.deepStrictEqual(efPlmn(efBytes(efPlmnEnc('250', '99'))), { mcc: '250', mnc: '99' });
|
||||||
|
assert.throws(() => efPlmnEnc('26', '01'), /MCC/);
|
||||||
|
assert.throws(() => efPlmnEnc('262', '1'), /MNC/);
|
||||||
|
assert.throws(() => efPlmnEnc('26a', '01'), /MCC/);
|
||||||
|
assert.throws(() => efPlmnEnc('262', '01a'), /MNC/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('efActEnc encodes every technology name and the spec bit tables', () => {
|
||||||
|
assert.strictEqual(efActEnc('UTRAN'), '8000');
|
||||||
|
assert.strictEqual(efActEnc('NG-RAN'), '0800');
|
||||||
|
assert.strictEqual(efActEnc('E-UTRAN WB-S1, E-UTRAN NB-S1'), '4000');
|
||||||
|
assert.strictEqual(efActEnc('E-UTRAN NB-S1'), '5000');
|
||||||
|
assert.strictEqual(efActEnc('E-UTRAN WB-S1'), '6000');
|
||||||
|
assert.strictEqual(efActEnc('GSM'), '0084');
|
||||||
|
assert.strictEqual(efActEnc('EC-GSM-IoT'), '0088');
|
||||||
|
assert.strictEqual(efActEnc('GSM, EC-GSM-IoT'), '0080');
|
||||||
|
assert.strictEqual(efActEnc('GSM COMPACT'), '0040');
|
||||||
|
assert.strictEqual(efActEnc('cdma2000 HRPD'), '0020');
|
||||||
|
assert.strictEqual(efActEnc('cdma2000 1xRTT'), '0010');
|
||||||
|
// every name the decoder prints survives an encode/decode round trip
|
||||||
|
for (const name of EF_ACT_TECHS) {
|
||||||
|
const act = parseInt(efActEnc(name), 16);
|
||||||
|
assert.ok(efPlmnAct(act).indexOf(name) !== -1, name + ' round trip');
|
||||||
|
}
|
||||||
|
assert.throws(() => efActEnc(''), /at least one/);
|
||||||
|
assert.throws(() => efActEnc([]), /at least one/);
|
||||||
|
assert.throws(() => efActEnc('BOGUS'), /Unknown access technology/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('efPlmnAct decodes the EC-GSM-IoT-only 0x0088 (the old 0x0086 branch was unreachable)', () => {
|
||||||
|
// Table 4.2.5.2: b8=1, b4=1, b3=0 = 0x0088; the mask 0x008C makes 0x0086
|
||||||
|
// collapse to 0x0084 (GSM), so the EC-only branch could never fire.
|
||||||
|
assert.deepStrictEqual(efPlmnAct(0x0088), ['EC-GSM-IoT']);
|
||||||
|
assert.deepStrictEqual(efPlmnAct(0x008C), ['EC-GSM-IoT', 'GSM']);
|
||||||
|
assert.deepStrictEqual(efPlmnAct(0x0080), ['EC-GSM-IoT', 'GSM']);
|
||||||
|
assert.deepStrictEqual(efPlmnAct(0x0084), ['GSM']);
|
||||||
|
assert.deepStrictEqual(efPlmnAct(0x0040), ['GSM COMPACT']);
|
||||||
|
assert.deepStrictEqual(efPlmnAct(0x0020), ['cdma2000 HRPD']);
|
||||||
|
assert.deepStrictEqual(efPlmnAct(0x0010), ['cdma2000 1xRTT']);
|
||||||
|
assert.deepStrictEqual(efPlmnAct(0x8000), ['UTRAN']);
|
||||||
|
assert.deepStrictEqual(efPlmnAct(0x0800), ['NG-RAN']);
|
||||||
|
assert.deepStrictEqual(efPlmnAct(0x4000), ['E-UTRAN NB-S1', 'E-UTRAN WB-S1']);
|
||||||
|
assert.deepStrictEqual(efPlmnAct(0x5000), ['E-UTRAN NB-S1']);
|
||||||
|
assert.deepStrictEqual(efPlmnAct(0x6000), ['E-UTRAN WB-S1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
const SPN_ORIG = '0147534d2d52204348ffffffffffffffff';
|
||||||
|
|
||||||
|
test('EF.SPN round-trips, and an unchanged name keeps the original coding', () => {
|
||||||
|
const dec = efDecSpn(efBytes(SPN_ORIG));
|
||||||
|
assert.deepStrictEqual(dec, { show_in_hplmn: true, hide_in_oplmn: false, name: 'GSM-R CH' });
|
||||||
|
assert.strictEqual(efEncSpn(dec, SPN_ORIG), SPN_ORIG.toUpperCase());
|
||||||
|
// only the condition byte changes: the text bytes are copied verbatim
|
||||||
|
const flags = efEncSpn({ show_in_hplmn: false, hide_in_oplmn: true, name: 'GSM-R CH' }, SPN_ORIG);
|
||||||
|
assert.strictEqual(flags, '02' + SPN_ORIG.slice(2).toUpperCase());
|
||||||
|
// a different name is re-coded (GSM-7 octets, FF padding to 16 bytes)
|
||||||
|
const text = efEncSpn({ show_in_hplmn: true, hide_in_oplmn: false, name: 'GSM-R CH2' }, SPN_ORIG);
|
||||||
|
assert.strictEqual(text.slice(0, 2), '01');
|
||||||
|
assert.strictEqual(text.length, 34);
|
||||||
|
assert.strictEqual(text.slice(2), '47534D2D5220434832FFFFFFFFFFFFFF');
|
||||||
|
assert.strictEqual(efDecSpn(efBytes(text)).name, 'GSM-R CH2');
|
||||||
|
// a character outside the GSM 7-bit alphabet goes UCS-2BE behind 0x80
|
||||||
|
const ucs = efEncSpn({ name: '\u0416' }, SPN_ORIG);
|
||||||
|
assert.strictEqual(ucs, '00800416' + 'FF'.repeat(13));
|
||||||
|
assert.strictEqual(efDecSpn(efBytes(ucs)).name, '\u0416');
|
||||||
|
assert.throws(() => efEncSpn({ name: 'A'.repeat(17) }, SPN_ORIG), /too long/);
|
||||||
|
});
|
||||||
|
|
||||||
|
const PLMN_ORIG = '62F2100084';
|
||||||
|
|
||||||
|
test('EF.PLMNwAcT round-trips, adds/removes rows and refuses an overflow', () => {
|
||||||
|
const dec = efDecPlmnWact(efBytes(PLMN_ORIG));
|
||||||
|
assert.deepStrictEqual(dec.plmns, [{ mcc: '262', mnc: '01', access_tech: 'GSM' }]);
|
||||||
|
assert.strictEqual(efEncPlmnList(dec.plmns, PLMN_ORIG), PLMN_ORIG);
|
||||||
|
assert.strictEqual(efEncPlmnList([], PLMN_ORIG), 'FFFFFFFFFF');
|
||||||
|
// two entries in a ten-byte file
|
||||||
|
const ten = PLMN_ORIG + '22F8608000';
|
||||||
|
const rows = [
|
||||||
|
{ mcc: '262', mnc: '01', access_tech: 'GSM' },
|
||||||
|
{ mcc: '228', mnc: '06', access_tech: 'UTRAN' },
|
||||||
|
];
|
||||||
|
assert.strictEqual(efEncPlmnList(rows, ten), ten);
|
||||||
|
assert.strictEqual(efEncPlmnList([rows[1]], ten), '22F8608000' + 'FF'.repeat(5));
|
||||||
|
// Table 4.2.5.1 both-form round trip through the list encoder
|
||||||
|
const both = efEncPlmnList([{ mcc: '262', mnc: '01', access_tech: 'E-UTRAN WB-S1, E-UTRAN NB-S1' }], ten);
|
||||||
|
assert.strictEqual(both.slice(0, 10), '62F2104000');
|
||||||
|
assert.deepStrictEqual(efDecPlmnWact(efBytes(both)).plmns[0].access_tech, 'E-UTRAN NB-S1, E-UTRAN WB-S1');
|
||||||
|
assert.throws(() => efEncPlmnList(rows, PLMN_ORIG), /Too many entries/);
|
||||||
|
assert.throws(() => efEncPlmnList([{ mcc: '26', mnc: '01', access_tech: 'GSM' }], ten), /MCC/);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ADN_1 = '42204841203120536963FFFFFFFFFFFF06810628560810FFFFFFFFFFFFFF';
|
||||||
|
const ADN_2 = 'ffffffffffffffffffffffffffffffffffffffff04b12143f5ffffffffffffffffff';
|
||||||
|
|
||||||
|
test('EF.ADN round-trips the decoder vectors byte-for-byte', () => {
|
||||||
|
const d1 = efDecAdn(efBytes(ADN_1));
|
||||||
|
assert.strictEqual(d1.name, 'B HA 1 Sic');
|
||||||
|
assert.strictEqual(d1.number, '6082658001');
|
||||||
|
assert.strictEqual(efEncAdn(d1, ADN_1), ADN_1.toUpperCase());
|
||||||
|
const d2 = efDecAdn(efBytes(ADN_2));
|
||||||
|
assert.strictEqual(d2.name, '');
|
||||||
|
assert.strictEqual(d2.number, '12345');
|
||||||
|
assert.strictEqual(efEncAdn(d2, ADN_2), ADN_2.toUpperCase());
|
||||||
|
assert.strictEqual(efEncAdn(d2, ADN_2).slice(-4), 'FFFF');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EF.ADN re-encodes an edited name and number in the record length', () => {
|
||||||
|
const out = efEncAdn({ name: 'Test', number: '+79001234567' }, ADN_1);
|
||||||
|
assert.strictEqual(out.length, ADN_1.length);
|
||||||
|
assert.strictEqual(out.slice(0, 8), '54657374'); // 'Test' as GSM-7 octets
|
||||||
|
assert.strictEqual(out.slice(32, 36), '0791'); // len 7 (1 + 6 bytes), TON/NPI +
|
||||||
|
const dec = efDecAdn(efBytes(out));
|
||||||
|
assert.strictEqual(dec.name, 'Test');
|
||||||
|
assert.strictEqual(dec.number, '+79001234567');
|
||||||
|
assert.strictEqual(dec.ext1, undefined);
|
||||||
|
// spaces are ignored
|
||||||
|
assert.strictEqual(efEncAdn({ name: 'Test', number: '+7 900 123 45 67' }, ADN_1), out);
|
||||||
|
// an emptied number: length 0, TON/NPI FF, digits FF
|
||||||
|
const empty = efEncAdn({ name: 'Test', number: '' }, ADN_1);
|
||||||
|
assert.strictEqual(empty.slice(32, 36), '00FF');
|
||||||
|
assert.strictEqual(efDecAdn(efBytes(empty)).number, '');
|
||||||
|
// a name-only edit of an all-FF record fills that record (32-byte record)
|
||||||
|
const fill = efEncAdn({ name: 'Ann', number: '123' }, 'FF'.repeat(32));
|
||||||
|
assert.strictEqual(fill.length, 64);
|
||||||
|
assert.strictEqual(efDecAdn(efBytes(fill)).name, 'Ann');
|
||||||
|
assert.strictEqual(efDecAdn(efBytes(fill)).number, '123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EF.ADN refuses a number extension, a short record and a bad number', () => {
|
||||||
|
const ext1 = ADN_1.slice(0, -2) + '03';
|
||||||
|
assert.strictEqual(efEncAdn({ name: 'B HA 1 Sic', number: '6082658001' }, ext1).slice(-4), 'FF03');
|
||||||
|
assert.throws(() => efEncAdn({ name: 'B HA 1 Sic', number: '6082658002' }, ext1), /EF\.EXT1/);
|
||||||
|
assert.throws(() => efEncAdn({}, '00'), /too short/);
|
||||||
|
assert.throws(() => efEncAdn({ number: '+' }, ADN_1), /digits/);
|
||||||
|
assert.throws(() => efEncAdn({ number: '12a' }, ADN_1), /digits/);
|
||||||
|
assert.throws(() => efEncAdn({ number: '1'.repeat(21) }, ADN_1), /too long/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EF.SPN and the ADN family share the encoder registry with the PLMN list', () => {
|
||||||
|
assert.strictEqual(efFindEncoder('EF.SPN', null).fid, '6f46');
|
||||||
|
assert.strictEqual(efFindEncoder('ef.adn', null).fid, '6f3a');
|
||||||
|
assert.strictEqual(efFindEncoder('ADF.USIM/EF.MSISDN', null).name, 'EF.MSISDN');
|
||||||
|
assert.strictEqual(efFindEncoder('EF.ALIAS', '6f60').enc, efEncPlmnList);
|
||||||
|
assert.strictEqual(efFindEncoder('EF.NOPE', 'abcd'), null);
|
||||||
|
assert.strictEqual(efHasEncoder('EF.ADN', null), true);
|
||||||
|
assert.strictEqual(efHasEncoder('EF.IMSI', null), false);
|
||||||
|
// one shared schema for the ADN family and one for the three PLMN files
|
||||||
|
assert.strictEqual(efFindEncoder('EF.ADN', null).fields, efFindEncoder('EF.BDN', null).fields);
|
||||||
|
assert.strictEqual(efFindEncoder('EF.PLMNwAcT', null).list, efFindEncoder('EF.HPLMNwAcT', null).list);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('encoder validation errors go through t() when the runtime provides it', () => {
|
||||||
|
globalThis.t = k => (k === 'Select at least one access technology' ? 'Выберите технологию' : k);
|
||||||
|
try {
|
||||||
|
assert.throws(() => efActEnc(''), /Выберите технологию/);
|
||||||
|
} finally {
|
||||||
|
delete globalThis.t;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// EF.PNN (TS 31.102 4.2.58): a record is a list of Network name TLVs. These
|
||||||
|
// are pySim's reference records (the 43/45 values decoded by efPnnText).
|
||||||
|
const PNN_1 = '430a82d432bbbc7eb75de432450a82d432bbbc7eb75de432ffffffff';
|
||||||
|
const PNN_2 = '430a82c596b34cbfbfe5eb39ffffffffffffffffffffffffffffffffffff';
|
||||||
|
|
||||||
|
test('EF.PNN round-trips the reference vectors byte-for-byte', () => {
|
||||||
|
assert.deepStrictEqual(efDecPnn(efBytes(PNN_1)), { full: 'Telekom.de', short: 'Telekom.de' });
|
||||||
|
assert.deepStrictEqual(efDecPnn(efBytes(PNN_2)), { full: 'E-Networks' });
|
||||||
|
const entry = efFindEncoder('EF.PNN', null);
|
||||||
|
assert.strictEqual(entry.fid, '6fc5');
|
||||||
|
assert.strictEqual(entry.items.addLabel, 'Add TLV');
|
||||||
|
for (const hex of [PNN_1, PNN_2]) {
|
||||||
|
const items = efDecItemsFromHex(entry, hex);
|
||||||
|
assert.strictEqual(efEncPnn(items, hex), hex.toUpperCase());
|
||||||
|
}
|
||||||
|
// a known name tag is decoded to its text, not kept raw
|
||||||
|
assert.deepStrictEqual(efDecItemsFromHex(entry, PNN_1), [
|
||||||
|
{ tag: '43', text: 'Telekom.de', orig: '430A82D432BBBC7EB75DE432' },
|
||||||
|
{ tag: '45', text: 'Telekom.de', orig: '450A82D432BBBC7EB75DE432' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EF.PNN encodes a non-GSM7 name as UCS-2 behind 0x90', () => {
|
||||||
|
const entry = efFindEncoder('EF.PNN', null);
|
||||||
|
const out = efEncPnn([{ tag: '43', text: 'Сеть' }], PNN_1);
|
||||||
|
assert.strictEqual(out.slice(0, 4), '4309'); // 1 + 4 UCS-2 chars
|
||||||
|
assert.strictEqual(out.slice(4, 6), '90');
|
||||||
|
assert.deepStrictEqual(efDecItemsFromHex(entry, out), [{ tag: '43', text: 'Сеть', orig: '430990042104350442044C' }]);
|
||||||
|
// the empty rest is FF padding up to the record length
|
||||||
|
assert.strictEqual(out.length, PNN_1.length);
|
||||||
|
assert.match(out.slice((2 + 9) * 2), /^F+$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EF.PNN re-encodes an edited name and keeps the other TLVs', () => {
|
||||||
|
const entry = efFindEncoder('EF.PNN', null);
|
||||||
|
const items = efDecItemsFromHex(entry, PNN_1).map(i =>
|
||||||
|
i.tag === '43' ? { tag: i.tag, text: 'NewNet' } : i);
|
||||||
|
const out = efEncPnn(items, PNN_1);
|
||||||
|
assert.ok(out.includes('450A82D432BBBC7EB75DE432'), 'the 45 TLV is unchanged');
|
||||||
|
assert.deepStrictEqual(efDecItemsFromHex(entry, out), [
|
||||||
|
{ tag: '43', text: 'NewNet', orig: '430786CEF2DD59A603' },
|
||||||
|
{ tag: '45', text: 'Telekom.de', orig: '450A82D432BBBC7EB75DE432' },
|
||||||
|
]);
|
||||||
|
assert.strictEqual(out.length, PNN_1.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EF.PNN adds and removes TLVs', () => {
|
||||||
|
const entry = efFindEncoder('EF.PNN', null);
|
||||||
|
const withInfo = efEncPnn([{ tag: '43', text: 'E-Networks' }, { tag: '80', hex: '00410042' }], PNN_2);
|
||||||
|
assert.deepStrictEqual(efDecItemsFromHex(entry, withInfo), [
|
||||||
|
{ tag: '43', text: 'E-Networks', orig: '430A82C596B34CBFBFE5EB39' },
|
||||||
|
{ tag: '80', hex: '00410042', orig: '800400410042' },
|
||||||
|
]);
|
||||||
|
const onlyFull = efEncPnn([{ tag: '43', text: 'E-Networks' }], PNN_2);
|
||||||
|
assert.deepStrictEqual(efDecItemsFromHex(entry, onlyFull), [{ tag: '43', text: 'E-Networks', orig: '430A82C596B34CBFBFE5EB39' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EF.PNN encodes tag 80 as raw bytes and preserves an unknown tag verbatim', () => {
|
||||||
|
const entry = efFindEncoder('EF.PNN', null);
|
||||||
|
const out = efEncPnn([{ tag: '80', hex: '00410042' }], 'FF'.repeat(16));
|
||||||
|
assert.strictEqual(out.slice(0, 12), '800400410042');
|
||||||
|
assert.deepStrictEqual(efDecItemsFromHex(entry, out), [{ tag: '80', hex: '00410042', orig: '800400410042' }]);
|
||||||
|
// an unknown tag parsed from the card is read-only and re-emitted as-is
|
||||||
|
const hex = '430a82d432bbbc7eb75de4325a02aabb' + 'ff'.repeat(14);
|
||||||
|
const items = efDecItemsFromHex(entry, hex);
|
||||||
|
assert.deepStrictEqual(items, [
|
||||||
|
{ tag: '43', text: 'Telekom.de', orig: '430A82D432BBBC7EB75DE432' },
|
||||||
|
{ tag: '5A', hex: 'AABB', raw: true, orig: '5A02AABB' },
|
||||||
|
]);
|
||||||
|
assert.strictEqual(efEncPnn(items, hex), hex.toUpperCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EF.PNN refuses duplicate names, bad hex and an overflowing record', () => {
|
||||||
|
assert.throws(() => efEncPnn([{ tag: '43', text: 'A' }, { tag: '43', text: 'B' }], PNN_1), /Only one full name/);
|
||||||
|
assert.throws(() => efEncPnn([{ tag: '45', text: 'A' }, { tag: '45', text: 'B' }], PNN_1), /Only one full name/);
|
||||||
|
assert.throws(() => efEncPnn([{ tag: '80', hex: '0G' }], PNN_1), /even number of hex/);
|
||||||
|
assert.throws(() => efEncPnn([{ tag: '80', hex: '0' }], PNN_1), /even number of hex/);
|
||||||
|
assert.throws(() => efEncPnn([{ tag: '4', hex: '00' }], PNN_1), /one hex byte/);
|
||||||
|
assert.throws(() => efEncPnn([{ tag: '43', text: 'X'.repeat(40) }], 'FF'.repeat(10)), /too short for these items/);
|
||||||
|
assert.throws(() => efEncPnn([{ tag: '80', hex: '00'.repeat(20) }], 'FF'.repeat(10)), /too short for these items/);
|
||||||
|
// an empty name omits its TLV (nothing but FF padding)
|
||||||
|
assert.strictEqual(efEncPnn([{ tag: '43', text: '' }], 'FF'.repeat(8)), 'FF'.repeat(8));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EF.PNN writes spare 0 when the packed text fills the last octet', () => {
|
||||||
|
const entry = efFindEncoder('EF.PNN', null);
|
||||||
|
// 8 septets = 56 bits = 7 octets exactly -> coding octet 0x80
|
||||||
|
const out = efEncPnn([{ tag: '43', text: 'Vodafone' }], 'FF'.repeat(16));
|
||||||
|
assert.strictEqual(out.slice(0, 4), '4308');
|
||||||
|
assert.strictEqual(out.slice(4, 6), '80');
|
||||||
|
assert.deepStrictEqual(efDecItemsFromHex(entry, out), [{ tag: '43', text: 'Vodafone', orig: '430880D637396C7EBBCB' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// EF.BDN (TS 31.102 4.2.44) is the X+15 layout: alpha, len, TON/NPI, BCD(10),
|
||||||
|
// CCP, EXT4, then the EF.CMI comparison-method pointer.
|
||||||
|
const BDN = '54455354' + '06' + '91' + '2143658709' + 'FF'.repeat(7) + '03';
|
||||||
|
|
||||||
|
test('EF.BDN round-trips the X+15 layout and preserves the CMI pointer', () => {
|
||||||
|
const bytes = efBytes(BDN);
|
||||||
|
assert.strictEqual(bytes.length, 19);
|
||||||
|
const dec = efDecAdn(bytes, 15);
|
||||||
|
assert.deepStrictEqual(dec, { name: 'TEST', number: '+1234567890', cmi: 3 });
|
||||||
|
assert.strictEqual(efEncAdn(dec, BDN, 15), BDN);
|
||||||
|
// the registry's BDN entry applies the 15-byte layout
|
||||||
|
const entry = efFindEncoder('EF.BDN', null);
|
||||||
|
assert.strictEqual(entry.fid, '6f4d');
|
||||||
|
assert.strictEqual(entry.enc(dec, BDN), BDN);
|
||||||
|
// editing the number keeps the alpha and the trailing CMI pointer
|
||||||
|
const edited = entry.enc({ name: 'TEST', number: '+79001234567' }, BDN);
|
||||||
|
assert.strictEqual(edited.slice(0, 8), '54455354');
|
||||||
|
assert.strictEqual(edited.slice(-2), '03');
|
||||||
|
const back = efDecAdn(efBytes(edited), 15);
|
||||||
|
assert.strictEqual(back.name, 'TEST');
|
||||||
|
assert.strictEqual(back.number, '+79001234567');
|
||||||
|
assert.strictEqual(back.cmi, 3);
|
||||||
|
assert.strictEqual(back.ext1, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the ADN family stays on the 14-byte layout', () => {
|
||||||
|
// the default is the old 14-byte layout: the registry entry and the
|
||||||
|
// 14-byte round trip are unchanged
|
||||||
|
assert.strictEqual(efFindEncoder('EF.ADN', null).enc(efDecAdn(efBytes(ADN_1)), ADN_1), ADN_1.toUpperCase());
|
||||||
|
assert.strictEqual(efEncAdn(efDecAdn(efBytes(ADN_1)), ADN_1), ADN_1.toUpperCase());
|
||||||
|
assert.notDeepStrictEqual(efDecAdn(efBytes(ADN_1)), efDecAdn(efBytes(ADN_1), 15));
|
||||||
|
// a BDN record decoded with the default layout is not the BDN result; a
|
||||||
|
// number EDIT through the 14-byte layout is refused (its EXT slot is the
|
||||||
|
// BDN's EF.CMI pointer), while the BDN layout writes it
|
||||||
|
assert.notDeepStrictEqual(efDecAdn(efBytes(BDN)), efDecAdn(efBytes(BDN), 15));
|
||||||
|
assert.throws(() => efEncAdn({ name: 'TEST', number: '+7900' }, BDN), /EF\.EXT1/);
|
||||||
|
assert.ok(efEncAdn({ name: 'TEST', number: '+7900' }, BDN, 15));
|
||||||
|
});
|
||||||
|
|
||||||
|
// A control-string/extended-BCD dialling number (SSC): the decoder only sees
|
||||||
|
// the digit fragment, so an untouched number must not be rebuilt from it.
|
||||||
|
const SSC_ADN = '54455354' + 'FF'.repeat(12) + '04' + 'FF' + 'BA01B0' + 'FF'.repeat(7) + 'FF' + 'FF';
|
||||||
|
|
||||||
|
test('F1: an untouched SSC number block is copied verbatim', () => {
|
||||||
|
const hex = SSC_ADN;
|
||||||
|
assert.strictEqual(efBytes(hex).length, 30);
|
||||||
|
assert.deepStrictEqual(efDecAdn(efBytes(hex)), { name: 'TEST', number: '100' });
|
||||||
|
// name-only edit: the [length, TON/NPI, 10 BCD/SSC] block is untouched
|
||||||
|
const out = efEncAdn({ name: 'NEW', number: '100' }, hex);
|
||||||
|
assert.strictEqual(out.slice(0, 6), '4E4557');
|
||||||
|
assert.strictEqual(out.slice(32, 56), hex.toUpperCase().slice(32, 56));
|
||||||
|
// no edit at all: byte-identical
|
||||||
|
assert.strictEqual(efEncAdn(efDecAdn(efBytes(hex)), hex), hex.toUpperCase());
|
||||||
|
// an all-FF record stays all-FF (the old encoder wrote a 00 length byte)
|
||||||
|
assert.strictEqual(efEncAdn({}, 'FF'.repeat(64)), 'FF'.repeat(64));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F4: an untouched all-FF SPN stays all-FF', () => {
|
||||||
|
const ff = 'FF'.repeat(17);
|
||||||
|
// the model exposes the FF display bits (both set), not a blank object
|
||||||
|
assert.deepStrictEqual(efDecSpn(efBytes(ff)), { show_in_hplmn: true, hide_in_oplmn: true });
|
||||||
|
assert.strictEqual(efEncSpn(efDecSpn(efBytes(ff)), ff), ff);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F5: unchanged PLMN rows keep unmodelled ACT bits and codings', () => {
|
||||||
|
// 0x8C00: UTRAN + NG-RAN + the Rel-18 satellite bit the form does not model
|
||||||
|
const rec = '62F2108C00';
|
||||||
|
const dec = efDecPlmnWact(efBytes(rec));
|
||||||
|
assert.strictEqual(dec.plmns[0].access_tech, 'NG-RAN, UTRAN');
|
||||||
|
assert.strictEqual(efEncPlmnList(dec.plmns, rec), rec);
|
||||||
|
// editing the MCC keeps the original ACT bytes (not 0x8800)
|
||||||
|
assert.strictEqual(efEncPlmnList([{ mcc: '250', mnc: '01', access_tech: 'NG-RAN, UTRAN' }], rec),
|
||||||
|
'52F010' + '8C00');
|
||||||
|
// equal codings are not canonicalised either
|
||||||
|
assert.strictEqual(efEncPlmnList(efDecPlmnWact(efBytes('62F210008C')).plmns, '62F210008C'), '62F210008C');
|
||||||
|
assert.strictEqual(efEncPlmnList(efDecPlmnWact(efBytes('62F2107000')).plmns, '62F2107000'), '62F2107000');
|
||||||
|
// a changed ACT is encoded canonically
|
||||||
|
assert.strictEqual(efEncPlmnList([{ mcc: '262', mnc: '01', access_tech: 'NG-RAN' }], rec), '62F2100800');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F9: an emptied full name is refused while other TLVs remain', () => {
|
||||||
|
assert.throws(() => efEncPnn([{ tag: '43', text: '' }, { tag: '45', text: 'Short' }], PNN_1),
|
||||||
|
/full name \(43\) is required/);
|
||||||
|
assert.throws(() => efEncPnn([{ tag: '43', text: '' }, { tag: '80', hex: '00' }], PNN_1),
|
||||||
|
/full name/);
|
||||||
|
// the empty omission is only for a record that ends up with no items
|
||||||
|
assert.strictEqual(efEncPnn([{ tag: '43', text: '' }], 'FF'.repeat(8)), 'FF'.repeat(8));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F10: a TLV value longer than 255 bytes is refused', () => {
|
||||||
|
const big = '00'.repeat(256);
|
||||||
|
assert.throws(() => efEncPnn([{ tag: '80', hex: big }], 'FF'.repeat(40)), /longer than 255/);
|
||||||
|
assert.throws(() => efEncPnn([{ tag: '5A', hex: big }], 'FF'.repeat(40)), /longer than 255/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F14: the encoder messages go through t() (unknown technology, too many rows)', () => {
|
||||||
|
globalThis.t = k => (k === 'Unknown access technology' ? 'U'
|
||||||
|
: (k === 'Too many entries for this file' ? 'T' : k));
|
||||||
|
try {
|
||||||
|
assert.throws(() => efActEnc('BOGUS'), /^Error: U: BOGUS$/);
|
||||||
|
assert.throws(() => efEncPlmnList([
|
||||||
|
{ mcc: '262', mnc: '01', access_tech: 'GSM' },
|
||||||
|
{ mcc: '228', mnc: '06', access_tech: 'GSM' },
|
||||||
|
], '62F2100084'), /^Error: T \(1 max\)$/);
|
||||||
|
} finally {
|
||||||
|
delete globalThis.t;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unchanged content re-encodes byte-identically (all fixtures)', () => {
|
||||||
|
const decode = (name, hex) => {
|
||||||
|
const bytes = efBytes(hex);
|
||||||
|
if (name === 'EF.SPN') return efDecSpn(bytes);
|
||||||
|
if (name === 'EF.PLMNwAcT') return efDecPlmnWact(bytes).plmns;
|
||||||
|
if (name === 'EF.BDN') return efDecAdn(bytes, 15);
|
||||||
|
if (name === 'EF.ADN') return efDecAdn(bytes);
|
||||||
|
if (name === 'EF.PNN') return efDecItemsFromHex(efFindEncoder('EF.PNN', null), hex);
|
||||||
|
throw new Error('no decoder for ' + name);
|
||||||
|
};
|
||||||
|
const cases = [
|
||||||
|
{ name: 'EF.SPN', fid: '6f46', hex: SPN_ORIG },
|
||||||
|
{ name: 'EF.SPN', fid: '6f46', hex: 'FF'.repeat(17) },
|
||||||
|
{ name: 'EF.PLMNwAcT', fid: '6f60', hex: PLMN_ORIG },
|
||||||
|
{ name: 'EF.PLMNwAcT', fid: '6f60', hex: PLMN_ORIG + '22F8608000' },
|
||||||
|
{ name: 'EF.PLMNwAcT', fid: '6f60', hex: '62F2108C00' },
|
||||||
|
{ name: 'EF.PLMNwAcT', fid: '6f60', hex: '62F210008C' },
|
||||||
|
{ name: 'EF.PLMNwAcT', fid: '6f60', hex: '62F2107000' },
|
||||||
|
{ name: 'EF.ADN', fid: '6f3a', hex: ADN_1 },
|
||||||
|
{ name: 'EF.ADN', fid: '6f3a', hex: ADN_2 },
|
||||||
|
{ name: 'EF.ADN', fid: '6f3a', hex: SSC_ADN },
|
||||||
|
{ name: 'EF.ADN', fid: '6f3a', hex: 'FF'.repeat(64) },
|
||||||
|
{ name: 'EF.BDN', fid: '6f4d', hex: BDN },
|
||||||
|
{ name: 'EF.BDN', fid: '6f4d', hex: 'FF'.repeat(38) },
|
||||||
|
{ name: 'EF.PNN', fid: '6fc5', hex: PNN_1 },
|
||||||
|
{ name: 'EF.PNN', fid: '6fc5', hex: PNN_2 },
|
||||||
|
{ name: 'EF.PNN', fid: '6fc5', hex: '800400410042' + 'FF'.repeat(16) },
|
||||||
|
{ name: 'EF.PNN', fid: '6fc5', hex: '430a82d432bbbc7eb75de4325a02aabb' + 'ff'.repeat(14) },
|
||||||
|
];
|
||||||
|
for (const c of cases) {
|
||||||
|
const entry = efFindEncoder(c.name, c.fid);
|
||||||
|
assert.strictEqual(entry.enc(decode(c.name, c.hex), c.hex), c.hex.toUpperCase(),
|
||||||
|
c.name + ' ' + c.hex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F2: low-byte packed, AddCI and UCS-2-padded names round-trip unchanged', () => {
|
||||||
|
const entry = efFindEncoder('EF.PNN', null);
|
||||||
|
// 0x82 spare 2, packed 41 21 = 'AB' (the old all-low heuristic read 'A!')
|
||||||
|
assert.strictEqual(efPnnText(efBytes('824121')), 'AB');
|
||||||
|
const records = [
|
||||||
|
'4303824121' + 'FF'.repeat(28), // low-byte packed 'AB'
|
||||||
|
'43038A4121' + 'FF'.repeat(28), // the same with the AddCI bit
|
||||||
|
'43079000410042FFFF' + 'FF'.repeat(20), // UCS-2 'AB' with FF padding
|
||||||
|
];
|
||||||
|
for (const hex of records) {
|
||||||
|
const items = efDecItemsFromHex(entry, hex);
|
||||||
|
assert.strictEqual(efEncPnn(items, hex), hex, hex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F2: editing one PNN name leaves the other TLV byte-identical', () => {
|
||||||
|
const entry = efFindEncoder('EF.PNN', null);
|
||||||
|
// 43 carries the AddCI-bit packing; 45 is a plain packed name
|
||||||
|
const hex = '43038A4121' + '4503824222' + 'FF'.repeat(24);
|
||||||
|
const items = efDecItemsFromHex(entry, hex);
|
||||||
|
assert.deepStrictEqual(items.map(i => i.text), ['AB', 'BD']);
|
||||||
|
const edited = items.map(i => i.tag === '43' ? { tag: i.tag, text: 'Z', orig: i.orig } : i);
|
||||||
|
const out = efEncPnn(edited, hex);
|
||||||
|
assert.ok(out.includes('4503824222'), 'the 45 TLV is unchanged');
|
||||||
|
assert.strictEqual(efDecItemsFromHex(entry, out)[1].text, 'BD');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F8: the ADN-family entries name their extension EF and use it in refusals', () => {
|
||||||
|
const names = ['EF.ADN', 'EF.FDN', 'EF.SDN', 'EF.MSISDN', 'EF.LND', 'EF.BDN'];
|
||||||
|
assert.deepStrictEqual(names.map(n => efFindEncoder(n, null).ext),
|
||||||
|
['EXT1', 'EXT2', 'EXT3', 'EXT5', 'EXT1', 'EXT4']);
|
||||||
|
// a non-FF extension slot refuses a changed number with its own EF name
|
||||||
|
const fdn = ADN_1.slice(0, -2) + '03'; // EXT2 set
|
||||||
|
assert.throws(() => efFindEncoder('EF.FDN', null).enc(
|
||||||
|
{ name: 'B HA 1 Sic', number: '6082658002' }, fdn), /EF\.EXT2/);
|
||||||
|
const sdn = ADN_1.slice(0, -2) + '03';
|
||||||
|
assert.throws(() => efFindEncoder('EF.SDN', null).enc(
|
||||||
|
{ name: 'B HA 1 Sic', number: '6082658002' }, sdn), /EF\.EXT3/);
|
||||||
|
const msisdn = ADN_1.slice(0, -2) + '03';
|
||||||
|
assert.throws(() => efFindEncoder('EF.MSISDN', null).enc(
|
||||||
|
{ name: 'B HA 1 Sic', number: '6082658002' }, msisdn), /EF\.EXT5/);
|
||||||
|
// BDN: the slot before the CMI pointer is EXT4
|
||||||
|
const bdnExt4 = '54455354' + '06' + '91' + '2143658709' + 'FF'.repeat(6) + '04' + '03';
|
||||||
|
assert.throws(() => efEncAdn({ name: 'TEST', number: '+79001234567' }, bdnExt4, 15, 'EXT4'), /EF\.EXT4/);
|
||||||
|
// the unchanged number still copies the extension slots verbatim
|
||||||
|
assert.strictEqual(efEncAdn(efDecAdn(efBytes(fdn)), fdn, 14, 'EXT2'), fdn.toUpperCase());
|
||||||
|
});
|
||||||
@@ -0,0 +1,655 @@
|
|||||||
|
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('(?:async\\s+)?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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractConst(src, name) {
|
||||||
|
const m = new RegExp('const ' + name + ' = ').exec(src);
|
||||||
|
if (!m) throw new Error('const ' + name + ' not found');
|
||||||
|
const begin = m.index + m[0].length;
|
||||||
|
const opener = src[begin];
|
||||||
|
const closer = opener === '[' ? ']' : (opener === '{' ? '}' : null);
|
||||||
|
if (!closer) throw new Error('unsupported const ' + name);
|
||||||
|
let depth = 0, i = begin;
|
||||||
|
for (; i < src.length; i++) {
|
||||||
|
if (src[i] === opener) depth++;
|
||||||
|
else if (src[i] === closer) {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) { i++; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'var ' + name + ' = ' + src.slice(begin, i) + ';\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
const FNS = [
|
||||||
|
'efBytes', 'efHex', 'efAllFf', 'efRstripFf', 'efUcs2Be', 'efGsm7Octets',
|
||||||
|
'efAnnexA', 'efBcdAddress', 'efPlmn', 'efPlmnAct', 'efDecSpn', 'efDecPlmnWact',
|
||||||
|
'efDecAdn', 'efFindDecoder', 'efDecodeBytes', 'efIsRawOnly', 'efTlv',
|
||||||
|
'efPnnText', 'efDecItemsFromHex',
|
||||||
|
'efGsm7PlainText', 'efEncTextBytes', 'efPlmnEnc', 'efActEnc', 'efEncSpn',
|
||||||
|
'efEncPlmnList', 'efPlmnRowOrig', 'efEncAdn', 'efEncAdnExt', 'efPnnNameBytes', 'efPnnHexBytes', 'efPnnOrigTlv', 'efEncPnn',
|
||||||
|
'efFindEncoder', 'efHasEncoder', 'efEncError',
|
||||||
|
'efDecModelValues', 'efDecEditModel', 'efActTechList', 'efDecFieldHtml',
|
||||||
|
'efDecRowHtml', 'efDecGridHtml', 'efDecItemRowHtml', 'efDecItemsHtml',
|
||||||
|
'efDecRecordsHtml', 'efDecEditHtml',
|
||||||
|
'efDecCollectValues', 'efDecCollectRows', 'efDecCollectItems',
|
||||||
|
'pysimFsDecRowCheck', 'pysimFsDecItemTag', 'pysimFsDecAddItem',
|
||||||
|
'pysimFsDecRemoveItem', 'pysimFsFindNode',
|
||||||
|
'pysimFsHasDecoder', 'pysimFsHasEncoder', 'pysimFsDecSave', 'pysimFsDecEdit',
|
||||||
|
'pysimFsPillsEnabled', 'pysimFsSyncContentVisibility', 'gsm7Decode',
|
||||||
|
'gsm7TextToSeptets', 'gsm7Encode',
|
||||||
|
];
|
||||||
|
|
||||||
|
let code = 'var pysimFsSelected = null;\nvar pysimFsTreeRoot = null;\nvar pysimFsDecModel = null;\n'
|
||||||
|
+ 'var pysimFsDecEditMode = false;\nvar pysimFsEditMode = false;\n';
|
||||||
|
for (const f of FNS) code += extractFunc(html, f) + '\n';
|
||||||
|
for (const c of ['GSM7_ALPHABET', 'GSM7_EXT_MAP', 'EF_ACT_TECHS', 'EF_SPN_FIELDS',
|
||||||
|
'EF_ADN_FIELDS', 'EF_PLMN_LIST', 'EF_PNN_ITEMS', 'EF_ENCODERS']) {
|
||||||
|
code += extractConst(html, c);
|
||||||
|
}
|
||||||
|
eval(code);
|
||||||
|
|
||||||
|
// The decoder entries these files resolve to (the full EF_DECODERS registry is
|
||||||
|
// pinned by ef_decode.test.js; only the model's lookup path is exercised here).
|
||||||
|
var EF_DECODERS = [
|
||||||
|
{ name: 'EF.SPN', fid: '6f46', fn: efDecSpn },
|
||||||
|
{ name: 'EF.PLMNwAcT', fid: '6f60', fn: efDecPlmnWact },
|
||||||
|
{ name: 'EF.OPLMNwAcT', fid: '6f61', fn: efDecPlmnWact },
|
||||||
|
{ name: 'EF.HPLMNwAcT', fid: '6f62', fn: efDecPlmnWact },
|
||||||
|
{ 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: b => efDecAdn(b, 15) },
|
||||||
|
];
|
||||||
|
|
||||||
|
globalThis.t = k => k;
|
||||||
|
globalThis.esc = s => String(s);
|
||||||
|
|
||||||
|
const SPN_ORIG = '0147534d2d52204348ffffffffffffffff';
|
||||||
|
const ADN_1 = '42204841203120536963FFFFFFFFFFFF06810628560810FFFFFFFFFFFFFF';
|
||||||
|
const ADN_FF = 'FF'.repeat(64);
|
||||||
|
const PLMN_ORIG = '62F210008422F8608000';
|
||||||
|
|
||||||
|
test('efFindEncoder / efHasEncoder resolve by name and FID, like the decoders', () => {
|
||||||
|
assert.strictEqual(efFindEncoder('EF.SPN', null).fid, '6f46');
|
||||||
|
assert.strictEqual(efFindEncoder('ef.plmnwact', null).name, 'EF.PLMNwAcT');
|
||||||
|
assert.strictEqual(efFindEncoder('MF/7F10/6F3A/EF.ADN', null).name, 'EF.ADN');
|
||||||
|
assert.strictEqual(efFindEncoder('EF.ALIAS', '6f40').name, 'EF.MSISDN');
|
||||||
|
assert.strictEqual(efFindEncoder('EF.IMSI', '6f07'), null);
|
||||||
|
assert.strictEqual(efFindEncoder(null, null), null);
|
||||||
|
assert.strictEqual(efHasEncoder('EF.SDN', null), true);
|
||||||
|
assert.strictEqual(efHasEncoder('EF.IMSI', null), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the model is a TR value set for SPN / PLMNwAcT and record rows for ADN', () => {
|
||||||
|
const spn = efDecEditModel('EF.SPN', null, { kind: 'transparent', data: SPN_ORIG });
|
||||||
|
assert.strictEqual(spn.mode, 'tr');
|
||||||
|
assert.strictEqual(spn.entry.name, 'EF.SPN');
|
||||||
|
assert.deepStrictEqual(spn.values, { show_in_hplmn: true, hide_in_oplmn: false, name: 'GSM-R CH' });
|
||||||
|
// an all-FF SPN exposes its FF display bits (both set, no name), so an
|
||||||
|
// untouched save keeps the file byte-identical
|
||||||
|
const emptySpn = efDecEditModel('EF.SPN', '6f46', { kind: 'transparent', data: 'FF'.repeat(17) });
|
||||||
|
assert.deepStrictEqual(emptySpn.values, { show_in_hplmn: true, hide_in_oplmn: true });
|
||||||
|
const plmn = efDecEditModel('EF.PLMNwAcT', null, { kind: 'transparent', data: PLMN_ORIG });
|
||||||
|
assert.strictEqual(plmn.values.plmns.length, 2);
|
||||||
|
assert.strictEqual(plmn.values.plmns[0].mcc, '262');
|
||||||
|
const adn = efDecEditModel('EF.ADN', null, { kind: 'record', records: [
|
||||||
|
{ num: 1, data: ADN_1 },
|
||||||
|
{ num: 2, data: ADN_FF },
|
||||||
|
] });
|
||||||
|
assert.strictEqual(adn.mode, 'records');
|
||||||
|
assert.strictEqual(adn.records.length, 2);
|
||||||
|
assert.strictEqual(adn.records[0].num, 1);
|
||||||
|
assert.strictEqual(adn.records[0].orig, ADN_1);
|
||||||
|
assert.strictEqual(adn.records[0].values.name, 'B HA 1 Sic');
|
||||||
|
assert.strictEqual(adn.records[0].values.number, '6082658001');
|
||||||
|
assert.deepStrictEqual(adn.records[1].values, {});
|
||||||
|
assert.strictEqual(adn.records[1].orig, ADN_FF);
|
||||||
|
// no encoder -> no model, even when a decoder exists
|
||||||
|
assert.strictEqual(efDecEditModel('EF.IMSI', null, { kind: 'transparent', data: '082982608200002080' }), null);
|
||||||
|
assert.strictEqual(efDecEditModel('EF.NOPE', 'abcd', { kind: 'transparent', data: '00' }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the TR form is a field grid carrying data-key inputs and the original hex', () => {
|
||||||
|
const model = efDecEditModel('EF.SPN', null, { kind: 'transparent', data: SPN_ORIG });
|
||||||
|
const out = efDecEditHtml(model, SPN_ORIG);
|
||||||
|
assert.match(out, /class="pysim-dec-edit"/);
|
||||||
|
assert.match(out, /data-mode="tr"/);
|
||||||
|
assert.ok(out.includes('data-orig="' + SPN_ORIG + '"'));
|
||||||
|
assert.match(out, /data-key="show_in_hplmn" checked/);
|
||||||
|
assert.match(out, /data-key="hide_in_oplmn"/);
|
||||||
|
assert.match(out, /data-key="name" value="GSM-R CH"/);
|
||||||
|
assert.strictEqual((out.match(/class="pysim-dec-item /g) || []).length, 3);
|
||||||
|
assert.doesNotMatch(out, /pysim-fs-record-cb/);
|
||||||
|
// the form's booleans/labels go through t() and the labels are the spec
|
||||||
|
// bit semantics (TS 31.102 4.2.12)
|
||||||
|
assert.match(out, /Display the network name in HPLMN \/ an SPDI PLMN/);
|
||||||
|
assert.match(out, /Hide the service provider name outside HPLMN \/ the SPDI PLMNs/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the TR list form is a row editor with per-row remove and an Add button', () => {
|
||||||
|
const model = efDecEditModel('EF.PLMNwAcT', null, { kind: 'transparent', data: PLMN_ORIG });
|
||||||
|
const out = efDecEditHtml(model, PLMN_ORIG);
|
||||||
|
assert.match(out, /class="pysim-dec-edit"/);
|
||||||
|
assert.strictEqual((out.match(/class="pysim-dec-row /g) || []).length, 2);
|
||||||
|
assert.strictEqual((out.match(/class="pysim-dec-remove/g) || []).length, 2);
|
||||||
|
assert.ok(out.includes('✕'));
|
||||||
|
assert.match(out, /onclick="pysimFsDecAddRow\(\)"/);
|
||||||
|
assert.match(out, />Add network</);
|
||||||
|
assert.match(out, /data-key="mcc" value="262"/);
|
||||||
|
assert.match(out, /data-key="mnc" value="01"/);
|
||||||
|
assert.strictEqual((out.match(/data-tech="GSM" checked/g) || []).length, 1);
|
||||||
|
assert.strictEqual((out.match(/data-tech="UTRAN" checked/g) || []).length, 1);
|
||||||
|
assert.match(out, /data-tech="NG-RAN"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the record form is one block per record with a hidden selection checkbox', () => {
|
||||||
|
const model = efDecEditModel('EF.ADN', null, { kind: 'record', records: [
|
||||||
|
{ num: 1, data: ADN_1 },
|
||||||
|
{ num: 2, data: ADN_FF },
|
||||||
|
] });
|
||||||
|
const out = efDecEditHtml(model);
|
||||||
|
assert.match(out, /data-mode="records"/);
|
||||||
|
assert.strictEqual((out.match(/class="pysim-dec-rec /g) || []).length, 2);
|
||||||
|
assert.match(out, /data-rec="1" data-orig="4220/);
|
||||||
|
assert.match(out, /data-rec="2" data-orig="FFFF/);
|
||||||
|
assert.strictEqual((out.match(/class="pysim-fs-record-cb shrink-0 hidden"/g) || []).length, 2);
|
||||||
|
assert.match(out, /Record 1/);
|
||||||
|
assert.match(out, /Record 2 — empty/);
|
||||||
|
assert.match(out, /data-key="name" value="B HA 1 Sic"/);
|
||||||
|
assert.match(out, /data-key="number" value="6082658001"/);
|
||||||
|
// every input auto-checks its record block
|
||||||
|
assert.match(out, /oninput="pysimFsDecRowCheck\(this\)"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
function fakeRoot(inputs, techs) {
|
||||||
|
return {
|
||||||
|
querySelector: sel => {
|
||||||
|
const m = /^\[data-key="(.+)"\]$/.exec(sel);
|
||||||
|
return (m && inputs[m[1]]) || null;
|
||||||
|
},
|
||||||
|
querySelectorAll: sel => sel === '[data-tech]' ? (techs || []) : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('efDecCollectValues reads the DOM inputs back into the decoder shape', () => {
|
||||||
|
const spn = efDecCollectValues(fakeRoot({
|
||||||
|
show_in_hplmn: { checked: true },
|
||||||
|
hide_in_oplmn: { checked: false },
|
||||||
|
name: { value: 'GSM-R CH' },
|
||||||
|
}), EF_SPN_FIELDS);
|
||||||
|
assert.deepStrictEqual(spn, { show_in_hplmn: true, hide_in_oplmn: false, name: 'GSM-R CH' });
|
||||||
|
// a missing boolean reads false, a missing text field ''
|
||||||
|
assert.deepStrictEqual(efDecCollectValues(fakeRoot({}), EF_ADN_FIELDS), { name: '', number: '' });
|
||||||
|
const row = efDecCollectValues(fakeRoot({
|
||||||
|
mcc: { value: '262' },
|
||||||
|
mnc: { value: '01' },
|
||||||
|
}, [
|
||||||
|
{ checked: true, getAttribute: () => 'GSM' },
|
||||||
|
{ checked: true, getAttribute: () => 'UTRAN' },
|
||||||
|
{ checked: false, getAttribute: () => 'NG-RAN' },
|
||||||
|
]), EF_PLMN_LIST.itemFields);
|
||||||
|
assert.deepStrictEqual(row, { mcc: '262', mnc: '01', access_tech: 'GSM, UTRAN' });
|
||||||
|
assert.deepStrictEqual(efDecCollectValues(null, EF_ADN_FIELDS), {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('efDecCollectRows reads the PLMN row editor back into the decoder shape', () => {
|
||||||
|
const rows = [
|
||||||
|
fakeRoot({ mcc: { value: '262' }, mnc: { value: '01' } }, [{ checked: true, getAttribute: () => 'GSM' }]),
|
||||||
|
fakeRoot({ mcc: { value: '228' }, mnc: { value: '06' } }, [{ checked: true, getAttribute: () => 'UTRAN' }]),
|
||||||
|
];
|
||||||
|
const out = efDecCollectRows({ querySelectorAll: sel => sel === '.pysim-dec-row' ? rows : [] }, EF_PLMN_LIST.itemFields);
|
||||||
|
assert.deepStrictEqual(out, [
|
||||||
|
{ mcc: '262', mnc: '01', access_tech: 'GSM' },
|
||||||
|
{ mcc: '228', mnc: '06', access_tech: 'UTRAN' },
|
||||||
|
]);
|
||||||
|
assert.deepStrictEqual(efDecCollectRows(null, EF_PLMN_LIST.itemFields), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changing a record input checks its block', () => {
|
||||||
|
const cb = { checked: false };
|
||||||
|
const rec = { querySelector: sel => sel === '.pysim-fs-record-cb' ? cb : null };
|
||||||
|
const el = { closest: sel => sel === '.pysim-dec-rec' ? rec : null };
|
||||||
|
pysimFsDecRowCheck(el);
|
||||||
|
assert.strictEqual(cb.checked, true);
|
||||||
|
pysimFsDecRowCheck(null);
|
||||||
|
assert.strictEqual(cb.checked, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pysimFsHasEncoder resolves the selected file by name or FID', () => {
|
||||||
|
pysimFsTreeRoot = { name: 'MF', children: [{ name: 'EF.SPN', fid: '6f46' }, { name: 'EF.ALIAS', fid: '6f3a' }] };
|
||||||
|
pysimFsSelected = 'EF.SPN';
|
||||||
|
assert.strictEqual(pysimFsHasEncoder(), true);
|
||||||
|
pysimFsSelected = 'EF.ALIAS';
|
||||||
|
assert.strictEqual(pysimFsHasEncoder(), true);
|
||||||
|
pysimFsSelected = '6F46';
|
||||||
|
assert.strictEqual(pysimFsHasEncoder(), true);
|
||||||
|
pysimFsSelected = 'EF.IMSI';
|
||||||
|
assert.strictEqual(pysimFsHasEncoder(), false);
|
||||||
|
pysimFsSelected = null;
|
||||||
|
assert.strictEqual(pysimFsHasEncoder(), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the Edit decoded button is encoder-gated next to Edit raw', () => {
|
||||||
|
const btn = /<button[^>]*id="pysim-fs-edit-dec-btn"[^>]*>/.exec(html);
|
||||||
|
assert.ok(btn, 'Edit decoded button');
|
||||||
|
assert.ok(btn[0].includes('data-needs="card"'));
|
||||||
|
assert.ok(btn[0].includes('data-needs-encoder'));
|
||||||
|
assert.ok(btn[0].includes('onclick="pysimFsDecEdit()"'));
|
||||||
|
assert.match(html, /data-l10n="Edit decoded">Edit decoded</);
|
||||||
|
assert.ok(html.indexOf('id="pysim-fs-edit-btn"') < html.indexOf('id="pysim-fs-edit-dec-btn"'));
|
||||||
|
const avail = extractFunc(html, 'pysimApplyAvailability');
|
||||||
|
assert.ok(avail.includes("hasAttribute('data-needs-encoder')"));
|
||||||
|
assert.ok(avail.includes("t('No decoded editor for this file')"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the edit flow reads raw, renders the model and saves only checked records', () => {
|
||||||
|
const edit = extractFunc(html, 'pysimFsDecEdit');
|
||||||
|
assert.ok(edit.includes("body.mode = 'raw'"));
|
||||||
|
assert.ok(edit.includes('efDecEditModel'));
|
||||||
|
assert.ok(edit.includes('efDecEditHtml'));
|
||||||
|
assert.ok(edit.includes('pysimFsDecEditMode = true'));
|
||||||
|
assert.ok(edit.includes("querySelectorAll('.pysim-fs-record-cb')"));
|
||||||
|
|
||||||
|
const save = extractFunc(html, 'pysimFsSave');
|
||||||
|
assert.ok(save.includes('pysimFsDecEditMode'));
|
||||||
|
assert.ok(save.includes('pysimFsDecSave'));
|
||||||
|
|
||||||
|
const decSave = extractFunc(html, 'pysimFsDecSave');
|
||||||
|
assert.ok(decSave.includes("pysimFetch('/api/write'"));
|
||||||
|
assert.ok(decSave.includes("querySelectorAll('.pysim-dec-rec')"));
|
||||||
|
assert.ok(decSave.includes("querySelector('.pysim-fs-record-cb')"));
|
||||||
|
assert.ok(decSave.includes('if (cb && !cb.checked) continue;'), 'only checked records are written');
|
||||||
|
assert.ok(decSave.includes('body.record_nr = parseInt(num)'));
|
||||||
|
assert.ok(decSave.includes('entry.enc('));
|
||||||
|
assert.ok(decSave.includes('efDecCollectRows(root, entry.list.itemFields)'));
|
||||||
|
assert.ok(decSave.includes("getAttribute('data-orig')"));
|
||||||
|
assert.ok(decSave.includes('_pysimArrCache = null'));
|
||||||
|
|
||||||
|
const add = extractFunc(html, 'pysimFsDecAddRow');
|
||||||
|
assert.ok(add.includes('efDecRowHtml'));
|
||||||
|
const remove = extractFunc(html, 'pysimFsDecRemoveRow');
|
||||||
|
assert.ok(remove.includes("closest('.pysim-dec-row')"));
|
||||||
|
});
|
||||||
|
|
||||||
|
function fakeRecordBlock(num, orig, inputs, checked) {
|
||||||
|
return {
|
||||||
|
querySelector: sel => {
|
||||||
|
if (sel === '.pysim-fs-record-cb') return { checked: checked };
|
||||||
|
const m = /^\[data-key="(.+)"\]$/.exec(sel);
|
||||||
|
return (m && inputs[m[1]]) || null;
|
||||||
|
},
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
getAttribute: k => k === 'data-rec' ? String(num) : (k === 'data-orig' ? orig : null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveStubs(result) {
|
||||||
|
const bodies = [];
|
||||||
|
globalThis.pysimFetch = async (path, body) => {
|
||||||
|
bodies.push({ path: path, body: body });
|
||||||
|
return typeof result === 'function' ? result(path, body) : result;
|
||||||
|
};
|
||||||
|
globalThis.pysimFsSelectBody = () => ({ name: 'EF.ADN' });
|
||||||
|
globalThis.pysimFsShowError = (el, sw, err) => { el.textContent = 'SW: ' + sw + ' — ' + err; };
|
||||||
|
let cancelled = 0;
|
||||||
|
globalThis.pysimFsCancel = () => { cancelled++; };
|
||||||
|
return { bodies: bodies, cancelled: () => cancelled };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the decoded save writes a TR list as one re-encoded /api/write', async () => {
|
||||||
|
const entry = efFindEncoder('EF.PLMNwAcT', null);
|
||||||
|
pysimFsDecModel = { entry: entry, mode: 'tr' };
|
||||||
|
const rows = [
|
||||||
|
fakeRoot({ mcc: { value: '262' }, mnc: { value: '01' } }, [{ checked: true, getAttribute: () => 'GSM' }]),
|
||||||
|
fakeRoot({ mcc: { value: '228' }, mnc: { value: '06' } }, [{ checked: true, getAttribute: () => 'UTRAN' }]),
|
||||||
|
];
|
||||||
|
const root = {
|
||||||
|
getAttribute: k => k === 'data-orig' ? PLMN_ORIG : null,
|
||||||
|
querySelectorAll: sel => sel === '.pysim-dec-row' ? rows : [],
|
||||||
|
};
|
||||||
|
const out = { querySelector: sel => sel === '.pysim-dec-edit' ? root : null, querySelectorAll: () => [] };
|
||||||
|
const stubs = saveStubs({ success: true, sw: '9000' });
|
||||||
|
const status = { textContent: '' };
|
||||||
|
await pysimFsDecSave(null, 'EF.PLMNwAcT', status, out, false);
|
||||||
|
assert.strictEqual(stubs.bodies.length, 1);
|
||||||
|
assert.strictEqual(stubs.bodies[0].path, '/api/write');
|
||||||
|
assert.strictEqual(stubs.bodies[0].body.data, PLMN_ORIG);
|
||||||
|
assert.strictEqual(stubs.bodies[0].body.record_nr, undefined);
|
||||||
|
assert.strictEqual(status.textContent, 'SW: 9000 OK');
|
||||||
|
assert.strictEqual(stubs.cancelled(), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the decoded save writes only the checked ADN records, with record_nr', async () => {
|
||||||
|
const entry = efFindEncoder('EF.ADN', null);
|
||||||
|
pysimFsDecModel = { entry: entry, mode: 'records' };
|
||||||
|
const blocks = [
|
||||||
|
fakeRecordBlock(1, ADN_1, { name: { value: 'Ann' }, number: { value: '123' } }, true),
|
||||||
|
fakeRecordBlock(2, ADN_FF, { name: { value: '' }, number: { value: '' } }, false),
|
||||||
|
];
|
||||||
|
const out = { querySelector: () => null, querySelectorAll: sel => sel === '.pysim-dec-rec' ? blocks : [] };
|
||||||
|
const stubs = saveStubs({ success: true, sw: '9000' });
|
||||||
|
const status = { textContent: '' };
|
||||||
|
await pysimFsDecSave(null, 'EF.ADN', status, out, false);
|
||||||
|
assert.strictEqual(stubs.bodies.length, 1);
|
||||||
|
assert.strictEqual(stubs.bodies[0].body.record_nr, 1);
|
||||||
|
assert.strictEqual(stubs.bodies[0].body.data, efEncAdn({ name: 'Ann', number: '123' }, ADN_1));
|
||||||
|
assert.strictEqual(status.textContent, 'SW: 9000 OK');
|
||||||
|
assert.strictEqual(stubs.cancelled(), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no checked record means no write; a failed write stops at that record', async () => {
|
||||||
|
const entry = efFindEncoder('EF.ADN', null);
|
||||||
|
pysimFsDecModel = { entry: entry, mode: 'records' };
|
||||||
|
const off = [fakeRecordBlock(1, ADN_1, { name: { value: 'Ann' }, number: { value: '1' } }, false)];
|
||||||
|
const out = { querySelector: () => null, querySelectorAll: sel => sel === '.pysim-dec-rec' ? off : [] };
|
||||||
|
const stubs = saveStubs({ success: true, sw: '9000' });
|
||||||
|
const status = { textContent: '' };
|
||||||
|
await pysimFsDecSave(null, 'EF.ADN', status, out, false);
|
||||||
|
assert.strictEqual(stubs.bodies.length, 0);
|
||||||
|
assert.match(status.textContent, /No records selected/);
|
||||||
|
assert.strictEqual(stubs.cancelled(), 0);
|
||||||
|
|
||||||
|
const on = [fakeRecordBlock(1, ADN_1, { name: { value: 'Ann' }, number: { value: '1' } }, true)];
|
||||||
|
const out2 = { querySelector: () => null, querySelectorAll: sel => sel === '.pysim-dec-rec' ? on : [] };
|
||||||
|
const stubs2 = saveStubs(() => ({ success: false, sw: '6982', error: 'Security status not satisfied' }));
|
||||||
|
const status2 = { textContent: '' };
|
||||||
|
await pysimFsDecSave(null, 'EF.ADN', status2, out2, false);
|
||||||
|
assert.strictEqual(stubs2.bodies.length, 1);
|
||||||
|
assert.match(status2.textContent, /6982/);
|
||||||
|
assert.match(status2.textContent, /record 1/);
|
||||||
|
assert.strictEqual(stubs2.cancelled(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// EF.PNN: the fourth shape - a record is a list of TLVs.
|
||||||
|
const PNN_1 = '430a82d432bbbc7eb75de432450a82d432bbbc7eb75de432ffffffff';
|
||||||
|
|
||||||
|
test('the model builds per-record item lists for an items encoder', () => {
|
||||||
|
const model = efDecEditModel('EF.PNN', '6fc5', { kind: 'record', records: [
|
||||||
|
{ num: 1, data: PNN_1 },
|
||||||
|
{ num: 2, data: ADN_FF },
|
||||||
|
] });
|
||||||
|
assert.strictEqual(model.mode, 'records');
|
||||||
|
assert.deepStrictEqual(model.records[0].items, [
|
||||||
|
{ tag: '43', text: 'Telekom.de', orig: '430A82D432BBBC7EB75DE432' },
|
||||||
|
{ tag: '45', text: 'Telekom.de', orig: '450A82D432BBBC7EB75DE432' },
|
||||||
|
]);
|
||||||
|
assert.strictEqual(model.records[0].values, undefined);
|
||||||
|
assert.deepStrictEqual(model.records[1].items, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the items form renders a tag select, the value input, remove and Add TLV per record', () => {
|
||||||
|
const model = efDecEditModel('EF.PNN', '6fc5', { kind: 'record', records: [
|
||||||
|
{ num: 1, data: PNN_1 },
|
||||||
|
{ num: 2, data: ADN_FF },
|
||||||
|
] });
|
||||||
|
const out = efDecEditHtml(model);
|
||||||
|
assert.strictEqual((out.match(/class="pysim-dec-item-row /g) || []).length, 2);
|
||||||
|
assert.strictEqual((out.match(/class="pysim-dec-items /g) || []).length, 2);
|
||||||
|
assert.strictEqual((out.match(/data-itag/g) || []).length, 2);
|
||||||
|
assert.strictEqual((out.match(/class="pysim-dec-remove /g) || []).length, 2);
|
||||||
|
assert.strictEqual((out.match(/class="pysim-dec-add /g) || []).length, 2);
|
||||||
|
assert.match(out, /data-tag="43"/);
|
||||||
|
assert.match(out, /data-tag="45"/);
|
||||||
|
assert.match(out, /data-ikey="text" value="Telekom.de"/);
|
||||||
|
assert.match(out, /<option value="43" data-kind="name" selected>43 Full name<\/option>/);
|
||||||
|
assert.match(out, />Add TLV</);
|
||||||
|
// the empty record still gets its own Add TLV button
|
||||||
|
assert.match(out, /Record 2 — empty/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unknown tag in an items record stays read-only without a remove button', () => {
|
||||||
|
const hex = '430a82d432bbbc7eb75de4325a02aabb' + 'ff'.repeat(14);
|
||||||
|
const model = efDecEditModel('EF.PNN', '6fc5', { kind: 'record', records: [{ num: 1, data: hex }] });
|
||||||
|
const out = efDecEditHtml(model);
|
||||||
|
assert.match(out, /data-tag="5A"/);
|
||||||
|
assert.strictEqual((out.match(/data-itag/g) || []).length, 1, 'the raw row has no tag select');
|
||||||
|
assert.match(out, /data-ikey="hex" value="AABB" readonly/);
|
||||||
|
assert.strictEqual((out.match(/class="pysim-dec-remove /g) || []).length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
function fakeItemRow(tag, kind, value) {
|
||||||
|
return {
|
||||||
|
getAttribute: k => k === 'data-tag' ? tag : null,
|
||||||
|
querySelector: sel => sel === '[data-ikey]' ? { getAttribute: () => kind, value: value } : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('efDecCollectItems reads the TLV rows in DOM order', () => {
|
||||||
|
const rows = [
|
||||||
|
fakeItemRow('43', 'text', 'Name'),
|
||||||
|
fakeItemRow('80', 'hex', '00410042'),
|
||||||
|
fakeItemRow('5A', 'hex', 'AABB'),
|
||||||
|
];
|
||||||
|
const root = { querySelectorAll: sel => sel === '.pysim-dec-item-row' ? rows : [] };
|
||||||
|
assert.deepStrictEqual(efDecCollectItems(root), [
|
||||||
|
{ tag: '43', text: 'Name' },
|
||||||
|
{ tag: '80', hex: '00410042' },
|
||||||
|
{ tag: '5A', hex: 'AABB' },
|
||||||
|
]);
|
||||||
|
assert.deepStrictEqual(efDecCollectItems(null), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the decoded save encodes item records through the entry encoder', async () => {
|
||||||
|
const entry = efFindEncoder('EF.PNN', null);
|
||||||
|
pysimFsDecModel = { entry: entry, mode: 'records' };
|
||||||
|
const rows = [
|
||||||
|
fakeItemRow('43', 'text', 'Telekom.de'),
|
||||||
|
fakeItemRow('80', 'hex', '00410042'),
|
||||||
|
];
|
||||||
|
const block = {
|
||||||
|
querySelector: sel => sel === '.pysim-fs-record-cb' ? { checked: true } : null,
|
||||||
|
querySelectorAll: sel => sel === '.pysim-dec-item-row' ? rows : [],
|
||||||
|
getAttribute: k => k === 'data-rec' ? '1' : (k === 'data-orig' ? PNN_1 : null),
|
||||||
|
};
|
||||||
|
const out = { querySelector: () => null, querySelectorAll: sel => sel === '.pysim-dec-rec' ? [block] : [] };
|
||||||
|
const stubs = saveStubs({ success: true, sw: '9000' });
|
||||||
|
const status = { textContent: '' };
|
||||||
|
await pysimFsDecSave(null, 'EF.PNN', status, out, false);
|
||||||
|
assert.strictEqual(stubs.bodies.length, 1);
|
||||||
|
assert.strictEqual(stubs.bodies[0].body.record_nr, 1);
|
||||||
|
assert.strictEqual(stubs.bodies[0].body.data,
|
||||||
|
efEncPnn([{ tag: '43', text: 'Telekom.de' }, { tag: '80', hex: '00410042' }], PNN_1));
|
||||||
|
assert.strictEqual(status.textContent, 'SW: 9000 OK');
|
||||||
|
assert.strictEqual(stubs.cancelled(), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the item add/remove/tag handlers are wired to the record', () => {
|
||||||
|
const add = extractFunc(html, 'pysimFsDecAddItem');
|
||||||
|
assert.ok(add.includes("querySelector('.pysim-dec-items')"));
|
||||||
|
assert.ok(add.includes('efDecItemRowHtml'));
|
||||||
|
assert.ok(add.includes('pysimFsDecRowCheck'));
|
||||||
|
const rem = extractFunc(html, 'pysimFsDecRemoveItem');
|
||||||
|
assert.ok(rem.includes("closest('.pysim-dec-item-row')"));
|
||||||
|
assert.ok(rem.includes('pysimFsDecRowCheck'));
|
||||||
|
const tag = extractFunc(html, 'pysimFsDecItemTag');
|
||||||
|
assert.ok(tag.includes("setAttribute('data-tag'"));
|
||||||
|
assert.ok(tag.includes("setAttribute('data-ikey'"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the BDN model decodes the X+15 layout and keeps the CMI pointer out of the form', () => {
|
||||||
|
const BDN = '54455354' + '06' + '91' + '2143658709' + 'FF'.repeat(7) + '03';
|
||||||
|
const model = efDecEditModel('EF.BDN', '6f4d', { kind: 'record', records: [{ num: 1, data: BDN }] });
|
||||||
|
assert.deepStrictEqual(model.records[0].values, { name: 'TEST', number: '+1234567890', cmi: 3 });
|
||||||
|
const out = efDecEditHtml(model);
|
||||||
|
assert.match(out, /data-key="name" value="TEST"/);
|
||||||
|
assert.match(out, /data-key="number" value="\+1234567890"/);
|
||||||
|
assert.doesNotMatch(out, /cmi/);
|
||||||
|
// the save path re-encodes with the 15-byte layout (the CMI byte survives)
|
||||||
|
const entry = efFindEncoder('EF.BDN', null);
|
||||||
|
assert.strictEqual(entry.enc({ name: 'TEST', number: '+1234567890' }, BDN), BDN);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A control-string/extended-BCD ADN record (the decoder only sees '100').
|
||||||
|
const SSC_ADN = '54455354' + 'FF'.repeat(12) + '04' + 'FF' + 'BA01B0' + 'FF'.repeat(7) + 'FF' + 'FF';
|
||||||
|
|
||||||
|
function adnBlock(num, orig, name, number, checked) {
|
||||||
|
const inputs = { name: { value: name }, number: { value: number } };
|
||||||
|
return {
|
||||||
|
querySelector: sel => {
|
||||||
|
if (sel === '.pysim-fs-record-cb') return { checked: checked };
|
||||||
|
const m = /^\[data-key="(.+)"\]$/.exec(sel);
|
||||||
|
return m ? inputs[m[1]] : null;
|
||||||
|
},
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
getAttribute: k => k === 'data-rec' ? String(num) : (k === 'data-orig' ? orig : null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('F1: the save path writes an untouched SSC record byte-identically', async () => {
|
||||||
|
const entry = efFindEncoder('EF.ADN', null);
|
||||||
|
pysimFsDecModel = { entry: entry, mode: 'records' };
|
||||||
|
const out = { querySelector: () => null,
|
||||||
|
querySelectorAll: sel => sel === '.pysim-dec-rec' ? [adnBlock(1, SSC_ADN, 'TEST', '100', true)] : [] };
|
||||||
|
const stubs = saveStubs({ success: true, sw: '9000' });
|
||||||
|
const status = { textContent: '' };
|
||||||
|
await pysimFsDecSave(null, 'EF.ADN', status, out, false);
|
||||||
|
assert.strictEqual(stubs.bodies.length, 1);
|
||||||
|
assert.strictEqual(stubs.bodies[0].body.record_nr, 1);
|
||||||
|
assert.strictEqual(stubs.bodies[0].body.data, SSC_ADN);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F3: a later record that cannot be encoded writes nothing', async () => {
|
||||||
|
const entry = efFindEncoder('EF.ADN', null);
|
||||||
|
pysimFsDecModel = { entry: entry, mode: 'records' };
|
||||||
|
// record 2 has a non-FF EXT1 and an edited number -> efEncAdn refuses
|
||||||
|
const ext1 = ADN_1.slice(0, -2) + '03';
|
||||||
|
const blocks = [
|
||||||
|
adnBlock(1, ADN_1, 'B HA 1 Sic', '6082658001', true),
|
||||||
|
adnBlock(2, ext1, 'B HA 1 Sic', '6082658002', true),
|
||||||
|
];
|
||||||
|
const out = { querySelector: () => null, querySelectorAll: sel => sel === '.pysim-dec-rec' ? blocks : [] };
|
||||||
|
const stubs = saveStubs({ success: true, sw: '9000' });
|
||||||
|
const status = { textContent: '' };
|
||||||
|
await pysimFsDecSave(null, 'EF.ADN', status, out, false);
|
||||||
|
assert.strictEqual(stubs.bodies.length, 0, 'nothing may be written');
|
||||||
|
assert.match(status.textContent, /record 2/);
|
||||||
|
assert.match(status.textContent, /EF\.EXT1/);
|
||||||
|
assert.strictEqual(stubs.cancelled(), 0, 'the form stays open');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F3: a failed write names the record and how many were written', async () => {
|
||||||
|
const entry = efFindEncoder('EF.ADN', null);
|
||||||
|
pysimFsDecModel = { entry: entry, mode: 'records' };
|
||||||
|
const blocks = [
|
||||||
|
adnBlock(1, ADN_1, 'B HA 1 Sic', '6082658001', true),
|
||||||
|
adnBlock(2, ADN_1, 'B HA 1 Sic', '6082658001', true),
|
||||||
|
];
|
||||||
|
const out = { querySelector: () => null, querySelectorAll: sel => sel === '.pysim-dec-rec' ? blocks : [] };
|
||||||
|
let calls = 0;
|
||||||
|
const stubs = saveStubs(() => (++calls === 1
|
||||||
|
? { success: true, sw: '9000' }
|
||||||
|
: { success: false, sw: '6982', error: 'Security status not satisfied' }));
|
||||||
|
const status = { textContent: '' };
|
||||||
|
await pysimFsDecSave(null, 'EF.ADN', status, out, false);
|
||||||
|
assert.strictEqual(stubs.bodies.length, 2);
|
||||||
|
assert.match(status.textContent, /record 2/);
|
||||||
|
assert.match(status.textContent, /1 of 2 records written/);
|
||||||
|
assert.strictEqual(stubs.cancelled(), 0, 'the form stays open');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6: a missing decoded model refuses to write', async () => {
|
||||||
|
pysimFsDecModel = null;
|
||||||
|
const stubs = saveStubs({ success: true, sw: '9000' });
|
||||||
|
const status = { textContent: '' };
|
||||||
|
await pysimFsDecSave(null, 'EF.ADN', status, { querySelector: () => null, querySelectorAll: () => [] }, false);
|
||||||
|
assert.strictEqual(stubs.bodies.length, 0);
|
||||||
|
assert.match(status.textContent, /No decoded editor/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F2: untouched PNN items keep their TLV bytes through the DOM and the save', async () => {
|
||||||
|
const entry = efFindEncoder('EF.PNN', null);
|
||||||
|
const model = efDecEditModel('EF.PNN', '6fc5', { kind: 'record', records: [{ num: 1, data: PNN_1 }] });
|
||||||
|
const form = efDecEditHtml(model);
|
||||||
|
assert.ok(form.includes('data-iorig="430A82D432BBBC7EB75DE432"'));
|
||||||
|
assert.ok(form.includes('data-iorig="450A82D432BBBC7EB75DE432"'));
|
||||||
|
// the row carries its original TLV into the collection
|
||||||
|
const row = {
|
||||||
|
getAttribute: k => k === 'data-tag' ? '43' : (k === 'data-iorig' ? '430A82D432BBBC7EB75DE432' : null),
|
||||||
|
querySelector: sel => sel === '[data-ikey]' ? { getAttribute: () => 'text', value: 'Telekom.de' } : null,
|
||||||
|
};
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
efDecCollectItems({ querySelectorAll: sel => sel === '.pysim-dec-item-row' ? [row] : [] }),
|
||||||
|
[{ tag: '43', text: 'Telekom.de', orig: '430A82D432BBBC7EB75DE432' }]);
|
||||||
|
// an untouched save is byte-identical
|
||||||
|
pysimFsDecModel = { entry: entry, mode: 'records' };
|
||||||
|
const rows = efDecItemsFromHex(entry, PNN_1).map(it => ({
|
||||||
|
getAttribute: k => k === 'data-tag' ? it.tag : (k === 'data-iorig' ? it.orig : null),
|
||||||
|
querySelector: sel => sel === '[data-ikey]' ? {
|
||||||
|
getAttribute: () => (it.text !== undefined ? 'text' : 'hex'),
|
||||||
|
value: it.text !== undefined ? it.text : it.hex,
|
||||||
|
} : null,
|
||||||
|
}));
|
||||||
|
const block = {
|
||||||
|
querySelector: sel => sel === '.pysim-fs-record-cb' ? { checked: true } : null,
|
||||||
|
querySelectorAll: sel => sel === '.pysim-dec-item-row' ? rows : [],
|
||||||
|
getAttribute: k => k === 'data-rec' ? '1' : (k === 'data-orig' ? PNN_1 : null),
|
||||||
|
};
|
||||||
|
const out = { querySelector: () => null, querySelectorAll: sel => sel === '.pysim-dec-rec' ? [block] : [] };
|
||||||
|
const stubs = saveStubs({ success: true, sw: '9000' });
|
||||||
|
const status = { textContent: '' };
|
||||||
|
await pysimFsDecSave(null, 'EF.PNN', status, out, false);
|
||||||
|
assert.strictEqual(stubs.bodies[0].body.data, PNN_1.toUpperCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F12: a selection change during the read abandons the decoded edit', async () => {
|
||||||
|
pysimFsSelected = 'EF.SPN';
|
||||||
|
pysimFsTreeRoot = { name: 'MF', children: [{ name: 'EF.SPN', fid: '6f46' }] };
|
||||||
|
const fakeEl = () => {
|
||||||
|
const classes = new Set();
|
||||||
|
return {
|
||||||
|
innerHTML: '', textContent: '',
|
||||||
|
classList: {
|
||||||
|
add: (...c) => c.forEach(x => classes.add(x)),
|
||||||
|
remove: (...c) => c.forEach(x => classes.delete(x)),
|
||||||
|
toggle: (c, on) => { if (on) classes.add(c); else classes.delete(c); },
|
||||||
|
contains: c => classes.has(c),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const content = fakeEl();
|
||||||
|
const status = fakeEl();
|
||||||
|
const buttons = {
|
||||||
|
'pysim-fs-content': content, 'pysim-fs-status': status,
|
||||||
|
'pysim-fs-edit-btn': fakeEl(), 'pysim-fs-edit-dec-btn': fakeEl(),
|
||||||
|
'pysim-fs-save-btn': fakeEl(), 'pysim-fs-cancel-btn': fakeEl(),
|
||||||
|
};
|
||||||
|
globalThis.document = {
|
||||||
|
getElementById: id => buttons[id] || null,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
};
|
||||||
|
globalThis.t = k => k;
|
||||||
|
globalThis.esc = s => String(s);
|
||||||
|
let release = null;
|
||||||
|
globalThis.pysimFetch = () => new Promise(res => { release = () => res({ success: true, sw: '9000', data: SPN_ORIG }); });
|
||||||
|
globalThis.pysimFsSelectBody = () => ({});
|
||||||
|
globalThis.pysimFsShowError = () => {};
|
||||||
|
let resets = 0;
|
||||||
|
globalThis.pysimFsResetEdit = () => { resets++; };
|
||||||
|
const pending = pysimFsDecEdit();
|
||||||
|
pysimFsSelected = 'EF.ADN';
|
||||||
|
release();
|
||||||
|
await pending;
|
||||||
|
assert.strictEqual(resets, 1);
|
||||||
|
assert.strictEqual(content.innerHTML, '', 'the old file must not render over the new selection');
|
||||||
|
});
|
||||||
@@ -24,6 +24,8 @@ function extractFunc(src, name) {
|
|||||||
let code = 'var pysimFsDecodedMode = false;\n'
|
let code = 'var pysimFsDecodedMode = false;\n'
|
||||||
+ 'var pysimFsEditMode = false;\n'
|
+ 'var pysimFsEditMode = false;\n'
|
||||||
+ 'var pysimFsEditData = null;\n'
|
+ 'var pysimFsEditData = null;\n'
|
||||||
|
+ 'var pysimFsDecEditMode = false;\n'
|
||||||
|
+ 'var pysimFsDecModel = null;\n'
|
||||||
+ 'var pysimFsSelected = null;\n'
|
+ 'var pysimFsSelected = null;\n'
|
||||||
+ 'var pysimFsTreeRoot = null;\n';
|
+ 'var pysimFsTreeRoot = null;\n';
|
||||||
for (const fn of ['pysimFsSetMode', 'pysimFsPillsEnabled', 'pysimFsEdit',
|
for (const fn of ['pysimFsSetMode', 'pysimFsPillsEnabled', 'pysimFsEdit',
|
||||||
|
|||||||
@@ -553,6 +553,59 @@ test('ramOpChanged clears the executed status only on a real op change', () => {
|
|||||||
assert.ok(!els['ram-install-params'].classList.contains('hidden'));
|
assert.ok(!els['ram-install-params'].classList.contains('hidden'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the RAM preset banner is content-driven (no empty amber flash)', () => {
|
||||||
|
// A view / op switch used to un-hide #ram-preset-warning by the op kind
|
||||||
|
// (v3.28.0): with no warning text the empty bordered box stayed visible
|
||||||
|
// until the next status poll rendered it away - the amber block flashing
|
||||||
|
// on every SCP80/RAM view change. Its visibility belongs to the renderer.
|
||||||
|
const els = fakeRamDocument(['ram-op', 'ram-card-sel', 'ram-preset-warning',
|
||||||
|
'ram-install-params', 'ram-cap-rows', 'ram-compat-rows', 'ram-app-rows',
|
||||||
|
'ram-delete-rows', 'ram-del-package-row', 'ram-preset-cell', 'ram-exec-btn',
|
||||||
|
'ram-batch-panel', 'ram-result', 'ram-explorer', 'ram-steps', 'ram-progress',
|
||||||
|
'sp-preset-warning', 'tar-preset-warning']);
|
||||||
|
els['ram-preset-warning'].classList.add('hidden');
|
||||||
|
const prev = {
|
||||||
|
cards: globalThis.cards,
|
||||||
|
t: globalThis.t,
|
||||||
|
iccid: globalThis._pysimCardIccid,
|
||||||
|
batchFetch: globalThis.ramBatchFetch,
|
||||||
|
};
|
||||||
|
globalThis.cards = [];
|
||||||
|
globalThis._pysimCardIccid = null; // no card session: no warning text
|
||||||
|
globalThis.t = s => s;
|
||||||
|
globalThis.ramBatchFetch = () => {};
|
||||||
|
// The real renderer, defined where the extracted ramOpChanged finds it
|
||||||
|
// (a plain function body sees only the globals).
|
||||||
|
const src = ['cardsNormIccid', 'spPresetWarning', 'ramIsBatchOp', 'ramPresetIdx',
|
||||||
|
'spPresetIdx', 'tarPresetIdx', 'spPresetWarningRender']
|
||||||
|
.map(f => extractFunc(html, f)).join('\n');
|
||||||
|
const real = new Function(src + '\nreturn spPresetWarningRender;')();
|
||||||
|
let calls = 0;
|
||||||
|
globalThis.spPresetWarningRender = () => { calls++; return real(); };
|
||||||
|
try {
|
||||||
|
_ramOpLast = 'explore';
|
||||||
|
els['ram-op'].value = 'explore';
|
||||||
|
ramOpChanged();
|
||||||
|
assert.strictEqual(calls, 1, 'the content-driven renderer must own the banner');
|
||||||
|
assert.ok(els['ram-preset-warning'].classList.contains('hidden'),
|
||||||
|
'an empty banner must not be un-hidden by the op dispatch');
|
||||||
|
// a batch op keeps it hidden as well (the preset resolves per card)
|
||||||
|
els['ram-op'].value = 'batch';
|
||||||
|
ramOpChanged();
|
||||||
|
assert.ok(els['ram-preset-warning'].classList.contains('hidden'));
|
||||||
|
// the visibility is never toggled here by the op kind again
|
||||||
|
assert.ok(!extractFunc(html, 'ramOpChanged').includes("'ram-preset-warning'"));
|
||||||
|
} finally {
|
||||||
|
globalThis.spPresetWarningRender = () => {};
|
||||||
|
if (prev.cards === undefined) delete globalThis.cards; else globalThis.cards = prev.cards;
|
||||||
|
if (prev.t === undefined) delete globalThis.t; else globalThis.t = prev.t;
|
||||||
|
if (prev.iccid === undefined) delete globalThis._pysimCardIccid;
|
||||||
|
else globalThis._pysimCardIccid = prev.iccid;
|
||||||
|
if (prev.batchFetch === undefined) delete globalThis.ramBatchFetch;
|
||||||
|
else globalThis.ramBatchFetch = prev.batchFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('ramRender keeps the selected card preset across rebuilds', () => {
|
test('ramRender keeps the selected card preset across rebuilds', () => {
|
||||||
const els = fakeRamDocument(['ram-card-sel', 'ram-op', 'ram-install-params', 'ram-result', 'ram-explorer', 'ram-steps', 'ram-progress']);
|
const els = fakeRamDocument(['ram-card-sel', 'ram-op', 'ram-install-params', 'ram-result', 'ram-explorer', 'ram-steps', 'ram-progress']);
|
||||||
globalThis.cards = [{ name: 'A' }, { name: 'B' }, { name: 'C' }];
|
globalThis.cards = [{ name: 'A' }, { name: 'B' }, { name: 'C' }];
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pysim-simple-server"
|
name = "pysim-simple-server"
|
||||||
version = "3.32.5"
|
version = "3.33.0"
|
||||||
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
||||||
license = { text = "GPL-2.0-or-later" }
|
license = { text = "GPL-2.0-or-later" }
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ from osmocom.tlv import BER_TLV_IE
|
|||||||
from osmocom.utils import rpad
|
from osmocom.utils import rpad
|
||||||
|
|
||||||
|
|
||||||
VERSION = '3.32.5'
|
VERSION = '3.33.0'
|
||||||
|
|
||||||
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
||||||
|
|
||||||
@@ -249,6 +249,24 @@ def _strip_ansi(text):
|
|||||||
return re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', text)
|
return re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', text)
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_exception(output):
|
||||||
|
"""The first non-empty line of a cmd2/pySim exception captured from a
|
||||||
|
command's output, or None.
|
||||||
|
|
||||||
|
cmd2 catches a command's exception, prints ``EXCEPTION of type ...`` and
|
||||||
|
(in debug mode) a traceback instead of re-raising, so the HTTP handlers
|
||||||
|
that only look for ``got (XXXX)`` reported success while nothing was
|
||||||
|
written. Both markers are command-level failure evidence.
|
||||||
|
"""
|
||||||
|
text = output or ''
|
||||||
|
if 'EXCEPTION of type' not in text and 'Traceback (most recent call last)' not in text:
|
||||||
|
return None
|
||||||
|
for line in text.splitlines():
|
||||||
|
if line.strip():
|
||||||
|
return line.strip()
|
||||||
|
return 'command failed'
|
||||||
|
|
||||||
|
|
||||||
def _parse_help_text(text):
|
def _parse_help_text(text):
|
||||||
result = {'usage': '', 'description': '', 'args': []}
|
result = {'usage': '', 'description': '', 'args': []}
|
||||||
lines = text.split('\n')
|
lines = text.split('\n')
|
||||||
@@ -9843,6 +9861,12 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
sys.stderr = old_stderr
|
sys.stderr = old_stderr
|
||||||
finally:
|
finally:
|
||||||
apdu_times = _end_apdu_time_collection()
|
apdu_times = _end_apdu_time_collection()
|
||||||
|
exc = _cmd_exception(output)
|
||||||
|
if exc:
|
||||||
|
resp = {'success': False, 'error': exc}
|
||||||
|
self._send_json(resp)
|
||||||
|
self._log_resp(resp)
|
||||||
|
return
|
||||||
sw_match = re.search(r'SW:\s*(\w+)', output)
|
sw_match = re.search(r'SW:\s*(\w+)', output)
|
||||||
err_match = re.search(r'got (\w+)', output)
|
err_match = re.search(r'got (\w+)', output)
|
||||||
if err_match:
|
if err_match:
|
||||||
@@ -9931,6 +9955,12 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
finally:
|
finally:
|
||||||
app.stdout = old_stdout
|
app.stdout = old_stdout
|
||||||
sys.stderr = old_stderr
|
sys.stderr = old_stderr
|
||||||
|
exc = _cmd_exception(output)
|
||||||
|
if exc:
|
||||||
|
resp = {'success': False, 'error': exc}
|
||||||
|
self._send_json(resp)
|
||||||
|
self._log_resp(resp)
|
||||||
|
return
|
||||||
sw_match = re.search(r'SW:\s*(\w+)', output)
|
sw_match = re.search(r'SW:\s*(\w+)', output)
|
||||||
err_match = re.search(r'got (\w+)', output)
|
err_match = re.search(r'got (\w+)', output)
|
||||||
if err_match:
|
if err_match:
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""cmd2/pySim exception output must fail /api/write and /api/read.
|
||||||
|
|
||||||
|
`onecmd_plus_hooks()` catches a command's exception and prints
|
||||||
|
``EXCEPTION of type ...`` (plus a traceback in debug mode) instead of
|
||||||
|
re-raising, so the handlers that only looked for ``got (XXXX)`` reported a
|
||||||
|
successful write while nothing was written. The handlers now treat both
|
||||||
|
markers as failure; the SW-based success/failure semantics stay as before.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from pysim_simple_server import server as srv
|
||||||
|
from pysim_simple_server.server import PysimHandler, _cmd_exception
|
||||||
|
|
||||||
|
|
||||||
|
class FakeApp:
|
||||||
|
def __init__(self, output):
|
||||||
|
self.output = output
|
||||||
|
self.stdout = None
|
||||||
|
self.rs = types.SimpleNamespace(lchan=[types.SimpleNamespace(selected_file=None)])
|
||||||
|
|
||||||
|
def onecmd_plus_hooks(self, cmd):
|
||||||
|
# pySim writes into the app's stdout, which the handler redirects
|
||||||
|
self.stdout.write(self.output)
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_handler(path, body, app):
|
||||||
|
h = object.__new__(PysimHandler)
|
||||||
|
h.path = path
|
||||||
|
h.command = 'POST'
|
||||||
|
h.headers = {}
|
||||||
|
h.server = types.SimpleNamespace(app=app, log_requests=False, pbr=None)
|
||||||
|
h._read_body = lambda: body
|
||||||
|
h._log_req = lambda *a, **k: None
|
||||||
|
h._log_resp = lambda *a, **k: None
|
||||||
|
sent = []
|
||||||
|
h._send_json = lambda data, status=200: sent.append((data, status))
|
||||||
|
return h, sent
|
||||||
|
|
||||||
|
|
||||||
|
class CmdExceptionTests(unittest.TestCase):
|
||||||
|
def test_the_helper_detects_both_markers_and_returns_the_first_line(self):
|
||||||
|
self.assertIsNone(_cmd_exception(''))
|
||||||
|
self.assertIsNone(_cmd_exception('SW: 9000\n'))
|
||||||
|
exc = ("EXCEPTION of type 'ValueError' occurred with message: 'boom'\n"
|
||||||
|
"Traceback (most recent call last):\n File x\nValueError: boom\n")
|
||||||
|
self.assertEqual(_cmd_exception(exc),
|
||||||
|
"EXCEPTION of type 'ValueError' occurred with message: 'boom'")
|
||||||
|
tb = 'Traceback (most recent call last):\n File x\nRuntimeError: boom\n'
|
||||||
|
self.assertEqual(_cmd_exception(tb), 'Traceback (most recent call last):')
|
||||||
|
|
||||||
|
|
||||||
|
class WriteExceptionTests(unittest.TestCase):
|
||||||
|
def _write(self, output):
|
||||||
|
app = FakeApp(output)
|
||||||
|
h, sent = _fake_handler('/api/write', {
|
||||||
|
'name': 'EF.ADN', 'data': 'AA' * 30, 'record_nr': 1, 'allow_probe': True,
|
||||||
|
}, app)
|
||||||
|
body = {'name': 'EF.ADN', 'data': 'AA' * 30, 'record_nr': 1, 'allow_probe': True}
|
||||||
|
h._read_body = lambda: body
|
||||||
|
with mock.patch.object(srv, '_select_with_parent', lambda *a, **k: (None, None)), \
|
||||||
|
mock.patch.object(srv, '_get_file_type', lambda *a: 'linear_fixed'), \
|
||||||
|
mock.patch.object(srv, '_arr_refresh_after_write', lambda *a, **k: None):
|
||||||
|
PysimHandler._do_POST(h)
|
||||||
|
self.assertEqual(len(sent), 1, sent)
|
||||||
|
return sent[0][0]
|
||||||
|
|
||||||
|
def test_a_cmd2_exception_is_not_a_successful_write(self):
|
||||||
|
resp = self._write("EXCEPTION of type 'Exception' occurred with message: 'no card'\n")
|
||||||
|
self.assertFalse(resp['success'])
|
||||||
|
self.assertIn('EXCEPTION of type', resp['error'])
|
||||||
|
self.assertNotIn('sw', resp)
|
||||||
|
|
||||||
|
def test_a_traceback_is_not_a_successful_write(self):
|
||||||
|
resp = self._write('Traceback (most recent call last):\n File x\nValueError: boom\n')
|
||||||
|
self.assertFalse(resp['success'])
|
||||||
|
self.assertEqual(resp['error'], 'Traceback (most recent call last):')
|
||||||
|
|
||||||
|
def test_the_sw_semantics_are_unchanged(self):
|
||||||
|
self.assertEqual(self._write('SW: 9000\n'), {'success': True, 'sw': '9000'})
|
||||||
|
self.assertEqual(self._write('SW: 6982 - Security status not satisfied\n'
|
||||||
|
'got 6982'),
|
||||||
|
{'success': False, 'sw': '6982',
|
||||||
|
'error': 'Security status not satisfied'})
|
||||||
|
|
||||||
|
|
||||||
|
class ReadExceptionTests(unittest.TestCase):
|
||||||
|
def _read(self, output, mode='raw'):
|
||||||
|
app = FakeApp(output)
|
||||||
|
body = {'name': 'EF.ADN', 'mode': mode, 'allow_probe': True, 'record_nr': 1}
|
||||||
|
h, sent = _fake_handler('/api/read', body, app)
|
||||||
|
with mock.patch.object(srv, '_select_with_parent', lambda *a, **k: (None, None)), \
|
||||||
|
mock.patch.object(srv, '_get_file_type', lambda *a: 'transparent'), \
|
||||||
|
mock.patch.object(srv, '_collect_apdu_times', lambda: None), \
|
||||||
|
mock.patch.object(srv, '_end_apdu_time_collection', lambda: []):
|
||||||
|
PysimHandler._do_POST(h)
|
||||||
|
self.assertEqual(len(sent), 1, sent)
|
||||||
|
return sent[0][0]
|
||||||
|
|
||||||
|
def test_a_cmd2_exception_is_not_a_successful_read(self):
|
||||||
|
resp = self._read("EXCEPTION of type 'Exception' occurred with message: 'no card'\n")
|
||||||
|
self.assertFalse(resp['success'])
|
||||||
|
self.assertIn('EXCEPTION of type', resp['error'])
|
||||||
|
resp = self._read('Traceback (most recent call last):\n File x\n', mode='decoded')
|
||||||
|
self.assertFalse(resp['success'])
|
||||||
|
self.assertEqual(resp['error'], 'Traceback (most recent call last):')
|
||||||
|
|
||||||
|
def test_a_normal_read_keeps_the_sw_parse(self):
|
||||||
|
resp = self._read('AABB\nSW: 9000\n')
|
||||||
|
self.assertTrue(resp['success'])
|
||||||
|
self.assertEqual(resp['sw'], '9000')
|
||||||
|
self.assertEqual(resp['data'], 'AABB')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user