Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 101ef21e9f | |||
| d036ef79a1 | |||
| 9411fd775d | |||
| ded63c94e4 | |||
| 1174a340db | |||
| f92eb8f131 | |||
| 7e8c483eb1 | |||
| 8a08f642d4 | |||
| cdd3820064 | |||
| aa8c9c6f12 |
@@ -79,9 +79,17 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
ZIP="dist/simple-${TAG#v}.zip"
|
||||
VER="${TAG#v}"
|
||||
ZIP="dist/simple-$VER.zip"
|
||||
# The release body is the CHANGELOG section of the version; a missing
|
||||
# section falls back to generated notes.
|
||||
if python tools/changelog_section.py "$VER" > /tmp/notes.md 2>/dev/null; then
|
||||
NOTES="--notes-file /tmp/notes.md"
|
||||
else
|
||||
NOTES="--generate-notes"
|
||||
fi
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
gh release upload "$TAG" "$ZIP" --clobber
|
||||
else
|
||||
gh release create "$TAG" "$ZIP" --title "$TAG" --generate-notes
|
||||
gh release create "$TAG" "$ZIP" --title "$TAG" $NOTES
|
||||
fi
|
||||
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to SIMple (the PWA and `pysim-simple-server`).
|
||||
The format follows [Keep a Changelog](https://keepachangelog.com/);
|
||||
versions use `MAJOR.MINOR.PATCH` (see the README).
|
||||
|
||||
History before the first packaged release (v3.31.1) lives in the git log —
|
||||
every change carries a descriptive commit message.
|
||||
|
||||
## [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
|
||||
|
||||
### Fixed
|
||||
|
||||
- A card removed during the **startup initialization** no longer leaves the server
|
||||
reporting a phantom equipped card. The init caught the PC/SC error (and printed a
|
||||
traceback) but kept the card object it had already built, so `card_present` and
|
||||
`_CARD_CONNECTED` stayed True — and the auto-equip then skipped every later insertion
|
||||
(its `_CARD_CONNECTED` early return), leaving only a manual Equip or a restart. A
|
||||
PC/SC error now prints one line (`INIT: card removed during initialization …`), drops
|
||||
the half-initialized session and lets the presence monitor equip the next card.
|
||||
|
||||
## [3.32.4] — 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. 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
|
||||
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
|
||||
2–4 s delay and a traceback in the log. A link created at a cardless startup has
|
||||
never connected either, which the same mark covers. The rebuild still runs in the
|
||||
equip worker (never in the pyscard monitor thread) and `_ensure_transport` releases
|
||||
the old link first.
|
||||
|
||||
## [3.32.2] — 2026-10-21
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The auto-equip loop after a card swap on Windows.** A failed initialization with
|
||||
the card still present was reported as "card gone during initialization" and treated
|
||||
as a no-op success — so the three retries never ran and the watchdog re-armed every
|
||||
few seconds forever. It is a failure now, and **every failed attempt marks the
|
||||
PC/SC transport for a rebuild**, so the retry starts from a fresh link (the release
|
||||
of the old link happens first, so it cannot inherit a stale card handle). The error
|
||||
pySim swallowed is printed as `AUTO-EQUIP: init output:` with its traceback.
|
||||
|
||||
## [3.32.1] — 2026-10-21
|
||||
|
||||
### Added
|
||||
|
||||
- **"Start counter value"** in the outfile import: the counter every created keyset
|
||||
starts from. It defaults to the store's value (`1`); raise it for cards whose
|
||||
counters are already past it. The API takes `start_counter` (1–10 hex digits;
|
||||
a bad value is a 400), and the preview carries the value.
|
||||
|
||||
## [3.32.0] — 2026-10-21
|
||||
|
||||
### Added
|
||||
|
||||
- **Updating a packaged install** — `update.sh` / `update.bat` (and `tools/update.py`
|
||||
beneath them): downloads the latest release package, replaces the application files
|
||||
in place and re-installs the app into the **existing `.venv`**. pysim and its
|
||||
prerequisites (the slow part: pyscard builds from source on Linux) are kept, so an
|
||||
update takes seconds. `--check` reports the current and latest versions, `--version`
|
||||
picks a specific release, `--zip` updates from a local package (offline), `--no-pip`
|
||||
refreshes the files only, `--force` re-applies the same version.
|
||||
- The release workflow now takes the release notes from this file's matching version
|
||||
section (falling back to generated notes when it is missing).
|
||||
|
||||
### Changed
|
||||
|
||||
- Installs made from the v3.31.x packages have no updater yet: copy `update.sh`,
|
||||
`update.bat` and `tools/update.py` from this package once (or re-run `setup` from it).
|
||||
Later updates use `update.sh` / `update.bat` — or `git pull` for a clone.
|
||||
|
||||
## [3.31.2] — 2026-10-20
|
||||
|
||||
### Changed
|
||||
|
||||
- The **SCP81 TLS-PSK listener states its Python requirement**: it needs Python 3.13+
|
||||
(`ssl`'s PSK callbacks exist only since 3.13). Below that it raises a clear error
|
||||
instead of `AttributeError`, and the PSK tests are skipped (they run on 3.13+).
|
||||
- The READMEs and the setup scripts: **Windows strongly recommends Python 3.13**
|
||||
(pyscard has wheels up to 3.13; on 3.14 pyscard and some pysim dependency wheels are
|
||||
unavailable and build from source with MSVC).
|
||||
|
||||
## [3.31.1] — 2026-10-20
|
||||
|
||||
The first packaged release (`simple-3.31.1.zip`).
|
||||
|
||||
### Added
|
||||
|
||||
- **EF_PBR (phonebook reference) support** — the PBR is decoded in the decoded file
|
||||
views (the `A8`/`A9`/`AA` containers, the `C0`–`CC` kinds, the master EF), and the
|
||||
phonebook files it references (they are not in pySim's model) are added to the file
|
||||
tree server-side, selectable and readable.
|
||||
- **In-app dialogs** — the native alert/confirm/prompt dialogs are replaced by a shared
|
||||
modal (queued, keyboard-aware, focus-trapped, ARIA-labelled).
|
||||
- **Windows console encoding guard** — the `start.bat` PowerShell pipe can no longer
|
||||
kill the server on a character the ANSI code page lacks.
|
||||
- **GPL-2.0-or-later licensing** (`LICENSE`, `THIRD-PARTY-NOTICES`), matching pysim.
|
||||
- The curated **release package** (`tools/build_release.py`) and the tag-triggered
|
||||
`.github/workflows/release.yml`.
|
||||
|
||||
### Changed
|
||||
|
||||
- `setup.sh` / `setup.bat` and the READMEs: on Linux `pyscard` builds from source and
|
||||
needs the PC/SC headers (`libpcsclite-dev`).
|
||||
@@ -30,11 +30,17 @@ The package is built from the tagged source — no git history, no development f
|
||||
Git is still required: `setup` installs **pysim** from its upstream repository
|
||||
(nothing from pysim or its dependencies is bundled).
|
||||
|
||||
**Updates are fast:** `./update.sh` (or `update.bat`) downloads the latest release
|
||||
package, replaces the application files in place and re-installs the app into the
|
||||
existing `.venv` — pysim and its prerequisites are kept, so nothing is rebuilt.
|
||||
`./update.sh --check` reports the current and latest versions, `--version X.Y.Z`
|
||||
picks a specific release and `--zip file.zip` updates from a local package.
|
||||
|
||||
**2. Clone the repository (for development, or changes not released yet).**
|
||||
|
||||
```sh
|
||||
git clone https://github.com/anttro/simple.git
|
||||
cd simple
|
||||
git clone https://github.com/anttro/simple.git # or --depth 1: no history,
|
||||
cd simple # `git pull` still updates it
|
||||
./setup.sh # or setup.bat on Windows
|
||||
./start.sh # or start.bat
|
||||
```
|
||||
@@ -587,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).
|
||||
|
||||
- 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)
|
||||
- **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
|
||||
- **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. **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)`
|
||||
- **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
|
||||
|
||||
@@ -748,6 +755,7 @@ The bundled Python server wraps [pySim](https://osmocom.org/projects/pysim/wiki)
|
||||
|--------|-------------|
|
||||
| `setup.sh` / `setup.bat` | Creates `.venv/`, installs pysim and the server. Run once after cloning or after unzipping the release package. |
|
||||
| `start.sh` / `start.bat` | Starts the server from the venv (serves the PWA + API on `:8080`). |
|
||||
| `update.sh` / `update.bat` | Updates a packaged install in place: fetches the latest release package, replaces the app files and re-installs the app into the existing `.venv` (pysim is kept). `--check`, `--version X.Y.Z`, `--zip FILE`, `--no-pip`, `--force`. |
|
||||
|
||||
`start.sh` asks for the first PC/SC reader when `pcscd` is running; otherwise the server still auto-detects a PC/SC reader itself, and with none present pysim falls back to its serial default `/dev/ttyUSB0` (pass `-d <device>` for a serial/FTDI reader). `start.bat` always passes `-p 0` (PC/SC is built into Windows). Extra arguments are passed through to the server, e.g. `./start.sh --gsmtap` or `start.bat --gsmtap 10.0.0.5:4729`. If no reader is found the server still starts ("Reader: none") — initialize the card later via the **Equip** button.
|
||||
|
||||
@@ -792,7 +800,7 @@ its MSVC build chain) is not pulled in.
|
||||
- **"Failed to establish context: Access denied"** — `pcscd` isn't running or the user lacks permission: `sudo systemctl enable --now pcscd && sudo usermod -a -G pcscd $USER`.
|
||||
- **"device file /dev/ttyUSB0 does not exist"** — no serial reader; connect a USB reader or pass `-d` explicitly. The server still starts without a reader.
|
||||
- **"Service not available" (0x8010001D) / reader gone after a `pcscd` restart** — the server recovers by itself: a watchdog revives pyscard's presence monitor and the next equip (automatic after re-insertion, or the **Equip** button) recreates the PC/SC transport (releasing the old link first). No server restart needed.
|
||||
- **"Card was removed" errors / auto-equip fails after a card swap** — a normal card swap reconnects on the existing transport (card-level PC/SC errors no longer force a transport rebuild), a failed auto-equip is retried up to 3 times, and the watchdog re-arms it every 5 s while a card is present. A removed card also clears the card state completely, so handlers answer "no card" instead of transmitting over the dead card.
|
||||
- **"Card was removed" errors / auto-equip fails after a card swap** — a card insertion marks the PC/SC link for a rebuild (so the **first** attempt already uses a fresh link — on Windows the old one cannot connect after a swap), the auto-equip retries up to 3 times, and **every failed attempt rebuilds the transport** before the next one. A card removed while the server initializes at startup leaves it cardless with one line (no traceback), and the next insertion equips normally. The swallowed init error (with its traceback) is now printed as `AUTO-EQUIP: init output:`, and a card that is present but produced no card is a failure, not a silent "card gone". The watchdog re-arms with a doubling delay (5 s up to 60 s) while a card is present; a removed card clears the card state completely, so handlers answer "no card" instead of transmitting over the dead card.
|
||||
|
||||
### API reference
|
||||
|
||||
|
||||
+13
-5
@@ -30,11 +30,17 @@ SIMple — инструментарий для специалистов по UIC
|
||||
разработки. Git всё равно нужен: `setup` ставит **pysim** из его upstream-репозитория
|
||||
(ни pysim, ни его зависимости в пакет не входят).
|
||||
|
||||
**Обновление быстрое:** `./update.sh` (или `update.bat`) скачивает последний релизный
|
||||
пакет, заменяет файлы приложения на месте и переустанавливает приложение в существующий
|
||||
`.venv` — pysim и его зависимости остаются, ничего не пересобирается.
|
||||
`./update.sh --check` показывает текущую и последнюю версии, `--version X.Y.Z` —
|
||||
конкретный релиз, `--zip file.zip` — обновление из локального пакета.
|
||||
|
||||
**2. Клонировать репозиторий (для разработки или ещё не выпущенных изменений).**
|
||||
|
||||
```sh
|
||||
git clone https://github.com/anttro/simple.git
|
||||
cd simple
|
||||
git clone https://github.com/anttro/simple.git # или --depth 1: без истории,
|
||||
cd simple # `git pull` всё равно обновляет
|
||||
./setup.sh # или setup.bat в Windows
|
||||
./start.sh # или start.bat
|
||||
```
|
||||
@@ -562,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 файла; снимок разрешает их из собственной захваченной копии).
|
||||
|
||||
- Элементы сгруппированы (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 до чтения)
|
||||
- **Edit raw** — редактирование сырых hex-данных и **Save** для записи (или **Cancel**); для transparent-файлов — textarea, для record-файлов — по полю на запись с флажками выбора. Декодированный вид только для чтения — **Edit raw** сначала переключается на hex и при необходимости читает файл
|
||||
- **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 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 — `(пусто)`
|
||||
- **ADM** — файлы, требующие администраторский PIN, возвращают `6982`/`9804`; если в подходящем пресете карты (тот же ICCID) есть ключ ADM, рядом с ошибкой появляется кнопка **«Проверить ADM»**, а значок в заголовке (`ADM ✓/✗ ⚿`) становится кликабельным. Каждый неверный ключ расходует попытку (остаток показывается, повторная попытка требует подтверждения); заблокированный ADM требует ключа разблокировки карты
|
||||
|
||||
@@ -732,6 +739,7 @@ PWA проверяет версию сервера при подключении
|
||||
|--------|-------------|
|
||||
| `setup.sh` / `setup.bat` | Создаёт `.venv/`, ставит pysim и сервер. Запускать один раз после клонирования или распаковки релизного пакета. |
|
||||
| `start.sh` / `start.bat` | Запускает сервер из venv (раздаёт PWA + API на `:8080`). |
|
||||
| `update.sh` / `update.bat` | Обновляет пакетную установку на месте: скачивает последний релизный пакет, заменяет файлы приложения и переустанавливает приложение в существующий `.venv` (pysim сохраняется). `--check`, `--version X.Y.Z`, `--zip FILE`, `--no-pip`, `--force`. |
|
||||
|
||||
`start.sh` при работающем `pcscd` запрашивает первый PC/SC-ридер; иначе сервер сам автоопределяет PC/SC-ридер, а если его нет — pysim откатывается на последовательное устройство по умолчанию `/dev/ttyUSB0` (для serial/FTDI-ридера укажите `-d <устройство>`). `start.bat` всегда передаёт `-p 0`. Дополнительные аргументы передаются серверу, например `./start.sh --gsmtap` или `start.bat --gsmtap 10.0.0.5:4729`. Без ридера сервер всё равно стартует («Reader: none») — карту можно инициализировать позже кнопкой **Equip**.
|
||||
|
||||
@@ -776,7 +784,7 @@ pysim-simple-server --http-port 8080
|
||||
- **"Failed to establish context: Access denied"** — `pcscd` не запущен или нет прав: `sudo systemctl enable --now pcscd && sudo usermod -a -G pcscd $USER`.
|
||||
- **"device file /dev/ttyUSB0 does not exist"** — нет serial-ридера; подключите USB-ридер или укажите `-d`. Сервер всё равно стартует без ридера.
|
||||
- **"Service not available" (0x8010001D) / ридер пропал после перезапуска `pcscd`** — сервер восстанавливается сам: сторож перезапускает монитор присутствия pyscard, а следующий equip (автоматически после повторной вставки карты или кнопкой **Equip**) пересоздаёт транспорт PC/SC (сначала освобождая старый линк). Перезапуск сервера не нужен.
|
||||
- **Ошибки "Card was removed" / equip не срабатывает после смены карты** — обычная смена карты переподключается на существующем транспорте (карточные ошибки PC/SC больше не вызывают пересоздание транспорта), неудавшийся авто-equip повторяется до 3 раз, а сторож перезапускает его каждые 5 с, пока карта в ридере. Снятие карты теперь полностью очищает состояние, поэтому обработчики отвечают «нет карты», а не шлют APDU мёртвой карте.
|
||||
- **Ошибки "Card was removed" / equip не срабатывает после смены карты** — вставка карты помечает соединение PC/SC для пересоздания (поэтому **первая** попытка уже использует свежее соединение — в Windows старое после смены карты подключиться не может), авто-equip повторяется до 3 раз, и **каждая неудачная попытка пересоздаёт транспорт** перед следующей. Карта, снятая во время инициализации при старте сервера, оставляет его без карты с одной строкой (без трассировки), а следующая вставка инициализируется обычным образом. Проглоченная ошибка инициализации (с трассировкой) теперь печатается как `AUTO-EQUIP: init output:`, а «карта есть, но карта не создана» — это ошибка, а не тихое «карта пропала». Сторож повторяет с удвоением задержки (5 с … 60 с), пока карта в ридере; снятие карты полностью очищает состояние, поэтому обработчики отвечают «нет карты», а не шлют APDU мёртвой карте.
|
||||
|
||||
### Справочник API
|
||||
|
||||
|
||||
@@ -351,6 +351,10 @@ into a preview.
|
||||
- `tar_keyset` — an optional **global default keyset** (1–15) for the TAR
|
||||
entries that do not name one (the API's shorthand; the PWA sends per-row
|
||||
keysets instead).
|
||||
- `start_counter` — the counter **every created keyset starts from** (1–10 hex
|
||||
digits; whitespace stripped, uppercased). Without it the store's default
|
||||
(`1`) applies. A bad value is a `400`; the import stores the protocol's
|
||||
10-digit form.
|
||||
|
||||
Recognised columns (case-insensitive): `ICCID` (the digits + odd-nibble F
|
||||
pad, the plain digits, spaced digits or the raw nibble-swapped EF hex all
|
||||
|
||||
@@ -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.
|
||||
File diff suppressed because one or more lines are too long
+4
-3
@@ -371,7 +371,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-sm mb-3">The <strong>SCP81</strong> column shows whether the preset supplies a usable PSK pair: <strong>✓</strong> (green) — identity and key set; <strong>⚠</strong> (amber) — only one of the two, so the listener ignores the preset; <strong>—</strong> — no PSK. PSK identity and key must be set together. <strong>Add preset</strong> opens the editor with a fresh preset; <strong>Edit</strong> loads the row's preset into it so fields can be changed without re-entering the card; <strong>Save</strong> stores it and returns to the list, <strong>Cancel</strong> discards the edits; <strong>Remove</strong> deletes the row (list only). Presets can be shared with <strong>Export to file</strong> and restored with <strong>Import from file</strong>. The selected card preset auto-fills the Secured Packet form and sets the TAR for the operation (RAM/GP → ISD TAR, SIM RFM → UICC RFM TAR, USIM RFM → ADF RFM TAR); a TAR typed into the Secured Packet form by hand is kept when sending - the counter, keys and the TAR's MSL are still re-read from the preset; the SPI1 is seeded from the TAR's MSL and a lower value warns (the server returns it as a <code class="font-mono text-sm">warning</code> too); edits are pushed into a running SCP81 listener automatically.</p>
|
||||
<p class="text-sm mb-3"><strong>Import outfile</strong> imports many cards at once from a card vendor's <em>outfile</em> — the personalization tool's plain-text dump: a banner and a configuration header, then a whitespace-separated <strong>column line</strong> (the tokens include <code class="font-mono text-sm">ICCID</code>; the first token may carry a <code class="font-mono text-sm">var_out:</code>-style prefix) and one row per card. Everything before the column line and every unused column is ignored. Every data row must carry a value for <strong>every declared column</strong> — the format is positional, so an empty value is indistinguishable from a missing one — and a <code class="font-mono text-sm">KICn</code>/<code class="font-mono text-sm">KIDn</code> pair named in the header must have a usable value in every row; a violation makes the whole file invalid (the preview reports the line and the import is refused). The parser reads <code class="font-mono text-sm">ICCID</code> (digits, the digits with an odd-nibble <code class="font-mono text-sm">F</code> pad, spaced digits or the raw EF hex — all normalised to one form), <code class="font-mono text-sm">PIN1</code>/<code class="font-mono text-sm">PIN2</code>/<code class="font-mono text-sm">PUK1</code>/<code class="font-mono text-sm">PUK2</code>, <code class="font-mono text-sm">ADM</code> (the key's hex value, 4–16 hex digits in byte pairs — e.g. <code class="font-mono text-sm">3632333239463337</code>), <code class="font-mono text-sm">PSK</code> (16 bytes; an optional <code class="font-mono text-sm">PSKID</code>/<code class="font-mono text-sm">PSK_ID</code> names its identity, defaulting to the card's ICCID) and the numbered key pairs <code class="font-mono text-sm">KICn</code>/<code class="font-mono text-sm">KIDn</code> — one keyset per pair, up to 15. The <strong>KIc/KID bytes are never in the file</strong>: the keyset number is the column number (<code class="font-mono text-sm">KIC2</code> → keyset 2) and the algorithm nibble follows the key size — 8 bytes DES, 24 bytes 3DES3, and for <strong>16 bytes</strong> the preview's choice, <strong>3DES2</strong> (default) or <strong>AES</strong>; any other size skips that keyset with a note. The TARs never come from the file either: the preview shows the TAR table the imported cards get (the three role entries with their spec TARs and MSL 16 by default, editable, with <strong>+ TAR</strong> for extra entries) and <strong>every row carries its own keyset</strong> — a select over the keysets found in the file (the first one is preselected; a non-zero MSL needs one, marked amber), so each TAR's packets use exactly the keyset you choose. A <strong>common name</strong> is applied to every imported card (empty falls back to the ICCID); <strong>Import mode</strong> chooses <strong>Merge</strong> (default — existing presets are kept, duplicate ICCIDs are skipped and reported) or <strong>Replace</strong> (the store is wiped first). The preview lists the parsed cards (ICCID, keysets, the codes found) and every skipped row/key with its reason before anything is stored.</p>
|
||||
<p class="text-sm mb-3"><strong>Import outfile</strong> imports many cards at once from a card vendor's <em>outfile</em> — the personalization tool's plain-text dump: a banner and a configuration header, then a whitespace-separated <strong>column line</strong> (the tokens include <code class="font-mono text-sm">ICCID</code>; the first token may carry a <code class="font-mono text-sm">var_out:</code>-style prefix) and one row per card. Everything before the column line and every unused column is ignored. Every data row must carry a value for <strong>every declared column</strong> — the format is positional, so an empty value is indistinguishable from a missing one — and a <code class="font-mono text-sm">KICn</code>/<code class="font-mono text-sm">KIDn</code> pair named in the header must have a usable value in every row; a violation makes the whole file invalid (the preview reports the line and the import is refused). The parser reads <code class="font-mono text-sm">ICCID</code> (digits, the digits with an odd-nibble <code class="font-mono text-sm">F</code> pad, spaced digits or the raw EF hex — all normalised to one form), <code class="font-mono text-sm">PIN1</code>/<code class="font-mono text-sm">PIN2</code>/<code class="font-mono text-sm">PUK1</code>/<code class="font-mono text-sm">PUK2</code>, <code class="font-mono text-sm">ADM</code> (the key's hex value, 4–16 hex digits in byte pairs — e.g. <code class="font-mono text-sm">3632333239463337</code>), <code class="font-mono text-sm">PSK</code> (16 bytes; an optional <code class="font-mono text-sm">PSKID</code>/<code class="font-mono text-sm">PSK_ID</code> names its identity, defaulting to the card's ICCID) and the numbered key pairs <code class="font-mono text-sm">KICn</code>/<code class="font-mono text-sm">KIDn</code> — one keyset per pair, up to 15. The <strong>KIc/KID bytes are never in the file</strong>: the keyset number is the column number (<code class="font-mono text-sm">KIC2</code> → keyset 2) and the algorithm nibble follows the key size — 8 bytes DES, 24 bytes 3DES3, and for <strong>16 bytes</strong> the preview's choice, <strong>3DES2</strong> (default) or <strong>AES</strong>; any other size skips that keyset with a note. The TARs never come from the file either: the preview shows the TAR table the imported cards get (the three role entries with their spec TARs and MSL 16 by default, editable, with <strong>+ TAR</strong> for extra entries) and <strong>every row carries its own keyset</strong> — a select over the keysets found in the file (the first one is preselected; a non-zero MSL needs one, marked amber), so each TAR's packets use exactly the keyset you choose. <strong>Start counter value</strong> sets the counter every created keyset starts from (default <code class="font-mono text-sm">1</code> — the store's value; raise it for cards already past it). A <strong>common name</strong> is applied to every imported card (empty falls back to the ICCID); <strong>Import mode</strong> chooses <strong>Merge</strong> (default — existing presets are kept, duplicate ICCIDs are skipped and reported) or <strong>Replace</strong> (the store is wiped first). The preview lists the parsed cards (ICCID, keysets, the codes found) and every skipped row/key with its reason before anything is stored.</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
@@ -406,8 +406,9 @@
|
||||
<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>
|
||||
<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>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>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. <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>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>
|
||||
|
||||
+955
-43
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'simple-v457';
|
||||
const CACHE = 'simple-v466';
|
||||
const URLS = [
|
||||
'index.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', () => {
|
||||
// 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.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("el.setAttribute('onclick', 'pysimVerifyAdm()')"));
|
||||
});
|
||||
|
||||
@@ -113,8 +113,8 @@ test('the preview and the import both post the outfile endpoint', () => {
|
||||
// the keysets travel per TAR row (v3.29.1): no global tar_keyset
|
||||
assert.ok(!preview.includes('tar_keyset'), 'the preview must not send a global keyset');
|
||||
assert.ok(!imp.includes('tar_keyset'), 'the import must not send a global keyset');
|
||||
assert.match(preview, /tars: cardsOutfileTarsFromForm\(\)/);
|
||||
assert.match(imp, /tars: cardsOutfileTarsFromForm\(\), mode: o\.mode/);
|
||||
assert.match(preview, /tars: cardsOutfileTarsFromForm\(\), start_counter: o\.cntr/);
|
||||
assert.match(imp, /tars: cardsOutfileTarsFromForm\(\), start_counter: o\.cntr, mode: o\.mode/);
|
||||
});
|
||||
|
||||
test('the 16-byte algorithm values match the server map', () => {
|
||||
@@ -225,10 +225,12 @@ test('cardsOutfileControls reads the panel controls back into the state', () =>
|
||||
setEl('cards-of-name', ' Batch 7 ');
|
||||
setEl('cards-of-algo', 'aes');
|
||||
setEl('cards-of-mode', 'replace');
|
||||
setEl('cards-of-cntr', ' 1a ');
|
||||
app.cardsOutfileControls();
|
||||
assert.strictEqual(app.state.name, 'Batch 7');
|
||||
assert.strictEqual(app.state.algorithm16, 'aes');
|
||||
assert.strictEqual(app.state.mode, 'replace');
|
||||
assert.strictEqual(app.state.cntr, '1A'); // the store's uppercase hex form
|
||||
});
|
||||
|
||||
test('picking a file previews it with the file name and the TAR defaults', async () => {
|
||||
@@ -254,6 +256,10 @@ test('picking a file previews it with the file name and the TAR defaults', async
|
||||
assert.match(els['cards-outfile-panel'].innerHTML, /id="cards-of-tar-0-keyset"/);
|
||||
assert.match(els['cards-outfile-panel'].innerHTML, /<option value="1" selected>Keyset 1 — 15\/15<\/option>/);
|
||||
assert.match(els['cards-outfile-panel'].innerHTML, /8970119000004600098/);
|
||||
// the start counter field defaults to the store's value and travels in the body
|
||||
assert.match(els['cards-outfile-panel'].innerHTML, /id="cards-of-cntr"/);
|
||||
assert.match(els['cards-outfile-panel'].innerHTML, /value="1"/);
|
||||
assert.strictEqual(body.start_counter, '1');
|
||||
});
|
||||
|
||||
test('a failing preview shows the server message in the panel', async () => {
|
||||
|
||||
@@ -325,7 +325,7 @@ test('efFlatten renders fields, services and PLMNs for display', () => {
|
||||
[['Service 5', 'Call Control by USIM']]);
|
||||
assert.deepStrictEqual(efFlatten({ plmns: [{ mcc: '262', mnc: '01', access_tech: 'GSM' }] }),
|
||||
[['PLMN 1', '262-01 (GSM)']]);
|
||||
assert.deepStrictEqual(efFlatten({ show_in_hplmn: true }), [['Show in HPLMN', 'yes']]);
|
||||
assert.deepStrictEqual(efFlatten({ 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']]);
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
assert.match(out, /IMSI/);
|
||||
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"/);
|
||||
// the label column is content-sized, not a fixed 10rem block
|
||||
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/);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const out = efRenderFieldsHtml([['ICCID', '8988'], ['Very long label', 'x']]);
|
||||
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.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'
|
||||
+ 'var pysimFsEditMode = false;\n'
|
||||
+ 'var pysimFsEditData = null;\n'
|
||||
+ 'var pysimFsDecEditMode = false;\n'
|
||||
+ 'var pysimFsDecModel = null;\n'
|
||||
+ 'var pysimFsSelected = null;\n'
|
||||
+ 'var pysimFsTreeRoot = null;\n';
|
||||
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'));
|
||||
});
|
||||
|
||||
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', () => {
|
||||
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' }];
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "pysim-simple-server"
|
||||
version = "3.31.2"
|
||||
version = "3.33.0"
|
||||
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
||||
license = { text = "GPL-2.0-or-later" }
|
||||
requires-python = ">=3.9"
|
||||
|
||||
@@ -18,7 +18,7 @@ from . import presets
|
||||
from . import test_scripts
|
||||
from . import test_suites
|
||||
from . import testscript
|
||||
from .server import PysimHandler, StderrApduTracer, _LoggingApduTracer, VERSION, _send_terminal_profile, _DefaultProactiveHandler, _handle_proactive_chain, _send_status, _init_proactive_session, _timing_on, _tlog, _set_menu_timeout, start_card_monitor, set_auto_equip, _read_iccid, _read_arr, _read_pbr, _netstate_read, _netstate_install, _LineFilter, _test_command_type, migrate_scripts_to_suites
|
||||
from .server import PysimHandler, StderrApduTracer, _LoggingApduTracer, VERSION, _send_terminal_profile, _DefaultProactiveHandler, _handle_proactive_chain, _send_status, _init_proactive_session, _timing_on, _tlog, _set_menu_timeout, start_card_monitor, set_auto_equip, _read_iccid, _read_arr, _read_pbr, _netstate_read, _netstate_install, _LineFilter, _test_command_type, migrate_scripts_to_suites, _is_pcsc_error, _clear_app_card_state
|
||||
|
||||
|
||||
_server_start = 0
|
||||
@@ -295,8 +295,26 @@ def main():
|
||||
break
|
||||
_handle_proactive_chain(scc, st_sw)
|
||||
_tlog('terminal_profile_drain: %.0fms' % ((time.time() - t_phase) * 1000))
|
||||
except Exception:
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
except Exception as e:
|
||||
if _is_pcsc_error(e):
|
||||
# The card (or the reader) went away during the startup init -
|
||||
# a normal state, not a crash. Drop the half-initialized
|
||||
# session: keeping the card object would leave the server
|
||||
# reporting a phantom equipped card, and the auto-equip would
|
||||
# then skip every later insertion (_CARD_CONNECTED is True).
|
||||
# The presence monitor equips the next card (live 2026-10-21).
|
||||
sys.stderr.write('INIT: card removed during initialization - server ready; '
|
||||
'insert a card or press Equip\n')
|
||||
iccid = None
|
||||
netstate_files = None
|
||||
arr_files = None
|
||||
pbr_files = None
|
||||
card = None
|
||||
rs = None
|
||||
scc = None
|
||||
_clear_app_card_state(app)
|
||||
else:
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
if app is not None and tracer is not None:
|
||||
if opts.apdu_trace:
|
||||
# PysimApp.__init__ routes PySimLogger through app.poutput() (app.stdout)
|
||||
|
||||
@@ -184,13 +184,23 @@ def _hex_value(tok):
|
||||
return v if v and len(v) % 2 == 0 else ''
|
||||
|
||||
|
||||
def parse_outfile(text, name='', algorithm16='3des2', tars=None, tar_keyset=None):
|
||||
def parse_outfile(text, name='', algorithm16='3des2', tars=None, tar_keyset=None,
|
||||
start_counter=None):
|
||||
"""Parse an outfile into preset dicts (the store's shape).
|
||||
|
||||
Returns ``{'presets': [...], 'report': {...}}``. Every skipped row or key
|
||||
is named in the report (up to ``SKIP_REPORT_MAX`` lines, then counted) -
|
||||
nothing is silently dropped. Raises ValueError on a malformed TAR table.
|
||||
``start_counter`` (1-10 hex digits) is written into every created keyset -
|
||||
the counter an operation against that keyset starts from; without it the
|
||||
store's default ('1') applies. Returns
|
||||
``{'presets': [...], 'report': {...}}``. Every skipped row or key is named
|
||||
in the report (up to ``SKIP_REPORT_MAX`` lines, then counted) - nothing is
|
||||
silently dropped. Raises ValueError on a malformed TAR table or counter.
|
||||
"""
|
||||
counter = None
|
||||
if start_counter not in (None, ''):
|
||||
counter = re.sub(r'\s', '', str(start_counter)).upper()
|
||||
if not re.fullmatch(r'[0-9A-F]{1,10}', counter):
|
||||
raise ValueError('start_counter must be 1-10 hex digits: %r'
|
||||
% (start_counter,))
|
||||
columns, line_index = find_columns(text)
|
||||
report = {'columns': columns or [], 'header_lines': max(line_index, 0),
|
||||
'rows': 0, 'cards': 0, 'keysets': [], 'tars': 0,
|
||||
@@ -256,8 +266,11 @@ def parse_outfile(text, name='', algorithm16='3des2', tars=None, tar_keyset=None
|
||||
% (row_no, n, len(kic_key) // 2))
|
||||
continue
|
||||
byte = '%02X' % ((n << 4) | algo)
|
||||
keysets.append({'kic': byte, 'kid': byte,
|
||||
'kicKey': kic_key, 'kidKey': kid_key})
|
||||
keyset = {'kic': byte, 'kid': byte,
|
||||
'kicKey': kic_key, 'kidKey': kid_key}
|
||||
if counter is not None:
|
||||
keyset['cntr'] = counter
|
||||
keysets.append(keyset)
|
||||
if not keysets:
|
||||
skip('line %d: no usable keyset' % row_no)
|
||||
continue
|
||||
|
||||
@@ -37,7 +37,7 @@ from osmocom.tlv import BER_TLV_IE
|
||||
from osmocom.utils import rpad
|
||||
|
||||
|
||||
VERSION = '3.31.2'
|
||||
VERSION = '3.33.0'
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
result = {'usage': '', 'description': '', 'args': []}
|
||||
lines = text.split('\n')
|
||||
@@ -5426,7 +5444,8 @@ def _ensure_transport(server):
|
||||
try:
|
||||
old.disconnect()
|
||||
except Exception as e:
|
||||
sys.stderr.write('TRANSPORT: releasing the old link failed: %s\n' % e)
|
||||
sys.stderr.write('TRANSPORT: releasing the old link failed '
|
||||
'(continuing with a fresh one): %s\n' % e)
|
||||
try:
|
||||
sl = factory()
|
||||
except Exception as e:
|
||||
@@ -5701,8 +5720,8 @@ def _auto_equip_attempt(server):
|
||||
if not _ensure_transport(server):
|
||||
return False
|
||||
server.equipping = True
|
||||
out = StringIO()
|
||||
try:
|
||||
out = StringIO()
|
||||
old_stdout, old_stderr = app.stdout, sys.stderr
|
||||
old_debug = getattr(app, 'debug', None)
|
||||
app.stdout = out
|
||||
@@ -5716,9 +5735,17 @@ def _auto_equip_attempt(server):
|
||||
app.debug = old_debug
|
||||
app.stdout = old_stdout
|
||||
sys.stderr = old_stderr
|
||||
if not getattr(server, 'card_present', False) or server.app.card is None:
|
||||
if not getattr(server, 'card_present', False):
|
||||
# A real removal: _handle_card_disconnect already cleared the
|
||||
# session, nothing to retry.
|
||||
sys.stderr.write('AUTO-EQUIP: card gone during initialization\n')
|
||||
return True
|
||||
if server.app.card is None:
|
||||
# The presence monitor still reports the card, so this is a
|
||||
# failed initialization, not a swap - never a silent no-op
|
||||
# (a Windows card swap showed an endless "card gone"
|
||||
# re-arm loop because this returned True: 2026-10-21).
|
||||
raise RuntimeError('the card is present but the init produced no card')
|
||||
if 'Traceback (most recent call last)' in out.getvalue():
|
||||
raise RuntimeError('equip reported an error (see the captured output)')
|
||||
if not _app_equip_complete(app):
|
||||
@@ -5729,12 +5756,31 @@ def _auto_equip_attempt(server):
|
||||
return True
|
||||
except Exception as e:
|
||||
sys.stderr.write('AUTO-EQUIP failed: %s\n' % e)
|
||||
# A dead transport must be rebuilt before the next attempt;
|
||||
# card-level failures reconnect on the existing link. Unequip the
|
||||
# half-initialized shell as well: a failed init can leave command
|
||||
# sets registered that the next equip would refuse to re-register.
|
||||
if _is_transport_fatal(e):
|
||||
_handle_card_disconnect(stale=True)
|
||||
# Show what pySim swallowed (cmd2 printed it into the capture):
|
||||
# without it the failure is undiagnosable in the run log.
|
||||
captured = out.getvalue().strip()
|
||||
if 'Traceback (most recent call last)' in captured:
|
||||
lines = captured.splitlines()
|
||||
start = next(i for i, l in enumerate(lines)
|
||||
if 'Traceback (most recent call last)' in l)
|
||||
body = lines[start:start + 10]
|
||||
tail = [l for l in lines[start:] if l.strip()][-3:]
|
||||
if tail and tail[-1] not in body:
|
||||
# The exception itself is the last line of the traceback;
|
||||
# a rich-box traceback can be longer than the head slice.
|
||||
body.append('...')
|
||||
body.extend(tail)
|
||||
sys.stderr.write('AUTO-EQUIP: init output:\n%s\n'
|
||||
% '\n'.join(body))
|
||||
# ANY failed attempt leaves the PC/SC card handle suspect: after a
|
||||
# card swap on Windows the link can stay unable to connect, so
|
||||
# every retry failed and the watchdog re-armed forever (live
|
||||
# 2026-10-21). Mark the transport stale so the retry rebuilds it -
|
||||
# _ensure_transport releases the old link first, so the new one
|
||||
# never inherits a stale handle (the 2026-09-24 bug).
|
||||
_handle_card_disconnect(stale=True)
|
||||
# Unequip the half-initialized shell too: a failed init can leave
|
||||
# command sets registered that the next equip would refuse.
|
||||
_clear_app_card_state(app)
|
||||
return False
|
||||
finally:
|
||||
@@ -5799,6 +5845,15 @@ class _CardPresenceObserver(CardObserver):
|
||||
with _CARD_LOCK:
|
||||
if _server_ref:
|
||||
_server_ref.card_present = True
|
||||
# A freshly inserted card gets a fresh PC/SC link: after a
|
||||
# swap the old link can stay unable to connect (seen on
|
||||
# Windows: the first init failed and only the retry's
|
||||
# rebuild recovered it), and a link created at a cardless
|
||||
# startup has never connected. Marking it here (under the
|
||||
# lock) makes the FIRST attempt rebuild - the rebuild
|
||||
# itself runs in the equip worker, never in this thread.
|
||||
global _TRANSPORT_STALE
|
||||
_TRANSPORT_STALE = True
|
||||
trigger_auto = True
|
||||
if trigger_auto and _AUTO_EQUIP:
|
||||
_auto_equip_trigger()
|
||||
@@ -9806,6 +9861,12 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
sys.stderr = old_stderr
|
||||
finally:
|
||||
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)
|
||||
err_match = re.search(r'got (\w+)', output)
|
||||
if err_match:
|
||||
@@ -9894,6 +9955,12 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
finally:
|
||||
app.stdout = old_stdout
|
||||
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)
|
||||
err_match = re.search(r'got (\w+)', output)
|
||||
if err_match:
|
||||
@@ -11610,7 +11677,8 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
name=str(body.get('name') or ''),
|
||||
algorithm16=algorithm16,
|
||||
tars=tars,
|
||||
tar_keyset=tar_keyset)
|
||||
tar_keyset=tar_keyset,
|
||||
start_counter=body.get('start_counter'))
|
||||
except ValueError as e:
|
||||
resp = {'error': str(e)}
|
||||
self._send_json(resp, 400)
|
||||
|
||||
@@ -86,4 +86,5 @@ echo.
|
||||
|
||||
echo === Setup complete ===
|
||||
echo Run start.bat to start the server.
|
||||
echo Updating later: update.bat ^(reuses .venv - pysim is not re-installed^).
|
||||
pause
|
||||
@@ -62,4 +62,5 @@ fi
|
||||
|
||||
echo ""
|
||||
echo "=== Setup complete ==="
|
||||
echo "Run ./start.sh to start the server."
|
||||
echo "Run ./start.sh to start the server."
|
||||
echo "Updating later: ./update.sh (reuses .venv - pysim is not re-installed)."
|
||||
@@ -35,8 +35,11 @@ class TestCardPresenceObserver(unittest.TestCase):
|
||||
self.trigger_mock = self.trigger.start()
|
||||
self.saved_auto = S._AUTO_EQUIP
|
||||
S._AUTO_EQUIP = True
|
||||
self.saved_stale = S._TRANSPORT_STALE
|
||||
S._TRANSPORT_STALE = False
|
||||
|
||||
def tearDown(self):
|
||||
S._TRANSPORT_STALE = self.saved_stale
|
||||
S._AUTO_EQUIP = self.saved_auto
|
||||
self.trigger.stop()
|
||||
self.patcher.stop()
|
||||
@@ -61,6 +64,16 @@ class TestCardPresenceObserver(unittest.TestCase):
|
||||
self.assertEqual(self.disconnects, [])
|
||||
self.trigger_mock.assert_called_once()
|
||||
|
||||
def test_insertion_marks_the_transport_for_a_rebuild(self):
|
||||
# The first equip attempt must already use a fresh link: after a swap
|
||||
# the old one cannot connect on Windows, and a cardless-startup link
|
||||
# never connected (v3.32.4).
|
||||
S._TRANSPORT_STALE = False
|
||||
self.server.card_present = False
|
||||
self.observer.update(None, ([FakeCard('Test Reader 00 00')], []))
|
||||
self.assertTrue(S._TRANSPORT_STALE)
|
||||
self.trigger_mock.assert_called_once()
|
||||
|
||||
def test_insertion_does_not_trigger_when_disabled(self):
|
||||
S._AUTO_EQUIP = False
|
||||
self.server.card_present = False
|
||||
|
||||
+111
-3
@@ -269,9 +269,50 @@ class AutoEquipTests(unittest.TestCase):
|
||||
self.assertTrue(server._auto_equip_attempts(srv, 3, lambda s: None))
|
||||
self.assertEqual(state['calls'], 2)
|
||||
self.assertEqual(applied, [srv])
|
||||
# the failed attempt left the handle suspect; the retry's
|
||||
# _ensure_transport (patched here) would rebuild and clear the flag
|
||||
self.assertTrue(server._TRANSPORT_STALE)
|
||||
|
||||
def test_a_failed_init_rebuilds_the_transport_before_the_retry(self):
|
||||
# The Windows swap end to end: the first init fails with the card
|
||||
# present, the retry starts from a fresh PC/SC link and succeeds -
|
||||
# and the old link is released before the new one is built.
|
||||
srv = self.make_server(None)
|
||||
released = []
|
||||
built = []
|
||||
|
||||
class Link:
|
||||
def disconnect(self):
|
||||
released.append(self)
|
||||
|
||||
old = Link()
|
||||
srv.sl = old
|
||||
srv.transport_factory = lambda: built.append(Link()) or built[-1]
|
||||
calls = {'n': 0}
|
||||
|
||||
def onecmd(cmd):
|
||||
calls['n'] += 1
|
||||
if calls['n'] == 1:
|
||||
raise RuntimeError('ProtocolError: Connection failed')
|
||||
# a completed equip installs both (the failure unequipped the shell)
|
||||
srv.app.card = SimpleNamespace(_scc='scc')
|
||||
srv.app.lchan = 'lchan'
|
||||
|
||||
srv.app.onecmd_plus_hooks = onecmd
|
||||
with mock.patch.object(server, '_apply_equipped_card', lambda s: None):
|
||||
self.assertTrue(server._auto_equip_attempts(srv, 3, lambda s: None))
|
||||
self.assertEqual(calls['n'], 2)
|
||||
self.assertEqual(len(built), 1)
|
||||
self.assertEqual(released, [old])
|
||||
self.assertIs(srv.sl, built[0])
|
||||
self.assertFalse(server._TRANSPORT_STALE)
|
||||
|
||||
def test_card_level_failure_keeps_the_transport(self):
|
||||
def test_a_failed_attempt_marks_the_transport_for_the_rebuild(self):
|
||||
# A card-level error used to keep the link: after a Windows card swap
|
||||
# the link stayed unable to connect, every retry failed and the
|
||||
# watchdog re-armed forever (live 2026-10-21). Any failed attempt now
|
||||
# marks the transport stale so the retry rebuilds it (which releases
|
||||
# the old link first - the 2026-09-24 bug).
|
||||
srv = self.make_server(None)
|
||||
|
||||
def onecmd(cmd):
|
||||
@@ -282,7 +323,7 @@ class AutoEquipTests(unittest.TestCase):
|
||||
srv.app.onecmd_plus_hooks = onecmd
|
||||
with mock.patch.object(server, '_ensure_transport', lambda s: True):
|
||||
self.assertFalse(server._auto_equip_attempt(srv))
|
||||
self.assertFalse(server._TRANSPORT_STALE)
|
||||
self.assertTrue(server._TRANSPORT_STALE)
|
||||
|
||||
def test_transport_fatal_failure_marks_the_transport(self):
|
||||
srv = self.make_server(None)
|
||||
@@ -341,7 +382,7 @@ class AutoEquipTests(unittest.TestCase):
|
||||
mock.patch.object(server, '_apply_equipped_card',
|
||||
lambda s: self.fail('_apply_equipped_card must not run')):
|
||||
self.assertFalse(server._auto_equip_attempt(srv))
|
||||
self.assertFalse(server._TRANSPORT_STALE)
|
||||
self.assertTrue(server._TRANSPORT_STALE)
|
||||
|
||||
def test_captured_equip_traceback_is_a_failure(self):
|
||||
# cmd2 prints swallowed exceptions (with cmd2 debug: a traceback) to
|
||||
@@ -358,6 +399,73 @@ class AutoEquipTests(unittest.TestCase):
|
||||
lambda s: self.fail('_apply_equipped_card must not run')):
|
||||
self.assertFalse(server._auto_equip_attempt(srv))
|
||||
|
||||
def test_a_card_present_init_without_a_card_is_a_failure(self):
|
||||
# The Windows loop: the presence monitor still reports the card, the
|
||||
# init produced nothing - it must retry (and rebuild), never report a
|
||||
# silent "card gone" success that re-arms forever (2026-10-21).
|
||||
srv = self.make_server(None)
|
||||
|
||||
def onecmd(cmd):
|
||||
pass # cmd2 swallowed the real error: no card built
|
||||
|
||||
srv.app.onecmd_plus_hooks = onecmd
|
||||
with mock.patch.object(server, '_ensure_transport', lambda s: True):
|
||||
self.assertFalse(server._auto_equip_attempt(srv))
|
||||
self.assertTrue(server._TRANSPORT_STALE)
|
||||
|
||||
def test_a_card_removed_during_the_init_is_still_a_silent_no_op(self):
|
||||
srv = self.make_server(None)
|
||||
|
||||
def onecmd(cmd):
|
||||
srv.card_present = False # the monitor saw the removal mid-init
|
||||
|
||||
srv.app.onecmd_plus_hooks = onecmd
|
||||
with mock.patch.object(server, '_ensure_transport', lambda s: True), \
|
||||
mock.patch('sys.stderr', new=StringIO()) as err:
|
||||
self.assertTrue(server._auto_equip_attempt(srv))
|
||||
self.assertIn('card gone during initialization', err.getvalue())
|
||||
self.assertFalse(server._TRANSPORT_STALE)
|
||||
|
||||
def test_the_swallowed_traceback_reaches_the_log(self):
|
||||
srv = self.make_server(None)
|
||||
|
||||
def onecmd(cmd):
|
||||
srv.app.card = SimpleNamespace(_scc='scc')
|
||||
sys.stderr.write('Traceback (most recent call last):\n'
|
||||
' File "x", line 1, in equip\n'
|
||||
'ProtocolError: Connection failed\n')
|
||||
|
||||
srv.app.onecmd_plus_hooks = onecmd
|
||||
with mock.patch.object(server, '_ensure_transport', lambda s: True), \
|
||||
mock.patch.object(server, '_apply_equipped_card', lambda s: None), \
|
||||
mock.patch('sys.stderr', new=StringIO()) as err:
|
||||
self.assertFalse(server._auto_equip_attempt(srv))
|
||||
out = err.getvalue()
|
||||
self.assertIn('AUTO-EQUIP: init output:', out)
|
||||
self.assertIn('ProtocolError: Connection failed', out)
|
||||
|
||||
def test_a_long_traceback_keeps_its_exception_line(self):
|
||||
# cmd2 renders a rich-box traceback; a fixed head slice cut the
|
||||
# exception itself off (seen live on Windows 2026-10-21), so the tail
|
||||
# is always included.
|
||||
srv = self.make_server(None)
|
||||
|
||||
def onecmd(cmd):
|
||||
srv.app.card = SimpleNamespace(_scc='scc')
|
||||
sys.stderr.write('Traceback (most recent call last):\n')
|
||||
for i in range(30):
|
||||
sys.stderr.write(' File "f%d", line %d, in step%d\n' % (i, i, i))
|
||||
sys.stderr.write('CardConnectionException: No card is inserted\n')
|
||||
|
||||
srv.app.onecmd_plus_hooks = onecmd
|
||||
with mock.patch.object(server, '_ensure_transport', lambda s: True), \
|
||||
mock.patch.object(server, '_apply_equipped_card', lambda s: None), \
|
||||
mock.patch('sys.stderr', new=StringIO()) as err:
|
||||
self.assertFalse(server._auto_equip_attempt(srv))
|
||||
out = err.getvalue()
|
||||
self.assertIn('AUTO-EQUIP: init output:', out)
|
||||
self.assertIn('CardConnectionException: No card is inserted', out)
|
||||
|
||||
def test_failed_attempt_unequips_the_half_initialized_shell(self):
|
||||
calls = []
|
||||
srv = self.make_server(None)
|
||||
|
||||
@@ -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()
|
||||
@@ -979,6 +979,26 @@ class PresetStoreHttpTests(unittest.TestCase):
|
||||
self.assertEqual(status, 400, resp)
|
||||
self.assertIn('the file is invalid', resp['error'])
|
||||
self.assertEqual(len(self.store.list()), before)
|
||||
# the import form's start counter (v3.32.1): every created keyset
|
||||
# carries it; an unusable value is a 400
|
||||
status, resp = self._post('/api/presets/import-outfile', {
|
||||
'text': text, 'start_counter': '1A'})
|
||||
self.assertEqual(status, 200, resp)
|
||||
self.assertEqual([k.get('cntr') for k in resp['presets'][0]['keysets']],
|
||||
['1A'] * len(resp['presets'][0]['keysets']))
|
||||
status, resp = self._post('/api/presets/import-outfile', {
|
||||
'text': text, 'start_counter': 'XYZ'})
|
||||
self.assertEqual(status, 400, resp)
|
||||
self.assertIn('start_counter', resp['error'])
|
||||
# ...and an import stores the protocol's padded form (a fresh ICCID:
|
||||
# merge mode skips a duplicate)
|
||||
fresh = text.replace('8970119000004600098', '8970119000004600999')
|
||||
status, resp = self._post('/api/presets/import-outfile', {
|
||||
'text': fresh, 'start_counter': '1A', 'mode': 'merge'})
|
||||
self.assertEqual(status, 200, resp)
|
||||
got = [p for p in self.store.list()
|
||||
if p['iccid'] == '8970119000004600999']
|
||||
self.assertEqual([k['cntr'] for k in got[0]['keysets']], ['000000001A'])
|
||||
# a non-list TAR table is the caller's error, never a silent fallback
|
||||
status, resp = self._post('/api/presets/import-outfile', {
|
||||
'text': text, 'tars': 'nope'})
|
||||
|
||||
@@ -219,6 +219,26 @@ class OptionTests(unittest.TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
outfile.parse_outfile(text, tars=[{'tar': '000000', 'msl': '16', 'keyset': 'x'}])
|
||||
|
||||
def test_the_start_counter_is_written_into_every_keyset(self):
|
||||
"""The import form's "Start counter value": every created keyset
|
||||
carries it (the store pads it to the protocol's 10-digit form); without
|
||||
it the store's default ('1') applies. An unusable value is the
|
||||
caller's error, never a silent default (v3.32.1)."""
|
||||
res = outfile.parse_outfile(alfa_text(), start_counter='1a')
|
||||
ks = res['presets'][0]['keysets']
|
||||
self.assertGreaterEqual(len(ks), 2)
|
||||
self.assertEqual([k.get('cntr') for k in ks], ['1A'] * len(ks))
|
||||
# normalised: whitespace stripped, uppercase
|
||||
res = outfile.parse_outfile(alfa_text(), start_counter=' a1 ')
|
||||
self.assertEqual(res['presets'][0]['keysets'][0]['cntr'], 'A1')
|
||||
# unset: no field at all - the store fills its default
|
||||
res = outfile.parse_outfile(alfa_text())
|
||||
self.assertNotIn('cntr', res['presets'][0]['keysets'][0])
|
||||
# an unusable value refuses the whole parse
|
||||
for bad in ('XYZ', '1G', '1234567890A', ' '):
|
||||
with self.assertRaises(ValueError):
|
||||
outfile.parse_outfile(alfa_text(), start_counter=bad)
|
||||
|
||||
def test_a_malformed_tar_table_raises(self):
|
||||
with self.assertRaises(ValueError):
|
||||
outfile.parse_outfile(alfa_text(), tars=[{'tar': 'ZZ', 'msl': '16'}])
|
||||
|
||||
@@ -69,6 +69,8 @@ class ReleasePackageTests(unittest.TestCase):
|
||||
'pysim_simple_server/data/mcc-mnc-list.json',
|
||||
'pysim_simple_server/data/mcc-mnc-list.LICENSE',
|
||||
'setup.sh', 'setup.bat', 'start.sh', 'start.bat',
|
||||
'update.sh', 'update.bat', 'tools/update.py',
|
||||
'tools/changelog_section.py', 'CHANGELOG.md',
|
||||
'pyproject.toml', 'requirements-pysim.txt',
|
||||
'README.md', 'README_RUS.md', 'LICENSE', 'THIRD-PARTY-NOTICES',
|
||||
'docs/api.md'):
|
||||
|
||||
@@ -8,6 +8,7 @@ line and no traceback, and the pySim-internal 'Waiting for card...' /
|
||||
failure keeps the stock-pysim fallback and its traceback.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import io
|
||||
import sys
|
||||
import unittest
|
||||
@@ -158,3 +159,28 @@ def _parser():
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
class StartupCardLostTests(unittest.TestCase):
|
||||
"""A card removed during the startup init leaves the server cardless.
|
||||
|
||||
The startup code used to keep the card object it had already built:
|
||||
``card_present`` and ``_CARD_CONNECTED`` stayed True, so the server
|
||||
reported a phantom equipped card and the auto-equip skipped every later
|
||||
insertion (its ``_CARD_CONNECTED`` early return). The PC/SC error must
|
||||
drop the half-initialized session instead - and not print a traceback for
|
||||
a normal removal."""
|
||||
|
||||
def test_a_pcsc_interruption_drops_the_session(self):
|
||||
src = inspect.getsource(srv_main.main)
|
||||
self.assertIn('_is_pcsc_error(e)', src)
|
||||
self.assertIn('card removed during initialization', src)
|
||||
self.assertIn('_clear_app_card_state(app)', src)
|
||||
# the locals that feed card_present / _CARD_CONNECTED are cleared
|
||||
for line in ('iccid = None', 'card = None', 'rs = None', 'scc = None'):
|
||||
self.assertIn(line, src)
|
||||
|
||||
def test_the_session_state_is_derived_from_the_cleared_card(self):
|
||||
# the assignments that would otherwise report a phantom card
|
||||
src = inspect.getsource(srv_main.main)
|
||||
self.assertIn('server.card_present = card is not None', src)
|
||||
self.assertIn('_CARD_CONNECTED = card is not None', src)
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Updating a packaged install in place (tools/update.py).
|
||||
|
||||
The update replaces the application files, keeps the .venv (pysim and its
|
||||
prerequisites - the slow part of a reinstall) and the run log, preserves the
|
||||
scripts' exec bits, and refuses a package whose version does not match or that
|
||||
carries unsafe paths. tests/test_release_package.py pins that the update
|
||||
scripts are in the package.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
UPDATE = REPO / 'tools' / 'update.py'
|
||||
CHANGELOG_SECTION = REPO / 'tools' / 'changelog_section.py'
|
||||
|
||||
|
||||
def _load(path, name):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _package(path, version, entries=None):
|
||||
"""A minimal release package: simple-<version>/... with the real updater."""
|
||||
root = 'simple-%s/' % version
|
||||
with zipfile.ZipFile(path, 'w') as z:
|
||||
def add(name, data, mode=0o644):
|
||||
info = zipfile.ZipInfo(root + name)
|
||||
info.external_attr = mode << 16
|
||||
z.writestr(info, data)
|
||||
add('pysim_simple_server/server.py', "VERSION = '%s'\n" % version)
|
||||
add('README.md', '# readme %s\n' % version)
|
||||
add('update.sh', '#!/bin/sh\n', 0o755)
|
||||
add('tools/update.py', UPDATE.read_text(encoding='utf-8'))
|
||||
for name, data in (entries or {}).items():
|
||||
add(name, data)
|
||||
return path
|
||||
|
||||
|
||||
def _app(tmp):
|
||||
"""A simulated packaged install: app files, a .venv and the run log."""
|
||||
app = Path(tmp) / 'app'
|
||||
(app / 'tools').mkdir(parents=True)
|
||||
(app / 'tools' / 'update.py').write_text(UPDATE.read_text(encoding='utf-8'))
|
||||
(app / 'pysim_simple_server').mkdir()
|
||||
(app / 'pysim_simple_server' / 'server.py').write_text("VERSION = '1.0.0'\n")
|
||||
(app / 'README.md').write_text('# old\n')
|
||||
(app / '.venv').mkdir()
|
||||
(app / '.venv' / 'MARKER').write_text('venv')
|
||||
(app / 'simple_lastrun.log').write_text('log')
|
||||
return app
|
||||
|
||||
|
||||
class InPlaceUpdateTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.dir = Path(self.tmp.name)
|
||||
self.app = _app(self.tmp.name)
|
||||
self.zip = _package(self.dir / 'simple-2.0.0.zip', '2.0.0',
|
||||
{'frontend/new-asset.txt': 'new'})
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def run_update(self, *args):
|
||||
return subprocess.run(
|
||||
[sys.executable, str(self.app / 'tools' / 'update.py')] + list(args),
|
||||
capture_output=True, text=True)
|
||||
|
||||
def test_the_files_are_replaced_and_the_venv_and_log_kept(self):
|
||||
r = self.run_update('--zip', str(self.zip), '--no-pip')
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
self.assertIn("VERSION = '2.0.0'",
|
||||
(self.app / 'pysim_simple_server' / 'server.py').read_text())
|
||||
self.assertEqual((self.app / 'README.md').read_text(), '# readme 2.0.0\n')
|
||||
self.assertEqual((self.app / 'frontend' / 'new-asset.txt').read_text(), 'new')
|
||||
# pysim lives in .venv - it must survive untouched, as does the run log
|
||||
self.assertEqual((self.app / '.venv' / 'MARKER').read_text(), 'venv')
|
||||
self.assertEqual((self.app / 'simple_lastrun.log').read_text(), 'log')
|
||||
self.assertIn('restart the server', r.stdout)
|
||||
|
||||
@unittest.skipIf(os.name == 'nt', 'POSIX permission bits')
|
||||
def test_the_scripts_keep_their_exec_bits(self):
|
||||
self.run_update('--zip', str(self.zip), '--no-pip')
|
||||
# zipfile drops the mode on extraction; the updater restores it
|
||||
self.assertTrue(os.access(self.app / 'update.sh', os.X_OK))
|
||||
|
||||
def test_the_same_version_is_not_reapplied_unless_forced(self):
|
||||
self.run_update('--zip', str(self.zip), '--no-pip')
|
||||
r = self.run_update('--zip', str(self.zip), '--no-pip')
|
||||
self.assertEqual(r.returncode, 0)
|
||||
self.assertIn('already up to date', r.stdout)
|
||||
(self.app / 'README.md').write_text('# edited\n')
|
||||
r = self.run_update('--zip', str(self.zip), '--no-pip', '--force')
|
||||
self.assertEqual(r.returncode, 0)
|
||||
self.assertEqual((self.app / 'README.md').read_text(), '# readme 2.0.0\n')
|
||||
|
||||
def test_check_reports_both_versions_without_touching_anything(self):
|
||||
r = self.run_update('--check', '--version', '2.0.0')
|
||||
self.assertEqual(r.returncode, 0)
|
||||
self.assertIn('current: 1.0.0', r.stdout)
|
||||
self.assertIn('latest: 2.0.0', r.stdout)
|
||||
self.assertEqual((self.app / 'README.md').read_text(), '# old\n')
|
||||
|
||||
def test_a_version_mismatch_is_refused(self):
|
||||
bad = self.dir / 'simple-9.9.9.zip'
|
||||
_package(bad, '8.8.8') # the file name says 9.9.9
|
||||
r = self.run_update('--zip', str(bad), '--no-pip', '--version', '9.9.9')
|
||||
self.assertNotEqual(r.returncode, 0)
|
||||
self.assertIn('does not match', r.stderr)
|
||||
self.assertEqual((self.app / 'pysim_simple_server' / 'server.py').read_text(),
|
||||
"VERSION = '1.0.0'\n")
|
||||
|
||||
def test_an_unsafe_path_is_refused(self):
|
||||
bad = self.dir / 'simple-2.0.0-evil.zip'
|
||||
with zipfile.ZipFile(bad, 'w') as z:
|
||||
z.writestr('simple-2.0.0/../../evil.txt', 'x')
|
||||
z.writestr('simple-2.0.0/pysim_simple_server/server.py', "VERSION = '2.0.0'\n")
|
||||
r = self.run_update('--zip', str(bad), '--no-pip')
|
||||
self.assertNotEqual(r.returncode, 0)
|
||||
self.assertIn('unsafe path', r.stderr)
|
||||
self.assertFalse((self.dir / 'evil.txt').exists())
|
||||
|
||||
|
||||
class ChangelogSectionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.mod = _load(CHANGELOG_SECTION, 'changelog_section')
|
||||
|
||||
def test_the_current_version_has_a_section(self):
|
||||
# the server version is the single source of truth
|
||||
text = (REPO / 'pysim_simple_server' / 'server.py').read_text(encoding='utf-8')
|
||||
ver = re.search(r"^VERSION = '([^']+)'$", text, re.M).group(1)
|
||||
body = self.mod.section(ver)
|
||||
self.assertIsNotNone(body, 'CHANGELOG.md has no [%s] section' % ver)
|
||||
self.assertIn('### ', body)
|
||||
|
||||
def test_an_unknown_version_has_no_section(self):
|
||||
self.assertIsNone(self.mod.section('0.0.0'))
|
||||
|
||||
def test_the_helper_exits_non_zero_for_an_unknown_version(self):
|
||||
r = subprocess.run([sys.executable, str(CHANGELOG_SECTION), '0.0.0'],
|
||||
capture_output=True, text=True)
|
||||
self.assertNotEqual(r.returncode, 0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -37,8 +37,9 @@ PACKAGE_ENTRIES = (
|
||||
'docs',
|
||||
'tools',
|
||||
'setup.sh', 'setup.bat', 'start.sh', 'start.bat',
|
||||
'update.sh', 'update.bat',
|
||||
'pyproject.toml', 'requirements-pysim.txt',
|
||||
'README.md', 'README_RUS.md',
|
||||
'README.md', 'README_RUS.md', 'CHANGELOG.md',
|
||||
'LICENSE', 'THIRD-PARTY-NOTICES',
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Print the CHANGELOG.md section of a version (the release notes body).
|
||||
|
||||
Usage: python tools/changelog_section.py X.Y.Z
|
||||
Exits non-zero when the version has no section - the workflow then falls back
|
||||
to generated notes.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
CHANGELOG = Path(__file__).resolve().parent.parent / 'CHANGELOG.md'
|
||||
|
||||
|
||||
def section(version):
|
||||
text = CHANGELOG.read_text(encoding='utf-8')
|
||||
pat = re.compile(r'^## \[%s\][^\n]*\n(.*?)(?=^## |\Z)'
|
||||
% re.escape(version), re.M | re.S)
|
||||
m = pat.search(text)
|
||||
if not m:
|
||||
return None
|
||||
body = m.group(1).strip()
|
||||
return body + '\n' if body else None
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) != 2:
|
||||
sys.exit('usage: changelog_section.py X.Y.Z')
|
||||
body = section(argv[1])
|
||||
if body is None:
|
||||
sys.exit('error: no CHANGELOG.md section for %s' % argv[1])
|
||||
sys.stdout.write(body)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv))
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Update a packaged SIMple install in place.
|
||||
|
||||
The release package is a snapshot of the application, and the slow part of a
|
||||
reinstall is pysim and its prerequisites (built into ``.venv`` by setup -
|
||||
pyscard compiles from source on Linux). This script downloads the latest
|
||||
release package, replaces the application files in place and re-installs the
|
||||
app into the **existing** ``.venv``: pysim is kept, so an update takes seconds.
|
||||
|
||||
Usage:
|
||||
python tools/update.py [--check] [--version X.Y.Z] [--zip FILE] [--no-pip] [--force]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
APP = Path(__file__).resolve().parent.parent
|
||||
REPO_API = 'https://api.github.com/repos/anttro/simple/releases/latest'
|
||||
ASSET_URL = 'https://github.com/anttro/simple/releases/download/v%s/simple-%s.zip'
|
||||
UA = {'User-Agent': 'simple-update'}
|
||||
# Never overwritten even if a package carried them (it never does).
|
||||
SKIP_TOP = {'.venv', 'dist', '__pycache__', '.git'}
|
||||
|
||||
|
||||
def current_version():
|
||||
"""The installed app's VERSION - the same source the build reads."""
|
||||
text = (APP / 'pysim_simple_server' / 'server.py').read_text(encoding='utf-8')
|
||||
m = re.search(r"^VERSION = '([^']+)'$", text, re.M)
|
||||
if not m:
|
||||
sys.exit('error: cannot read VERSION from pysim_simple_server/server.py')
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def latest_version():
|
||||
req = urllib.request.Request(REPO_API, headers=UA)
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
tag = (json.load(r).get('tag_name') or '').strip()
|
||||
if not tag.startswith('v') or len(tag) < 2:
|
||||
sys.exit('error: unexpected release tag %r' % tag)
|
||||
return tag[1:]
|
||||
|
||||
|
||||
def download(version, dest):
|
||||
url = ASSET_URL % (version, version)
|
||||
req = urllib.request.Request(url, headers=UA)
|
||||
with urllib.request.urlopen(req, timeout=120) as r, open(dest, 'wb') as f:
|
||||
shutil.copyfileobj(r, f)
|
||||
|
||||
|
||||
def safe_extract(zf, dest):
|
||||
"""Extract, refusing paths outside dest and restoring the permission bits
|
||||
(zipfile drops them, which would strip +x from setup.sh/start.sh/update.sh)."""
|
||||
dest = Path(dest).resolve()
|
||||
for info in zf.infolist():
|
||||
target = (dest / info.filename).resolve()
|
||||
if target != dest and dest not in target.parents:
|
||||
sys.exit('error: unsafe path in the package: %s' % info.filename)
|
||||
zf.extractall(dest)
|
||||
for info in zf.infolist():
|
||||
mode = (info.external_attr >> 16) & 0o777
|
||||
if mode and not info.is_dir():
|
||||
try:
|
||||
os.chmod(dest / info.filename, mode)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def verify(root, version):
|
||||
"""The package must be the version it claims (the tag is the version)."""
|
||||
src = root / 'pysim_simple_server' / 'server.py'
|
||||
if not src.is_file():
|
||||
sys.exit('error: the package lacks pysim_simple_server/server.py')
|
||||
m = re.search(r"^VERSION = '([^']+)'$", src.read_text(encoding='utf-8'), re.M)
|
||||
if not m or m.group(1) != version:
|
||||
sys.exit('error: the package version (%s) does not match %s'
|
||||
% (m.group(1) if m else '?', version))
|
||||
|
||||
|
||||
def replace_tree(new_root):
|
||||
"""Copy the new package over the app directory; the venv and the run log
|
||||
(neither is in the package) stay untouched."""
|
||||
count = 0
|
||||
for src in sorted(new_root.rglob('*')):
|
||||
if not src.is_file():
|
||||
continue
|
||||
rel = src.relative_to(new_root)
|
||||
if rel.parts[0] in SKIP_TOP or '__pycache__' in rel.parts or src.suffix == '.pyc':
|
||||
continue
|
||||
dst = APP / rel
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dst)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def venv_python():
|
||||
for rel in ('bin/python', 'Scripts/python.exe'):
|
||||
p = APP / '.venv' / rel
|
||||
if p.exists():
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def reinstall():
|
||||
py = venv_python()
|
||||
if py is None:
|
||||
print('note: no .venv in %s - run setup.sh / setup.bat (or install manually)' % APP)
|
||||
return False
|
||||
# --no-build-isolation: setuptools is in the venv (setup installed it);
|
||||
# this keeps the update working offline.
|
||||
r = subprocess.run([str(py), '-m', 'pip', 'install', '--no-build-isolation',
|
||||
'-e', str(APP)], capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
print('warning: the files were updated, but "pip install -e" failed:')
|
||||
print((r.stderr or r.stdout).strip()[-800:])
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description='Update a packaged SIMple install in place.')
|
||||
ap.add_argument('--check', action='store_true',
|
||||
help='print the current and latest versions and exit')
|
||||
ap.add_argument('--version', metavar='X.Y.Z',
|
||||
help='update to this release (default: the latest)')
|
||||
ap.add_argument('--zip', metavar='FILE',
|
||||
help='update from a local package file (offline)')
|
||||
ap.add_argument('--no-pip', action='store_true',
|
||||
help='replace the files only (do not re-install into .venv)')
|
||||
ap.add_argument('--force', action='store_true', help='re-apply the same version')
|
||||
args = ap.parse_args()
|
||||
|
||||
cur = current_version()
|
||||
version = args.version
|
||||
if not version and not args.zip:
|
||||
try:
|
||||
version = latest_version()
|
||||
except Exception as e:
|
||||
sys.exit('error: cannot reach GitHub (%s); pass --version X.Y.Z' % e)
|
||||
if args.check:
|
||||
print('current: %s' % cur)
|
||||
print('latest: %s' % (version or '?'))
|
||||
return 0
|
||||
if not version:
|
||||
m = re.search(r'simple-([0-9][^\\/]*)\.zip$', str(args.zip))
|
||||
if not m:
|
||||
sys.exit('error: cannot tell the package version; pass --version')
|
||||
version = m.group(1)
|
||||
if version == cur and not args.force:
|
||||
print('already up to date (%s)' % cur)
|
||||
return 0
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix='simple-update-') as tmp:
|
||||
tmp = Path(tmp)
|
||||
if args.zip:
|
||||
zpath = Path(args.zip)
|
||||
if not zpath.is_file():
|
||||
sys.exit('error: %s not found' % zpath)
|
||||
else:
|
||||
zpath = tmp / 'package.zip'
|
||||
print('downloading %s ...' % (ASSET_URL % (version, version)))
|
||||
try:
|
||||
download(version, zpath)
|
||||
except Exception as e:
|
||||
sys.exit('error: download failed (%s)' % e)
|
||||
try:
|
||||
with zipfile.ZipFile(zpath) as zf:
|
||||
safe_extract(zf, tmp)
|
||||
except zipfile.BadZipFile as e:
|
||||
sys.exit('error: %s is not a valid package: %s' % (zpath, e))
|
||||
root = tmp / ('simple-%s' % version)
|
||||
if not root.is_dir():
|
||||
found = [p for p in tmp.iterdir()
|
||||
if p.is_dir() and (p / 'pysim_simple_server').is_dir()]
|
||||
if len(found) != 1:
|
||||
sys.exit('error: the package layout is unexpected')
|
||||
root = found[0]
|
||||
verify(root, version)
|
||||
count = replace_tree(root)
|
||||
|
||||
print('updated %s -> %s (%d files)' % (cur, version, count))
|
||||
if args.no_pip:
|
||||
print('note: --no-pip - .venv was left untouched')
|
||||
elif reinstall():
|
||||
print('app re-installed into .venv (pysim kept)')
|
||||
print('restart the server: ./start.sh (or start.bat)')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
@echo off
|
||||
REM Update this packaged SIMple install in place (see tools\update.py --help).
|
||||
REM The .venv is reused - pysim and its prerequisites are not re-installed.
|
||||
if exist "%~dp0.venv\Scripts\python.exe" (
|
||||
"%~dp0.venv\Scripts\python.exe" "%~dp0tools\update.py" %*
|
||||
) else (
|
||||
python "%~dp0tools\update.py" %*
|
||||
)
|
||||
pause
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# Update this packaged SIMple install in place (see tools/update.py --help).
|
||||
# The .venv is reused - pysim and its prerequisites are not re-installed.
|
||||
set -e
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
if [ -x "$SCRIPT_DIR/.venv/bin/python" ]; then
|
||||
PY="$SCRIPT_DIR/.venv/bin/python"
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
PY=python3
|
||||
else
|
||||
PY=python
|
||||
fi
|
||||
exec "$PY" "$SCRIPT_DIR/tools/update.py" "$@"
|
||||
Reference in New Issue
Block a user