Compare commits
67 Commits
47539c594f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0370fa58ad | |||
| 2213e25385 | |||
| bf8abbb0fd | |||
| a9a4d03b09 | |||
| fb6f80ef1d | |||
| 2494e04984 | |||
| b49e6728b4 | |||
| dfb9551eb7 | |||
| 8459040856 | |||
| a5cccee17c | |||
| 2a559371ae | |||
| e0e86e31af | |||
| 2ec7a2d6c0 | |||
| 26f0063696 | |||
| ddc6cb8f9d | |||
| 85a66af7a5 | |||
| 92271d6726 | |||
| 4598a70db2 | |||
| 82fb2c8800 | |||
| f16e24ba11 | |||
| 8b814eb2f8 | |||
| 56c0e60462 | |||
| 68aa37f093 | |||
| 5485d61390 | |||
| 45c72d874f | |||
| 1fe5c6347d | |||
| c4ed064318 | |||
| 2a43424ae6 | |||
| 5b9e821d6d | |||
| f1ed6a2a48 | |||
| 4b19b58bb0 | |||
| 6f63b5172c | |||
| 6d30e7095e | |||
| a261675aad | |||
| 450f1de68a | |||
| 74efc20bf5 | |||
| d2bd2ed798 | |||
| ccd0b6018c | |||
| 9363f209ee | |||
| 33443d4f28 | |||
| fe10603aea | |||
| 57eb412b6d | |||
| 7288c22830 | |||
| 25787017e5 | |||
| b50be83da4 | |||
| 6c57ff4fd5 | |||
| 895d0b7b36 | |||
| ef00b2c3f4 | |||
| 0fa9548c59 | |||
| 30ff0f7a74 | |||
| 5d096453fb | |||
| d728a5a341 | |||
| f64f2dac7c | |||
| 612f267349 | |||
| 00c84307aa | |||
| efbdb5024c | |||
| fa4064f1e8 | |||
| 71da10b637 | |||
| aa6a9ed1b5 | |||
| 36281e02fd | |||
| 88f67f5e13 | |||
| 994075920d | |||
| 130b9d7e3b | |||
| 1e067dc883 | |||
| 6bfbb00994 | |||
| 944fccf67b | |||
| 758603a9ad |
@@ -4,3 +4,4 @@ __pycache__/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
AGENTS.md
|
||||
|
||||
@@ -31,13 +31,13 @@ npm run build
|
||||
|
||||
## Interface
|
||||
|
||||
Four top-level tabs: **C-APDU**, **SCP80**, **Response parser**, **Card reader**. The C-APDU and SCP80 tabs each have sub-tabs.
|
||||
Five top-level tabs: **Remote APDU**, **SCP80**, **Profiler**, **Card reader**, and **Phone simulator**. **Remote APDU** and **SCP80** use pill sub-tabs; the Card reader tab has three sub-tabs: **File manager**, **pySim command line**, and **Raw APDU**; the Profiler tab lists **Profiles**, **Card snapshots**, and **Custom files**.
|
||||
|
||||
---
|
||||
|
||||
## C-APDU tab
|
||||
## Remote APDU tab
|
||||
|
||||
Builds command APDUs (C-APDUs). Five sub-tabs cover different card generations and command sets.
|
||||
Builds command APDUs (C-APDUs). Seven sub-tabs cover different card generations, command sets and decoding tools: **SIM RFM**, **USIM RFM**, **Expanded Script**, **RAM/GP**, **HTTP OTA**, **C-APDU Parser**, and **Response parser**.
|
||||
|
||||
### SIM RFM
|
||||
|
||||
@@ -312,8 +312,41 @@ Swaps nibble pairs of an even-length hex string.
|
||||
- ETSI TS 102 225: Secured packet structure for (U)SIM toolkit
|
||||
- pySim: enc_imsi() implementation
|
||||
|
||||
### C-APDU Parser
|
||||
|
||||
Pastes raw APDU hex and renders a collapsible tree. It auto-detects the container: an **Expanded Script** (leading `AA` or `AE80`, decoded per ETSI TS 102 226 §5.2.1) or a **Compact C-APDU chain** (a sequence of ISO 7816 C-APDUs). Each node shows its label, hex, and a short description; parent nodes expand to reveal their sub-elements.
|
||||
|
||||
### HTTP OTA
|
||||
|
||||
Builds the Remote Application Management over HTTP payloads defined in GlobalPlatform **GPC v2.2 Amendment B v1.1** (§4.7). Two modes:
|
||||
|
||||
- **Trigger (Push SMS)** — administration session triggering parameters (`81 > 83 > 84/[85]/[86]/89`, Table 4-3). This is the message that asks the card's Security Domain to dial out and start an HTTP session.
|
||||
- **Store (SD admin params)** — writes the same parameters as card (Security Domain) data via **STORE DATA in TLV mode** (`80 E2 90 00`, P1=90 = last block + BER-TLV per GP v2.2 Amendment B v1.1.3), wrapped in tag `85` (or `A5`) per Table 4-4.
|
||||
|
||||
| Section | Tag | Contents |
|
||||
|---|---|---|
|
||||
| Connection parameters | `84` | COMPREHENSION-TLVs needed to open the TCP connection (OPEN CHANNEL per TS 102 223): Device Identities `02`, Alpha `80`, Bearer `01`, vendor TLVs. Row editor + presets, editable hex. |
|
||||
| Security parameters | `85` | Table 4-6: LV PSK Identity (text), LV Key version/KID. Identifies the PSK TLS key (RFC 4279). |
|
||||
| Retry policy | `86` | Table 4-7: retry counter (2 bytes, e.g. `B000`), retry waiting delay as the TS 102 223 timer TLV (`25 03 HH MM SS`), optional vendor-specific report-failure TLV. |
|
||||
| HTTP POST | `89` | Tables 4-8/9/10: Host header (`8A`), X-Admin-From agent ID (`8B`), URI (`8C`) — text converted to octets. |
|
||||
|
||||
The **Command Scripting template** checkbox wraps the whole `81` triggering command in the definite-length Expanded Remote Application data format (`AA`, ETSI TS 102 226 §5.2.1) for TARs that process the expanded format. **Pack into Secured packet** sends the built payload to the SCP80 tab for SPI/counter filling — insert the TAR the SD listens on (typically the OTASD TAR) there.
|
||||
|
||||
---
|
||||
|
||||
|
||||
### Response parser
|
||||
|
||||
Decodes a raw command response: pick the command that was sent, enter the SW (e.g. `9000`) and the response data hex, then press **Decode**.
|
||||
|
||||
- **Command** — SIM/USIM group (SELECT, STATUS, READ/UPDATE, PIN ops, CAT commands like TERMINAL PROFILE/ENVELOPE/FETCH/TERMINAL RESPONSE, MANAGE CHANNEL, ...) or RAM/GP group (INSTALL, LOAD, DELETE, GET/STORE DATA, auth, SCP commands).
|
||||
- **SW decode** — status words resolved against generic, UICC (TS 102 221), and GlobalPlatform maps, with context auto-detected.
|
||||
- **Privilege decode** — GET DATA / INSTALL response payloads decode the privilege bytes into human-readable flags.
|
||||
- **Response data** — raw hex rendered and interpreted per command (e.g. SELECT FCP templates).
|
||||
|
||||
---
|
||||
|
||||
|
||||
## SCP80 tab
|
||||
|
||||
The **SCP80** top-level tab groups SCP80-related views, switched by three pills: **Secured Packet**, **Cards**, and **RAM**. Assembles secured packets per ETSI TS 102 225.
|
||||
@@ -443,17 +476,6 @@ Delete confirms via a browser prompt before sending the GP `DELETE` command via
|
||||
|
||||
---
|
||||
|
||||
## Response parser tab
|
||||
|
||||
Decodes a raw command response: pick the command that was sent, enter the SW (e.g. `9000`) and the response data hex, then press **Decode**.
|
||||
|
||||
- **Command** — SIM/USIM group (SELECT, STATUS, READ/UPDATE, PIN ops, CAT commands like TERMINAL PROFILE/ENVELOPE/FETCH/TERMINAL RESPONSE, MANAGE CHANNEL, ...) or RAM/GP group (INSTALL, LOAD, DELETE, GET/STORE DATA, auth, SCP commands).
|
||||
- **SW decode** — status words resolved against generic, UICC (TS 102 221), and GlobalPlatform maps, with context auto-detected.
|
||||
- **Privilege decode** — GET DATA / INSTALL response payloads decode the privilege bytes into human-readable flags.
|
||||
- **Response data** — raw hex rendered and interpreted per command (e.g. SELECT FCP templates).
|
||||
|
||||
---
|
||||
|
||||
## Card Reader (pySim integration)
|
||||
|
||||
Connects to the bundled [`pysim-otaman-server`](pysim_otaman_server/) for live card operations.
|
||||
@@ -464,24 +486,69 @@ Connects to the bundled [`pysim-otaman-server`](pysim_otaman_server/) for live c
|
||||
|
||||
Browse the UICC filesystem in a tree view. Files are shown with names, FIDs, and AIDs (for ADFs). Click to read contents.
|
||||
|
||||
- Entries are grouped with DFs above EFs and sorted by **FID** or symbolic **Name** (pills above the tree, remembered in `localStorage`)
|
||||
- **Read** — reads the selected file (auto-detects transparent vs record files)
|
||||
- **Edit** — switch to edit mode, modify hex data, click **Save** to write back
|
||||
- **Raw / Decoded** — toggle between hex dump and pysim-decoded JSON view
|
||||
- Selecting a file shows its FID, file type, size / record layout and the decoded FCI above the contents
|
||||
- Missing files are shown in red (✗); a present but empty DF shows `(empty)`
|
||||
- **Probe all files** — walks the whole tree (incl. custom files), marks every entry present/absent with *N / total* progress, stoppable, and ends with a summary; browsing itself stays lazy
|
||||
|
||||
### Custom Files
|
||||
### Command Hints
|
||||
|
||||
Type a command name in the **pySim command line** input. Usage hints appear as a tooltip after 300ms. Command autocomplete suggestions appear above the input.
|
||||
|
||||
---
|
||||
|
||||
## Profiler
|
||||
|
||||
Verifies that a card matches a named **profile** — an ordered set of rules describing the expected file system and, optionally, file contents. Profiles are stored in `localStorage`.
|
||||
|
||||
- **New profile** creates an empty ruleset; **Profile from card** scans the equipped card and generates one rule per existing file; **Profile from snapshot** generates the same ruleset from a saved snapshot (same ignore/mask/FCP-FCI options, no card reader, name prefilled from the snapshot); **Import profile** loads a ruleset from JSON (the name is stored inside the file).
|
||||
- Each profile row has **Check card ▶** (run against the equipped card), **Check card snapshot** (run offline against a saved snapshot), **Edit**, **Export**, and **Delete**.
|
||||
|
||||
A filesystem rule is defined by:
|
||||
|
||||
- **Path** — `MF`-rooted (e.g. `MF/7F10/6F3A`) or ADF AID-rooted (e.g. `A0000000871002/6F07`).
|
||||
- **FCP/FCI check** — **Filetype only (FCP)**, **Filetype + size (FCP)** (adds file size, or record length/count for record files), or **Exact FCI** (byte-for-byte comparison of the raw SELECT FCP template `'62'`, catching FID/AID, life-cycle status, security-attribute, and proprietary-parameter changes).
|
||||
- **File attributes** — file type, size, record length and record count, taken from the FCP template (any may be left unset).
|
||||
- **Check contents** (optional) — **Exact** hex equality, or **Mask** where `?` is a per-nibble wildcard (a mask with no `?` is a prefix match, e.g. `0891` for the IMSI MCC/MNC). Record files store a per-record list.
|
||||
|
||||
The check report marks each verified aspect (e.g. *filetype ✓, size ✗, contents ✓*), lists mismatches as read-only monospace expected/actual fields aligned in one column, and shows a decoded per-parameter FCI comparison for FCI mismatches. Corrupt FCI data shows whatever decoded before the faulty part plus an explicit decode-failure note; record mismatches list the *matching records*. In the report the mismatch fields and FCI comparison columns are labelled `expected (profile name)` and `actual (card ICCID)` for a live check, or `actual (snapshot name)` for a snapshot check; the results header reads `Profile verification results for: <profile> → <card ICCID>` (or `… → <snapshot name>`; snapshot comparison: `Snapshot comparison results: <master> → <checked>`). **Only mismatches** in the results header hides all passing files and keeps failures and errors only.
|
||||
|
||||
#### “Profile from card” scan options
|
||||
|
||||
The scan dialog asks for a profile name and offers the FCP/FCI mode described above, an **Ignore contents of files** checklist of frequently-overwritten files (all checked by default except `EF.ARR`; the header checkbox toggles the whole list) — `EF.LOCI`, `EF.PSLOCI`, `EF.EPSLOCI`, `EF.5GS3GPPLOCI`, `EF.Keys`, `EF.KeysPS`, `EF.SMS`, `EF.Kc`, `EF.KcGPRS`, `EF.LOCIGPRS`, `EF.CBMID`, `EF.SMSS`, `EF.ACC`, `EF.EPSNSC`, `EF.START-HFN`, `EF.ARR` — and two checked-by-default mask options that capture only the first 4 bytes of `EF.IMSI` and `EF.ICCID` (uncheck for exact matching). A progress line shows *N / total files* with the current path; the options are locked while scanning. Rules are created only for files that actually exist (a FCP template is returned); custom files from the **Custom files** sub-tab are included under the same existence check.
|
||||
|
||||
#### Card snapshots
|
||||
|
||||
The list view has two tabs — **Profiles** and **Card snapshots**. A snapshot is an immutable capture of the card filesystem: for every existing file it stores the path, symbolic name, file type, size (or record length/count), the raw FCI from the SELECT response, and the contents whenever the file is readable (no ignore list, no masking). The ICCID is decoded from EF.ICCID and shown next to the snapshot name. The scan also measures every card command (SELECT / READ BINARY / READ RECORD) from command to response and stores min/avg/max per type plus the total scan time; the snapshot view shows these in the summary and the select/read time per file (read time per record). Timings are display-only and ignored by checks/comparisons.
|
||||
|
||||
- **New snapshot** scans the card; **Import snapshot** loads JSON.
|
||||
- Each snapshot row has **Open** (all captured data read-only, raw FCI with decoded FCI and contents; only the name is editable), **Export**, and **Delete**.
|
||||
- **Check card snapshot** on a profile row runs the profile rules against a snapshot picked from the list, without a card reader. Files whose contents were not captured are reported as unverifiable errors.
|
||||
- **Compare snapshots** compares two snapshots offline exactly like a profile check: pick the *master* snapshot and the *snapshot to check*, optionally masking the first 4 bytes of EF.IMSI/EF.ICCID (on by default), and get the same report. Every file must match exactly (exact FCI, contents); files present only in the checked snapshot are reported as extra files. In the comparison report the mismatch fields and FCI comparison columns are labeled with the master/checked snapshot names instead of expected/actual.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
|
||||
#### Custom files
|
||||
|
||||
Files not in pysim's model can be added manually:
|
||||
|
||||
1. Switch to the **Custom files** sub-tab
|
||||
1. Switch to the **Custom files** tab in the Profiler list
|
||||
2. Enter the file path (e.g., `3F00/6F46`) and an alias (e.g., `EF.SPN`)
|
||||
3. Click **Add** — the file appears in the tree in italics (unverified)
|
||||
4. Click the file to verify existence — on success, it behaves like a model file
|
||||
4. Use **Edit** on a row to reload it into the form (the button becomes **Save** and a **Cancel** button appears) or **Delete** to remove it
|
||||
5. Click the file to verify existence — on success, it behaves like a model file
|
||||
|
||||
Custom files persist in `localStorage` across sessions. Export/import as JSON for sharing.
|
||||
|
||||
### Proactive UICC Pill
|
||||
## Phone simulator
|
||||
|
||||
The **Proactive UICC** sub-tab in the Card Reader provides real-time CAT session interaction:
|
||||
The **Phone simulator** tab provides real-time CAT session interaction. It has two pills: **Phone** (STK menu, STATUS and polling, subscribed events, proactive command log) and **TR Config** (response data injected into TERMINAL RESPONSEs for proactive commands).
|
||||
|
||||
**Subscribed Events** — the card's SET UP EVENT LIST is displayed with per-event **Send** buttons. Clicking opens a form specific to the event type:
|
||||
|
||||
@@ -496,7 +563,7 @@ The **Proactive UICC** sub-tab in the Card Reader provides real-time CAT session
|
||||
|
||||
**Proactive Command Log** — chronological list of proactive commands encountered (seconds elapsed, type code, name, byte count). Covers SET UP MENU, SET UP EVENT LIST, POLL INTERVAL, DISPLAY TEXT, SELECT ITEM, and PROVIDE LOCAL INFORMATION.
|
||||
|
||||
**PLI Data Dictionary** — editable per-qualifier hex values for all 22 PROVIDE LOCAL INFORMATION qualifiers (TS 102 223 + TS 131 111). 10 qualifiers have inline decode/encode forms (toggle):
|
||||
**TR Config: PLI data dictionary** — editable per-qualifier hex values for all 22 PROVIDE LOCAL INFORMATION qualifiers (TS 102 223 + TS 131 111). 10 qualifiers have inline decode/encode forms (toggle):
|
||||
|
||||
| Code | Decoded fields |
|
||||
|------|--------------|
|
||||
@@ -513,12 +580,6 @@ The **Proactive UICC** sub-tab in the Card Reader provides real-time CAT session
|
||||
|
||||
Values persist on the server until restart. Apply → hex updates; Save → POSTs to server. The server will use these values to populate TERMINAL RESPONSE data for future PLI proactive commands.
|
||||
|
||||
### Command Hints
|
||||
|
||||
Type a command name in the **pySim command line** input. Usage hints appear as a tooltip after 300ms. Command autocomplete suggestions appear above the input.
|
||||
|
||||
---
|
||||
|
||||
## PWA
|
||||
|
||||
OTAMan is a Progressive Web App and can be installed for offline use. Use the **INSTALL PWA** button in the header, or use the browser's install prompt.
|
||||
@@ -526,6 +587,14 @@ OTAMan is a Progressive Web App and can be installed for offline use. Use the **
|
||||
- Service worker pre-caches all assets on first visit
|
||||
- App icons at 192×192 and 512×512
|
||||
|
||||
## Theme
|
||||
|
||||
A dark theme is included. It follows the system preference and can be toggled manually with the header button (🌙/☀️); the choice is stored in `localStorage`.
|
||||
|
||||
## Localization
|
||||
|
||||
The interface is in English with Russian support. The language is detected from `navigator.language`; the header toggle (EN/RU) stores the choice in `localStorage`. Switching the language also re-renders visible dynamic views (profile lists, check reports, snapshots, cards, proactive views).
|
||||
|
||||
## Server (pysim-otaman-server)
|
||||
|
||||
The bundled Python server wraps [pySim](https://osmocom.org/projects/pysim/wiki) and serves both the OTAMan PWA (from `frontend/`) and a JSON API under `/api/*`.
|
||||
@@ -569,7 +638,11 @@ pysim-otaman-server --http-port 8080
|
||||
| `--log-requests` | Log request/response payloads to stderr |
|
||||
| `--sms-oa` / `--sms-sm-sc` | SMS-DELIVER originating address / SM-SC for PoR-in-submit |
|
||||
| `--terminal-profile` | TERMINAL PROFILE payload hex (default 10-byte GSM profile) |
|
||||
| `--poll-interval` | Idle interval before automatic STATUS polling (default 30s) |
|
||||
| `--poll-interval` | Idle interval before automatic STATUS polling (default 30s; `0` disables polling) |
|
||||
| `--full-pysim-init` | Use pysim's stock init/equip (redundant card resets). The default init/equip is reset-free — only explicit equip/reset reconnect the card |
|
||||
| `--no-auto-equip` | Do not initialize a card automatically right after it is inserted (default: auto-equip on) |
|
||||
| `--menu-timeout` | Auto-answer a paused STK command with a timeout TERMINAL RESPONSE (default 60s; `0` disables) |
|
||||
| `--timing` | Log phase durations, card resets and APDU counters with elapsed timestamps |
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
|
||||
+93
-28
@@ -31,13 +31,13 @@ npm run build
|
||||
|
||||
## Интерфейс
|
||||
|
||||
Четыре вкладки: **C-APDU**, **SCP80**, **Response parser**, **Card reader**. Вкладки C-APDU и SCP80 имеют подвкладки.
|
||||
Пять вкладок: **Remote APDU**, **SCP80**, **Profiler**, **Card reader** и **Phone simulator**. Вкладки Remote APDU и SCP80 используют пиллы-подвкладки; во вкладке Card reader три подвкладки: **File manager**, **pySim command line** и **Raw APDU**; во вкладке Profiler — **Profiles**, **Card snapshots** и **Custom files**.
|
||||
|
||||
---
|
||||
|
||||
## Вкладка C-APDU
|
||||
## Вкладка Remote APDU
|
||||
|
||||
Построение команд APDU (C-APDU). Пять подвкладок для разных поколений карт и наборов команд.
|
||||
Построение команд APDU (C-APDU). Семь подвкладок для разных поколений карт, наборов команд и инструментов разбора: **SIM RFM**, **USIM RFM**, **Expanded Script**, **RAM/GP**, **HTTP OTA**, **Разбор C-APDU** и **«Парсер ответов»**.
|
||||
|
||||
### SIM RFM
|
||||
|
||||
@@ -286,8 +286,41 @@ CLA = `80` (GlobalPlatform v2.3.1). Удалённое управление со
|
||||
- ETSI TS 102 225
|
||||
- pySim: enc_imsi()
|
||||
|
||||
### Разбор C-APDU
|
||||
|
||||
Вставьте сырой hex APDU — отобразится раскрывающееся дерево. Контейнер определяется автоматически: **Expanded Script** (ведущие `AA` или `AE80`, разбор по ETSI TS 102 226 §5.2.1) или **компактная цепочка C-APDU** (последовательность ISO 7816 C-APDU). У каждого узла — метка, hex и краткое описание; родительские узлы раскрываются до подэлементов.
|
||||
|
||||
### HTTP OTA
|
||||
|
||||
Сборка payload-ов Remote Application Management over HTTP по GlobalPlatform **GPC v2.2 Amendment B v1.1** (§4.7). Два режима:
|
||||
|
||||
- **Trigger (Push SMS)** — параметры запуска административной сессии (`81 > 83 > 84/[85]/[86]/89`, Table 4-3). Это сообщение просит Security Domain карты выйти в сеть и начать HTTP-сессию.
|
||||
- **Store (SD admin params)** — запись тех же параметров как данных карты (Security Domain) через **STORE DATA в TLV-режиме** (`80 E2 90 00`, P1=90 = последний блок + BER-TLV по GP v2.2 Amendment B v1.1.3), обёрнутых в тег `85` (или `A5`) по Table 4-4.
|
||||
|
||||
| Секция | Тег | Содержимое |
|
||||
|---|---|---|
|
||||
| Параметры соединения | `84` | COMPREHENSION-TLV для открытия TCP-соединения (OPEN CHANNEL по TS 102 223): Device Identities `02`, Alpha `80`, Bearer `01`, вендорские TLV. Редактор строк + пресеты, редактируемый hex. |
|
||||
| Параметры безопасности | `85` | Table 4-6: LV PSK Identity (текст), LV Key version/KID. Идентифицирует ключ PSK TLS (RFC 4279). |
|
||||
| Политика повторов | `86` | Table 4-7: счётчик повторов (2 байта, напр. `B000`), задержка повтора как timer TLV TS 102 223 (`25 03 HH MM SS`), опциональный вендорский TLV отчёта об ошибке. |
|
||||
| HTTP POST | `89` | Tables 4-8/9/10: заголовок Host (`8A`), X-Admin-From agent ID (`8B`), URI (`8C`) — текст преобразуется в октеты. |
|
||||
|
||||
Чекбокс **Command Scripting template** оборачивает всю команду `81` в формат Expanded Remote Application с определённой длиной (`AA`, ETSI TS 102 226 §5.2.1) для TAR, обрабатывающих расширенный формат. **Pack into Secured packet** отправляет собранный payload на вкладку SCP80 для заполнения SPI/счётчика — укажите там TAR, который слушает SD (обычно OTASD).
|
||||
|
||||
---
|
||||
|
||||
|
||||
### Парсер ответов
|
||||
|
||||
Декодирование ответа команды: выберите отправленную команду, введите SW (например, `9000`) и данные ответа в hex, затем нажмите **Decode**.
|
||||
|
||||
- **Команда** — группа SIM/USIM (SELECT, STATUS, READ/UPDATE, операции с PIN, CAT-команды TERMINAL PROFILE/ENVELOPE/FETCH/TERMINAL RESPONSE, MANAGE CHANNEL, ...) или группа RAM/GP (INSTALL, LOAD, DELETE, GET/STORE DATA, аутентификация, команды SCP).
|
||||
- **Декодирование SW** — статусные слова по картам generic, UICC (TS 102 221) и GlobalPlatform с автоопределением контекста.
|
||||
- **Декодирование привилегий** — байты привилегий из ответов GET DATA / INSTALL в читаемые флаги.
|
||||
- **Данные ответа** — hex с интерпретацией по команде (например, шаблоны FCP из SELECT).
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Вкладка SCP80
|
||||
|
||||
Вкладка **SCP80** группирует SCP80-виды, переключаемые тремя пиллами: **Secured Packet**, **Cards** и **RAM**. Сборка защищённых пакетов по ETSI TS 102 225.
|
||||
@@ -417,17 +450,6 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
|
||||
|
||||
---
|
||||
|
||||
## Вкладка Response parser
|
||||
|
||||
Декодирование ответа команды: выберите отправленную команду, введите SW (например, `9000`) и данные ответа в hex, затем нажмите **Decode**.
|
||||
|
||||
- **Команда** — группа SIM/USIM (SELECT, STATUS, READ/UPDATE, операции с PIN, CAT-команды TERMINAL PROFILE/ENVELOPE/FETCH/TERMINAL RESPONSE, MANAGE CHANNEL, ...) или группа RAM/GP (INSTALL, LOAD, DELETE, GET/STORE DATA, аутентификация, команды SCP).
|
||||
- **Декодирование SW** — статусные слова по картам generic, UICC (TS 102 221) и GlobalPlatform с автоопределением контекста.
|
||||
- **Декодирование привилегий** — байты привилегий из ответов GET DATA / INSTALL в читаемые флаги.
|
||||
- **Данные ответа** — hex с интерпретацией по команде (например, шаблоны FCP из SELECT).
|
||||
|
||||
---
|
||||
|
||||
## Card Reader (интеграция с pySim)
|
||||
|
||||
Подключение к встроенному [`pysim-otaman-server`](pysim_otaman_server/) для работы с картой.
|
||||
@@ -438,24 +460,69 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
|
||||
|
||||
Дерево файлов UICC. Отображаются имена, FID и AID (для ADF). Клик для чтения содержимого.
|
||||
|
||||
- Элементы сгруппированы (DF выше EF) и отсортированы по **FID** или символьному **имени** (пиллы над деревом, выбор сохраняется в `localStorage`)
|
||||
- **Read** — чтение файла (автоопределение transparent/record)
|
||||
- **Edit** — режим редактирования, измените hex-данные и нажмите **Save** для записи
|
||||
- **Raw / Decoded** — переключение между hex-дампом и декодированным JSON
|
||||
- При выборе файла над содержимым показываются FID, тип файла, размер / структура записей и декодированный FCI
|
||||
- Отсутствующие файлы показаны красным (✗); существующий пустой DF — `(пусто)`
|
||||
- **Проверить все файлы** — обход всего дерева (включая пользовательские) с пометкой «есть/нет», прогрессом *N / всего*, возможностью остановки и сводкой в конце; сам просмотр остаётся ленивым
|
||||
|
||||
### Пользовательские файлы
|
||||
### Подсказки команд
|
||||
|
||||
Введите имя команды в **pySim command line**. Подсказки по использованию появляются через 300 мс. Автодополнение команд — над полем ввода.
|
||||
|
||||
---
|
||||
|
||||
## Профайлер
|
||||
|
||||
Проверка соответствия карты именованному **профилю** — упорядоченному набору правил, описывающих ожидаемую файловую систему и (опционально) содержимое файлов. Профили хранятся в `localStorage`.
|
||||
|
||||
- **Новый профиль** создаёт пустой набор правил; **Профиль с карты** сканирует подключённую карту и создаёт по правилу на каждый существующий файл; **Профиль из снимка** создаёт тот же набор правил из сохранённого снимка (те же опции игнорирования/масок/FCP-FCI, без картридера, имя подставляется из снимка); **Импорт профиля** загружает набор из JSON (имя хранится внутри файла).
|
||||
- В каждой строке профиля: **Проверить карту ▶** (на подключённой карте), **Проверить снимок карты** (offline по сохранённому снимку), **Редактировать**, **Экспорт** и **Удалить**.
|
||||
|
||||
Правило файловой системы задаётся:
|
||||
|
||||
- **Путь** — от `MF` (напр. `MF/7F10/6F3A`) или от AID ADF (напр. `A0000000871002/6F07`).
|
||||
- **Проверка FCP/FCI** — **Только тип файла (FCP)**, **Тип файла + размер (FCP)** (добавляет размер файла или длину/число записей) либо **Полный FCI** (побайтовое сравнение сырого шаблона FCP `'62'` из ответа SELECT — ловит изменения FID/AID, life-cycle, security attributes и проприетарных параметров).
|
||||
- **Атрибуты файла** — тип, размер, длина и число записей из шаблона FCP (любое можно не задавать).
|
||||
- **Проверка содержимого** (опционально) — **Exact** (точное равенство hex) или **Mask**, где `?` — пониббловый джокер (маска без `?` — префиксное совпадение, напр. `0891` для MCC/MNC IMSI). Для record-файлов хранится список записей.
|
||||
|
||||
Отчёт проверки помечает каждый аспект (напр. *тип файла ✓, размер ✗, содержимое ✓*), показывает расхождения как поля только для чтения (ожидаемое/фактическое в одной колонке) и декодированное сравнение параметров FCI для расхождений FCI. Повреждённые FCI показывают всё, что удалось декодировать, плюс явное сообщение об ошибке; для записей указываются *совпадающие записи*. В отчёте поля расхождений и колонки сравнения FCI подписаны `ожидалось (имя профиля)` и `фактически (ICCID карты)` для проверки карты либо `фактически (имя снимка)` для проверки снимка; в заголовке отчёта — `Результаты проверки профиля: <профиль> → <ICCID карты>` (или `… → <имя снимка>`; для сравнения снимков — `Результаты сравнения снимков: <эталон> → <проверяемый>`). Опция **«Только расхождения»** скрывает все совпавшие файлы, оставляя несовпадения и ошибки.
|
||||
|
||||
#### Опции сканирования «Профиль с карты»
|
||||
|
||||
Диалог сканирования запрашивает имя профиля и предлагает режим FCP/FCI, список **«Игнорировать содержимое файлов»** (все включены, кроме `EF.ARR`; чекбокс в заголовке переключает весь список) — `EF.LOCI`, `EF.PSLOCI`, `EF.EPSLOCI`, `EF.5GS3GPPLOCI`, `EF.Keys`, `EF.KeysPS`, `EF.SMS`, `EF.Kc`, `EF.KcGPRS`, `EF.LOCIGPRS`, `EF.CBMID`, `EF.SMSS`, `EF.ACC`, `EF.EPSNSC`, `EF.START-HFN`, `EF.ARR` — и две включённые по умолчанию маски, сохраняющие только первые 4 байта `EF.IMSI` и `EF.ICCID`. Строка прогресса показывает *N / всего файлов* с текущим путём; во время сканирования опции заблокированы. Правила создаются только для существующих файлов; пользовательские файлы из подвкладки **Custom files** проверяются на существование так же.
|
||||
|
||||
#### Снимки карт
|
||||
|
||||
Представление списка имеет две вкладки — **«Профили»** и **«Снимки карт»**. Снимок — неизменяемая фиксация файловой системы: путь, символьное имя, тип, размер (или длина/число записей), сырой FCI и содержимое (если читается) каждого существующего файла. ICCID декодируется из EF.ICCID и показывается рядом с именем. При сканировании также измеряется время каждой команды карты (SELECT / READ BINARY / READ RECORD) от отправки до ответа; сохраняются min/сред/max по типам и общее время сканирования — они показываются в сводке снимка и по файлам/записям. Время носит информационный характер и не используется при проверках и сравнении.
|
||||
|
||||
- **Новый снимок** сканирует карту; **Импорт снимка** загружает JSON.
|
||||
- В строке снимка: **Открыть** (все данные только для чтения, сырой FCI с декодированным и содержимое; редактируется только имя), **Экспорт**, **Удалить**.
|
||||
- **Проверить снимок карты** в строке профиля выполняет правила профиля на выбранном снимке без картридера. Файлы без захваченного содержимого помечаются как непроверяемые ошибки.
|
||||
- **Сравнить снимки** сравнивает два снимка offline так же, как проверка профиля: выберите *эталонный* снимок и *снимок для проверки*, при необходимости включите маску первых 4 байт EF.IMSI/EF.ICCID (включена по умолчанию). Всё должно совпадать точно (FCI, содержимое); файлы только в проверяемом снимке помечаются как лишние. В отчёте поля расхождений и колонки сравнения FCI подписаны именами эталонного и проверяемого снимков вместо expected/actual.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
|
||||
#### Пользовательские файлы
|
||||
|
||||
Файлы, отсутствующие в модели pysim, можно добавить вручную:
|
||||
|
||||
1. Перейдите на вкладку **Custom files**
|
||||
1. Перейдите на вкладку **Custom files** в списке профайлера
|
||||
2. Введите путь (например, `3F00/6F46`) и псевдоним (например, `EF.SPN`)
|
||||
3. Нажмите **Add** — файл появится в дереве курсивом (непроверенный)
|
||||
4. Кликните для проверки существования — при успехе работает как обычный файл
|
||||
4. Кнопка **Edit** загружает запись в форму (кнопка становится **Save**, появляется **Cancel**), **Delete** удаляет запись без подтверждения
|
||||
5. Кликните для проверки существования — при успехе работает как обычный файл
|
||||
|
||||
Пользовательские файлы сохраняются в `localStorage`. Экспорт/импорт в JSON для обмена.
|
||||
|
||||
### Proactive UICC
|
||||
## Симулятор телефона
|
||||
|
||||
Подраздел **Proactive UICC** во вкладке Card Reader обеспечивает взаимодействие с CAT-сессией в реальном времени:
|
||||
Вкладка **«Симулятор телефона»** обеспечивает взаимодействие с CAT-сессией в реальном времени. Две подвкладки: **«Телефон»** (меню STK, STATUS и опрос, подписанные события, журнал проактивных команд) и **«Конфигурация TR»** (данные ответов, подставляемые в TERMINAL RESPONSE для проактивных команд).
|
||||
|
||||
**Subscribed Events** — список событий SET UP EVENT LIST с кнопками **Send**. Клик открывает форму для конкретного типа события:
|
||||
|
||||
@@ -466,7 +533,7 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
|
||||
|
||||
**Proactive Command Log** — хронологический список проактивных команд. Каждая строка показывает время, код типа, имя и декодированный квалификатор.
|
||||
|
||||
**PLI Data Dictionary** — редактируемые hex-значения для всех 22 квалификаторов PROVIDE LOCAL INFORMATION (TS 102 223 + TS 131 111). 10 квалификаторов имеют встроенные формы декодирования/кодирования:
|
||||
**Конфигурация TR: словарь PLI** — редактируемые hex-значения для всех 22 квалификаторов PROVIDE LOCAL INFORMATION (TS 102 223 + TS 131 111). 10 квалификаторов имеют встроенные формы декодирования/кодирования:
|
||||
|
||||
| Код | Декодированные поля |
|
||||
|------|--------------|
|
||||
@@ -483,12 +550,6 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
|
||||
|
||||
Значения сохраняются на сервере до перезапуска. Apply → hex обновляется; Save → POST на сервер.
|
||||
|
||||
### Подсказки команд
|
||||
|
||||
Введите имя команды в **pySim command line**. Подсказки по использованию появляются через 300 мс. Автодополнение команд — над полем ввода.
|
||||
|
||||
---
|
||||
|
||||
## PWA
|
||||
|
||||
OTAMan — Progressive Web App. Можно установить для offline-использования через кнопку **INSTALL PWA** или через браузер.
|
||||
@@ -502,7 +563,7 @@ OTAMan — Progressive Web App. Можно установить для offline-
|
||||
|
||||
## Локализация
|
||||
|
||||
Интерфейс на английском с поддержкой русского языка. Язык определяется из `navigator.language`. Кнопка переключения (EN/RU) в заголовке сохраняет выбор в `localStorage`.
|
||||
Интерфейс на английском с поддержкой русского языка. Язык определяется из `navigator.language`. Кнопка переключения (EN/RU) в заголовке сохраняет выбор в `localStorage`. При переключении языка динамические представления (списки профилей, отчёты проверок, снимки, карты, proactive) перерисовываются.
|
||||
|
||||
## Совместимость версий
|
||||
|
||||
@@ -557,7 +618,11 @@ pysim-otaman-server --http-port 8080
|
||||
| `--no-card-init` | Пропустить инициализацию карты (сохранить CAT-сессию) |
|
||||
| `--apdu-trace` | Лог APDU-трафика в stderr |
|
||||
| `--log-requests` | Лог запросов/ответов в stderr |
|
||||
| `--poll-interval` | Интервал автоопроса STATUS (по умолчанию 30с) |
|
||||
| `--poll-interval` | Интервал автоопроса STATUS (по умолчанию 30с; `0` отключает опрос) |
|
||||
| `--full-pysim-init` | Штатная инициализация/equip из pysim (с лишними сбросами карты). По умолчанию инициализация без лишних сбросов — карта переподключается только по явным equip/reset |
|
||||
| `--no-auto-equip` | Не инициализировать карту автоматически сразу после вставки (по умолчанию автоинициализация включена) |
|
||||
| `--menu-timeout` | Автоответ timeout TERMINAL RESPONSE на приостановленную STK-команду (по умолчанию 60с; `0` отключает) |
|
||||
| `--timing` | Лог длительности фаз, сбросов карты и счётчиков APDU с отметками времени |
|
||||
|
||||
### Устранение неполадок
|
||||
|
||||
|
||||
+136
-67
@@ -55,7 +55,7 @@ Returns server version for compatibility checking.
|
||||
|
||||
**Example response:**
|
||||
```json
|
||||
{"version": "1.9.12"}
|
||||
{"version": "2.1.2"}
|
||||
```
|
||||
|
||||
### `GET /api/status`
|
||||
@@ -143,7 +143,7 @@ The SPI2 `por_in_submit` bit (0x20) selects submit-mode PoR.
|
||||
|
||||
### `POST /api/ram-install`
|
||||
|
||||
Install a Java Card `.cap` file on the card via GlobalPlatform commands (INSTALL[for load] → LOAD ×N → INSTALL[for install (+ make selectable)]) wrapped in SCP80 secured packets. Each step is sent via ENVELOPE and its PoR is checked; the sequence aborts on the first PoR error. Requires pySim with `pySim.javacard.CapFile` and `pySim.global_platform` available on the server.
|
||||
Install a Java Card `.cap` file on the card via GlobalPlatform commands (INSTALL[for load] → LOAD ×N → INSTALL[for install (+ make selectable)]) wrapped in SCP80 secured packets. Each step is sent via ENVELOPE and its PoR is checked; the sequence aborts on the first PoR error. The `.cap` archive (a ZIP of nested components) is parsed server-side in `_cap_parse`; no external tooling is required.
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
@@ -213,60 +213,6 @@ and the decoded SPI fields.
|
||||
"diffs": [], "spi": {"counter": "counter_must_be_higher", ...}}
|
||||
```
|
||||
|
||||
### `POST /api/ram-install`
|
||||
|
||||
Install a Java Card `.cap` file on the card via GlobalPlatform commands (INSTALL[for load] → LOAD ×N → INSTALL[for install (+ make selectable)]) wrapped in SCP80 secured packets. Each step is sent via ENVELOPE and its PoR is checked; the sequence aborts on the first PoR error. Requires pySim with `pySim.javacard.CapFile` and `pySim.global_platform` available on the server.
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"cap_hex": "DECAFFED...",
|
||||
"sd_aid": "A000000003000000",
|
||||
"install_params": "C90000",
|
||||
"stk_params": "",
|
||||
"nv_quota": 0,
|
||||
"volatile_quota": 0,
|
||||
"make_selectable": true,
|
||||
"spi1": "0E", "spi2": "01",
|
||||
"kic": "15", "kid": "15",
|
||||
"tar": "000000",
|
||||
"cntr": "0000000001",
|
||||
"kicKey": "D6FCC023...",
|
||||
"kidKey": "1B07E7E0..."
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Req | Description |
|
||||
|---|---|---|
|
||||
| `cap_hex` | yes | Even-length hex of the `.cap` file (zipped Java Card CAP), max 48 kB (98304 hex chars) |
|
||||
| `sd_aid` | no | Security Domain AID for INSTALL[for load]; empty → default ISD `A000000003000000` |
|
||||
| `install_params` | no | Hex C9 TLV install parameters; if empty, `gen_install_parameters()` is used with the quota/stk params |
|
||||
| `stk_params` | no | Hex CA TLV (TS 102 226 §8.2.1.3.2.1) for SIM toolkit app-specific params |
|
||||
| `nv_quota` / `volatile_quota` | no | Integer memory quotas (bytes) for `gen_install_parameters()` |
|
||||
| `make_selectable` | no | If true (default), final INSTALL uses P1=`0C` (install + make selectable) |
|
||||
|
||||
**Response (success):**
|
||||
```json
|
||||
{"success": true, "failed_step": null,
|
||||
"steps": [{"name": "install_for_load", "apdu": "80E60200...", "por_status": "por_ok", "sw": "9000"},
|
||||
{"name": "load_0", "apdu": "80E80000...", "por_status": "por_ok", "sw": "9000"},
|
||||
{"name": "install_for_install", "apdu": "80E60C00...", "por_status": "por_ok", "sw": "9000"}],
|
||||
"final_cntr": "0000000004",
|
||||
"load_file_aid": "A000000003000000",
|
||||
"module_aid": "A000000003000000",
|
||||
"application_aid": "A000000003000000"}
|
||||
```
|
||||
|
||||
**Response (failure):**
|
||||
```json
|
||||
{"success": false, "failed_step": "load_1",
|
||||
"steps": [{"name": "install_for_load", "por_status": "por_ok", "sw": "9000"},
|
||||
{"name": "load_1", "por_status": "rc_error", "sw": null}],
|
||||
"error": "..."}
|
||||
```
|
||||
|
||||
The `steps` array contains one entry per GP command. `final_cntr` is the counter value after all successful steps (use it to update the card preset). The response is not streamed — all steps run server-side before the JSON is returned.
|
||||
|
||||
### `GET /api/menu`
|
||||
|
||||
Returns the SIM Toolkit SETUP MENU captured from the card's TERMINAL PROFILE
|
||||
@@ -299,7 +245,9 @@ or
|
||||
### `POST /api/menu-respond`
|
||||
|
||||
Sends `TERMINAL RESPONSE` to the current proactive command with the given result
|
||||
code. Continues the proactive chain if the card responds with `91XX`.
|
||||
code. Continues the proactive chain if the card responds with `91XX`. If no
|
||||
response arrives within `--menu-timeout` seconds (default 60, `0` disables), the
|
||||
server watchdog sends the `timeout` result itself.
|
||||
|
||||
```json
|
||||
{"result": "ok", "item_id": 1}
|
||||
@@ -308,9 +256,9 @@ code. Continues the proactive chain if the card responds with `91XX`.
|
||||
| `result` | TERMINAL RESPONSE code | Meaning |
|
||||
|---|---|---|
|
||||
| `ok` | `0x00` | Command performed successfully |
|
||||
| `back` | `0x12` | Backward move requested |
|
||||
| `cancel` | `0x10` | Proactive session terminated |
|
||||
| `timeout` | `0x11` | No response from user |
|
||||
| `cancel` | `0x10` | Proactive session terminated by the user |
|
||||
| `back` | `0x11` | Backward move in the proactive session requested by the user |
|
||||
| `timeout` | `0x12` | No response from the user |
|
||||
|
||||
### `GET /api/stk-status`
|
||||
|
||||
@@ -324,25 +272,38 @@ Returns the current STK session state.
|
||||
Read file content. Auto-detects transparent vs record files.
|
||||
|
||||
```json
|
||||
{"name": "EF.ICCID", "fid": "2FE2", "parent_sel": "3F00", "mode": "raw"}
|
||||
{"name": "EF.ICCID", "fid": "2FE2", "parent_path": ["MF"], "mode": "raw"}
|
||||
```
|
||||
|
||||
Returns:
|
||||
Returns transparent data:
|
||||
```json
|
||||
{"success": true, "sw": "9000", "file_type": "transparent", "data": "..."}
|
||||
{"success": true, "sw": "9000", "file_type": "transparent", "data": "...",
|
||||
"apdu_times": [{"type": "select", "ms": 12}, {"type": "read_binary", "ms": 9}]}
|
||||
```
|
||||
|
||||
Returns records:
|
||||
```json
|
||||
{"success": true, "sw": "9000", "file_type": "linear_fixed",
|
||||
"records": [{"num": 1, "data": "..."}, {"num": 2, "data": "..."}],
|
||||
"apdu_times": [{"type": "select", "ms": 12},
|
||||
{"type": "read_record", "ms": 11}, {"type": "read_record", "ms": 13}]}
|
||||
```
|
||||
|
||||
`apdu_times` reports each command's duration (command sent to response
|
||||
received) classified as `select`, `read_binary` or `read_record`; the PWA uses
|
||||
it for snapshot timing statistics. Other commands are not reported.
|
||||
|
||||
### `POST /api/write`
|
||||
|
||||
Write raw hex data to a file.
|
||||
|
||||
```json
|
||||
{"name": "EF.ICCID", "fid": "2FE2", "data": "A0A1A2...", "parent_sel": "3F00"}
|
||||
{"name": "EF.ICCID", "fid": "2FE2", "data": "A0A1A2...", "parent_path": ["MF"]}
|
||||
```
|
||||
|
||||
For record files:
|
||||
```json
|
||||
{"name": "EF.ADN", "fid": "6F3A", "data": "A0A1...", "record_nr": 1, "parent_sel": "7F10"}
|
||||
{"name": "EF.ADN", "fid": "6F3A", "data": "A0A1...", "record_nr": 1, "parent_path": ["MF", "7F10"]}
|
||||
```
|
||||
|
||||
Returns:
|
||||
@@ -355,14 +316,32 @@ Returns:
|
||||
Select a file by name or FID, with optional parent selection.
|
||||
|
||||
```json
|
||||
{"name": "EF.ICCID", "fid": "2FE2", "parent_sel": "3F00"}
|
||||
{"name": "EF.ICCID", "fid": "2FE2", "parent_path": ["MF"]}
|
||||
```
|
||||
|
||||
`parent_path` lists the path segments from MF to the parent (ADF names or
|
||||
FIDs); the legacy single-segment `parent_sel` is still accepted but is only
|
||||
unambiguous for ADFs. Resolution is strictly parent-scoped: model-known files
|
||||
are selected through the requested parent only (pySim `select_file()`), never
|
||||
via pySim's global selectables or its `probe_file()` model injection, so a
|
||||
same-FID file under another parent is never picked and the filesystem model
|
||||
is not modified. `allow_probe: true` (PWA custom files) additionally allows a
|
||||
model-unknown 4-hex FID to be selected directly; any temporary model object
|
||||
created for it is detached again before the response is sent.
|
||||
|
||||
Returns:
|
||||
```json
|
||||
{"name": "EF.ICCID", "fid": "2FE2", "file_type": "transparent", "exists": true}
|
||||
{"name": "EF.ICCID", "fid": "2FE2", "file_type": "transparent",
|
||||
"file_size": 10, "record_len": null, "num_of_rec": null,
|
||||
"fci_hex": "621082024021...",
|
||||
"apdu_times": [{"type": "select", "ms": 12}], "exists": true}
|
||||
```
|
||||
|
||||
`fci_hex` is the raw FCP template (`'62'`) from the SELECT response, used by
|
||||
the PWA's Exact FCI checks; `file_size` / `record_len` / `num_of_rec` drive
|
||||
the profiler's size and record checks. When the file does not exist the
|
||||
endpoint responds `404` with `{"error": "...", "exists": false}`.
|
||||
|
||||
### `POST /api/tree`
|
||||
|
||||
Get directory listing with typed children.
|
||||
@@ -371,7 +350,97 @@ Get directory listing with typed children.
|
||||
{"name": "MF", "fid": "3F00"}
|
||||
```
|
||||
|
||||
Use `parent_path` (or the legacy `parent_sel`) to list a subdirectory, e.g.
|
||||
`{"name": "DF.GSM-ACCESS", "fid": "5F3B", "parent_path": ["MF", "ADF.USIM"]}`.
|
||||
|
||||
Returns:
|
||||
```json
|
||||
{"exists": true, "name": "MF", "fid": "3F00", "file_type": "df", "children": [{"name": "EF.ICCID", "fid": "2fe2", "isDir": false}]}
|
||||
```
|
||||
|
||||
### `GET /api/events`
|
||||
|
||||
Returns the event list captured from the card's SET UP EVENT LIST (an array of
|
||||
event byte values, or `[]` when none was received).
|
||||
|
||||
### `POST /api/event-send`
|
||||
|
||||
Sends an `ENVELOPE(Event Download)` for a subscribed event.
|
||||
|
||||
```json
|
||||
{"event_type": 4, "event_data": "01A0"}
|
||||
```
|
||||
|
||||
`event_type` is required (the SET UP EVENT LIST event byte); `event_data` is
|
||||
optional hex for events that carry data. Returns the SW and any response data:
|
||||
|
||||
```json
|
||||
{"sw": "9000", "data": "..."}
|
||||
```
|
||||
|
||||
### `GET /api/proactive-log`
|
||||
|
||||
Returns the last 50 proactive commands fetched during CAT sessions, newest
|
||||
first:
|
||||
|
||||
```json
|
||||
[{"type_hex": "25", "type_name": "SET UP MENU", "elapsed": 3.2, "bytes": 97}]
|
||||
```
|
||||
|
||||
### `POST /api/status-poll`
|
||||
|
||||
Manually sends `STATUS` (F2) and, if the card answers `91XX`, runs the
|
||||
proactive chain (FETCH → TERMINAL RESPONSE) until it settles. Returns:
|
||||
|
||||
```json
|
||||
{"sw": "9000", "proactive": true}
|
||||
```
|
||||
|
||||
### `POST /api/rescue`
|
||||
|
||||
Recovers a stuck CAT session by clearing the pending state and re-sending the
|
||||
TERMINAL PROFILE. Returns whether a menu and event list were captured again:
|
||||
|
||||
```json
|
||||
{"menu": true, "events": [4, 5]}
|
||||
```
|
||||
|
||||
### `GET /api/poll-status`
|
||||
|
||||
Background STATUS polling state.
|
||||
|
||||
```json
|
||||
{"enabled": true, "interval": 300}
|
||||
```
|
||||
|
||||
### `POST /api/poll-toggle`
|
||||
|
||||
Turns background STATUS polling on or off.
|
||||
|
||||
```json
|
||||
{"enabled": true}
|
||||
```
|
||||
|
||||
Returns the new state (`{"enabled": ..., "interval": ...}`).
|
||||
|
||||
### `GET /api/pli-qualifiers`
|
||||
|
||||
Lists the PROVIDE LOCAL INFORMATION qualifier codes with their names.
|
||||
|
||||
```json
|
||||
[{"code": "00", "name": "Location Information"}, {"code": "0A", "name": "Battery Charge Level"}]
|
||||
```
|
||||
|
||||
### `GET /api/pli-dict`
|
||||
|
||||
Returns the current PLI data dictionary as a qualifier-code map.
|
||||
|
||||
```json
|
||||
{"00": "0291...", "0A": "64"}
|
||||
```
|
||||
|
||||
### `POST /api/pli-dict`
|
||||
|
||||
Updates dictionary entries. Body is a map of qualifier code to hex value; keys
|
||||
must be known qualifiers and values valid hex, otherwise they are ignored.
|
||||
Returns the updated dictionary.
|
||||
|
||||
+112
-90
@@ -44,13 +44,14 @@
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Шапка</strong> — версия приложения, кнопка <strong>INSTALL PWA</strong> (появляется, когда браузер предлагает установку, для офлайн-работы), ссылки на проект на GitHub и на эту справку, переключатель языка <strong>EN/RU</strong> и переключатель тёмной/светлой <strong>темы</strong>.</li>
|
||||
<li>Выбор языка и темы хранится в <code class="font-mono text-sm">localStorage</code> и сохраняется между перезагрузками.</li>
|
||||
<li>Ссылка <strong>справка</strong> открывает эту документацию на разделе, соответствующем текущему представлению (например, подвкладка «Профайлер» открывает §5.6).</li>
|
||||
<li>Вкладки верхнего уровня: <strong>Remote APDU</strong> (<strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong>, <strong>HTTP OTA</strong>, <strong>Разбор C-APDU</strong>, <strong>«Парсер ответов»</strong>), <strong>SCP80</strong> (<strong>Secured Packet</strong>, <strong>Карты</strong>, <strong>RAM</strong>), <strong>«Профайлер»</strong> (вкладки <strong>«Профили»</strong>, <strong>«Снимки карт»</strong>, <strong>«Пользовательские файлы»</strong>), <strong>«Картридер»</strong> (<strong>Файловый менеджер</strong>, <strong>Командная строка pySim</strong>, <strong>Отправка APDU</strong>) и <strong>«Симулятор телефона»</strong>.</li>
|
||||
<li>Ссылка <strong>справка</strong> открывает эту документацию на разделе, соответствующем текущему представлению (например, вкладка «Профайлер» открывает §5).</li>
|
||||
</ul>
|
||||
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="c-apdu" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">2. Вкладка C-APDU</h2>
|
||||
<p class="mb-3">Построение командных APDU (C-APDU). Шесть подвкладок охватывают разные поколения карт и наборы команд: <strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong>, <strong>HTTP OTA</strong> и <strong>Разбор C-APDU</strong>.</p>
|
||||
<h2 id="c-apdu" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">2. Вкладка Remote APDU</h2>
|
||||
<p class="mb-3">Построение командных APDU (C-APDU). Семь подвкладок охватывают разные поколения карт, наборы команд и инструменты разбора: <strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong>, <strong>HTTP OTA</strong>, <strong>Разбор C-APDU</strong> и <strong>«Парсер ответов»</strong>.</p>
|
||||
|
||||
<h3 id="sim-rfm" class="text-lg font-medium mb-2">2.1 SIM RFM</h3>
|
||||
<p class="mb-2">CLA = <code class="font-mono text-sm">A0</code> (GSM 11.11 / TS 151 011, ISO 7816-4). Удалённое управление файлами классических SIM-карт.</p>
|
||||
@@ -138,7 +139,7 @@
|
||||
</ul>
|
||||
|
||||
<h4 id="expanded-response" class="font-medium mb-2 text-base">Декодирование ответов (TS 102 226 §5.2.2)</h4>
|
||||
<p class="text-sm mb-2">Входящие ответы Proof of Receipt декодируются сервером — формат expanded Remote Application response data (TS 102 226 §5.2.2) или компактный формат. Представление Secured Packet показывает результат после <strong>Отправить на карту</strong> (см. <a href="#secured-packet" class="text-blue-600 dark:text-blue-400 hover:underline">§3.1</a>): статус PoR (TAR, счётчик, сырой PoR), а статусное слово и данные ответа последней команды подставляются на вкладку <strong>«Парсер ответов»</strong>.</p>
|
||||
<p class="text-sm mb-2">Входящие ответы Proof of Receipt декодируются сервером — формат expanded Remote Application response data (TS 102 226 §5.2.2) или компактный формат. Представление Secured Packet показывает результат после <strong>Отправить на карту</strong> (см. <a href="#secured-packet" class="text-blue-600 dark:text-blue-400 hover:underline">§3.1</a>): статус PoR (TAR, счётчик, сырой PoR), а статусное слово и данные ответа последней команды подставляются в подвкладку <strong>«Парсер ответов»</strong> (Remote APDU).</p>
|
||||
|
||||
<h3 id="ram-gp" class="text-lg font-medium mb-2">2.4 RAM/GP</h3>
|
||||
<p class="mb-2">CLA = <code class="font-mono text-sm">80</code> (GlobalPlatform Card Specification v2.3.1). Команды удалённого управления приложениями. Строятся тем же сборщиком цепочки, что и SIM/USIM.</p>
|
||||
@@ -227,6 +228,17 @@
|
||||
<p class="text-sm mb-2"><strong>Упаковать в Secured packet</strong> отправляет готовый payload на вкладку SCP80 для заполнения SPI/счётчика — там укажите TAR, который слушает SD (обычно TAR OTASD).</p>
|
||||
|
||||
|
||||
|
||||
<h3 id="response-parser" class="text-lg font-medium mb-2">2.8 «Парсер ответов»</h3>
|
||||
<p class="mb-3">Декодирует raw-ответ команды: выберите отправленную команду, введите SW (например, <code class="font-mono text-sm">9000</code>) и hex данных ответа, затем нажмите <strong>Декодировать</strong>. Поля также автоматически заполняются статусным словом и данными ответа последней команды после успешного нажатия <strong>Отправить на карту</strong> (см. <a href="#secured-packet" class="text-blue-600 dark:text-blue-400 hover:underline">§3.1</a>).</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1">
|
||||
<li><strong>Команда</strong> — группа SIM/USIM (SELECT, STATUS, READ/UPDATE, PIN-операции, CAT-команды типа TERMINAL PROFILE/ENVELOPE/FETCH/TERMINAL RESPONSE, MANAGE CHANNEL, …) или группа RAM/GP (INSTALL, LOAD, DELETE, GET/STORE DATA, auth, SCP-команды).</li>
|
||||
<li><strong>Декодирование SW</strong> — статусные слова разрешаются по generic-, UICC- (TS 102 221) и GlobalPlatform-таблицам, контекст определяется автоматически.</li>
|
||||
<li><strong>Декодирование привилегий</strong> — ответы GET DATA / INSTALL декодируют байты привилегий в читаемые флаги.</li>
|
||||
<li><strong>Данные ответа</strong> — raw hex отображается и интерпретируется согласно команде (например, FCP-шаблоны SELECT).</li>
|
||||
</ul>
|
||||
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="scp80" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">3. Вкладка SCP80</h2>
|
||||
<p class="mb-3">Верхнеуровневая вкладка <strong>SCP80</strong> объединяет разделы, связанные с SCP80. Переключение — тремя переключателями: <strong>Secured Packet</strong>, <strong>Карты</strong> и <strong>RAM</strong>. Собирает защищённые пакеты SCP80 по ETSI TS 102 225.</p>
|
||||
@@ -260,7 +272,7 @@
|
||||
<li>AES требует счётчик с защитой от повтора: биты SPI1 b5 b4 должны быть <code class="font-mono text-sm">10</code> (счётчик больше) или <code class="font-mono text-sm">11</code> (счётчик +1) согласно TS 102 225 §5.1.2/§5.1.3.1</li>
|
||||
<li>Байт паддинга настраивается (<code class="font-mono text-sm">00</code> по умолчанию или <code class="font-mono text-sm">FF</code>)</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">Кнопка <strong>Проверить в pySim</strong> сверяет собранный пакет с эталонной реализацией <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. Кнопка <strong>Отправить на карту</strong> доставляет пакет через ENVELOPE SMS-PP-DOWNLOAD (при подключении к серверу). Полученный Proof of Receipt декодируется и показывается строкой статуса PoR (статус, TAR, счётчик, сырой PoR); статусное слово и данные ответа последней команды подставляются на вкладку <strong>«Парсер ответов»</strong>, а успешный PoR увеличивает счётчик повторов и очищает пакет.</p>
|
||||
<p class="text-sm mb-3">Кнопка <strong>Проверить в pySim</strong> сверяет собранный пакет с эталонной реализацией <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. Кнопка <strong>Отправить на карту</strong> доставляет пакет через ENVELOPE SMS-PP-DOWNLOAD (при подключении к серверу). Полученный Proof of Receipt декодируется и показывается строкой статуса PoR (статус, TAR, счётчик, сырой PoR); статусное слово и данные ответа последней команды подставляются в подвкладку <strong>«Парсер ответов»</strong> (Remote APDU), а успешный PoR увеличивает счётчик повторов и очищает пакет.</p>
|
||||
|
||||
<h3 id="cards" class="text-lg font-medium mb-2">3.2 Карты</h3>
|
||||
<p class="mb-2">Хранит предустановки карт локально в браузере (<code class="font-mono text-sm">localStorage</code>), чтобы представление Secured Packet могло автоматически подставлять ключи и параметры.</p>
|
||||
@@ -301,91 +313,25 @@
|
||||
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="response-parser" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">4. Вкладка «Парсер ответов»</h2>
|
||||
<p class="mb-3">Декодирует raw-ответ команды: выберите отправленную команду, введите SW (например, <code class="font-mono text-sm">9000</code>) и hex данных ответа, затем нажмите <strong>Декодировать</strong>. Поля также автоматически заполняются статусным словом и данными ответа последней команды после успешного нажатия <strong>Отправить на карту</strong> (см. <a href="#secured-packet" class="text-blue-600 dark:text-blue-400 hover:underline">§3.1</a>).</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1">
|
||||
<li><strong>Команда</strong> — группа SIM/USIM (SELECT, STATUS, READ/UPDATE, PIN-операции, CAT-команды типа TERMINAL PROFILE/ENVELOPE/FETCH/TERMINAL RESPONSE, MANAGE CHANNEL, …) или группа RAM/GP (INSTALL, LOAD, DELETE, GET/STORE DATA, auth, SCP-команды).</li>
|
||||
<li><strong>Декодирование SW</strong> — статусные слова разрешаются по generic-, UICC- (TS 102 221) и GlobalPlatform-таблицам, контекст определяется автоматически.</li>
|
||||
<li><strong>Декодирование привилегий</strong> — ответы GET DATA / INSTALL декодируют байты привилегий в читаемые флаги.</li>
|
||||
<li><strong>Данные ответа</strong> — raw hex отображается и интерпретируется согласно команде (например, FCP-шаблоны SELECT).</li>
|
||||
</ul>
|
||||
<h2 id="card-reader" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">4. Вкладка «Картридер» (pySim)</h2>
|
||||
<p class="mb-3">Подключение к локальному <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> для работы с картой: введите URL сервера (по умолчанию <code class="font-mono text-sm">http://127.0.0.1:8080</code>) и нажмите <strong>Подключиться</strong>. Область статуса показывает состояние ридера/карты, а <strong>Подключить карту</strong> (пере)инициализирует карту после вставки. Подвкладки: <strong>Файловый менеджер</strong>, <strong>Командная строка pySim</strong> и <strong>Отправка APDU</strong>. <strong>«Профайлер»</strong> и <strong>«Симулятор телефона»</strong> — отдельные вкладки верхнего уровня.</p>
|
||||
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="card-reader" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">5. Вкладка «Картридер» (pySim)</h2>
|
||||
<p class="mb-3">Подключение к локальному <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> для работы с картой: введите URL сервера (по умолчанию <code class="font-mono text-sm">http://127.0.0.1:8080</code>) и нажмите <strong>Подключиться</strong>. Область статуса показывает состояние ридера/карты, а <strong>Подключить карту</strong> (пере)инициализирует карту после вставки. Подвкладки: <strong>Файловый менеджер</strong>, <strong>Пользовательские файлы</strong>, <strong>Профайлер</strong>, <strong>Командная строка pySim</strong>, <strong>Отправка APDU</strong> и <strong>Проактивный UICC</strong>.</p>
|
||||
|
||||
<h3 id="file-manager" class="text-lg font-medium mb-2">5.1 Файловый менеджер</h3>
|
||||
<p class="text-sm mb-2">Дерево файловой системы отображается слева; выбор файла открывает панель деталей справа.</p>
|
||||
<h3 id="file-manager" class="text-lg font-medium mb-2">4.1 Файловый менеджер</h3>
|
||||
<p class="text-sm mb-2">Дерево файловой системы отображается слева; выбор файла открывает панель деталей справа. Элементы сгруппированы: DF выше EF, сортировка по <strong>FID</strong> или символьному <strong>имени</strong> (пиллы над деревом; выбор сохраняется в <code class="font-mono text-sm">localStorage</code>). При выборе файла над содержимым также показываются FID, тип файла, размер / структура записей и декодированный FCI.</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Прочитать</strong> — чтение файла (автоопределение transparent/record)</li>
|
||||
<li><strong>Редактировать</strong> — изменение hex-данных, <strong>Сохранить</strong> для записи (или <strong>Отмена</strong>)</li>
|
||||
<li><strong>Данные как на карте / Декодированные данные</strong> — переключение между hex-дампом и декодированным JSON</li>
|
||||
<li><strong>Проверить все файлы</strong> — обход всего дерева (включая пользовательские файлы) с пометкой каждого элемента: есть (обычный вид) или нет (красный ✗, без стрелки разворачивания); существующие пустые DF показывают <code class="font-mono text-sm">(пусто)</code>. Отображается прогресс <em>N / всего</em>, обход можно остановить; в конце — сводка «есть/нет». Файлы проверяются только при разворачивании или проверке — просмотр остаётся ленивым.</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="custom-files" class="text-lg font-medium mb-2">5.2 Пользовательские файлы</h3>
|
||||
<p class="text-sm mb-3">Добавление файлов, не покрытых моделью pySim: введите полный путь (например, <code class="font-mono text-sm">3F00/7F20/6F46</code>) и псевдоним (например, <code class="font-mono text-sm">EF.SPN</code>), затем нажмите <strong>Добавить</strong>; добавленные файлы появляются в дереве «Файловый менеджер». Список сохраняется в <code class="font-mono text-sm">localStorage</code>; обмен — <strong>Экспорт в JSON</strong> / <strong>Экспорт в файл</strong> и <strong>Импорт из файла</strong> / <strong>Вставить и импортировать</strong> / <strong>Импорт JSON из буфера</strong>.</p>
|
||||
|
||||
<h3 id="pysim-cmdline" class="text-lg font-medium mb-2">5.3 Командная строка pySim</h3>
|
||||
<h3 id="pysim-cmdline" class="text-lg font-medium mb-2">4.2 Командная строка pySim</h3>
|
||||
<p class="text-sm mb-3">Выполнение любых команд pySim-shell с подсказками (300 мс) и автодополнением.</p>
|
||||
|
||||
<h3 id="raw-apdu" class="text-lg font-medium mb-2">5.4 Отправка APDU</h3>
|
||||
<h3 id="raw-apdu" class="text-lg font-medium mb-2">4.3 Отправка APDU</h3>
|
||||
<p class="text-sm mb-3">Отправка произвольного APDU и просмотр ответа.</p>
|
||||
|
||||
<h3 id="proactive-uicc" class="text-lg font-medium mb-2">5.5 Проактивный UICC</h3>
|
||||
<p class="text-sm mb-3">Работа с сессией Card Application Toolkit: меню STK, подписанные события, журнал проактивных команд, словарь данных PROVIDE LOCAL INFORMATION и опрос STATUS.</p>
|
||||
|
||||
<h4 id="stk-menu" class="font-medium mb-1">5.5.1 Меню STK</h4>
|
||||
<p class="text-sm mb-3">Если карта выдала команду SET UP MENU, вверху этого представления появляется блок «Меню STK» с изумрудной кнопкой <strong>STK: <название></strong>, открывающей оверлей меню (браузер STK-меню карты). Если карта не задала меню, вместо кнопки показывается «Меню не задано картой». Состояние меню обновляется при каждом открытии представления.</p>
|
||||
|
||||
<h4 id="subscribed-events" class="font-medium mb-1">5.5.2 Подписанные события (SET UP EVENT LIST)</h4>
|
||||
<p class="text-sm mb-2">События, которые отслеживает карта. У каждого события есть кнопка <strong>Отправить</strong>, открывающая форму, специфичную для типа события:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>События без данных</strong> (User Activity, Idle Screen, Data Available, …) — уведомление в один клик</li>
|
||||
<li><strong>Location Status</strong> — выпадающий список: Normal / Limited / No service (тег <code class="font-mono text-sm">9B</code>)</li>
|
||||
<li><strong>Access Technology Change</strong> — 13 типов RAT (тег <code class="font-mono text-sm">BF</code>)</li>
|
||||
<li><strong>Network Rejection</strong> — полная адаптивная форма: тип регистрации (LU / GPRS / EPS / 5GS), поля местоположения (MCC, MNC, LAC, RAC, TAC), технология доступа и единый выпадающий список из 53 кодов причин (EMM, GMM, 5GMM и LU)</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">Отправка события использует <code class="font-mono text-sm">ENVELOPE(Event Download)</code> по TS 102 223 / TS 131 111.</p>
|
||||
|
||||
<h4 id="proactive-log" class="font-medium mb-1">5.5.3 Журнал проактивных команд</h4>
|
||||
<p class="text-sm mb-2">Хронологический список извлечённых проактивных команд. Каждая строка показывает время, код типа, имя и декодированный квалификатор (для команд, у которых он есть). Для команд с данными ответа показывается строка <code class="font-mono text-sm">Ответ:</code> с байтами TERMINAL RESPONSE (без служебных TLV); ответы PROVIDE LOCAL INFORMATION декодируются через словарь данных PLI.</p>
|
||||
|
||||
<h4 id="pli-dict" class="font-medium mb-1">5.5.4 Словарь данных PROVIDE LOCAL INFORMATION</h4>
|
||||
<p class="text-sm mb-2">Редактируемые hex-значения для всех 22 квалификаторов PLI (TS 102 223 §8.6 + TS 131 111). У десяти квалификаторов есть встроенные формы декодирования/кодирования:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>00</strong> Location Info (MCC, MNC, LAC/TAC, Cell ID)</li>
|
||||
<li><strong>01</strong> IMEI · <strong>03</strong> Дата/время/TZ · <strong>04</strong> Язык · <strong>05</strong> Timing Advance</li>
|
||||
<li><strong>06</strong> Access Technology · <strong>08</strong> IMEISV · <strong>09</strong> Search Mode</li>
|
||||
<li><strong>0A</strong> Battery · <strong>0E</strong> Multiple Access Technologies</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">Значения хранятся на сервере до перезапуска. Когда карта выдаёт PLI, сервер вставляет значения словаря в TERMINAL RESPONSE.</p>
|
||||
|
||||
<h4 id="status-polling" class="font-medium mb-1">5.5.5 Опрос STATUS</h4>
|
||||
<p class="text-sm mb-3">Кнопка <strong>Отправить STATUS</strong> отправляет STATUS (F2) вручную. Переключатель <strong>Опрос</strong> включает фоновый опрос: после настраиваемого интервала бездействия (аргумент сервера <code class="font-mono text-sm">--poll-interval</code>, 1–255 с, по умолчанию 30 с) сервер отправляет STATUS и обрабатывает любую ожидающую проактивную команду. При извлечении карты опрос останавливается, а состояние карты сбрасывается.</p>
|
||||
|
||||
<h3 id="profiler" class="text-lg font-medium mb-2">5.6 Профайлер</h3>
|
||||
<p class="text-sm mb-2">Проверяет соответствие карты именованному <strong>профилю</strong> — упорядоченному набору правил, описывающих ожидаемую файловую систему и (опционально) содержимое файлов. Профили хранятся в <code class="font-mono text-sm">localStorage</code>.</p>
|
||||
<h4 class="font-medium mb-1">Список профилей</h4>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Новый профиль</strong> — создаёт пустой набор правил, запросив имя.</li>
|
||||
<li><strong>Профиль с карты</strong> — сканирует подключённую карту и создаёт по одному правилу на каждый существующий файл (см. ниже), затем открывает редактор.</li>
|
||||
<li><strong>Импорт профиля</strong> — загружает набор правил из JSON-файла (имя хранится внутри JSON).</li>
|
||||
<li>В каждой строке профиля показаны имя и время создания, а также действия <strong>Проверить карту ▶</strong>, <strong>Редактировать</strong>, <strong>Экспорт</strong> (скачать JSON) и <strong>Удалить</strong>.</li>
|
||||
</ul>
|
||||
<h4 class="font-medium mb-1">Правила файловой системы</h4>
|
||||
<p class="text-sm mb-2">Правила выполняются последовательно. Редактор показывает символьное имя файла pySim (если известно) рядом с путём правила; <strong>Добавить правило</strong> добавляет правило, <strong>Сохранить</strong> сохраняет изменения. Правило файловой системы задаётся:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Путь</strong> — начинается с <code class="font-mono text-sm">MF</code> (например, <code class="font-mono text-sm">MF/7F10/6F3A</code>) или с AID ADF (например, <code class="font-mono text-sm">A0000000871002/6F07</code>).</li>
|
||||
<li><strong>Проверка FCP/FCI</strong> — какая часть информации об управлении файлом проверяется: <strong>Только тип файла (FCP)</strong> (существование + тип файла), <strong>Тип файла + размер (FCP)</strong> (добавляются размер файла либо длина/число записей для record-файлов) или <strong>Полный FCI</strong> (добавляется побайтовое сравнение сырого ответа SELECT — шаблона FCP <code class="font-mono text-sm">'62'</code> — выявляет изменения FID/AID, жизненного цикла, атрибутов безопасности и проприетарных параметров).</li>
|
||||
<li><strong>Атрибуты файла</strong> — тип файла, размер, длина записи и число записей из FCP-шаблона (любой можно оставить незаданным).</li>
|
||||
<li><strong>Проверить содержимое</strong> (опционально) — <strong>Точное</strong> (точное совпадение hex) или <strong>Маска</strong>, где <code class="font-mono text-sm">?</code> — шаблон на один полубайт (маска без <code class="font-mono text-sm">?</code> — совпадение префикса, например <code class="font-mono text-sm">0891</code> для MCC/MNC из IMSI). Для record-файлов хранится список по записям.</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3"><strong>Проверить карту</strong> выполняет каждое правило на подключённой карте и показывает строку прогресса и отчёт прохождения. Рядом с путём файла указывается, что именно проверялось (например, <em>тип файла и размер, содержимое</em> или <em>полный FCI</em>); если часть проверок прошла, а часть нет — каждый аспект помечается (<em>тип файла ✓, размер ✗, содержимое ✓</em>), а расхождения расписываются ниже. Несовпавшие сырые данные (FCI, содержимое, данные записей) показываются как поля только для чтения с моноширинным шрифтом — ожидаемое над фактическим, в одной и той же колонке — для удобного сравнения. Для record-файлов при расхождении содержимого добавляется пометка <em>совпадающие записи: 1-5, 7-10</em> со списком записей, которые совпали.</p>
|
||||
<h4 class="font-medium mb-1">Опции сканирования «Профиль с карты»</h4>
|
||||
<p class="text-sm mb-2">Диалог сканирования запрашивает имя профиля и предлагает селектор <strong>«Проверка FCP/FCI»</strong> (те же три режима, по умолчанию <strong>Тип файла + размер (FCP)</strong>), применяемый ко всем создаваемым правилам, а также список <strong>«Игнорировать содержимое файлов»</strong> (все отмечены по умолчанию) часто перезаписываемых файлов, содержимое которых пропускается: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>. Ещё две отмеченные по умолчанию опции <strong>«Сравнивать первые 4 байта для»</strong> <code class="font-mono text-sm">EF.IMSI</code> и <code class="font-mono text-sm">EF.ICCID</code> захватывают содержимое этих файлов как маску только первых 4 байт (снимите для точного сравнения). Строка прогресса показывает <em>N / всего файлов</em> с текущим путём файла во время сканирования; при сканировании опции скрываются, а кнопки блокируются. Правила создаются только для файлов, которые реально существуют на карте (возвращён FCP-шаблон); отсутствующие файлы пропускаются. Пользовательские файлы из подвкладки <strong>«Пользовательские файлы»</strong> включаются с той же проверкой существования.</p>
|
||||
|
||||
<h3 id="usage-scenarios" class="text-lg font-medium mb-2">5.7 Сценарии использования</h3>
|
||||
<h3 id="usage-scenarios" class="text-lg font-medium mb-2">4.4 Сценарии использования</h3>
|
||||
|
||||
<h4 id="scenario-a" class="font-medium mb-1">Сценарий A — Работа с файлами, не входящими в модель pySim («Пользовательские файлы»)</h4>
|
||||
<ol class="list-decimal list-inside text-sm space-y-1 mb-3">
|
||||
@@ -401,7 +347,7 @@
|
||||
<h4 id="scenario-b" class="font-medium mb-1">Сценарий B — Симуляция реальной сетевой среды для тестирования SIM</h4>
|
||||
<p class="text-sm mb-1"><strong>B.1 Ответы на PROVIDE LOCAL INFORMATION (PLI)</strong></p>
|
||||
<ol class="list-decimal list-inside text-sm space-y-1 mb-3">
|
||||
<li>Откройте <strong>Проактивный UICC</strong> → <strong>Данные для PROVIDE LOCAL INFORMATION</strong>.</li>
|
||||
<li>Откройте <strong>Симулятор телефона</strong> → <strong>Данные для PROVIDE LOCAL INFORMATION</strong>.</li>
|
||||
<li>Используйте формы декодирования/кодирования для IMEI (<code class="font-mono text-sm">01</code>), Location Info (<code class="font-mono text-sm">00</code>), Access Technology (<code class="font-mono text-sm">06</code>) и т.д.</li>
|
||||
<li>Нажмите <strong>Сохранить</strong> — значения сохранятся на сервере.</li>
|
||||
<li>Включите <strong>Опрос</strong> (интервал 30 с), чтобы карта периодически выдавала PLI.</li>
|
||||
@@ -422,11 +368,87 @@
|
||||
</ul>
|
||||
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="server" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">6. Установка сервера</h2>
|
||||
<p class="mb-3">Для работы с картой (вкладка «Картридер», «Проактивный UICC», доставка OTA) нужен локальный <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> — встроенный в OTAMan HTTP-сервер, оборачивающий pySim, работающий с ридером через PC/SC или serial и раздающий сам PWA (откройте <code class="font-mono text-sm">http://127.0.0.1:8080</code>).</p>
|
||||
|
||||
<h3 id="prerequisites" class="text-lg font-medium mb-2">6.1 Требования</h3>
|
||||
<section class="mb-10">
|
||||
<h2 id="profiler" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">5. Профайлер</h2>
|
||||
<p class="text-sm mb-2">Проверяет соответствие карты именованному <strong>профилю</strong> — упорядоченному набору правил, описывающих ожидаемую файловую систему и (опционально) содержимое файлов. Профили хранятся в <code class="font-mono text-sm">localStorage</code>.</p>
|
||||
<h4 class="font-medium mb-1">Список профилей</h4>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Новый профиль</strong> — создаёт пустой набор правил, запросив имя.</li>
|
||||
<li><strong>Профиль с карты</strong> — сканирует подключённую карту и создаёт по одному правилу на каждый существующий файл (см. ниже), затем открывает редактор.</li>
|
||||
<li><strong>Профиль из снимка</strong> — выбирает сохранённый снимок карты и создаёт по правилу на каждый захваченный файл с теми же опциями сканирования (см. ниже), без картридера; имя профиля подставляется из имени снимка.</li>
|
||||
<li><strong>Импорт профиля</strong> — загружает набор правил из JSON-файла (имя хранится внутри JSON).</li>
|
||||
<li>В каждой строке профиля показаны имя и время создания, а также действия <strong>Проверить карту ▶</strong>, <strong>Проверить снимок карты</strong>, <strong>Редактировать</strong>, <strong>Экспорт</strong> (скачать JSON) и <strong>Удалить</strong>.</li>
|
||||
</ul>
|
||||
<h4 class="font-medium mb-1">Правила файловой системы</h4>
|
||||
<p class="text-sm mb-2">Правила выполняются последовательно. Редактор показывает символьное имя файла pySim (если известно) рядом с путём правила; <strong>Добавить правило</strong> добавляет правило, <strong>Сохранить</strong> сохраняет изменения. Правило файловой системы задаётся:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Путь</strong> — начинается с <code class="font-mono text-sm">MF</code> (например, <code class="font-mono text-sm">MF/7F10/6F3A</code>) или с AID ADF (например, <code class="font-mono text-sm">A0000000871002/6F07</code>).</li>
|
||||
<li><strong>Проверка FCP/FCI</strong> — какая часть информации об управлении файлом проверяется: <strong>Только тип файла (FCP)</strong> (существование + тип файла), <strong>Тип файла + размер (FCP)</strong> (добавляются размер файла либо длина/число записей для record-файлов) или <strong>Полный FCI</strong> (добавляется побайтовое сравнение сырого ответа SELECT — шаблона FCP <code class="font-mono text-sm">'62'</code> — выявляет изменения FID/AID, жизненного цикла, атрибутов безопасности и проприетарных параметров).</li>
|
||||
<li><strong>Атрибуты файла</strong> — тип файла, размер, длина записи и число записей из FCP-шаблона (любой можно оставить незаданным).</li>
|
||||
<li><strong>Проверить содержимое</strong> (опционально) — <strong>Точное</strong> (точное совпадение hex) или <strong>Маска</strong>, где <code class="font-mono text-sm">?</code> — шаблон на один полубайт (маска без <code class="font-mono text-sm">?</code> — совпадение префикса, например <code class="font-mono text-sm">0891</code> для MCC/MNC из IMSI). Для record-файлов хранится список по записям.</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3"><strong>Проверить карту</strong> выполняет каждое правило на подключённой карте и показывает строку прогресса и отчёт прохождения. Рядом с путём файла указывается, что именно проверялось (например, <em>тип файла и размер, содержимое</em> или <em>полный FCI</em>); если часть проверок прошла, а часть нет — каждый аспект помечается (<em>тип файла ✓, размер ✗, содержимое ✓</em>), а расхождения расписываются ниже. Несовпавшие сырые данные (FCI, содержимое, данные записей) показываются как поля только для чтения с моноширинным шрифтом — ожидаемое над фактическим, в одной и той же колонке — для удобного сравнения; для расхождений FCI дополнительно показывается декодированное сравнение по параметрам (размер файла, дескриптор/структура, жизненный цикл, FID, SFI, проприетарные параметры…). Декодированный просмотр FCI также отображается рядом с полем FCI hex при редактировании правила. Если данные FCI повреждены, показывается всё, что удалось декодировать до места ошибки, вместе с явным сообщением об ошибке декодирования. Для record-файлов при расхождении содержимого добавляется пометка <em>совпадающие записи: 1-5, 7-10</em> со списком записей, которые совпали. В отчёте поля расхождений и колонки сравнения FCI подписаны <em>ожидалось (имя профиля)</em> и <em>фактически (ICCID карты)</em>, а в заголовке отчёта выводится <em>Результаты проверки профиля: <профиль> → <ICCID карты></em>. Опция <strong>«Только расхождения»</strong> в заголовке отчёта скрывает все совпавшие файлы и оставляет только несовпадения и ошибки.</p>
|
||||
<h4 class="font-medium mb-1">Опции сканирования «Профиль с карты»</h4>
|
||||
<p class="text-sm mb-2">Диалог сканирования запрашивает имя профиля и предлагает селектор <strong>«Проверка FCP/FCI»</strong> (те же три режима, по умолчанию <strong>Тип файла + размер (FCP)</strong>), применяемый ко всем создаваемым правилам, а также список <strong>«Игнорировать содержимое файлов»</strong> (все отмечены по умолчанию, кроме <code class="font-mono text-sm">EF.ARR</code>; флажок в заголовке отмечает или снимает весь список) часто перезаписываемых файлов, содержимое которых пропускается: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Ещё две отмеченные по умолчанию опции <strong>«Сравнивать первые 4 байта для»</strong> <code class="font-mono text-sm">EF.IMSI</code> и <code class="font-mono text-sm">EF.ICCID</code> захватывают содержимое этих файлов как маску только первых 4 байт (снимите для точного сравнения). Строка прогресса показывает <em>N / всего файлов</em> с текущим путём файла во время сканирования; при сканировании опции скрываются, а кнопки блокируются. Правила создаются только для файлов, которые реально существуют на карте (возвращён FCP-шаблон); отсутствующие файлы пропускаются. Пользовательские файлы из подвкладки <strong>«Пользовательские файлы»</strong> включаются с той же проверкой существования. Тот же диалог и опции использует <strong>«Профиль из снимка»</strong>: вместо карты обходятся захваченные файлы выбранного снимка; для файлов, содержимое которых не было захвачено, правило создаётся без проверки содержимого (при последующей проверке профиля они помечаются как непроверяемые).</p>
|
||||
|
||||
<h4 id="card-snapshots" class="font-medium mb-1">Снимки карт</h4>
|
||||
<p class="text-sm mb-2">Представление списка имеет две вкладки — <strong>«Профили»</strong> и <strong>«Снимки карт»</strong>. Снимок карты — неизменяемая фиксация файловой системы карты: для каждого существующего файла сохраняются путь, символьное имя, тип, размер (или длина/число записей), сырой FCI из ответа SELECT и содержимое, если файл читается (без списка игнорирования и без масок). ICCID декодируется из EF.ICCID и показывается рядом с именем снимка. При сканировании измеряется время каждой команды карты (SELECT, READ BINARY, READ RECORD) от отправки до ответа; снимок хранит min/сред/max по каждому типу команд и общее время сканирования, а в представлении эти значения показываются в сводке под заголовком, время select/read — для каждого файла и время чтения — для каждой записи. Время носит информационный характер и не используется при проверках и сравнении.</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Новый снимок</strong> — запрашивает имя и сканирует карту, затем возвращает к списку.</li>
|
||||
<li><strong>Импорт снимка</strong> — загружает снимок из JSON-файла.</li>
|
||||
<li>В каждой строке снимка — <strong>Открыть</strong>, <strong>Экспорт</strong> и <strong>Удалить</strong>. <strong>Открыть</strong> показывает все захваченные данные только для чтения (сырой FCI с декодированным FCI, содержимое); редактируется только имя снимка.</li>
|
||||
<li><strong>Проверить снимок карты</strong> в строке профиля выполняет правила профиля на выбранном из списка снимке, без картридера. Отчёт такой же, как при проверке карты: фактическая сторона подписана именем снимка (<em>фактически (имя снимка)</em>), а в заголовке — <em>Результаты проверки профиля: <профиль> → <имя снимка></em>; файлы, содержимое которых не было захвачено при сканировании, помечаются как непроверяемые ошибки.</li>
|
||||
<li><strong>Сравнить снимки</strong> сравнивает два снимка без картридера так же, как проверка профиля: выберите <em>эталонный</em> снимок и <em>снимок для проверки</em>, при необходимости включите маску первых 4 байт EF.IMSI/EF.ICCID (включена по умолчанию) и получите такой же отчёт; в этом отчёте заголовок — <em>Результаты сравнения снимков: <эталон> → <проверяемый></em>, а поля расхождений и колонки сравнения FCI подписаны именами эталонного и проверяемого снимков вместо expected/actual. Файлы, которые есть только в проверяемом снимке, помечаются как лишние. «К списку» возвращает на вкладку «Снимки карт».</li>
|
||||
</ul>
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
<h4 id="custom-files" class="font-medium mb-1">Пользовательские файлы</h4>
|
||||
<p class="text-sm mb-3">Добавление файлов, не покрытых моделью pySim: введите полный путь (например, <code class="font-mono text-sm">3F00/7F20/6F46</code>) и псевдоним (например, <code class="font-mono text-sm">EF.SPN</code>), затем нажмите <strong>Добавить</strong>; добавленные файлы появляются в дереве «Файловый менеджер». В каждой строке есть кнопки <strong>«Редактировать»</strong> (загружает запись в форму — кнопка становится <strong>«Сохранить»</strong>, появляется <strong>«Отмена»</strong>) и <strong>«Удалить»</strong> (без подтверждения). Список сохраняется в <code class="font-mono text-sm">localStorage</code>; обмен — <strong>Экспорт в JSON</strong> / <strong>Экспорт в файл</strong> и <strong>Импорт из файла</strong> / <strong>Вставить и импортировать</strong> / <strong>Импорт JSON из буфера</strong>.</p>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="proactive-uicc" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">6. Симулятор телефона</h2>
|
||||
<p class="text-sm mb-3">Работа с сессией Card Application Toolkit. Две подвкладки: <strong>«Телефон»</strong> (меню STK, STATUS и опрос, подписанные события, журнал проактивных команд) и <strong>«Конфигурация TR»</strong> (данные ответов, подставляемые в TERMINAL RESPONSE для проактивных команд).</p>
|
||||
|
||||
<h3 id="stk-menu" class="text-lg font-medium mb-2">6.1 Меню STK</h3>
|
||||
<p class="text-sm mb-3">Если карта выдала команду SET UP MENU, вверху этого представления появляется блок «Меню STK» с изумрудной кнопкой <strong>STK: <название></strong>, открывающей оверлей меню (браузер STK-меню карты). Если карта не задала меню, вместо кнопки показывается «Меню не задано картой». Состояние меню обновляется при каждом открытии представления. Интерактивные проактивные команды всегда получают TERMINAL RESPONSE: оверлей ждёт вашего выбора, и если вы не ответили и не нажали <strong>Timeout</strong>, сервер сам отвечает результатом timeout через <code class="font-mono text-sm">--menu-timeout</code> секунд (по умолчанию 60, <code class="font-mono text-sm">0</code> отключает). <strong>Назад</strong> и <strong>Timeout</strong> продолжают диалог с картой: если карта в ответ выдаёт следующую проактивную команду (SELECT ITEM или DISPLAY TEXT), панель показывает её; кэшированное верхнее меню появляется только когда карте больше нечего выполнять.</p>
|
||||
|
||||
<h3 id="subscribed-events" class="text-lg font-medium mb-2">6.2 Подписанные события (SET UP EVENT LIST)</h3>
|
||||
<p class="text-sm mb-2">События, которые отслеживает карта. У каждого события есть кнопка <strong>Отправить</strong>, открывающая форму, специфичную для типа события:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>События без данных</strong> (User Activity, Idle Screen, Data Available, …) — уведомление в один клик</li>
|
||||
<li><strong>Location Status</strong> — выпадающий список: Normal / Limited / No service (тег <code class="font-mono text-sm">9B</code>)</li>
|
||||
<li><strong>Access Technology Change</strong> — 13 типов RAT (тег <code class="font-mono text-sm">BF</code>)</li>
|
||||
<li><strong>Network Rejection</strong> — полная адаптивная форма: тип регистрации (LU / GPRS / EPS / 5GS), поля местоположения (MCC, MNC, LAC, RAC, TAC), технология доступа и единый выпадающий список из 53 кодов причин (EMM, GMM, 5GMM и LU)</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">Отправка события использует <code class="font-mono text-sm">ENVELOPE(Event Download)</code> по TS 102 223 / TS 131 111.</p>
|
||||
|
||||
<h3 id="proactive-log" class="text-lg font-medium mb-2">6.3 Журнал проактивных команд</h3>
|
||||
<p class="text-sm mb-2">Хронологический список извлечённых проактивных команд. Каждая строка показывает время, код типа, имя и декодированный квалификатор (для команд, у которых он есть). Для команд с данными ответа показывается строка <code class="font-mono text-sm">Ответ:</code> с байтами TERMINAL RESPONSE (без служебных TLV); ответы PROVIDE LOCAL INFORMATION декодируются через словарь данных PLI.</p>
|
||||
|
||||
<h3 id="status-polling" class="text-lg font-medium mb-2">6.4 Опрос STATUS</h3>
|
||||
<p class="text-sm mb-3">Кнопка <strong>Отправить STATUS</strong> отправляет STATUS (F2) вручную. Переключатель <strong>Опрос</strong> включает фоновый опрос: после настраиваемого интервала бездействия (аргумент сервера <code class="font-mono text-sm">--poll-interval</code>, 1–255 с, по умолчанию 30 с, <code class="font-mono text-sm">0</code> отключает опрос) сервер отправляет STATUS и обрабатывает любую ожидающую проактивную команду. При извлечении карты опрос останавливается, а состояние карты сбрасывается.</p>
|
||||
|
||||
<h3 id="pli-dict" class="text-lg font-medium mb-2">6.5 «Конфигурация TR» — данные ответа PROVIDE LOCAL INFORMATION</h3>
|
||||
<p class="text-sm mb-2">Редактируемые hex-значения для всех 22 квалификаторов PLI (TS 102 223 §8.6 + TS 131 111). У десяти квалификаторов есть встроенные формы декодирования/кодирования:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>00</strong> Location Info (MCC, MNC, LAC/TAC, Cell ID)</li>
|
||||
<li><strong>01</strong> IMEI · <strong>03</strong> Дата/время/TZ · <strong>04</strong> Язык · <strong>05</strong> Timing Advance</li>
|
||||
<li><strong>06</strong> Access Technology · <strong>08</strong> IMEISV · <strong>09</strong> Search Mode</li>
|
||||
<li><strong>0A</strong> Battery · <strong>0E</strong> Multiple Access Technologies</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">Значения хранятся на сервере до перезапуска. Когда карта выдаёт PLI, сервер вставляет значения словаря в TERMINAL RESPONSE.</p>
|
||||
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="server" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">7. Установка сервера</h2>
|
||||
<p class="mb-3">Для работы с картой (вкладка «Картридер», «Симулятор телефона», доставка OTA) нужен локальный <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> — встроенный в OTAMan HTTP-сервер, оборачивающий pySim, работающий с ридером через PC/SC или serial и раздающий сам PWA (откройте <code class="font-mono text-sm">http://127.0.0.1:8080</code>).</p>
|
||||
|
||||
<h3 id="prerequisites" class="text-lg font-medium mb-2">7.1 Требования</h3>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Python 3.8+</strong> с <code class="font-mono text-sm">pip</code></li>
|
||||
<li><strong>Git</strong></li>
|
||||
@@ -434,20 +456,20 @@
|
||||
<li><strong>Только Windows</strong> — используйте <strong>Python 3.10–3.13</strong> (рекомендуется 3.13): <code class="font-mono text-sm">pyscard</code> (обёртка драйвера PC/SC) поставляет готовые wheels для этих версий. На Python 3.9 / 3.14 pip собирает <code class="font-mono text-sm">pyscard</code> из исходников, для чего требуются Microsoft C++ Build Tools («Desktop development with C++»). Мост SMPP (<code class="font-mono text-sm">smpp.twisted3</code>) на Windows намеренно не устанавливается, поэтому для Python 3.10–3.13 C++ Build Tools не нужны.</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="quickstart-linux" class="text-lg font-medium mb-2">6.2 Быстрый старт — Linux / macOS</h3>
|
||||
<h3 id="quickstart-linux" class="text-lg font-medium mb-2">7.2 Быстрый старт — Linux / macOS</h3>
|
||||
<pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3">git clone https://github.com/anttro/otaman.git
|
||||
cd otaman
|
||||
chmod +x setup.sh start.sh
|
||||
./setup.sh # создаёт .venv, устанавливает pysim + сервер (однократно)
|
||||
./start.sh # запускает сервер (PWA + API, автоопределение ридера)</pre>
|
||||
|
||||
<h3 id="quickstart-windows" class="text-lg font-medium mb-2">6.3 Быстрый старт — Windows</h3>
|
||||
<h3 id="quickstart-windows" class="text-lg font-medium mb-2">7.3 Быстрый старт — Windows</h3>
|
||||
<pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3">git clone https://github.com/anttro/otaman.git
|
||||
cd otaman
|
||||
setup.bat # создаёт .venv, устанавливает pysim + сервер (однократно)
|
||||
start.bat # запускает сервер (PWA + API)</pre>
|
||||
|
||||
<h3 id="helper-scripts" class="text-lg font-medium mb-2">6.4 Вспомогательные скрипты</h3>
|
||||
<h3 id="helper-scripts" class="text-lg font-medium mb-2">7.4 Вспомогательные скрипты</h3>
|
||||
<table class="w-full text-sm mb-3 border-collapse">
|
||||
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Скрипт</th><th class="text-left py-1 px-2">Назначение</th></tr></thead>
|
||||
<tbody>
|
||||
@@ -456,7 +478,7 @@ start.bat # запускает сервер (PWA + API)</pre>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3 id="reader-autodetect" class="text-lg font-medium mb-2">6.5 Автоопределение ридера</h3>
|
||||
<h3 id="reader-autodetect" class="text-lg font-medium mb-2">7.5 Автоопределение ридера</h3>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>PC/SC (Linux)</strong> — <code class="font-mono text-sm">start.sh</code> передаёт <code class="font-mono text-sm">-p 0</code>, если запущен демон <code class="font-mono text-sm">pcscd</code></li>
|
||||
<li><strong>PC/SC (Windows)</strong> — <code class="font-mono text-sm">start.bat</code> всегда использует <code class="font-mono text-sm">-p 0</code> (PC/SC встроен в Windows)</li>
|
||||
@@ -465,7 +487,7 @@ start.bat # запускает сервер (PWA + API)</pre>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">Если карта отсутствует, вкладка «Картридер» показывает «Карта не обнаружена. Вставьте карту и нажмите Подключить карту».</p>
|
||||
|
||||
<h3 id="manual-install" class="text-lg font-medium mb-2">6.6 Ручная установка</h3>
|
||||
<h3 id="manual-install" class="text-lg font-medium mb-2">7.6 Ручная установка</h3>
|
||||
<pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3"># Создать и активировать venv
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate # Linux/macOS
|
||||
@@ -484,7 +506,7 @@ pysim-otaman-server --http-port 8080</pre>
|
||||
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="compatibility" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">7. Совместимость версий</h2>
|
||||
<h2 id="compatibility" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">8. Совместимость версий</h2>
|
||||
<table class="w-full text-sm mb-3 border-collapse">
|
||||
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">PWA (OTAMan)</th><th class="text-left py-1 px-2">Сервер</th><th class="text-left py-1 px-2">Статус</th></tr></thead>
|
||||
<tbody>
|
||||
|
||||
+111
-89
@@ -44,13 +44,14 @@
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Header</strong> — the app version, an <strong>INSTALL PWA</strong> button (shown when the browser offers installation, enabling offline use), links to the project on GitHub and to this help, an <strong>EN/RU</strong> language toggle, and a dark/light <strong>theme</strong> toggle.</li>
|
||||
<li>Language and theme choices are stored in <code class="font-mono text-sm">localStorage</code> and persist across reloads.</li>
|
||||
<li>The <strong>help</strong> link opens this documentation at the section matching the current view (e.g. the Profiler sub-tab opens §5.6).</li>
|
||||
<li>Top-level tabs: <strong>Remote APDU</strong> (<strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong>, <strong>HTTP OTA</strong>, <strong>C-APDU Parser</strong>, <strong>Response parser</strong>), <strong>SCP80</strong> (<strong>Secured Packet</strong>, <strong>Cards</strong>, <strong>RAM</strong>), <strong>Profiler</strong> (list tabs <strong>Profiles</strong>, <strong>Card snapshots</strong>, <strong>Custom files</strong>), <strong>Card reader</strong> (<strong>File manager</strong>, <strong>pySim command line</strong>, <strong>Raw APDU</strong>), and <strong>Phone simulator</strong>.</li>
|
||||
<li>The <strong>help</strong> link opens this documentation at the section matching the current view (e.g. the Profiler tab opens §5).</li>
|
||||
</ul>
|
||||
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="c-apdu" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">2. C-APDU tab</h2>
|
||||
<p class="mb-3">Builds command APDUs (C-APDUs). Six sub-tabs cover different card generations and command sets: <strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong>, <strong>HTTP OTA</strong>, and <strong>C-APDU Parser</strong>.</p>
|
||||
<h2 id="c-apdu" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">2. Remote APDU tab</h2>
|
||||
<p class="mb-3">Builds command APDUs (C-APDUs). Seven sub-tabs cover different card generations, command sets and decoding tools: <strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong>, <strong>HTTP OTA</strong>, <strong>C-APDU Parser</strong>, and <strong>Response parser</strong>.</p>
|
||||
|
||||
<h3 id="sim-rfm" class="text-lg font-medium mb-2">2.1 SIM RFM</h3>
|
||||
<p class="mb-2">CLA = <code class="font-mono text-sm">A0</code> (GSM 11.11 / TS 151 011, ISO 7816-4). Remote File Management for classic SIM cards.</p>
|
||||
@@ -138,7 +139,7 @@
|
||||
</ul>
|
||||
|
||||
<h4 id="expanded-response" class="font-medium mb-2 text-base">Response decoding (TS 102 226 §5.2.2)</h4>
|
||||
<p class="text-sm mb-2">Incoming Proof-of-Receipt responses are decoded by the server — expanded Remote Application response data (TS 102 226 §5.2.2) or the compact format. The Secured Packet view shows the outcome after <strong>Send to Card</strong> (see <a href="#secured-packet" class="text-blue-600 dark:text-blue-400 hover:underline">§3.1</a>): the PoR status (TAR, counter, raw PoR), with the last command’s status word and response data filled into the <strong>Response parser</strong> tab.</p>
|
||||
<p class="text-sm mb-2">Incoming Proof-of-Receipt responses are decoded by the server — expanded Remote Application response data (TS 102 226 §5.2.2) or the compact format. The Secured Packet view shows the outcome after <strong>Send to Card</strong> (see <a href="#secured-packet" class="text-blue-600 dark:text-blue-400 hover:underline">§3.1</a>): the PoR status (TAR, counter, raw PoR), with the last command’s status word and response data filled into the <strong>Response parser</strong> pill under Remote APDU.</p>
|
||||
|
||||
<h3 id="ram-gp" class="text-lg font-medium mb-2">2.4 RAM/GP</h3>
|
||||
<p class="mb-2">CLA = <code class="font-mono text-sm">80</code> (GlobalPlatform Card Specification v2.3.1). Remote Application Management commands for card content management. Built with the same chain builder as SIM/USIM: add rows, fill fields, and the chain preview updates automatically.</p>
|
||||
@@ -227,6 +228,17 @@
|
||||
<p class="text-sm mb-2"><strong>Pack into Secured packet</strong> sends the built payload to the SCP80 tab for SPI/counter filling — insert the TAR the SD listens on (typically the OTASD TAR) there.</p>
|
||||
|
||||
|
||||
|
||||
<h3 id="response-parser" class="text-lg font-medium mb-2">2.8 Response parser</h3>
|
||||
<p class="mb-3">Decodes a raw command response: pick the command that was sent, enter the SW (e.g. <code class="font-mono text-sm">9000</code>) and the response data hex, then press <strong>Decode</strong>. The fields are also auto-filled with the last command’s status word and response data after a successful “Send to Card” (see <a href="#secured-packet" class="text-blue-600 dark:text-blue-400 hover:underline">§3.1</a>).</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1">
|
||||
<li><strong>Command</strong> — SIM/USIM group (SELECT, STATUS, READ/UPDATE, PIN ops, CAT commands like TERMINAL PROFILE/ENVELOPE/FETCH/TERMINAL RESPONSE, MANAGE CHANNEL, …) or RAM/GP group (INSTALL, LOAD, DELETE, GET/STORE DATA, auth, SCP commands).</li>
|
||||
<li><strong>SW decode</strong> — status words resolved against generic, UICC (TS 102 221), and GlobalPlatform maps, with context auto-detected.</li>
|
||||
<li><strong>Privilege decode</strong> — GET DATA / INSTALL response payloads decode the privilege bytes into human-readable flags.</li>
|
||||
<li><strong>Response data</strong> — raw hex rendered and interpreted per command (e.g. SELECT FCP templates).</li>
|
||||
</ul>
|
||||
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="scp80" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">3. SCP80 tab</h2>
|
||||
<p class="mb-3">The <strong>SCP80</strong> top-level tab groups the SCP80-related views. It is switched by three pills: <strong>Secured Packet</strong>, <strong>Cards</strong>, and <strong>RAM</strong>. Assembles SCP80 secured packets per ETSI TS 102 225.</p>
|
||||
@@ -301,91 +313,25 @@
|
||||
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="response-parser" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">4. Response parser tab</h2>
|
||||
<p class="mb-3">Decodes a raw command response: pick the command that was sent, enter the SW (e.g. <code class="font-mono text-sm">9000</code>) and the response data hex, then press <strong>Decode</strong>. The fields are also auto-filled with the last command’s status word and response data after a successful “Send to Card” (see <a href="#secured-packet" class="text-blue-600 dark:text-blue-400 hover:underline">§3.1</a>).</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1">
|
||||
<li><strong>Command</strong> — SIM/USIM group (SELECT, STATUS, READ/UPDATE, PIN ops, CAT commands like TERMINAL PROFILE/ENVELOPE/FETCH/TERMINAL RESPONSE, MANAGE CHANNEL, …) or RAM/GP group (INSTALL, LOAD, DELETE, GET/STORE DATA, auth, SCP commands).</li>
|
||||
<li><strong>SW decode</strong> — status words resolved against generic, UICC (TS 102 221), and GlobalPlatform maps, with context auto-detected.</li>
|
||||
<li><strong>Privilege decode</strong> — GET DATA / INSTALL response payloads decode the privilege bytes into human-readable flags.</li>
|
||||
<li><strong>Response data</strong> — raw hex rendered and interpreted per command (e.g. SELECT FCP templates).</li>
|
||||
</ul>
|
||||
<h2 id="card-reader" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">4. Card reader (pySim) tab</h2>
|
||||
<p class="mb-3">Connects to a local <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> for live card operations: enter the server URL (default <code class="font-mono text-sm">http://127.0.0.1:8080</code>) and press <strong>Connect</strong>. The status area shows the reader/card state, and <strong>Equip card</strong> (re)initializes the card after insertion. Sub-tabs: <strong>File manager</strong>, <strong>pySim command line</strong>, and <strong>Raw APDU</strong>. The <strong>Profiler</strong> and <strong>Phone simulator</strong> are separate top-level tabs.</p>
|
||||
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="card-reader" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">5. Card reader (pySim) tab</h2>
|
||||
<p class="mb-3">Connects to a local <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> for live card operations: enter the server URL (default <code class="font-mono text-sm">http://127.0.0.1:8080</code>) and press <strong>Connect</strong>. The status area shows the reader/card state, and <strong>Equip card</strong> (re)initializes the card after insertion. Sub-tabs: <strong>File manager</strong>, <strong>Custom files</strong>, <strong>Profiler</strong>, <strong>pySim command line</strong>, <strong>Raw APDU</strong>, and <strong>Proactive UICC</strong>.</p>
|
||||
|
||||
<h3 id="file-manager" class="text-lg font-medium mb-2">5.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.</p>
|
||||
<h3 id="file-manager" class="text-lg font-medium mb-2">4.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 above the tree; 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 above the content pane.</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Read</strong> — reads the selected file (auto-detects transparent vs record files)</li>
|
||||
<li><strong>Edit</strong> — modify hex data, <strong>Save</strong> to write back (or <strong>Cancel</strong>)</li>
|
||||
<li><strong>Raw / Decoded</strong> — toggle between hex dump and pySim-decoded JSON</li>
|
||||
<li><strong>Probe all files</strong> — walks the whole tree (including custom files) and marks every entry present (normal) or absent (red ✗, no expand arrow); empty-but-present DFs show <code class="font-mono text-sm">(empty)</code>. Shows progress <em>N / total</em>, can be stopped, and finishes with a present/absent summary. Files are only verified when expanded or probed — browsing stays lazy.</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="custom-files" class="text-lg font-medium mb-2">5.2 Custom files</h3>
|
||||
<p class="text-sm mb-3">Add files that pySim’s model does not cover: enter the full path (e.g. <code class="font-mono text-sm">3F00/7F20/6F46</code>) and an alias (e.g. <code class="font-mono text-sm">EF.SPN</code>), then press <strong>Add</strong>; added files appear in the File manager tree. The list persists in <code class="font-mono text-sm">localStorage</code> and can be shared with <strong>Export as JSON</strong> / <strong>Export to file</strong> and restored with <strong>Import from file</strong> / <strong>Paste & import</strong> / <strong>Import JSON from clipboard</strong>.</p>
|
||||
|
||||
<h3 id="pysim-cmdline" class="text-lg font-medium mb-2">5.3 pySim command line</h3>
|
||||
<h3 id="pysim-cmdline" class="text-lg font-medium mb-2">4.2 pySim command line</h3>
|
||||
<p class="text-sm mb-3">Execute any pySim-shell command with usage hints (300 ms) and autocomplete.</p>
|
||||
|
||||
<h3 id="raw-apdu" class="text-lg font-medium mb-2">5.4 Raw APDU</h3>
|
||||
<h3 id="raw-apdu" class="text-lg font-medium mb-2">4.3 Raw APDU</h3>
|
||||
<p class="text-sm mb-3">Send an arbitrary APDU and view the raw response.</p>
|
||||
|
||||
<h3 id="proactive-uicc" class="text-lg font-medium mb-2">5.5 Proactive UICC</h3>
|
||||
<p class="text-sm mb-3">Interacts with the Card Application Toolkit session: the STK menu, subscribed events, the proactive command log, the PROVIDE LOCAL INFORMATION data dictionary, and STATUS polling.</p>
|
||||
|
||||
<h4 id="stk-menu" class="font-medium mb-1">5.5.1 STK menu</h4>
|
||||
<p class="text-sm mb-3">When the card has issued a SET UP MENU command, a “STK menu” block appears at the top of this view with an emerald <strong>STK: <title></strong> button that opens the menu overlay (same as the card’s STK menu browser). If the card has not set up a menu, the block shows “No menu set by the card” instead. The menu state is refreshed each time the view is opened.</p>
|
||||
|
||||
<h4 id="subscribed-events" class="font-medium mb-1">5.5.2 Subscribed events (SET UP EVENT LIST)</h4>
|
||||
<p class="text-sm mb-2">The events the card monitors. Each event has a <strong>Send</strong> button that opens a form specific to the event type:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>No-data events</strong> (User Activity, Idle Screen, Data Available, …) — one-click notification</li>
|
||||
<li><strong>Location Status</strong> — dropdown: Normal / Limited / No service (tag <code class="font-mono text-sm">9B</code>)</li>
|
||||
<li><strong>Access Technology Change</strong> — 13 RAT types (tag <code class="font-mono text-sm">BF</code>)</li>
|
||||
<li><strong>Network Rejection</strong> — full adaptive form: registration type (LU / GPRS / EPS / 5GS), location fields (MCC, MNC, LAC, RAC, TAC), access technology, and a 53-cause unified rejection cause dropdown covering EMM, GMM, 5GMM and LU causes</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">Sending an event uses <code class="font-mono text-sm">ENVELOPE(Event Download)</code> per TS 102 223 / TS 131 111.</p>
|
||||
|
||||
<h4 id="proactive-log" class="font-medium mb-1">5.5.3 Proactive command log</h4>
|
||||
<p class="text-sm mb-2">Chronological list of fetched proactive commands. Each row shows the elapsed time, type code, name, and a decoded qualifier (for commands that have one). Commands with response data show a <code class="font-mono text-sm">Response:</code> line with the TERMINAL RESPONSE bytes (boilerplate TLVs stripped); PROVIDE LOCAL INFORMATION responses are decoded using the PLI data dictionary decoders.</p>
|
||||
|
||||
<h4 id="pli-dict" class="font-medium mb-1">5.5.4 PROVIDE LOCAL INFORMATION data dictionary</h4>
|
||||
<p class="text-sm mb-2">Editable hex values for all 22 PLI qualifiers (TS 102 223 §8.6 + TS 131 111). Ten qualifiers have inline decode/encode forms:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>00</strong> Location Info (MCC, MNC, LAC/TAC, Cell ID)</li>
|
||||
<li><strong>01</strong> IMEI · <strong>03</strong> Date/Time/TZ · <strong>04</strong> Language · <strong>05</strong> Timing Advance</li>
|
||||
<li><strong>06</strong> Access Technology · <strong>08</strong> IMEISV · <strong>09</strong> Search Mode</li>
|
||||
<li><strong>0A</strong> Battery · <strong>0E</strong> Multiple Access Technologies</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">Values persist server-side until restart. When the card issues PLI, the server injects the dictionary values into the TERMINAL RESPONSE.</p>
|
||||
|
||||
<h4 id="status-polling" class="font-medium mb-1">5.5.5 STATUS polling</h4>
|
||||
<p class="text-sm mb-3">A <strong>Send STATUS</strong> button issues a manual STATUS (F2). A <strong>Polling</strong> toggle enables background polling: after a configurable idle interval (server CLI <code class="font-mono text-sm">--poll-interval</code>, 1–255 s, default 30 s) the server sends STATUS and handles any pending proactive command. Polling stops and card state resets if the card is removed.</p>
|
||||
|
||||
<h3 id="profiler" class="text-lg font-medium mb-2">5.6 Profiler</h3>
|
||||
<p class="text-sm mb-2">Verifies that a card matches a named <strong>profile</strong> — an ordered set of rules describing the expected file system and (optionally) file contents. Profiles are stored in <code class="font-mono text-sm">localStorage</code>.</p>
|
||||
<h4 class="font-medium mb-1">Profile list</h4>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>New profile</strong> — creates an empty ruleset after prompting for a name.</li>
|
||||
<li><strong>Profile from card</strong> — scans the equipped card and generates one rule per existing file (see below), then opens the editor.</li>
|
||||
<li><strong>Import profile</strong> — loads a ruleset from a JSON file (the name is stored inside the JSON).</li>
|
||||
<li>Each profile row shows its name and creation time, with <strong>Check card ▶</strong>, <strong>Edit</strong>, <strong>Export</strong> (download JSON), and <strong>Delete</strong> actions.</li>
|
||||
</ul>
|
||||
<h4 class="font-medium mb-1">Filesystem rules</h4>
|
||||
<p class="text-sm mb-2">Rules run sequentially. The editor shows the symbolic pySim name (when known) next to each rule’s path; use <strong>Add rule</strong> to append one and <strong>Save</strong> to keep the changes. A filesystem rule is defined by:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Path</strong> — starts with <code class="font-mono text-sm">MF</code> (e.g. <code class="font-mono text-sm">MF/7F10/6F3A</code>) or an ADF AID (e.g. <code class="font-mono text-sm">A0000000871002/6F07</code>).</li>
|
||||
<li><strong>FCP/FCI check</strong> — how much of the file control information to verify: <strong>Filetype only (FCP)</strong> (existence + file type), <strong>Filetype + size (FCP)</strong> (adds file size, or record length/count for record files), or <strong>Exact FCI</strong> (adds a byte-for-byte comparison of the raw SELECT response — the FCP template <code class="font-mono text-sm">'62'</code> — catching FID/AID, life-cycle status, security-attribute and proprietary-parameter changes).</li>
|
||||
<li><strong>File attributes</strong> — file type, size, record length and record count, taken from the FCP template (any may be left unset).</li>
|
||||
<li><strong>Check contents</strong> (optional) — <strong>Exact</strong> hex equality, or <strong>Mask</strong> where <code class="font-mono text-sm">?</code> is a per-nibble wildcard (a mask with no <code class="font-mono text-sm">?</code> is a prefix match, e.g. <code class="font-mono text-sm">0891</code> for the IMSI MCC/MNC). Record files store a per-record list.</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3"><strong>Check card</strong> runs every rule against the equipped card and shows a live progress line plus a pass/fail report. Each row states exactly what was verified next to the file path (e.g. <em>filetype and size, contents</em> or <em>exact FCI</em>); when some checks pass and others fail, each aspect is marked (<em>filetype ✓, size ✗, contents ✓</em>) with the mismatches detailed below. Mismatched raw data (FCI, contents, record data) is shown as read-only monospace fields — expected above actual, aligned in the same column — for easy comparison. For record files with a contents mismatch, a <em>matching records: 1-5, 7-10</em> note lists the records that did match.</p>
|
||||
<h4 class="font-medium mb-1">“Profile from card” scan options</h4>
|
||||
<p class="text-sm mb-2">The scan dialog asks for a profile name and offers a <strong>“FCP/FCI check”</strong> selector (the same three modes above, default <strong>Filetype + size</strong>) applied to every generated rule, plus an <strong>“Ignore contents of files”</strong> checklist (all checked by default) of frequently-overwritten files whose contents are skipped: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>. Two further checked-by-default options <strong>“Match first 4 bytes for”</strong> <code class="font-mono text-sm">EF.IMSI</code> and <code class="font-mono text-sm">EF.ICCID</code> capture those files’ contents as a mask of only the first 4 bytes (uncheck for exact matching). A progress line shows <em>N / total files</em> with the current file path while scanning; during the scan the options are hidden and the buttons are locked. Rules are created only for files that actually exist on the card (a FCP template is returned); missing files are skipped. Custom files from the <strong>Custom files</strong> sub-tab are included under the same existence check.</p>
|
||||
|
||||
<h3 id="usage-scenarios" class="text-lg font-medium mb-2">5.7 Usage scenarios</h3>
|
||||
<h3 id="usage-scenarios" class="text-lg font-medium mb-2">4.4 Usage scenarios</h3>
|
||||
|
||||
<h4 id="scenario-a" class="font-medium mb-1">Scenario A — Working with files not in pySim’s model (Custom files)</h4>
|
||||
<ol class="list-decimal list-inside text-sm space-y-1 mb-3">
|
||||
@@ -401,7 +347,7 @@
|
||||
<h4 id="scenario-b" class="font-medium mb-1">Scenario B — Simulating a real network environment for SIM testing</h4>
|
||||
<p class="text-sm mb-1"><strong>B.1 Answer PROVIDE LOCAL INFORMATION (PLI)</strong></p>
|
||||
<ol class="list-decimal list-inside text-sm space-y-1 mb-3">
|
||||
<li>Open <strong>Proactive UICC</strong> → <strong>PROVIDE LOCAL INFORMATION response data</strong>.</li>
|
||||
<li>Open <strong>Phone simulator</strong> → <strong>PROVIDE LOCAL INFORMATION response data</strong>.</li>
|
||||
<li>Use the decode/encode forms to set IMEI (<code class="font-mono text-sm">01</code>), Location Info (<code class="font-mono text-sm">00</code>), Access Technology (<code class="font-mono text-sm">06</code>), etc.</li>
|
||||
<li>Click <strong>Save</strong> — values persist server-side.</li>
|
||||
<li>Enable <strong>Polling</strong> (interval 30 s) so the card issues PLI periodically.</li>
|
||||
@@ -422,11 +368,87 @@
|
||||
</ul>
|
||||
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="server" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">6. Server installation</h2>
|
||||
<p class="mb-3">Live card operations (Card reader tab, Proactive UICC, OTA delivery) require the local <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> — a small HTTP server bundled with OTAMan that wraps pySim, talks to the reader over PC/SC or serial, and also serves the PWA itself (open <code class="font-mono text-sm">http://127.0.0.1:8080</code>).</p>
|
||||
|
||||
<h3 id="prerequisites" class="text-lg font-medium mb-2">6.1 Prerequisites</h3>
|
||||
<section class="mb-10">
|
||||
<h2 id="profiler" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">5. Profiler</h2>
|
||||
<p class="text-sm mb-2">Verifies that a card matches a named <strong>profile</strong> — an ordered set of rules describing the expected file system and (optionally) file contents. Profiles are stored in <code class="font-mono text-sm">localStorage</code>.</p>
|
||||
<h4 class="font-medium mb-1">Profile list</h4>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>New profile</strong> — creates an empty ruleset after prompting for a name.</li>
|
||||
<li><strong>Profile from card</strong> — scans the equipped card and generates one rule per existing file (see below), then opens the editor.</li>
|
||||
<li><strong>Profile from snapshot</strong> — picks a saved card snapshot and generates one rule per captured file using the same scan options (see below), without a card reader; the profile name is prefilled with the snapshot name.</li>
|
||||
<li><strong>Import profile</strong> — loads a ruleset from a JSON file (the name is stored inside the JSON).</li>
|
||||
<li>Each profile row shows its name and creation time, with <strong>Check card ▶</strong>, <strong>Check card snapshot</strong>, <strong>Edit</strong>, <strong>Export</strong> (download JSON), and <strong>Delete</strong> actions.</li>
|
||||
</ul>
|
||||
<h4 class="font-medium mb-1">Filesystem rules</h4>
|
||||
<p class="text-sm mb-2">Rules run sequentially. The editor shows the symbolic pySim name (when known) next to each rule’s path; use <strong>Add rule</strong> to append one and <strong>Save</strong> to keep the changes. A filesystem rule is defined by:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Path</strong> — starts with <code class="font-mono text-sm">MF</code> (e.g. <code class="font-mono text-sm">MF/7F10/6F3A</code>) or an ADF AID (e.g. <code class="font-mono text-sm">A0000000871002/6F07</code>).</li>
|
||||
<li><strong>FCP/FCI check</strong> — how much of the file control information to verify: <strong>Filetype only (FCP)</strong> (existence + file type), <strong>Filetype + size (FCP)</strong> (adds file size, or record length/count for record files), or <strong>Exact FCI</strong> (adds a byte-for-byte comparison of the raw SELECT response — the FCP template <code class="font-mono text-sm">'62'</code> — catching FID/AID, life-cycle status, security-attribute and proprietary-parameter changes).</li>
|
||||
<li><strong>File attributes</strong> — file type, size, record length and record count, taken from the FCP template (any may be left unset).</li>
|
||||
<li><strong>Check contents</strong> (optional) — <strong>Exact</strong> hex equality, or <strong>Mask</strong> where <code class="font-mono text-sm">?</code> is a per-nibble wildcard (a mask with no <code class="font-mono text-sm">?</code> is a prefix match, e.g. <code class="font-mono text-sm">0891</code> for the IMSI MCC/MNC). Record files store a per-record list.</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3"><strong>Check card</strong> runs every rule against the equipped card and shows a live progress line plus a pass/fail report. Each row states exactly what was verified next to the file path (e.g. <em>filetype and size, contents</em> or <em>exact FCI</em>); when some checks pass and others fail, each aspect is marked (<em>filetype ✓, size ✗, contents ✓</em>) with the mismatches detailed below. Mismatched raw data (FCI, contents, record data) is shown as read-only monospace fields — expected above actual, aligned in the same column — for easy comparison; FCI mismatches additionally show a decoded per-parameter comparison (file size, file descriptor/structure, life cycle, FID, SFI, proprietary parameters…). A decoded FCI preview is also shown beside the FCI hex field while editing a rule. If the FCI data is corrupt, whatever was decoded before the faulty part is shown together with an explicit decode-failure note. For record files with a contents mismatch, a <em>matching records: 1-5, 7-10</em> note lists the records that did match. In the report the mismatch fields and FCI comparison columns are labelled <em>expected (profile name)</em> and <em>actual (card ICCID)</em>, and the results header reads <em>Profile verification results for: <profile> → <card ICCID></em>. An <strong>Only mismatches</strong> option in the results header hides all passing files and keeps only failures and errors.</p>
|
||||
<h4 class="font-medium mb-1">“Profile from card” scan options</h4>
|
||||
<p class="text-sm mb-2">The scan dialog asks for a profile name and offers a <strong>“FCP/FCI check”</strong> selector (the same three modes above, default <strong>Filetype + size</strong>) applied to every generated rule, plus an <strong>“Ignore contents of files”</strong> checklist (all checked by default except <code class="font-mono text-sm">EF.ARR</code>; the header checkbox checks or unchecks the whole list) of frequently-overwritten files whose contents are skipped: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Two further checked-by-default options <strong>“Match first 4 bytes for”</strong> <code class="font-mono text-sm">EF.IMSI</code> and <code class="font-mono text-sm">EF.ICCID</code> capture those files’ contents as a mask of only the first 4 bytes (uncheck for exact matching). A progress line shows <em>N / total files</em> with the current file path while scanning; during the scan the options are hidden and the buttons are locked. Rules are created only for files that actually exist on the card (a FCP template is returned); missing files are skipped. Custom files from the <strong>Custom files</strong> sub-tab are included under the same existence check. The same dialog and options are used by <strong>Profile from snapshot</strong>, which walks the selected snapshot’s captured files instead of the card; rules for files whose contents were not captured during the scan get no content check (they are reported as unverifiable when the profile is later checked).</p>
|
||||
|
||||
<h4 id="card-snapshots" class="font-medium mb-1">Card snapshots</h4>
|
||||
<p class="text-sm mb-2">The list view has two tabs — <strong>Profiles</strong> and <strong>Card snapshots</strong>. A card snapshot is an immutable capture of the card filesystem: for every existing file it stores the path, symbolic name, file type, size (or record length/count), the raw FCI from the SELECT response, and the contents whenever the file is readable (no ignore list, no masking). The ICCID is decoded from EF.ICCID and shown next to the snapshot name. The scan also measures each card command (SELECT, READ BINARY, READ RECORD) from command to response; the snapshot stores min/avg/max per command type and the total scan time, and the view shows these in the summary under the title plus the select/read times per file and the read time per record. Timings are informational only and are not used by checks or comparisons.</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>New snapshot</strong> — asks for a name and scans the card, then returns to the list.</li>
|
||||
<li><strong>Import snapshot</strong> — loads a snapshot from a JSON file.</li>
|
||||
<li>Each snapshot row has <strong>Open</strong>, <strong>Export</strong>, and <strong>Delete</strong>. <strong>Open</strong> shows all captured data read-only (raw FCI with the decoded FCI, contents); only the snapshot name is editable.</li>
|
||||
<li><strong>Check card snapshot</strong> on a profile row runs the profile rules against a snapshot you pick from the list, without a card reader. The report is the same as a live check: the actual side is labelled with the snapshot name (<em>actual (snapshot name)</em>) and the header reads <em>Profile verification results for: <profile> → <snapshot name></em>; files whose contents were not captured during the scan are reported as unverifiable errors.</li>
|
||||
<li><strong>Compare snapshots</strong> compares two snapshots offline, exactly like a profile check: pick the <em>master</em> snapshot and the <em>snapshot to check</em>, optionally masking the first 4 bytes of EF.IMSI/EF.ICCID (on by default), and get the same pass/fail report; in that report the header reads <em>Snapshot comparison results: <master> → <checked></em> and the mismatch fields and the FCI comparison columns are labeled with the master and checked snapshot names instead of expected/actual. Files present only in the checked snapshot are reported as extra files. Back to list returns to the Card snapshots tab.</li>
|
||||
</ul>
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
<h4 id="custom-files" class="font-medium mb-1">Custom files</h4>
|
||||
<p class="text-sm mb-3">Add files that pySim’s model does not cover: enter the full path (e.g. <code class="font-mono text-sm">3F00/7F20/6F46</code>) and an alias (e.g. <code class="font-mono text-sm">EF.SPN</code>), then press <strong>Add</strong>; added files appear in the File manager tree. Each row has <strong>Edit</strong> (reloads the entry into the form — the button becomes <strong>Save</strong> and a <strong>Cancel</strong> button appears) and <strong>Delete</strong> (no confirmation) buttons. The list persists in <code class="font-mono text-sm">localStorage</code> and can be shared with <strong>Export as JSON</strong> / <strong>Export to file</strong> and restored with <strong>Import from file</strong> / <strong>Paste & import</strong> / <strong>Import JSON from clipboard</strong>.</p>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="proactive-uicc" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">6. Phone simulator</h2>
|
||||
<p class="text-sm mb-3">Interacts with the Card Application Toolkit session. The view has two pills: <strong>Phone</strong> (STK menu, STATUS and polling, subscribed events, proactive command log) and <strong>TR Config</strong> (response data injected into TERMINAL RESPONSEs for proactive commands).</p>
|
||||
|
||||
<h3 id="stk-menu" class="text-lg font-medium mb-2">6.1 STK menu</h3>
|
||||
<p class="text-sm mb-3">When the card has issued a SET UP MENU command, a “STK menu” block appears at the top of this view with an emerald <strong>STK: <title></strong> button that opens the menu overlay (same as the card’s STK menu browser). If the card has not set up a menu, the block shows “No menu set by the card” instead. The menu state is refreshed each time the view is opened. User-interactive proactive commands always get a TERMINAL RESPONSE: the overlay pauses for your choice, and if you neither answer nor press <strong>Timeout</strong>, the server answers with a timeout result after the <code class="font-mono text-sm">--menu-timeout</code> seconds (default 60, <code class="font-mono text-sm">0</code> disables). <strong>Back</strong> and <strong>Timeout</strong> keep the dialogue with the card going: when the card replies with a further proactive command (SELECT ITEM or DISPLAY TEXT) the panel shows it; the cached top menu appears only when the card has nothing more to execute.</p>
|
||||
|
||||
<h3 id="subscribed-events" class="text-lg font-medium mb-2">6.2 Subscribed events (SET UP EVENT LIST)</h3>
|
||||
<p class="text-sm mb-2">The events the card monitors. Each event has a <strong>Send</strong> button that opens a form specific to the event type:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>No-data events</strong> (User Activity, Idle Screen, Data Available, …) — one-click notification</li>
|
||||
<li><strong>Location Status</strong> — dropdown: Normal / Limited / No service (tag <code class="font-mono text-sm">9B</code>)</li>
|
||||
<li><strong>Access Technology Change</strong> — 13 RAT types (tag <code class="font-mono text-sm">BF</code>)</li>
|
||||
<li><strong>Network Rejection</strong> — full adaptive form: registration type (LU / GPRS / EPS / 5GS), location fields (MCC, MNC, LAC, RAC, TAC), access technology, and a 53-cause unified rejection cause dropdown covering EMM, GMM, 5GMM and LU causes</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">Sending an event uses <code class="font-mono text-sm">ENVELOPE(Event Download)</code> per TS 102 223 / TS 131 111.</p>
|
||||
|
||||
<h3 id="proactive-log" class="text-lg font-medium mb-2">6.3 Proactive command log</h3>
|
||||
<p class="text-sm mb-2">Chronological list of fetched proactive commands. Each row shows the elapsed time, type code, name, and a decoded qualifier (for commands that have one). Commands with response data show a <code class="font-mono text-sm">Response:</code> line with the TERMINAL RESPONSE bytes (boilerplate TLVs stripped); PROVIDE LOCAL INFORMATION responses are decoded using the PLI data dictionary decoders.</p>
|
||||
|
||||
<h3 id="status-polling" class="text-lg font-medium mb-2">6.4 STATUS polling</h3>
|
||||
<p class="text-sm mb-3">A <strong>Send STATUS</strong> button issues a manual STATUS (F2). A <strong>Polling</strong> toggle enables background polling: after a configurable idle interval (server CLI <code class="font-mono text-sm">--poll-interval</code>, 1–255 s, default 30 s, <code class="font-mono text-sm">0</code> disables polling) the server sends STATUS and handles any pending proactive command. Polling stops and card state resets if the card is removed.</p>
|
||||
|
||||
<h3 id="pli-dict" class="text-lg font-medium mb-2">6.5 TR Config — PROVIDE LOCAL INFORMATION response data</h3>
|
||||
<p class="text-sm mb-2">Editable hex values for all 22 PLI qualifiers (TS 102 223 §8.6 + TS 131 111). Ten qualifiers have inline decode/encode forms:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>00</strong> Location Info (MCC, MNC, LAC/TAC, Cell ID)</li>
|
||||
<li><strong>01</strong> IMEI · <strong>03</strong> Date/Time/TZ · <strong>04</strong> Language · <strong>05</strong> Timing Advance</li>
|
||||
<li><strong>06</strong> Access Technology · <strong>08</strong> IMEISV · <strong>09</strong> Search Mode</li>
|
||||
<li><strong>0A</strong> Battery · <strong>0E</strong> Multiple Access Technologies</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">Values persist server-side until restart. When the card issues PLI, the server injects the dictionary values into the TERMINAL RESPONSE.</p>
|
||||
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="server" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">7. Server installation</h2>
|
||||
<p class="mb-3">Live card operations (Card reader tab, Phone simulator, OTA delivery) require the local <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> — a small HTTP server bundled with OTAMan that wraps pySim, talks to the reader over PC/SC or serial, and also serves the PWA itself (open <code class="font-mono text-sm">http://127.0.0.1:8080</code>).</p>
|
||||
|
||||
<h3 id="prerequisites" class="text-lg font-medium mb-2">7.1 Prerequisites</h3>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Python 3.8+</strong> with <code class="font-mono text-sm">pip</code></li>
|
||||
<li><strong>Git</strong></li>
|
||||
@@ -434,20 +456,20 @@
|
||||
<li><strong>Windows only</strong> — use <strong>Python 3.10–3.13</strong> (3.13 recommended): <code class="font-mono text-sm">pyscard</code> (the PC/SC driver wrapper) ships precompiled wheels for these versions. On Python 3.9 / 3.14 pip builds <code class="font-mono text-sm">pyscard</code> from source, which requires Microsoft C++ Build Tools (“Desktop development with C++”). The SMPP bridge (<code class="font-mono text-sm">smpp.twisted3</code>) is intentionally not installed on Windows, so no C++ Build Tools are needed for Python 3.10–3.13.</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="quickstart-linux" class="text-lg font-medium mb-2">6.2 Quick start — Linux / macOS</h3>
|
||||
<h3 id="quickstart-linux" class="text-lg font-medium mb-2">7.2 Quick start — Linux / macOS</h3>
|
||||
<pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3">git clone https://github.com/anttro/otaman.git
|
||||
cd otaman
|
||||
chmod +x setup.sh start.sh
|
||||
./setup.sh # creates .venv, installs pysim + server (run once)
|
||||
./start.sh # starts the server (serves PWA + API, auto-detects reader)</pre>
|
||||
|
||||
<h3 id="quickstart-windows" class="text-lg font-medium mb-2">6.3 Quick start — Windows</h3>
|
||||
<h3 id="quickstart-windows" class="text-lg font-medium mb-2">7.3 Quick start — Windows</h3>
|
||||
<pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3">git clone https://github.com/anttro/otaman.git
|
||||
cd otaman
|
||||
setup.bat # creates .venv, installs pysim + server (run once)
|
||||
start.bat # starts the server (serves PWA + API)</pre>
|
||||
|
||||
<h3 id="helper-scripts" class="text-lg font-medium mb-2">6.4 Helper scripts</h3>
|
||||
<h3 id="helper-scripts" class="text-lg font-medium mb-2">7.4 Helper scripts</h3>
|
||||
<table class="w-full text-sm mb-3 border-collapse">
|
||||
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Script</th><th class="text-left py-1 px-2">Purpose</th></tr></thead>
|
||||
<tbody>
|
||||
@@ -456,7 +478,7 @@ start.bat # starts the server (serves PWA + API)</pre>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3 id="reader-autodetect" class="text-lg font-medium mb-2">6.5 Reader auto-detection</h3>
|
||||
<h3 id="reader-autodetect" class="text-lg font-medium mb-2">7.5 Reader auto-detection</h3>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>PC/SC (Linux)</strong> — <code class="font-mono text-sm">start.sh</code> passes <code class="font-mono text-sm">-p 0</code> when the <code class="font-mono text-sm">pcscd</code> daemon is running</li>
|
||||
<li><strong>PC/SC (Windows)</strong> — <code class="font-mono text-sm">start.bat</code> always uses <code class="font-mono text-sm">-p 0</code> (PC/SC is built into Windows)</li>
|
||||
@@ -465,7 +487,7 @@ start.bat # starts the server (serves PWA + API)</pre>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">If no card is present, the Card reader tab shows “No card detected”. Insert the card and click <strong>Equip card</strong> to initialize it.</p>
|
||||
|
||||
<h3 id="manual-install" class="text-lg font-medium mb-2">6.6 Manual installation</h3>
|
||||
<h3 id="manual-install" class="text-lg font-medium mb-2">7.6 Manual installation</h3>
|
||||
<pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3"># Create and activate a venv
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate # Linux/macOS
|
||||
@@ -484,7 +506,7 @@ pysim-otaman-server --http-port 8080</pre>
|
||||
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="compatibility" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">7. Version compatibility</h2>
|
||||
<h2 id="compatibility" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">8. Version compatibility</h2>
|
||||
<table class="w-full text-sm mb-3 border-collapse">
|
||||
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">PWA (OTAMan)</th><th class="text-left py-1 px-2">Server</th><th class="text-left py-1 px-2">Status</th></tr></thead>
|
||||
<tbody>
|
||||
|
||||
+1912
-356
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,8 @@
|
||||
"description": "Standalone offline HTML/JS tool for building APDU commands for SIM, USIM, and GlobalPlatform RAM, plus encoding conversions.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build": "npx tailwindcss -i src/style.css -o style.css --config tailwind.config.js",
|
||||
"build:prod": "NODE_ENV=production npx tailwindcss -i src/style.css -o style.css --config tailwind.config.js --minify",
|
||||
"build": "npx tailwindcss -i src/style.css -o style.css --config tailwind.config.js && cat src/contrast.css >> style.css",
|
||||
"build:prod": "NODE_ENV=production npx tailwindcss -i src/style.css -o style.css --config tailwind.config.js --minify && cat src/contrast.css >> style.css",
|
||||
"test": "node --test"
|
||||
},
|
||||
"repository": {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/* ===== Theme contrast adjustments ===== */
|
||||
|
||||
/* Light theme: darken softer (muted) text one step for higher contrast */
|
||||
.text-gray-400 {
|
||||
color: rgb(107 114 128 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-gray-500 {
|
||||
color: rgb(75 85 99 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-gray-600 {
|
||||
color: rgb(55 65 81 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
/* Dark theme: lighten softer (muted) text one step for higher contrast */
|
||||
.dark\:text-slate-500:is(.dark *) {
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark\:text-slate-400:is(.dark *) {
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
/* Dark theme: red text is too dark on dark backgrounds — lighten it */
|
||||
.dark :where(.text-red-500) {
|
||||
color: rgb(248 113 113 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark :where(.text-red-600) {
|
||||
color: rgb(248 113 113 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark :where(.text-red-700) {
|
||||
color: rgb(239 68 68 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
/* Light theme: darken light gray shades (backgrounds & borders) one step */
|
||||
.bg-gray-50 {
|
||||
background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-gray-100 {
|
||||
background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-gray-200 {
|
||||
background-color: rgb(209 213 219 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.border-gray-100 {
|
||||
border-color: rgb(229 231 235 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
.border-gray-200 {
|
||||
border-color: rgb(209 213 219 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
.border-gray-300 {
|
||||
border-color: rgb(156 163 175 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
.text-gray-300 {
|
||||
color: rgb(156 163 175 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.hover\:bg-gray-100:hover {
|
||||
background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.hover\:bg-gray-200:hover {
|
||||
background-color: rgb(209 213 219 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.hover\:bg-gray-300:hover {
|
||||
background-color: rgb(156 163 175 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
/* Dark theme: lighten gray fonts */
|
||||
.dark\:text-gray-400:is(.dark *) {
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark\:text-gray-600:is(.dark *) {
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
/* Dark theme: gray text without an explicit dark variant (ids, timestamps,
|
||||
expand markers, ...) — match the lighten-on-dark level used above */
|
||||
.dark .text-gray-300:not([class*="dark:text-"]) {
|
||||
color: rgb(148 163 184 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark .text-gray-400:not([class*="dark:text-"]) {
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark .text-gray-500:not([class*="dark:text-"]) {
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark .text-gray-600:not([class*="dark:text-"]) {
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark .text-gray-700:not([class*="dark:text-"]) {
|
||||
color: rgb(226 232 240 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark .text-gray-800:not([class*="dark:text-"]) {
|
||||
color: rgb(241 245 249 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
/* Dark theme: brighten gray borders one step so they stay visible on dark
|
||||
backgrounds */
|
||||
.dark\:border-slate-600:is(.dark *) {
|
||||
border-color: rgb(100 116 139 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
.dark\:border-slate-700:is(.dark *) {
|
||||
border-color: rgb(71 85 105 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
.dark\:border-slate-700\/50:is(.dark *) {
|
||||
border-color: rgb(71 85 105 / 0.5);
|
||||
}
|
||||
.dark\:border-slate-800:is(.dark *) {
|
||||
border-color: rgb(51 65 85 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
/* Dark theme: normal (non-muted) UI text — keep one step brighter than the
|
||||
muted gray level so the two remain distinguishable */
|
||||
.dark\:text-slate-300:is(.dark *) {
|
||||
color: rgb(226 232 240 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
+44
-3
@@ -1702,7 +1702,7 @@ video {
|
||||
|
||||
/* Dark theme: lighten softer (muted) text one step for higher contrast */
|
||||
.dark\:text-slate-500:is(.dark *) {
|
||||
color: rgb(148 163 184 / var(--tw-text-opacity, 1));
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark\:text-slate-400:is(.dark *) {
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
@@ -1753,8 +1753,49 @@ video {
|
||||
|
||||
/* Dark theme: lighten gray fonts */
|
||||
.dark\:text-gray-400:is(.dark *) {
|
||||
color: rgb(156 163 175 / var(--tw-text-opacity, 1));
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark\:text-gray-600:is(.dark *) {
|
||||
color: rgb(156 163 175 / var(--tw-text-opacity, 1));
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
/* Dark theme: gray text without an explicit dark variant (ids, timestamps,
|
||||
expand markers, ...) — match the lighten-on-dark level used above */
|
||||
.dark .text-gray-300:not([class*="dark:text-"]) {
|
||||
color: rgb(148 163 184 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark .text-gray-400:not([class*="dark:text-"]) {
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark .text-gray-500:not([class*="dark:text-"]) {
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark .text-gray-600:not([class*="dark:text-"]) {
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark .text-gray-700:not([class*="dark:text-"]) {
|
||||
color: rgb(226 232 240 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark .text-gray-800:not([class*="dark:text-"]) {
|
||||
color: rgb(241 245 249 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
/* Dark theme: brighten gray borders one step so they stay visible on dark
|
||||
backgrounds */
|
||||
.dark\:border-slate-600:is(.dark *) {
|
||||
border-color: rgb(100 116 139 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
.dark\:border-slate-700:is(.dark *) {
|
||||
border-color: rgb(71 85 105 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
.dark\:border-slate-700\/50:is(.dark *) {
|
||||
border-color: rgb(71 85 105 / 0.5);
|
||||
}
|
||||
.dark\:border-slate-800:is(.dark *) {
|
||||
border-color: rgb(51 65 85 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
/* Dark theme: normal (non-muted) UI text — keep one step brighter than the
|
||||
muted gray level so the two remain distinguishable */
|
||||
.dark\:text-slate-300:is(.dark *) {
|
||||
color: rgb(226 232 240 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
+18
-4
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'otaman-v70';
|
||||
const CACHE = 'otaman-v132';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
@@ -31,19 +31,33 @@ self.addEventListener('activate', e => {
|
||||
);
|
||||
});
|
||||
|
||||
const OFFLINE_RESPONSE = new Response('Offline: page not cached', {
|
||||
status: 503,
|
||||
statusText: 'Offline',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', e => {
|
||||
if (!e.request.url.startsWith('http')) return;
|
||||
if (new URL(e.request.url).pathname.startsWith('/api/')) return; // live data, never cache
|
||||
const path = new URL(e.request.url).pathname;
|
||||
if (path.startsWith('/api/')) return; // live data, never cache
|
||||
if (e.request.method !== 'GET') return;
|
||||
const isNavigate = e.request.mode === 'navigate' || e.request.url.endsWith('sw.js');
|
||||
const isNavigate = e.request.mode === 'navigate';
|
||||
const isSwScript = path.endsWith('/sw.js');
|
||||
if (isNavigate) {
|
||||
e.respondWith(
|
||||
fetch(e.request).then(res => {
|
||||
const clone = res.clone();
|
||||
caches.open(CACHE).then(c => c.put(e.request, clone));
|
||||
return res;
|
||||
}).catch(() => caches.match(e.request))
|
||||
}).catch(() =>
|
||||
caches.match(e.request)
|
||||
.then(r => r || caches.match('index.html'))
|
||||
.then(r => r || OFFLINE_RESPONSE)
|
||||
)
|
||||
);
|
||||
} else if (isSwScript) {
|
||||
e.respondWith(fetch(e.request).catch(() => OFFLINE_RESPONSE));
|
||||
} else {
|
||||
e.respondWith(
|
||||
caches.match(e.request).then(r => r || fetch(e.request).then(res => {
|
||||
|
||||
@@ -165,6 +165,39 @@ test('parseTlvList handles BER-TLVs', () => {
|
||||
assert.strictEqual(tlvs[1].tag, '82');
|
||||
});
|
||||
|
||||
test('parseBerLen handles short and long form lengths (ISO 7816-4 5.2)', () => {
|
||||
assert.deepStrictEqual(parseBerLen('1200', 0), { len: 18, consumed: 2 });
|
||||
assert.deepStrictEqual(parseBerLen('8112', 0), { len: 18, consumed: 4 });
|
||||
assert.deepStrictEqual(parseBerLen('820100', 0), { len: 256, consumed: 6 });
|
||||
assert.deepStrictEqual(parseBerLen('820182' + '0102030405060708', 0), { len: 386, consumed: 6 });
|
||||
});
|
||||
|
||||
test('parseTlvList parses long-form lengths (81/82)', () => {
|
||||
const short = parseTlvList('6212' + '8202412183026F078A010580020009880110');
|
||||
assert.strictEqual(short.length, 1);
|
||||
assert.strictEqual(short[0].tag, '62');
|
||||
assert.strictEqual(short[0].length, 18);
|
||||
|
||||
// same content with a long-form outer length
|
||||
const long81 = parseTlvList('628112' + '8202412183026F078A010580020009880110');
|
||||
assert.strictEqual(long81.length, 1);
|
||||
assert.strictEqual(long81[0].length, 18);
|
||||
assert.strictEqual(long81[0].value, short[0].value);
|
||||
|
||||
// 2-byte length form
|
||||
const long82 = parseTlvList('62820004' + '80020009');
|
||||
assert.strictEqual(long82.length, 1);
|
||||
assert.strictEqual(long82[0].length, 4);
|
||||
assert.strictEqual(long82[0].value, '80020009');
|
||||
|
||||
// inner TLV with a long-form length (A5 81 05 85 03 00 00 00)
|
||||
const inner = parseTlvList('A581058503000000');
|
||||
assert.strictEqual(inner.length, 1);
|
||||
assert.strictEqual(inner[0].tag, 'A5');
|
||||
assert.strictEqual(inner[0].length, 5);
|
||||
assert.strictEqual(inner[0].value, '8503000000');
|
||||
});
|
||||
|
||||
test('gsm7Decode unpacks "HI" from C824', () => {
|
||||
const bytes = new Uint8Array([0xC8, 0x24]);
|
||||
assert.strictEqual(gsm7Decode(bytes), 'HI');
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
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);
|
||||
}
|
||||
|
||||
let code = 'var _pysimCardStateKey = null;\nvar _pysimCardSession = null;\n'
|
||||
+ 'var _pysimServerAvailable = null;\nvar _pysimCardEquipped = false;\n'
|
||||
+ 'var _pysimProactiveSeq = null;\nvar _pysimStkSig = null;\n';
|
||||
code += extractFunc(html, 'pysimCardStateUpdate') + '\n';
|
||||
code += extractFunc(html, 'pysimAvailabilityState') + '\n';
|
||||
code += extractFunc(html, 'pysimControlDisabled') + '\n';
|
||||
code += extractFunc(html, 'pysimProactiveSeqChanged') + '\n';
|
||||
code += extractFunc(html, 'pysimStkStatusChanged') + '\n';
|
||||
code += '\nglobalThis.esc = s => s;\n';
|
||||
code += 'globalThis.t = s => s;\n';
|
||||
eval(code);
|
||||
|
||||
function setup() {
|
||||
const el = { textContent: 'status line', innerHTML: '' };
|
||||
const calls = { connected: [], resets: [], refreshStatus: [], proactive: 0 };
|
||||
_pysimCardStateKey = null;
|
||||
_pysimCardSession = null;
|
||||
_pysimProactiveSeq = null;
|
||||
globalThis.document = { getElementById: () => el, querySelectorAll: () => [] };
|
||||
globalThis.pysimSetConnected = v => calls.connected.push(v);
|
||||
globalThis.pysimResetCardData = refresh => calls.resets.push(refresh);
|
||||
globalThis.pysimApplyAvailability = () => {};
|
||||
globalThis.isViewVisible = () => true;
|
||||
globalThis.pysimProactiveLogRender = () => { calls.proactive++; };
|
||||
return { el, calls };
|
||||
}
|
||||
|
||||
function status(extra) {
|
||||
return Object.assign({ connected: false, card_present: false, equipping: false, auto_equip: false, card_session: 1 }, extra);
|
||||
}
|
||||
|
||||
test('disconnect without card shows the no-card message', () => {
|
||||
const { el, calls } = setup();
|
||||
pysimCardStateUpdate(status({}));
|
||||
assert.deepStrictEqual(calls.connected, [false]);
|
||||
assert.ok(el.innerHTML.includes('No card detected'), el.innerHTML);
|
||||
});
|
||||
|
||||
test('disconnect with card present shows the Equip hint when auto-equip is off', () => {
|
||||
const { el } = setup();
|
||||
pysimCardStateUpdate(status({ card_present: true }));
|
||||
assert.ok(el.innerHTML.includes('Card inserted — press Equip'), el.innerHTML);
|
||||
});
|
||||
|
||||
test('disconnect with auto-equip shows the initializing message', () => {
|
||||
const { el } = setup();
|
||||
pysimCardStateUpdate(status({ card_present: true, auto_equip: true }));
|
||||
assert.ok(el.innerHTML.includes('initializing'), el.innerHTML);
|
||||
});
|
||||
|
||||
test('unchanged state key does not touch the UI again', () => {
|
||||
const { el, calls } = setup();
|
||||
pysimCardStateUpdate(status({ card_session: 7 }));
|
||||
el.innerHTML = 'unchanged';
|
||||
calls.connected.length = 0;
|
||||
pysimCardStateUpdate(status({ card_session: 7 }));
|
||||
assert.deepStrictEqual(calls.connected, []);
|
||||
assert.strictEqual(el.innerHTML, 'unchanged');
|
||||
});
|
||||
|
||||
test('connected restores the UI and reloads card data', () => {
|
||||
const { calls } = setup();
|
||||
pysimCardStateUpdate(status({ connected: true, card_present: true, card_session: 2 }));
|
||||
assert.deepStrictEqual(calls.connected, [true]);
|
||||
assert.deepStrictEqual(calls.resets, [true]);
|
||||
});
|
||||
|
||||
test('card session change triggers a data reset', () => {
|
||||
const { calls } = setup();
|
||||
pysimCardStateUpdate(status({ card_session: 3 }));
|
||||
calls.resets.length = 0;
|
||||
pysimCardStateUpdate(status({ card_session: 4 }));
|
||||
assert.deepStrictEqual(calls.resets, [false]);
|
||||
});
|
||||
|
||||
test('first observation does not trigger a reset on its own', () => {
|
||||
const { calls } = setup();
|
||||
pysimCardStateUpdate(status({ card_session: 9 }));
|
||||
assert.deepStrictEqual(calls.resets, []);
|
||||
});
|
||||
|
||||
test('payload without connected flag is ignored', () => {
|
||||
const { calls } = setup();
|
||||
pysimCardStateUpdate({ reader: 'x' });
|
||||
pysimCardStateUpdate(null);
|
||||
assert.deepStrictEqual(calls.connected, []);
|
||||
});
|
||||
|
||||
test('availability state and control gating follow server/card state', () => {
|
||||
_pysimServerAvailable = null;
|
||||
assert.strictEqual(pysimAvailabilityState(), 'server-down');
|
||||
assert.strictEqual(pysimControlDisabled('server', 'server-down'), true);
|
||||
assert.strictEqual(pysimControlDisabled('card', 'server-down'), true);
|
||||
|
||||
_pysimServerAvailable = true;
|
||||
_pysimCardEquipped = false;
|
||||
assert.strictEqual(pysimAvailabilityState(), 'no-card');
|
||||
assert.strictEqual(pysimControlDisabled('server', 'no-card'), false);
|
||||
assert.strictEqual(pysimControlDisabled('card', 'no-card'), true);
|
||||
|
||||
_pysimCardEquipped = true;
|
||||
assert.strictEqual(pysimAvailabilityState(), 'card');
|
||||
assert.strictEqual(pysimControlDisabled('card', 'card'), false);
|
||||
assert.strictEqual(pysimControlDisabled('server', 'card'), false);
|
||||
});
|
||||
|
||||
test('no card with auto-equip enabled still shows the no-card message', () => {
|
||||
const { el } = setup();
|
||||
pysimCardStateUpdate(status({ connected: false, card_present: false, auto_equip: true }));
|
||||
assert.ok(el.innerHTML.includes('No card detected'), el.innerHTML);
|
||||
assert.ok(!el.innerHTML.includes('initializing'), el.innerHTML);
|
||||
});
|
||||
|
||||
test('proactive log refreshes when the status sequence changes', () => {
|
||||
const { calls } = setup();
|
||||
pysimCardStateUpdate(status({ proactive_seq: 7 }));
|
||||
pysimCardStateUpdate(status({ proactive_seq: 7 }));
|
||||
assert.strictEqual(calls.proactive, 1);
|
||||
pysimCardStateUpdate(status({ proactive_seq: 8 }));
|
||||
assert.strictEqual(calls.proactive, 2);
|
||||
});
|
||||
|
||||
test('proactive log is not refreshed while the phone view is hidden', () => {
|
||||
const { calls } = setup();
|
||||
globalThis.isViewVisible = () => false;
|
||||
pysimCardStateUpdate(status({ proactive_seq: 3 }));
|
||||
assert.strictEqual(calls.proactive, 0);
|
||||
});
|
||||
|
||||
test('pysimProactiveSeqChanged tracks the last sequence', () => {
|
||||
_pysimProactiveSeq = null;
|
||||
assert.ok(pysimProactiveSeqChanged(4));
|
||||
assert.ok(!pysimProactiveSeqChanged(4));
|
||||
assert.ok(pysimProactiveSeqChanged(5));
|
||||
assert.ok(!pysimProactiveSeqChanged(undefined));
|
||||
assert.ok(!pysimProactiveSeqChanged(null));
|
||||
});
|
||||
|
||||
test('pysimStkStatusChanged detects menu state transitions', () => {
|
||||
_pysimStkSig = null;
|
||||
assert.ok(pysimStkStatusChanged({ active: false, pending: false }));
|
||||
assert.ok(!pysimStkStatusChanged({ active: false, pending: false }));
|
||||
assert.ok(pysimStkStatusChanged({ active: true, pending: true, pending_type: 'select_item' }));
|
||||
assert.ok(!pysimStkStatusChanged({ active: true, pending: true, pending_type: 'select_item' }));
|
||||
assert.ok(pysimStkStatusChanged({ active: true, pending: false }));
|
||||
assert.ok(!pysimStkStatusChanged(null));
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
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);
|
||||
}
|
||||
|
||||
let code = 'var pysimCustomFiles = [];\nvar pysimCustomEditIndex = null;\n';
|
||||
for (const fn of ['pysimCustomSave', 'pysimCustomSubmit', 'pysimCustomEdit', 'pysimCustomEditCancel', 'pysimCustomRemove', 'pysimCustomRender']) {
|
||||
code += extractFunc(html, fn) + '\n';
|
||||
}
|
||||
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\n';
|
||||
eval(code);
|
||||
|
||||
function fakeEl(id) {
|
||||
const classes = new Set();
|
||||
return {
|
||||
id,
|
||||
value: '',
|
||||
innerHTML: '',
|
||||
textContent: '',
|
||||
attrs: {},
|
||||
focused: 0,
|
||||
classList: {
|
||||
add: (...cs) => cs.forEach(c => classes.add(c)),
|
||||
remove: (...cs) => cs.forEach(c => classes.delete(c)),
|
||||
contains: c => classes.has(c),
|
||||
},
|
||||
setAttribute(k, v) { this.attrs[k] = v; },
|
||||
focus() { this.focused++; },
|
||||
};
|
||||
}
|
||||
|
||||
function setup(entries) {
|
||||
const els = {
|
||||
'pysim-cf-path': fakeEl('pysim-cf-path'),
|
||||
'pysim-cf-name': fakeEl('pysim-cf-name'),
|
||||
'pysim-cf-list': fakeEl('pysim-cf-list'),
|
||||
'pysim-cf-add-btn': fakeEl('pysim-cf-add-btn'),
|
||||
'pysim-cf-cancel-btn': fakeEl('pysim-cf-cancel-btn'),
|
||||
};
|
||||
els['pysim-cf-cancel-btn'].classList.add('hidden');
|
||||
const store = {};
|
||||
globalThis.localStorage = {
|
||||
getItem: k => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = String(v); },
|
||||
};
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
globalThis.alertCalls = [];
|
||||
globalThis.alert = m => { globalThis.alertCalls.push(m); };
|
||||
pysimCustomFiles = (entries || []).map(e => Object.assign({}, e));
|
||||
pysimCustomEditIndex = null;
|
||||
return els;
|
||||
}
|
||||
|
||||
const entry = (fid, name, parentFid) => ({ path: (parentFid || '3F00') + '/' + fid, name, fid, parentFid: parentFid || '3F00' });
|
||||
|
||||
test('rows render Edit and Delete buttons instead of the X glyph', () => {
|
||||
const els = setup([entry('6F46', 'EF.SPN')]);
|
||||
pysimCustomRender();
|
||||
const out = els['pysim-cf-list'].innerHTML;
|
||||
assert.match(out, /pysimCustomEdit\(0\)/);
|
||||
assert.match(out, /pysimCustomRemove\(0\)/);
|
||||
assert.match(out, />Edit</);
|
||||
assert.match(out, />Delete</);
|
||||
assert.ok(!out.includes('✕'), 'old X glyph must be gone');
|
||||
});
|
||||
|
||||
test('edit fills the top form and switches it to Save mode', () => {
|
||||
const els = setup([entry('6F46', 'EF.SPN')]);
|
||||
pysimCustomEdit(0);
|
||||
assert.strictEqual(pysimCustomEditIndex, 0);
|
||||
assert.strictEqual(els['pysim-cf-path'].value, '3F00/6F46');
|
||||
assert.strictEqual(els['pysim-cf-name'].value, 'EF.SPN');
|
||||
assert.strictEqual(els['pysim-cf-add-btn'].textContent, 'Save');
|
||||
assert.strictEqual(els['pysim-cf-add-btn'].attrs['data-l10n'], 'Save');
|
||||
assert.ok(!els['pysim-cf-cancel-btn'].classList.contains('hidden'));
|
||||
assert.strictEqual(els['pysim-cf-path'].focused, 1);
|
||||
});
|
||||
|
||||
test('submit in edit mode updates the entry in place', () => {
|
||||
const els = setup([entry('6F46', 'EF.SPN')]);
|
||||
pysimCustomEdit(0);
|
||||
els['pysim-cf-path'].value = '3F00/7F20/6F46';
|
||||
els['pysim-cf-name'].value = 'EF.SPNX';
|
||||
pysimCustomSubmit();
|
||||
assert.strictEqual(pysimCustomFiles.length, 1);
|
||||
assert.deepStrictEqual(pysimCustomFiles[0], { path: '3F00/7F20/6F46', name: 'EF.SPNX', fid: '6F46', parentFid: '7F20' });
|
||||
assert.strictEqual(pysimCustomEditIndex, null);
|
||||
assert.strictEqual(els['pysim-cf-add-btn'].textContent, 'Add');
|
||||
assert.ok(els['pysim-cf-cancel-btn'].classList.contains('hidden'));
|
||||
});
|
||||
|
||||
test('saving an edited entry with its own path is not a duplicate', () => {
|
||||
const els = setup([entry('6F46', 'EF.SPN')]);
|
||||
pysimCustomEdit(0);
|
||||
pysimCustomSubmit();
|
||||
assert.strictEqual(pysimCustomFiles.length, 1);
|
||||
assert.deepStrictEqual(globalThis.alertCalls, []);
|
||||
});
|
||||
|
||||
test('editing to another entry path is rejected as duplicate', () => {
|
||||
const els = setup([entry('6F46', 'EF.SPN'), entry('6F44', 'EF.SPN2')]);
|
||||
pysimCustomEdit(0);
|
||||
els['pysim-cf-path'].value = '3F00/6F44';
|
||||
pysimCustomSubmit();
|
||||
assert.deepStrictEqual(globalThis.alertCalls, ['Path already exists']);
|
||||
assert.deepStrictEqual(pysimCustomFiles.map(c => c.path), ['3F00/6F46', '3F00/6F44']);
|
||||
assert.strictEqual(pysimCustomEditIndex, 0);
|
||||
});
|
||||
|
||||
test('cancel restores the Add mode and clears the inputs', () => {
|
||||
const els = setup([entry('6F46', 'EF.SPN')]);
|
||||
pysimCustomEdit(0);
|
||||
pysimCustomEditCancel();
|
||||
assert.strictEqual(pysimCustomEditIndex, null);
|
||||
assert.strictEqual(els['pysim-cf-path'].value, '');
|
||||
assert.strictEqual(els['pysim-cf-name'].value, '');
|
||||
assert.strictEqual(els['pysim-cf-add-btn'].textContent, 'Add');
|
||||
assert.strictEqual(els['pysim-cf-add-btn'].attrs['data-l10n'], 'Add');
|
||||
assert.ok(els['pysim-cf-cancel-btn'].classList.contains('hidden'));
|
||||
});
|
||||
|
||||
test('deleting the entry being edited cancels the edit', () => {
|
||||
const els = setup([entry('6F46', 'EF.SPN')]);
|
||||
pysimCustomEdit(0);
|
||||
pysimCustomRemove(0);
|
||||
assert.deepStrictEqual(pysimCustomFiles, []);
|
||||
assert.strictEqual(pysimCustomEditIndex, null);
|
||||
assert.strictEqual(els['pysim-cf-add-btn'].textContent, 'Add');
|
||||
});
|
||||
|
||||
test('deleting before the edited row shifts the edit index', () => {
|
||||
setup([entry('6F46', 'EF.SPN'), entry('6F44', 'EF.SPN2'), entry('6F42', 'EF.SPN3')]);
|
||||
pysimCustomEdit(2);
|
||||
pysimCustomRemove(0);
|
||||
assert.strictEqual(pysimCustomEditIndex, 1);
|
||||
assert.strictEqual(pysimCustomFiles[1].name, 'EF.SPN3');
|
||||
});
|
||||
|
||||
test('submit adds a new entry when not editing', () => {
|
||||
const els = setup([]);
|
||||
els['pysim-cf-path'].value = '3f00/6f46';
|
||||
els['pysim-cf-name'].value = 'EF.SPN';
|
||||
pysimCustomSubmit();
|
||||
assert.deepStrictEqual(pysimCustomFiles, [{ path: '3F00/6F46', name: 'EF.SPN', fid: '6F46', parentFid: '3F00' }]);
|
||||
assert.strictEqual(els['pysim-cf-path'].value, '');
|
||||
assert.strictEqual(els['pysim-cf-name'].value, '');
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
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);
|
||||
}
|
||||
|
||||
let code = '';
|
||||
code += extractFunc(html, 'getParentSel') + '\n';
|
||||
code += extractFunc(html, 'getParentPath') + '\n';
|
||||
code += extractFunc(html, 'pysimFsLoadChildren') + '\n';
|
||||
code += 'globalThis.pysimCustomInject = () => {};\n';
|
||||
eval(code);
|
||||
|
||||
let calls = [];
|
||||
let responses = [];
|
||||
let renders = 0;
|
||||
|
||||
function setup() {
|
||||
calls = [];
|
||||
responses = [];
|
||||
renders = 0;
|
||||
globalThis.pysimFetch = async (p, body) => {
|
||||
calls.push({ path: p, body: JSON.parse(JSON.stringify(body)) });
|
||||
const r = responses.shift();
|
||||
if (r instanceof Error) throw r;
|
||||
return JSON.parse(JSON.stringify(r));
|
||||
};
|
||||
globalThis.pysimFsRenderTree = () => { renders++; };
|
||||
const node = { name: 'DF.USIM', fid: '7fff', isDir: true, children: null, exists: null, parent: { name: 'MF', fid: '3f00' } };
|
||||
return node;
|
||||
}
|
||||
|
||||
test('tree error payload marks the directory as absent', async () => {
|
||||
const node = setup();
|
||||
responses = [
|
||||
{ success: false, error: 'SW ... 6a82', exists: false },
|
||||
{ success: false, error: 'SW ... 6a82', exists: false },
|
||||
];
|
||||
await pysimFsLoadChildren(node);
|
||||
assert.strictEqual(node.exists, false);
|
||||
assert.strictEqual(node.children, null);
|
||||
assert.strictEqual(renders, 1);
|
||||
assert.strictEqual(calls.length, 1);
|
||||
assert.strictEqual(calls[0].body.parent_sel, 'MF');
|
||||
assert.deepStrictEqual(calls[0].body.parent_path, ['MF']);
|
||||
});
|
||||
|
||||
test('error payload without exists is not treated as an empty listing', async () => {
|
||||
const node = setup();
|
||||
responses = [
|
||||
{ success: false, error: 'boom' },
|
||||
{ success: false, error: 'boom' },
|
||||
];
|
||||
await pysimFsLoadChildren(node);
|
||||
assert.strictEqual(node.exists, false);
|
||||
assert.strictEqual(node.children, null);
|
||||
assert.strictEqual(renders, 1);
|
||||
assert.strictEqual(calls.length, 1);
|
||||
});
|
||||
|
||||
test('a 200 exists:false response marks the directory absent without retrying', async () => {
|
||||
const node = setup();
|
||||
responses = [{ exists: false }];
|
||||
await pysimFsLoadChildren(node);
|
||||
assert.strictEqual(node.exists, false);
|
||||
assert.strictEqual(node.children, null);
|
||||
assert.strictEqual(calls.length, 1);
|
||||
});
|
||||
|
||||
test('a node with loaded children is not fetched again', async () => {
|
||||
const node = setup();
|
||||
node.exists = true;
|
||||
node.children = [{ name: 'EF.UPLMNWLAN', fid: '4f42', isDir: false, exists: true }];
|
||||
await pysimFsLoadChildren(node);
|
||||
assert.strictEqual(node.exists, true);
|
||||
assert.strictEqual(node.children.length, 1);
|
||||
assert.strictEqual(calls.length, 0);
|
||||
});
|
||||
|
||||
test('empty successful listing keeps the directory present', async () => {
|
||||
const node = setup();
|
||||
responses = [{ exists: true, children: [] }];
|
||||
await pysimFsLoadChildren(node);
|
||||
assert.strictEqual(node.exists, true);
|
||||
assert.deepStrictEqual(node.children, []);
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
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);
|
||||
}
|
||||
|
||||
let code = 'var pysimFsTreeRoot = null;\nvar _pysimFsProbe = null;\nvar pysimFsSort = "fid";\n';
|
||||
for (const fn of ['getParentSel', 'getParentPath', 'pysimFsSortChildren', 'pysimFsLoadChildren', 'pysimFsSelectBody', 'pysimFsProbeUi', 'pysimFsProbeAll']) {
|
||||
code += extractFunc(html, fn) + '\n';
|
||||
}
|
||||
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\nglobalThis.pysimCustomInject = () => {};\n';
|
||||
eval(code);
|
||||
|
||||
function fakeEl() {
|
||||
const classes = new Set();
|
||||
return {
|
||||
textContent: '',
|
||||
attrs: {},
|
||||
classList: {
|
||||
add: (...cs) => cs.forEach(c => classes.add(c)),
|
||||
remove: (...cs) => cs.forEach(c => classes.delete(c)),
|
||||
contains: c => classes.has(c),
|
||||
toggle: (c, on) => { if (on === undefined ? !classes.has(c) : on) classes.add(c); else classes.delete(c); },
|
||||
},
|
||||
setAttribute(k, v) { this.attrs[k] = v; },
|
||||
};
|
||||
}
|
||||
|
||||
let els = {};
|
||||
let calls = [];
|
||||
|
||||
function setup(routes) {
|
||||
els = { 'pysim-fs-probe-status': fakeEl(), 'pysim-fs-probe-btn': fakeEl() };
|
||||
calls = [];
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
globalThis.pysimFetch = async (p, body) => {
|
||||
calls.push({ path: p, body: body || {} });
|
||||
for (const r of routes) {
|
||||
if (r.path !== p) continue;
|
||||
if (r.name && (!body || body.name !== r.name)) continue;
|
||||
return typeof r.reply === 'function' ? r.reply(body, calls) : JSON.parse(JSON.stringify(r.reply));
|
||||
}
|
||||
throw new Error('unexpected fetch ' + p + ' ' + JSON.stringify(body));
|
||||
};
|
||||
globalThis.pysimFsRenderTree = () => {};
|
||||
}
|
||||
|
||||
function root(children) {
|
||||
pysimFsTreeRoot = { name: 'MF', fid: '3f00', isDir: true, expanded: true, exists: true, children: null, parent: null };
|
||||
pysimFsTreeRoot.children = children.map(c => Object.assign({ children: null, expanded: false, exists: true, parent: pysimFsTreeRoot }, c));
|
||||
return pysimFsTreeRoot;
|
||||
}
|
||||
|
||||
const df = (name, fid) => ({ name, fid, isDir: true });
|
||||
const ef = (name, fid) => ({ name, fid, isDir: false });
|
||||
const selectNames = () => calls.filter(c => c.path === '/api/select').map(c => c.body.name);
|
||||
|
||||
test('probes dirs and files, including custom entries, and reports counts', async () => {
|
||||
root([df('DF.A', '5f01'), ef('EF.ROOT', '2f01'), Object.assign(ef('EF.CUSTOM', '6fcc'), { custom: true })]);
|
||||
let sawStop = null;
|
||||
setup([
|
||||
{ path: '/api/tree', name: 'DF.A', reply: { exists: true, children: [{ name: 'EF.1', fid: '6f01', isDir: false }] } },
|
||||
{ path: '/api/select', name: 'EF.1', reply: () => { sawStop = els['pysim-fs-probe-btn'].textContent; return { exists: true }; } },
|
||||
{ path: '/api/select', name: 'EF.ROOT', reply: { error: 'SW 6a82', exists: false } },
|
||||
{ path: '/api/select', name: 'EF.CUSTOM', reply: { exists: true } },
|
||||
]);
|
||||
await pysimFsProbeAll();
|
||||
assert.strictEqual(sawStop, 'Stop');
|
||||
assert.deepStrictEqual(selectNames(), ['EF.1', 'EF.ROOT', 'EF.CUSTOM']);
|
||||
assert.strictEqual(pysimFsTreeRoot.children[0].children[0].exists, true);
|
||||
assert.strictEqual(pysimFsTreeRoot.children[1].exists, false);
|
||||
assert.strictEqual(pysimFsTreeRoot.children[2].exists, true);
|
||||
const status = els['pysim-fs-probe-status'].textContent;
|
||||
assert.match(status, /4\/4 files/);
|
||||
assert.match(status, /3 present/);
|
||||
assert.match(status, /1 absent/);
|
||||
assert.strictEqual(els['pysim-fs-probe-btn'].textContent, 'Probe all files');
|
||||
assert.strictEqual(els['pysim-fs-probe-btn'].attrs['data-l10n'], 'Probe all files');
|
||||
});
|
||||
|
||||
test('an absent directory is marked and its subtree is never fetched', async () => {
|
||||
root([df('DF.B', '5f02'), ef('EF.ROOT', '2f01')]);
|
||||
setup([
|
||||
{ path: '/api/tree', name: 'DF.B', reply: { success: false, error: 'SW 6a82', exists: false } },
|
||||
{ path: '/api/select', name: 'EF.ROOT', reply: { error: 'SW 6a82', exists: false } },
|
||||
]);
|
||||
await pysimFsProbeAll();
|
||||
assert.strictEqual(pysimFsTreeRoot.children[0].exists, false);
|
||||
assert.deepStrictEqual(selectNames(), ['EF.ROOT']);
|
||||
assert.strictEqual(calls.filter(c => c.path === '/api/tree' && c.body.name === 'DF.B').length, 1);
|
||||
const status = els['pysim-fs-probe-status'].textContent;
|
||||
assert.match(status, /2\/2 files/);
|
||||
assert.match(status, /0 present/);
|
||||
assert.match(status, /2 absent/);
|
||||
});
|
||||
|
||||
test('stop halts the walk and still reports a summary', async () => {
|
||||
root([ef('EF.X', '6f01'), ef('EF.Y', '6f02')]);
|
||||
setup([
|
||||
{ path: '/api/select', name: 'EF.X', reply: () => { _pysimFsProbe.stop = true; return { exists: true }; } },
|
||||
]);
|
||||
await pysimFsProbeAll();
|
||||
assert.deepStrictEqual(selectNames(), ['EF.X']);
|
||||
const status = els['pysim-fs-probe-status'].textContent;
|
||||
assert.ok(status.startsWith('Stopped —'), status);
|
||||
assert.strictEqual(els['pysim-fs-probe-btn'].textContent, 'Probe all files');
|
||||
});
|
||||
|
||||
test('select bodies carry the parent path and set allow_probe only for custom files', async () => {
|
||||
root([df('DF.A', '5f01'), Object.assign(ef('EF.CUSTOM', '6fcc'), { custom: true })]);
|
||||
const body = pysimFsSelectBody(pysimFsTreeRoot.children[0]);
|
||||
assert.deepStrictEqual(body.parent_path, ['MF']);
|
||||
assert.strictEqual(body.parent_sel, 'MF');
|
||||
assert.ok(!body.allow_probe);
|
||||
const custom = pysimFsSelectBody(pysimFsTreeRoot.children[1]);
|
||||
assert.deepStrictEqual(custom.parent_path, ['MF']);
|
||||
assert.strictEqual(custom.allow_probe, true);
|
||||
const nested = { name: 'EF.1', fid: '6f01', isDir: false, parent: pysimFsTreeRoot.children[0] };
|
||||
assert.deepStrictEqual(pysimFsSelectBody(nested).parent_path, ['MF', '5f01']);
|
||||
});
|
||||
|
||||
test('children of an absent directory are neither fetched nor selected', async () => {
|
||||
const stale = Object.assign(df('DF.C', '5f03'), {
|
||||
exists: false,
|
||||
children: [{ name: 'EF.STALE', fid: '6f0e', isDir: false, exists: true, children: null }],
|
||||
});
|
||||
root([stale, ef('EF.ROOT', '2f01')]);
|
||||
setup([
|
||||
{ path: '/api/select', name: 'EF.ROOT', reply: { exists: true } },
|
||||
]);
|
||||
await pysimFsProbeAll();
|
||||
assert.deepStrictEqual(selectNames(), ['EF.ROOT']);
|
||||
assert.strictEqual(stale.exists, false);
|
||||
assert.strictEqual(calls.filter(c => c.path === '/api/tree').length, 0);
|
||||
const status = els['pysim-fs-probe-status'].textContent;
|
||||
assert.match(status, /2\/2 files/);
|
||||
assert.match(status, /1 present/);
|
||||
assert.match(status, /1 absent/);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
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);
|
||||
}
|
||||
|
||||
let code = 'var pysimFsSort = "fid";\n';
|
||||
code += extractFunc(html, 'pysimFsSortChildren') + '\n';
|
||||
code += extractFunc(html, 'pysimFsRenderNode') + '\n';
|
||||
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\n';
|
||||
eval(code);
|
||||
|
||||
const ef = (name, fid) => ({ name, fid, isDir: false, exists: true, children: null, expanded: false });
|
||||
const df = (name, fid, extra) => Object.assign({ name, fid, isDir: true, exists: true, children: null, expanded: false }, extra || {});
|
||||
|
||||
test('absent directory renders a cross and no expand toggle', () => {
|
||||
const node = df('DF.WLAN', '5f40', { exists: false, children: [] });
|
||||
const out = pysimFsRenderNode(node, 0);
|
||||
assert.ok(out.includes('✗'), out);
|
||||
assert.ok(!out.includes('pysimFsToggleDir'), out);
|
||||
assert.ok(!out.includes('▶'), out);
|
||||
});
|
||||
|
||||
test('expanded empty directory shows the (empty) placeholder', () => {
|
||||
const node = df('DF.EMPTY', '5f00', { children: [], expanded: true });
|
||||
const out = pysimFsRenderNode(node, 0);
|
||||
assert.ok(out.includes('(empty)'), out);
|
||||
});
|
||||
|
||||
test('expanded directory with children renders no placeholder', () => {
|
||||
const node = df('DF.GSM', '7f20', { children: [ef('EF.IMSI', '6f07')], expanded: true });
|
||||
const out = pysimFsRenderNode(node, 0);
|
||||
assert.ok(out.includes('EF.IMSI'), out);
|
||||
assert.ok(!out.includes('(empty)'), out);
|
||||
});
|
||||
|
||||
test('collapsed directory hides its children', () => {
|
||||
const node = df('DF.GSM', '7f20', { children: [ef('EF.IMSI', '6f07')], expanded: false });
|
||||
const out = pysimFsRenderNode(node, 0);
|
||||
assert.ok(!out.includes('EF.IMSI'), out);
|
||||
});
|
||||
|
||||
test('non-existing directory hides previously loaded children', () => {
|
||||
const node = df('DF.WLAN', '5f40', { exists: false, expanded: true, children: [ef('EF.UPLMNWLAN', '4f42')] });
|
||||
const out = pysimFsRenderNode(node, 0);
|
||||
assert.ok(out.includes('✗'), out);
|
||||
assert.ok(!out.includes('EF.UPLMNWLAN'), out);
|
||||
assert.ok(!out.includes('(empty)'), out);
|
||||
assert.ok(!out.includes('pysimFsToggleDir'), out);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
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);
|
||||
}
|
||||
|
||||
eval(extractFunc(html, 'pysimFsSortChildren'));
|
||||
|
||||
const f = (fid, name) => ({ fid, name, isDir: false });
|
||||
const d = (fid, name) => ({ fid, name, isDir: true });
|
||||
|
||||
test('DFs sort before EFs in both modes', () => {
|
||||
const children = [f('6F07', 'EF.IMSI'), d('7F20', 'DF.GSM'), f('2FE2', 'EF.ICCID'), d('7F10', 'DF.TELECOM')];
|
||||
assert.deepStrictEqual(pysimFsSortChildren(children, 'fid').map(x => x.fid), ['7F10', '7F20', '2FE2', '6F07']);
|
||||
assert.deepStrictEqual(pysimFsSortChildren(children, 'name').map(x => x.name), ['DF.GSM', 'DF.TELECOM', 'EF.ICCID', 'EF.IMSI']);
|
||||
});
|
||||
|
||||
test('FID mode sorts EFs numerically by FID string', () => {
|
||||
const children = [f('6F3A', 'EF.ADN'), f('2FE2', 'EF.ICCID'), f('6F07', 'EF.IMSI')];
|
||||
assert.deepStrictEqual(pysimFsSortChildren(children, 'fid').map(x => x.fid), ['2FE2', '6F07', '6F3A']);
|
||||
});
|
||||
|
||||
test('name mode sorts case-insensitively', () => {
|
||||
const children = [f('6F3A', 'EF.ADN'), f('2FE2', 'ef.iccid'), f('6F07', 'EF.IMSI')];
|
||||
assert.deepStrictEqual(pysimFsSortChildren(children, 'name').map(x => x.name), ['EF.ADN', 'ef.iccid', 'EF.IMSI']);
|
||||
});
|
||||
|
||||
test('missing name falls back to the FID as the sort key', () => {
|
||||
const children = [f('2FE2', null), f('6F07', 'EF.IMSI'), f('6F3A', '')];
|
||||
// keys: '2FE2', 'EF.IMSI', '6F3A' -> '2FE2' < '6F3A' < 'EF.IMSI'
|
||||
assert.deepStrictEqual(pysimFsSortChildren(children, 'name').map(x => x.fid), ['2FE2', '6F3A', '6F07']);
|
||||
});
|
||||
|
||||
test('custom entries use their isDir flag for the DF priority', () => {
|
||||
const children = [f('6F3A', 'EF.ADN'), d('7F20', 'DF.GSM')];
|
||||
assert.strictEqual(pysimFsSortChildren(children, 'fid')[0].name, 'DF.GSM');
|
||||
});
|
||||
|
||||
test('equal keys keep a deterministic tie-break by the other field', () => {
|
||||
const children = [f('6F07', 'EF.SAME'), f('2FE2', 'EF.SAME')];
|
||||
assert.deepStrictEqual(pysimFsSortChildren(children, 'name').map(x => x.fid), ['2FE2', '6F07']);
|
||||
assert.deepStrictEqual(pysimFsSortChildren(children, 'fid').map(x => x.fid), ['2FE2', '6F07']);
|
||||
});
|
||||
|
||||
test('does not mutate the input array', () => {
|
||||
const children = [f('6F3A', 'B'), f('2FE2', 'A')];
|
||||
pysimFsSortChildren(children, 'fid');
|
||||
assert.deepStrictEqual(children.map(x => x.fid), ['6F3A', '2FE2']);
|
||||
});
|
||||
@@ -10,3 +10,81 @@ const closes = (html.match(/<\/div>/g) || []).length;
|
||||
test('HTML <div> tags are balanced', () => {
|
||||
assert.strictEqual(opens, closes, `Unbalanced divs: ${opens} opens vs ${closes} closes`);
|
||||
});
|
||||
|
||||
test('top-level tabs match the rearranged views', () => {
|
||||
const tabs = [...html.matchAll(/class="tab-btn[^"]*" data-tab="([^"]+)"/g)].map(m => m[1]);
|
||||
assert.deepStrictEqual(tabs, ['c-apdu', 'scp80', 'profiler', 'pysim', 'phone']);
|
||||
assert.match(html, /data-tab="c-apdu">Remote APDU</);
|
||||
});
|
||||
|
||||
test('response parser is a Remote APDU pill', () => {
|
||||
assert.match(html, /data-sub="response" onclick="cApduSwitchSubtab\('response'\)"/);
|
||||
assert.ok(html.includes('id="c-apdu-sub-response"'));
|
||||
});
|
||||
|
||||
test('profiler and phone simulator are top-level tab contents', () => {
|
||||
assert.ok(html.includes('id="tab-profiler" class="tab-content hidden"'));
|
||||
assert.ok(html.includes('id="tab-phone" class="tab-content hidden"'));
|
||||
});
|
||||
|
||||
test('phone simulator has Phone / TR Config pills', () => {
|
||||
assert.match(html, /data-phone-sub="phone" onclick="phoneSwitchSubtab\('phone'\)"/);
|
||||
assert.match(html, /data-phone-sub="tr" onclick="phoneSwitchSubtab\('tr'\)"/);
|
||||
assert.ok(html.includes('id="phone-sub-phone"'));
|
||||
assert.ok(html.includes('id="phone-sub-tr"'));
|
||||
});
|
||||
|
||||
test('scan name input starts scanning on Enter', () => {
|
||||
assert.match(html, /id="profiler-scan-name"[^>]*onkeydown="profilerScanNameKeydown\(event\)"/);
|
||||
});
|
||||
|
||||
test('snapshot view has a timing summary block', () => {
|
||||
assert.ok(html.includes('id="snapshot-summary"'));
|
||||
});
|
||||
|
||||
test('header state indicator and profiler custom-files tab', () => {
|
||||
assert.ok(html.includes('id="state-indicator"'));
|
||||
assert.ok(html.includes('id="profiler-list-custom"'));
|
||||
assert.ok(html.includes('data-list-tab="custom"'));
|
||||
assert.ok(!html.includes('data-pysim-sub="custom"'));
|
||||
});
|
||||
|
||||
test('file manager has FID / Name sort pills', () => {
|
||||
assert.match(html, /data-fs-sort="fid" onclick="pysimFsSetSort\('fid'\)"/);
|
||||
assert.match(html, /data-fs-sort="name" onclick="pysimFsSetSort\('name'\)"/);
|
||||
assert.ok(html.includes('pysim-fs-sort-pill'));
|
||||
});
|
||||
|
||||
test('custom files form has add/save and cancel controls', () => {
|
||||
assert.match(html, /id="pysim-cf-add-btn"[^>]*data-l10n="Add"/);
|
||||
assert.match(html, /id="pysim-cf-cancel-btn"[^>]*class="hidden[^"]*"[^>]*data-l10n="Cancel"/);
|
||||
assert.ok(html.includes("event.key==='Enter')pysimCustomSubmit()"));
|
||||
assert.ok(!html.includes('pysimCustomAdd'));
|
||||
});
|
||||
|
||||
test('file manager has a probe-all-files button and status line', () => {
|
||||
assert.match(html, /id="pysim-fs-probe-btn"[^>]*data-needs="card"/);
|
||||
assert.match(html, /id="pysim-fs-probe-btn"[^>]*data-l10n="Probe all files"/);
|
||||
assert.ok(html.includes('onclick="pysimFsProbeAll()"'));
|
||||
assert.ok(html.includes('id="pysim-fs-probe-status"'));
|
||||
});
|
||||
|
||||
test('file manager shows FCI info and keeps the selection in state, not the DOM', () => {
|
||||
const detail = html.indexOf('id="pysim-fs-detail"');
|
||||
const info = html.indexOf('id="pysim-fs-info"');
|
||||
const content = html.indexOf('id="pysim-fs-content"');
|
||||
assert.ok(detail !== -1 && info > detail && info < content, 'pysim-fs-info must sit above the content');
|
||||
assert.ok(html.includes('function pysimFsInfoHtml'));
|
||||
assert.ok(html.includes("pysimFsInfoHtml(sel)"));
|
||||
assert.ok(!html.includes('pysim-fs-filename'));
|
||||
assert.ok(html.includes('let pysimFsSelected = null;'));
|
||||
assert.ok(html.includes('pysimFsSelected = name;'));
|
||||
assert.ok(!html.includes('pysimFsSelect()'));
|
||||
});
|
||||
|
||||
test('profile list has a Profile from snapshot button', () => {
|
||||
assert.match(html, /data-l10n="Profile from snapshot">Profile from snapshot</);
|
||||
assert.ok(html.includes('onclick="profilerFromSnapshot()"'));
|
||||
assert.ok(html.includes('function profilerScanFromSnapshot(si)'));
|
||||
assert.ok(html.includes('function profilerBuildFileRuleFromSnapshot('));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
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);
|
||||
}
|
||||
|
||||
let code = 'var _pysimServerAvailable = null;\nvar _pysimCardEquipped = false;\nvar _pysimEquipping = false;\n';
|
||||
code += extractFunc(html, 'pysimAvailabilityState') + '\n';
|
||||
code += extractFunc(html, 'pysimUpdateStateIndicator') + '\n';
|
||||
code += 'globalThis.t = s => s;\n';
|
||||
eval(code);
|
||||
|
||||
function fakeEl() {
|
||||
const classes = new Set();
|
||||
return {
|
||||
classes,
|
||||
attrs: {},
|
||||
classList: {
|
||||
add: (...cs) => cs.forEach(c => classes.add(c)),
|
||||
remove: (...cs) => cs.forEach(c => classes.delete(c)),
|
||||
contains: c => classes.has(c),
|
||||
},
|
||||
setAttribute(k, v) { this.attrs[k] = v; },
|
||||
removeAttribute(k) { delete this.attrs[k]; },
|
||||
};
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const els = {
|
||||
'state-indicator': fakeEl(),
|
||||
'state-indicator-dot': fakeEl(),
|
||||
'state-indicator-img': fakeEl(),
|
||||
};
|
||||
els['state-indicator-img'].src = '';
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
_pysimServerAvailable = null;
|
||||
_pysimCardEquipped = false;
|
||||
_pysimEquipping = false;
|
||||
return els;
|
||||
}
|
||||
|
||||
test('unprobed server shows a gray dot and a Connecting title', () => {
|
||||
const els = setup();
|
||||
pysimUpdateStateIndicator();
|
||||
const { 'state-indicator': wrap, 'state-indicator-dot': dot, 'state-indicator-img': img } = els;
|
||||
assert.ok(dot.classes.has('text-gray-400'));
|
||||
assert.ok(!dot.classes.has('hidden'));
|
||||
assert.ok(img.classes.has('hidden'));
|
||||
assert.strictEqual(wrap.attrs.title, 'Connecting...');
|
||||
assert.strictEqual(dot.attrs.title, 'Connecting...');
|
||||
});
|
||||
|
||||
test('unreachable server shows a red dot', () => {
|
||||
const els = setup();
|
||||
_pysimServerAvailable = false;
|
||||
pysimUpdateStateIndicator();
|
||||
const { 'state-indicator': wrap, 'state-indicator-dot': dot, 'state-indicator-img': img } = els;
|
||||
assert.ok(dot.classes.has('text-red-500'));
|
||||
assert.ok(!dot.classes.has('text-gray-400'));
|
||||
assert.ok(img.classes.has('hidden'));
|
||||
assert.strictEqual(wrap.attrs.title, 'No server connection');
|
||||
assert.strictEqual(dot.attrs.title, 'No server connection');
|
||||
});
|
||||
|
||||
test('server up without a card shows nosim.svg', () => {
|
||||
const els = setup();
|
||||
_pysimServerAvailable = true;
|
||||
pysimUpdateStateIndicator();
|
||||
const { 'state-indicator': wrap, 'state-indicator-dot': dot, 'state-indicator-img': img } = els;
|
||||
assert.ok(dot.classes.has('hidden'));
|
||||
assert.ok(!img.classes.has('hidden'));
|
||||
assert.strictEqual(img.src, 'nosim.svg');
|
||||
assert.strictEqual(wrap.attrs.title, 'Server connected, no card equipped');
|
||||
assert.strictEqual(dot.attrs.title, undefined);
|
||||
});
|
||||
|
||||
test('equipped card shows sim.svg', () => {
|
||||
const els = setup();
|
||||
_pysimServerAvailable = true;
|
||||
_pysimCardEquipped = true;
|
||||
pysimUpdateStateIndicator();
|
||||
const { 'state-indicator': wrap, 'state-indicator-img': img } = els;
|
||||
assert.strictEqual(img.src, 'sim.svg');
|
||||
assert.strictEqual(wrap.attrs.title, 'Card equipped');
|
||||
});
|
||||
|
||||
test('equipping shows the animated sim_anim.svg', () => {
|
||||
const els = setup();
|
||||
_pysimServerAvailable = true;
|
||||
_pysimEquipping = true;
|
||||
pysimUpdateStateIndicator();
|
||||
const { 'state-indicator': wrap, 'state-indicator-img': img } = els;
|
||||
assert.strictEqual(img.src, 'sim_anim.svg');
|
||||
assert.strictEqual(wrap.attrs.title, 'Card inserted — initializing...');
|
||||
});
|
||||
|
||||
test('dot color transitions do not accumulate', () => {
|
||||
const els = setup();
|
||||
_pysimServerAvailable = false;
|
||||
pysimUpdateStateIndicator();
|
||||
_pysimServerAvailable = null;
|
||||
pysimUpdateStateIndicator();
|
||||
const dot = els['state-indicator-dot'];
|
||||
assert.ok(dot.classes.has('text-gray-400'));
|
||||
assert.ok(!dot.classes.has('text-red-500'));
|
||||
});
|
||||
|
||||
test('indicator markup carries the dot and image elements', () => {
|
||||
assert.match(html, /id="state-indicator-dot"/);
|
||||
assert.match(html, /id="state-indicator-img"[^>]*src="nosim\.svg"/);
|
||||
});
|
||||
|
||||
test('indicator image stays within the 32px header row budget', () => {
|
||||
const m = /id="state-indicator-img"[^>]*style="width:(\d+)px;height:(\d+)px"/.exec(html);
|
||||
assert.ok(m, 'inline image size not found');
|
||||
assert.strictEqual(m[1], m[2]);
|
||||
const size = Number(m[1]);
|
||||
assert.ok(size >= 24 && size <= 32, 'size ' + size + 'px would change the header height');
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
const code = extractFunc(html, 'phoneSwitchSubtab') + '\n' +
|
||||
'globalThis.setHelpAnchor = a => { globalThis._anchor = a; };\n' +
|
||||
'globalThis.stkCheckMenu = () => { globalThis._stk = (globalThis._stk || 0) + 1; };\n' +
|
||||
'globalThis.pysimEventsRender = () => { globalThis._events = (globalThis._events || 0) + 1; };\n' +
|
||||
'globalThis.pysimProactiveLogRender = () => { globalThis._log = (globalThis._log || 0) + 1; };\n' +
|
||||
'globalThis.pysimPollStatusInit = () => { globalThis._poll = (globalThis._poll || 0) + 1; };\n' +
|
||||
'globalThis.pysimPliRender = () => { globalThis._pli = (globalThis._pli || 0) + 1; };\n';
|
||||
eval(code);
|
||||
|
||||
function makeClassList() {
|
||||
const set = new Set();
|
||||
return {
|
||||
toggle: (c, on) => { on ? set.add(c) : set.delete(c); },
|
||||
has: c => set.has(c),
|
||||
};
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const buttons = [
|
||||
{ dataset: { phoneSub: 'phone' }, classList: makeClassList() },
|
||||
{ dataset: { phoneSub: 'tr' }, classList: makeClassList() },
|
||||
];
|
||||
const panels = {
|
||||
'phone-sub-phone': { classList: makeClassList() },
|
||||
'phone-sub-tr': { classList: makeClassList() },
|
||||
};
|
||||
globalThis.document = {
|
||||
querySelectorAll: sel => (sel === '.phone-subtab' ? buttons : []),
|
||||
getElementById: id => panels[id] || null,
|
||||
};
|
||||
globalThis._anchor = null;
|
||||
globalThis._stk = globalThis._events = globalThis._log = globalThis._poll = globalThis._pli = 0;
|
||||
return { buttons, panels };
|
||||
}
|
||||
|
||||
test('TR Config pill shows the TR panel and renders PLI data', () => {
|
||||
const { buttons, panels } = setup();
|
||||
phoneSwitchSubtab('tr');
|
||||
assert.ok(!panels['phone-sub-tr'].classList.has('hidden'));
|
||||
assert.ok(panels['phone-sub-phone'].classList.has('hidden'));
|
||||
assert.ok(buttons[1].classList.has('bg-blue-600'));
|
||||
assert.ok(!buttons[0].classList.has('bg-blue-600'));
|
||||
assert.strictEqual(globalThis._anchor, 'pli-dict');
|
||||
assert.strictEqual(globalThis._pli, 1);
|
||||
assert.strictEqual(globalThis._stk, 0);
|
||||
});
|
||||
|
||||
test('Phone pill shows the phone panel and renders CAT views', () => {
|
||||
const { buttons, panels } = setup();
|
||||
phoneSwitchSubtab('phone');
|
||||
assert.ok(!panels['phone-sub-phone'].classList.has('hidden'));
|
||||
assert.ok(panels['phone-sub-tr'].classList.has('hidden'));
|
||||
assert.ok(buttons[0].classList.has('bg-blue-600'));
|
||||
assert.strictEqual(globalThis._anchor, 'stk-menu');
|
||||
assert.strictEqual(globalThis._stk, 1);
|
||||
assert.strictEqual(globalThis._events, 1);
|
||||
assert.strictEqual(globalThis._log, 1);
|
||||
assert.strictEqual(globalThis._poll, 1);
|
||||
assert.strictEqual(globalThis._pli, 0);
|
||||
});
|
||||
@@ -21,12 +21,18 @@ function extractFunc(src, name, asyncFn) {
|
||||
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport'];
|
||||
const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput', 'profilerScanToggleAll', 'profilerScanIgnoreAllState', 'swapNibbles', 'decIccid', 'profilerSnapshotIccid', 'profilerValidateSnapshot', 'profilerListSwitch', 'profilerScanRefreshOptions', 'profilerLiveSource', 'profilerSnapshotSource', 'profilerVisibleResults', 'profilerMaskFidForFile', 'profilerRulesFromSnapshot', 'profilerExtraFileResults', 'profilerScanNameKeydown', 'profilerTimingStats', 'profilerTimingAccumulator', 'profilerFormatMs', 'profilerRenderSnapshotSummary', 'profilerSnapshotCountLabel', 'pysimFsInfoHtml', 'profilerLabelText', 'profilerResultsHeaderText', 'profilerRenderResultsView', 'profilerBuildFileRuleFromSnapshot', 'profilerSnapshotPickListHtml', 'profilerScanSetTarget'];
|
||||
let code = '';
|
||||
for (const f of FNS) code += extractFunc(html, f) + '\n';
|
||||
code += extractFunc(html, 'profilerBuildFileRule', true) + '\n';
|
||||
code += extractFunc(html, 'profilerRunRule', true) + '\n';
|
||||
code += extractFunc(html, 'profilerScanCard', true) + '\n';
|
||||
code += extractFunc(html, 'profilerBuildSnapshotFile', true) + '\n';
|
||||
code += extractFunc(html, 'profilerCardIccid', true) + '\n';
|
||||
code += extractFunc(html, 'profilerCheck', true) + '\n';
|
||||
code += extractFunc(html, 'profilerCheckSnapshot', true) + '\n';
|
||||
code += extractFunc(html, 'profilerScanSnapshot', true) + '\n';
|
||||
code += "var _scanTarget = 'profile';\nvar profilerResults = null;\nvar profilerResultsHeader = null;\nvar profilerMismatchOnly = false;\n";
|
||||
code += html.match(/const PROFILER_MASK_PREFIX4_FIDS = \{[\s\S]*?\n\};/)[0] + '\n';
|
||||
eval(code);
|
||||
|
||||
@@ -116,17 +122,29 @@ test('profile validation', () => {
|
||||
|
||||
function parseIgnoreFiles() {
|
||||
const raw = html.match(/const PROFILER_IGNORE_FILES = \[([\s\S]*?)\n\];/)[1];
|
||||
return [...raw.matchAll(/\{ fid: '([^']*)', name: '([^']*)' \}/g)].map(m => ({ fid: m[1], name: m[2] }));
|
||||
return [...raw.matchAll(/\{ fid: '([^']*)', name: '([^']*)'(?:, checked: (true|false))? \}/g)]
|
||||
.map(m => ({ fid: m[1], name: m[2], checked: m[3] !== 'false' }));
|
||||
}
|
||||
|
||||
test('ignore list FIDs are well-formed and KcGPRS uses the TS 51.011 FID (6F52)', () => {
|
||||
const files = parseIgnoreFiles();
|
||||
assert.ok(files.length >= 12);
|
||||
assert.ok(files.length >= 16);
|
||||
for (const f of files) {
|
||||
assert.match(f.fid, /^[0-9A-F]{4}$/, f.name + ' has a malformed FID');
|
||||
assert.ok(f.name.startsWith('EF.'), f.fid + ' has a malformed name');
|
||||
}
|
||||
assert.strictEqual(files.find(f => f.name === 'EF.KcGPRS').fid, '6F52');
|
||||
assert.strictEqual(files.find(f => f.name === 'EF.ACC').fid, '6F78');
|
||||
assert.strictEqual(files.find(f => f.name === 'EF.EPSNSC').fid, '6FE4');
|
||||
assert.strictEqual(files.find(f => f.name === 'EF.START-HFN').fid, '6F5B');
|
||||
assert.strictEqual(files.find(f => f.name === 'EF.ARR').fid, '2F06');
|
||||
});
|
||||
|
||||
test('ignore list checks every file by default except EF.ARR', () => {
|
||||
const files = parseIgnoreFiles();
|
||||
for (const f of files) {
|
||||
assert.strictEqual(f.checked, f.name !== 'EF.ARR', f.name + ' default-checked mismatch');
|
||||
}
|
||||
});
|
||||
|
||||
test('ignore list has no duplicate FIDs or names', () => {
|
||||
@@ -570,9 +588,895 @@ test('profilerRenderReport renders raw-data mismatches as aligned readonly field
|
||||
assert.ok(html.includes('font-mono'));
|
||||
assert.ok(html.includes('value="621082024021"'));
|
||||
assert.ok(html.includes('value="621082024022"'));
|
||||
assert.ok(html.includes('w-24 text-right'));
|
||||
assert.ok(html.includes('text-right shrink-0 w-24'));
|
||||
// non-raw check stays inline
|
||||
assert.ok(html.includes('content.records: expected'));
|
||||
assert.ok(!fciBlock.includes(': expected'));
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
// --- FCP/FCI decoding (ISO 7816-4 5.3.3, TS 102 221 11.1.1.4) ---
|
||||
|
||||
const FCP_TRANSPARENT = '6212800200098202412183026F078A0105880110';
|
||||
const FCP_LINFIX = '620E82054221006E0583026F3A8A0105';
|
||||
const FCP_DF = '620B8202782183027F208A0105';
|
||||
const FCP_BERTLV = '620B8202792183026F018A0105';
|
||||
|
||||
test('fcpDecode decodes a transparent EF FCP template', () => {
|
||||
const d = fcpDecode(FCP_TRANSPARENT);
|
||||
assert.strictEqual(d.ok, true);
|
||||
assert.strictEqual(d.template, '62');
|
||||
const by = {};
|
||||
d.items.forEach(it => { by[it.key] = it; });
|
||||
assert.strictEqual(by['80'].decoded, '9 bytes');
|
||||
assert.strictEqual(by['82'].decoded, 'not shareable, working EF, transparent, data coding 21');
|
||||
assert.strictEqual(by['83'].decoded, '6F07');
|
||||
assert.strictEqual(by['8A'].decoded, 'operational, activated');
|
||||
assert.strictEqual(by['88'].decoded, '2');
|
||||
});
|
||||
|
||||
test('fcpDecode decodes linear fixed file descriptor (record length/count)', () => {
|
||||
const d = fcpDecode(FCP_LINFIX);
|
||||
assert.strictEqual(d.ok, true);
|
||||
const fd = d.items.find(it => it.key === '82');
|
||||
assert.strictEqual(fd.decoded, 'not shareable, working EF, linear fixed, data coding 21, record length 110, 5 records');
|
||||
});
|
||||
|
||||
test('fcpDecode decodes DF and BER-TLV structures', () => {
|
||||
assert.strictEqual(fcpDecode(FCP_DF).items.find(it => it.key === '82').decoded, 'not shareable, DF/ADF, data coding 21');
|
||||
assert.strictEqual(fcpDecode(FCP_BERTLV).items.find(it => it.key === '82').decoded, 'not shareable, BER-TLV EF, data coding 21');
|
||||
});
|
||||
|
||||
test('fcpDecode maps life cycle status per table 11.7b', () => {
|
||||
const lcs = v => fcpDecode('6207820278218A01' + v).items.find(it => it.key === '8A').decoded;
|
||||
assert.strictEqual(lcs('00'), 'no information');
|
||||
assert.strictEqual(lcs('01'), 'creation');
|
||||
assert.strictEqual(lcs('03'), 'initialization');
|
||||
assert.strictEqual(lcs('05'), 'operational, activated');
|
||||
assert.strictEqual(lcs('04'), 'operational, deactivated');
|
||||
assert.strictEqual(lcs('0C'), 'termination');
|
||||
assert.strictEqual(lcs('0D'), 'termination');
|
||||
assert.strictEqual(lcs('80'), 'proprietary (80)');
|
||||
});
|
||||
|
||||
test('fcpDecode decodes A5 proprietary sub-TLVs', () => {
|
||||
const d = fcpDecode('62128202412183026F078A0105A5058503000000');
|
||||
const by = {};
|
||||
d.items.forEach(it => { by[it.key] = it; });
|
||||
assert.strictEqual(by['A5'].decoded, null);
|
||||
assert.strictEqual(by['A5/85'].decoded, '0 bytes');
|
||||
});
|
||||
|
||||
test('fcpDecode unwraps an FCI 6F template and rejects malformed input', () => {
|
||||
const d = fcpDecode('6F146212800200098202412183026F078A0105880110');
|
||||
assert.strictEqual(d.ok, true);
|
||||
assert.strictEqual(d.template, '62');
|
||||
assert.strictEqual(d.items.find(it => it.key === '80').decoded, '9 bytes');
|
||||
|
||||
assert.strictEqual(fcpDecode('').ok, false);
|
||||
assert.strictEqual(fcpDecode('ZZZZ').ok, false);
|
||||
assert.strictEqual(fcpDecode('6214800200098202412183').ok, false); // truncated
|
||||
assert.strictEqual(fcpDecode('6213' + FCP_TRANSPARENT.slice(4)).ok, false); // wrong outer length
|
||||
});
|
||||
|
||||
test('fcpDecode accepts long-form BER lengths (81/82) — editor preview regression', () => {
|
||||
// 62 12 <18B> vs 62 81 12 <same content>
|
||||
const long81 = '628112' + FCP_TRANSPARENT.slice(4);
|
||||
const d1 = fcpDecode(long81);
|
||||
assert.strictEqual(d1.ok, true);
|
||||
assert.strictEqual(d1.items.find(it => it.key === '80').decoded, '9 bytes');
|
||||
|
||||
// 2-byte length form
|
||||
const d2 = fcpDecode('62820004' + '80020009');
|
||||
assert.strictEqual(d2.ok, true);
|
||||
assert.strictEqual(d2.items.find(it => it.key === '80').decoded, '9 bytes');
|
||||
|
||||
// nested A5 with a long-form length (A5 81 05 …)
|
||||
const d3 = fcpDecode('62138202412183026F078A0105A581058503000000');
|
||||
assert.strictEqual(d3.ok, true);
|
||||
assert.strictEqual(d3.items.find(it => it.key === 'A5/85').decoded, '0 bytes');
|
||||
|
||||
// FCP content >= 128 bytes uses a long-form outer length
|
||||
let big = '';
|
||||
for (let i = 0; i < 13; i++) big += '8808' + '0102030405060708'; // 13 x 10B = 130B
|
||||
const d4 = fcpDecode('628182' + big);
|
||||
assert.strictEqual(d4.ok, true);
|
||||
assert.strictEqual(d4.items.filter(it => it.key === '88').length, 13);
|
||||
|
||||
global.t = s => s;
|
||||
assert.ok(profilerFciPreviewItems(long81).includes('File size: '));
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
test('fcpDiffHtml highlights differing FCI parameters', () => {
|
||||
global.t = s => s;
|
||||
const same = fcpDiffHtml(FCP_TRANSPARENT, FCP_TRANSPARENT);
|
||||
assert.ok(same.includes('File size'));
|
||||
assert.ok(!same.includes('text-red-600'));
|
||||
const diff = fcpDiffHtml(FCP_TRANSPARENT, '62128002000A8202412183026F078A0105880110');
|
||||
assert.ok(diff.includes('9 bytes'));
|
||||
assert.ok(diff.includes('10 bytes'));
|
||||
assert.ok(diff.includes('text-red-600'));
|
||||
assert.ok(fcpDiffHtml('garbage', FCP_TRANSPARENT).includes('Truncated length field'));
|
||||
assert.strictEqual(fcpDiffHtml('', ''), '');
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
test('pysimFsInfoHtml shows FID, type, size and the decoded FCI', () => {
|
||||
global.t = s => s;
|
||||
const hex = '621A82054221000F0583026F4F8A01058B036F06098002004B8801B0';
|
||||
const out = pysimFsInfoHtml({ fid: '6f4f', file_type: 'linear_fixed', file_size: 75, record_len: 15, num_of_rec: 5, fci_hex: hex });
|
||||
assert.ok(out.includes('FID: 6F4F'), out);
|
||||
assert.ok(out.includes('File type: linear_fixed'), out);
|
||||
assert.ok(out.includes('Size: 75'), out);
|
||||
assert.ok(out.includes('Record length: 15'), out);
|
||||
assert.ok(out.includes('Record count: 5'), out);
|
||||
assert.ok(!out.includes('Decoded FCI'), out);
|
||||
assert.ok(out.includes('File descriptor'), out);
|
||||
assert.ok(out.includes('75 bytes'), out);
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
test('pysimFsInfoHtml omits the FCI block without fci_hex and skips null fields', () => {
|
||||
global.t = s => s;
|
||||
const out = pysimFsInfoHtml({ fid: '6f07', file_type: 'transparent', file_size: null, record_len: null, num_of_rec: undefined, fci_hex: null });
|
||||
assert.ok(out.includes('FID: 6F07'), out);
|
||||
assert.ok(!out.includes('Size:'), out);
|
||||
assert.ok(!out.includes('Record length:'), out);
|
||||
assert.ok(!out.includes('Record count:'), out);
|
||||
assert.ok(!out.includes('Decoded FCI'), out);
|
||||
assert.strictEqual(pysimFsInfoHtml(null), '');
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
test('profilerFciPreviewItems renders decoded items and degrades gracefully', () => {
|
||||
global.t = s => s;
|
||||
assert.ok(profilerFciPreviewItems(FCP_TRANSPARENT).includes('File size: '));
|
||||
assert.ok(profilerFciPreviewItems('not hex').includes('Decode failed'));
|
||||
assert.strictEqual(profilerFciPreviewItems(''), '');
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
test('profilerFciInput stores the edited FCI and re-decodes the preview', () => {
|
||||
global.t = s => s;
|
||||
global.profilerDraft = { rules: [{ fciHex: '', fciMode: 'exact' }] };
|
||||
const el = { innerHTML: '' };
|
||||
global.document = { getElementById: id => (id === 'profiler-fci-preview-0' ? el : null) };
|
||||
profilerFciInput(0, FCP_TRANSPARENT);
|
||||
assert.strictEqual(global.profilerDraft.rules[0].fciHex, FCP_TRANSPARENT);
|
||||
assert.ok(el.innerHTML.includes('File size: '));
|
||||
profilerFciInput(0, 'zz');
|
||||
assert.strictEqual(el.innerHTML, '');
|
||||
global.profilerDraft = null;
|
||||
delete global.document;
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
test('profilerRenderReport shows the decoded FCI diff for a raw fci mismatch', () => {
|
||||
global.t = s => s;
|
||||
global.pysimCustomFiles = [];
|
||||
const html = profilerRenderReport([
|
||||
{ path: 'MF/6F07', name: 'EF.IMSI', status: 'fail', checks: [
|
||||
{ label: 'fci', ok: false, expected: FCP_TRANSPARENT, actual: '62128002000A8202412183026F078A0105880110' },
|
||||
] },
|
||||
]);
|
||||
assert.ok(html.includes('FCI parameters'));
|
||||
assert.ok(html.includes('9 bytes'));
|
||||
assert.ok(html.includes('10 bytes'));
|
||||
assert.ok(html.includes('text-red-600'));
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
// --- partial/corrupt FCI decoding ---
|
||||
|
||||
test('fcpDecode reports truncation but keeps the TLVs parsed before it', () => {
|
||||
// outer declares 22 bytes (0x16) but only the 18-byte body is present
|
||||
const truncOuter = '6216' + FCP_TRANSPARENT.slice(4);
|
||||
const d1 = fcpDecode(truncOuter);
|
||||
assert.strictEqual(d1.ok, false);
|
||||
assert.match(d1.error, /TLV 62 declares 22 bytes, only 18 available/);
|
||||
assert.strictEqual(d1.items.find(it => it.key === '80').decoded, '9 bytes');
|
||||
|
||||
// correct outer length, truncated inner TLV (82 declares 5 bytes, 2 present)
|
||||
const d2 = fcpDecode('620880020009820541 21'.replace(/ /g, ''));
|
||||
assert.strictEqual(d2.ok, false);
|
||||
assert.match(d2.error, /TLV 82 declares 5 bytes, only 2 available/);
|
||||
assert.strictEqual(d2.items.find(it => it.key === '80').decoded, '9 bytes');
|
||||
assert.ok(!d2.items.some(it => it.key === '82'));
|
||||
|
||||
// truncated length field
|
||||
const d3 = fcpDecode('6281');
|
||||
assert.strictEqual(d3.ok, false);
|
||||
assert.match(d3.error, /truncated length field/);
|
||||
assert.strictEqual(d3.items.length, 0);
|
||||
|
||||
// truncated inner A5 sub-TLV reports the context
|
||||
const d4 = fcpDecode('620A82024121A50488050102');
|
||||
assert.strictEqual(d4.ok, false);
|
||||
assert.match(d4.error, /In A5: TLV 88 declares 5 bytes, only 2 available/);
|
||||
});
|
||||
|
||||
test('profilerFciPreviewItems shows decoded data plus an explicit failure note', () => {
|
||||
global.t = s => s;
|
||||
const trunc = '6216' + FCP_TRANSPARENT.slice(4);
|
||||
const html = profilerFciPreviewItems(trunc);
|
||||
assert.ok(html.includes('File size: '), html);
|
||||
assert.ok(html.includes('Decode failed'), html);
|
||||
assert.ok(html.includes('declares 22 bytes'), html);
|
||||
// empty input renders nothing; garbage reports the failure
|
||||
assert.strictEqual(profilerFciPreviewItems(''), '');
|
||||
assert.ok(profilerFciPreviewItems('not hex').includes('Decode failed'));
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
test('fcpDiffHtml appends decode-failure notes for corrupt sides', () => {
|
||||
global.t = s => s;
|
||||
const trunc = '6216' + FCP_TRANSPARENT.slice(4);
|
||||
const html = fcpDiffHtml(trunc, FCP_TRANSPARENT);
|
||||
assert.ok(html.includes('FCI parameters'), html);
|
||||
assert.ok(html.includes('9 bytes'));
|
||||
assert.ok(html.includes('expected: ') && html.includes('TLV 62 declares 22 bytes'));
|
||||
// both sides empty -> nothing rendered
|
||||
assert.strictEqual(fcpDiffHtml('', ''), '');
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
test('profilerScanToggleAll / profilerScanIgnoreAllState manage the ignore checkboxes', () => {
|
||||
const boxes = [{ checked: true }, { checked: false }, { checked: true }];
|
||||
const header = { checked: false, indeterminate: false };
|
||||
global.document = {
|
||||
querySelectorAll: sel => (sel === '#profiler-scan-ignore input[data-ignore-fid]' ? boxes : []),
|
||||
getElementById: id => (id === 'profiler-scan-ignore-all' ? header : null),
|
||||
};
|
||||
|
||||
profilerScanIgnoreAllState();
|
||||
assert.strictEqual(header.checked, false);
|
||||
assert.strictEqual(header.indeterminate, true);
|
||||
|
||||
boxes[1].checked = true;
|
||||
profilerScanIgnoreAllState();
|
||||
assert.strictEqual(header.checked, true);
|
||||
assert.strictEqual(header.indeterminate, false);
|
||||
|
||||
profilerScanToggleAll(false);
|
||||
assert.ok(boxes.every(b => !b.checked));
|
||||
profilerScanIgnoreAllState();
|
||||
assert.strictEqual(header.checked, false);
|
||||
assert.strictEqual(header.indeterminate, false);
|
||||
|
||||
profilerScanToggleAll(true);
|
||||
assert.ok(boxes.every(b => b.checked));
|
||||
profilerScanIgnoreAllState();
|
||||
assert.strictEqual(header.checked, true);
|
||||
assert.strictEqual(header.indeterminate, false);
|
||||
|
||||
delete global.document;
|
||||
});
|
||||
|
||||
// --- card snapshots ---
|
||||
|
||||
test('decIccid decodes nibble-swapped EF.ICCID digits and strips F padding', () => {
|
||||
assert.strictEqual(decIccid('980711090000640090F8'), '8970119000004600098');
|
||||
assert.strictEqual(decIccid('98103254769810325476'), '89012345678901234567');
|
||||
assert.strictEqual(decIccid('98 07 11 09 00 00 64 00 90 f8'), '8970119000004600098');
|
||||
assert.strictEqual(decIccid(''), '');
|
||||
});
|
||||
|
||||
test('profilerSnapshotIccid finds EF.ICCID by name or FID and handles null cases', () => {
|
||||
const hex = '980711090000640090F8';
|
||||
assert.strictEqual(
|
||||
profilerSnapshotIccid([{ path: 'MF/2FE2', name: 'EF.ICCID', content: { kind: 'transparent', data: hex } }]),
|
||||
'8970119000004600098');
|
||||
assert.strictEqual(
|
||||
profilerSnapshotIccid([{ path: 'MF/6F07', name: 'x', content: { kind: 'transparent', data: hex } }]),
|
||||
null);
|
||||
assert.strictEqual(
|
||||
profilerSnapshotIccid([{ path: 'MF/2FE2', name: 'EF.ICCID', content: null }]),
|
||||
null);
|
||||
assert.strictEqual(profilerSnapshotIccid([]), null);
|
||||
});
|
||||
|
||||
test('profilerValidateSnapshot checks name and files array', () => {
|
||||
assert.strictEqual(profilerValidateSnapshot(null), 'Not an object');
|
||||
assert.strictEqual(profilerValidateSnapshot({ name: 'x' }), 'Missing files array');
|
||||
assert.strictEqual(profilerValidateSnapshot({ name: 'x', files: 'nope' }), 'Missing files array');
|
||||
assert.strictEqual(profilerValidateSnapshot({ files: [] }), 'Missing name');
|
||||
assert.strictEqual(profilerValidateSnapshot({ name: 'x', files: [null] }), 'Invalid file entry');
|
||||
assert.strictEqual(profilerValidateSnapshot({ name: 'x', files: [{}] }), 'File entry missing path');
|
||||
assert.strictEqual(profilerValidateSnapshot({ name: 'x', files: [{ path: 'MF/6F07' }] }), null);
|
||||
});
|
||||
|
||||
test('profilerBuildSnapshotFile captures exact contents for everything readable', async () => {
|
||||
// transparent file (IMSI) is captured exactly, no mask
|
||||
mockFetch({
|
||||
'/api/select': () => ({ name: 'EF.IMSI', fid: '6F07', file_type: 'transparent', file_size: 9, record_len: null, num_of_rec: null, fci_hex: '6212', exists: true }),
|
||||
'/api/read': () => ({ success: true, data: '082905911234567890' }),
|
||||
});
|
||||
const f = await profilerBuildSnapshotFile('MF/6F07', { fid: '6f07', name: 'EF.IMSI' });
|
||||
assert.strictEqual(f.name, 'EF.IMSI');
|
||||
assert.strictEqual(f.fileType, 'transparent');
|
||||
assert.strictEqual(f.fileSize, 9);
|
||||
assert.strictEqual(f.fciHex, '6212');
|
||||
assert.deepStrictEqual(f.content, { kind: 'transparent', data: '082905911234567890' });
|
||||
|
||||
// record file
|
||||
mockFetch({
|
||||
'/api/select': () => ({ name: 'EF.ADN', fid: '6F3A', file_type: 'linear_fixed', file_size: null, record_len: 2, num_of_rec: 2, fci_hex: '620E', exists: true }),
|
||||
'/api/read': () => ({ success: true, records: [{ num: 1, data: 'AA' }, { num: 2, data: 'BB' }] }),
|
||||
});
|
||||
const r = await profilerBuildSnapshotFile('MF/7F20/6F3A', { fid: '6F3A', name: 'EF.ADN' });
|
||||
assert.strictEqual(r.recordLen, 2);
|
||||
assert.strictEqual(r.numRecords, 2);
|
||||
assert.deepStrictEqual(r.content, { kind: 'record', records: [{ num: 1, data: 'AA' }, { num: 2, data: 'BB' }] });
|
||||
|
||||
// unreadable -> content null, metadata kept
|
||||
mockFetch({
|
||||
'/api/select': () => ({ name: 'EF.Kc', fid: '6F20', file_type: 'transparent', file_size: 9, record_len: null, num_of_rec: null, fci_hex: '620A', exists: true }),
|
||||
'/api/read': () => ({ success: false, sw: '6982' }),
|
||||
});
|
||||
const u = await profilerBuildSnapshotFile('MF/7F20/6F20', { fid: '6F20', name: 'EF.Kc' });
|
||||
assert.strictEqual(u.content, null);
|
||||
assert.strictEqual(u.fileSize, 9);
|
||||
});
|
||||
|
||||
test('profilerScanCard snapshot mode builds snapshot entries with ICCID', async () => {
|
||||
global.pysimCustomFiles = [];
|
||||
global.pysimFetch = async (path, body) => {
|
||||
if (path === '/api/tree') return { exists: true, name: 'MF', children: [
|
||||
{ name: 'EF.ICCID', fid: '2fe2', isDir: false },
|
||||
{ name: 'EF.IMSI', fid: '6f07', isDir: false },
|
||||
] };
|
||||
if (path === '/api/select') {
|
||||
const fid = body.path.split('/').pop();
|
||||
if (fid === '2FE2') return { name: 'EF.ICCID', fid: '2FE2', file_type: 'transparent', file_size: 10, record_len: null, num_of_rec: null, fci_hex: '620E', exists: true };
|
||||
return { name: 'EF.IMSI', fid: '6F07', file_type: 'transparent', file_size: 9, record_len: null, num_of_rec: null, fci_hex: '6212', exists: true };
|
||||
}
|
||||
if (path === '/api/read') {
|
||||
return body.path.endsWith('2FE2')
|
||||
? { success: true, data: '980711090000640090F8' }
|
||||
: { success: true, data: '082905911234567890' };
|
||||
}
|
||||
throw new Error('unexpected ' + path);
|
||||
};
|
||||
const files = await profilerScanCard(new Set(), new Set(), 'exact', undefined, new Set(), 'snapshot');
|
||||
assert.strictEqual(files.length, 2);
|
||||
assert.ok(!files[0].fciMode, 'snapshot entries carry no FCI mode');
|
||||
assert.strictEqual(profilerSnapshotIccid(files), '8970119000004600098');
|
||||
// IMSI captured exactly (no mask)
|
||||
assert.strictEqual(files.find(f => f.name === 'EF.IMSI').content.data, '082905911234567890');
|
||||
delete global.pysimCustomFiles;
|
||||
});
|
||||
|
||||
test('profilerListSwitch toggles the profiles/snapshots tabs', () => {
|
||||
const mkBtn = tab => ({
|
||||
dataset: { listTab: tab },
|
||||
classList: { _c: new Set(), toggle(c, on) { if (on) this._c.add(c); else this._c.delete(c); } },
|
||||
});
|
||||
const btns = [mkBtn('profiles'), mkBtn('snapshots'), mkBtn('custom')];
|
||||
const profilesEl = { hidden: false, classList: { toggle(cls, on) { profilesEl.hidden = on; } } };
|
||||
const snapsEl = { hidden: true, classList: { toggle(cls, on) { snapsEl.hidden = on; } } };
|
||||
const customEl = { hidden: true, classList: { toggle(cls, on) { customEl.hidden = on; } } };
|
||||
global.pysimCustomRender = () => {};
|
||||
global.setHelpAnchor = () => {};
|
||||
global.document = {
|
||||
querySelectorAll: sel => (sel === '.profiler-list-tab' ? btns : []),
|
||||
getElementById: id => (id === 'profiler-list-profiles' ? profilesEl : id === 'profiler-list-snapshots' ? snapsEl : id === 'profiler-list-custom' ? customEl : null),
|
||||
};
|
||||
|
||||
profilerListSwitch('snapshots');
|
||||
assert.strictEqual(profilesEl.hidden, true);
|
||||
assert.strictEqual(snapsEl.hidden, false);
|
||||
assert.ok(btns[0].classList._c.has('bg-gray-200'));
|
||||
assert.ok(btns[1].classList._c.has('bg-blue-600'));
|
||||
|
||||
profilerListSwitch('profiles');
|
||||
assert.strictEqual(profilesEl.hidden, false);
|
||||
assert.strictEqual(snapsEl.hidden, true);
|
||||
assert.ok(btns[0].classList._c.has('bg-blue-600'));
|
||||
assert.ok(btns[1].classList._c.has('bg-gray-200'));
|
||||
|
||||
profilerListSwitch('custom');
|
||||
assert.strictEqual(customEl.hidden, false);
|
||||
assert.strictEqual(profilesEl.hidden, true);
|
||||
assert.strictEqual(snapsEl.hidden, true);
|
||||
|
||||
delete global.document;
|
||||
delete global.pysimCustomRender;
|
||||
delete global.setHelpAnchor;
|
||||
});
|
||||
|
||||
test('profilerScanRefreshOptions re-translates mask labels without touching checkbox state', () => {
|
||||
global.t = s => (s === 'Match first 4 bytes for' ? 'FIRST4' : s);
|
||||
const span = { textContent: '' };
|
||||
const cb = {
|
||||
checked: true,
|
||||
parentElement: { querySelector: sel => (sel === 'span' ? span : null) },
|
||||
getAttribute: () => '6F07',
|
||||
};
|
||||
global.document = {
|
||||
querySelectorAll: sel => (sel === '#profiler-scan-mask input[data-mask-fid]' ? [cb] : []),
|
||||
};
|
||||
profilerScanRefreshOptions();
|
||||
assert.strictEqual(span.textContent, 'FIRST4 EF.IMSI');
|
||||
assert.strictEqual(cb.checked, true);
|
||||
delete global.document;
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
|
||||
function snapFile(path, extra) {
|
||||
return {
|
||||
path: path, name: 'EF.X', fileType: 'transparent', fileSize: 4,
|
||||
recordLen: null, numRecords: null, fciHex: '621082024021',
|
||||
content: { kind: 'transparent', data: 'AABB' }, ...(extra || {}),
|
||||
};
|
||||
}
|
||||
|
||||
test('profilerSnapshotSource selects file metadata by path (case-insensitive)', async () => {
|
||||
const src = profilerSnapshotSource({ files: [snapFile('MF/7F20/6F3A')] });
|
||||
const sel = await src.select('mf/7f20/6f3a');
|
||||
assert.strictEqual(sel.exists, true);
|
||||
assert.strictEqual(sel.file_type, 'transparent');
|
||||
assert.strictEqual(sel.file_size, 4);
|
||||
assert.strictEqual(sel.fci_hex, '621082024021');
|
||||
});
|
||||
|
||||
test('profilerSnapshotSource reports a missing file as not existing', async () => {
|
||||
const src = profilerSnapshotSource({ files: [snapFile('MF/7F20/6F3A')] });
|
||||
assert.deepStrictEqual(await src.select('MF/6F07'), { exists: false });
|
||||
});
|
||||
|
||||
test('profilerSnapshotSource read returns captured transparent data and records', async () => {
|
||||
const src = profilerSnapshotSource({ files: [
|
||||
snapFile('MF/6F3A'),
|
||||
snapFile('MF/6F3B', { fileType: 'linear_fixed', fileSize: null, recordLen: 2, numRecords: 1, content: { kind: 'record', records: [{ num: 1, data: 'AABB' }] } }),
|
||||
] });
|
||||
assert.strictEqual((await src.read('MF/6F3A')).data, 'AABB');
|
||||
assert.deepStrictEqual((await src.read('MF/6F3B')).records, [{ num: 1, data: 'AABB' }]);
|
||||
});
|
||||
|
||||
test('profilerSnapshotSource flags uncaptured contents', async () => {
|
||||
const src = profilerSnapshotSource({ files: [snapFile('MF/6F3A', { content: null })] });
|
||||
assert.deepStrictEqual(await src.read('MF/6F3A'), { success: false, sw: 'not-captured' });
|
||||
});
|
||||
|
||||
test('profilerRunRule against a snapshot passes on matching metadata and content', async () => {
|
||||
const src = profilerSnapshotSource({ files: [snapFile('MF/6F3A')] });
|
||||
const res = await profilerRunRule({ path: 'MF/6F3A', fileType: 'transparent', fileSize: 4, fciMode: 'type_size', content: { mode: 'exact', kind: 'transparent', expected: 'AABB' } }, src);
|
||||
assert.strictEqual(res.status, 'pass');
|
||||
});
|
||||
|
||||
test('profilerRunRule against a snapshot fails on a missing file', async () => {
|
||||
const src = profilerSnapshotSource({ files: [] });
|
||||
const res = await profilerRunRule({ path: 'MF/6F3A' }, src);
|
||||
assert.strictEqual(res.status, 'fail');
|
||||
assert.ok(res.checks.some(c => c.label === 'exists' && c.ok === false));
|
||||
});
|
||||
|
||||
test('profilerRunRule against a snapshot fails on metadata mismatch', async () => {
|
||||
const src = profilerSnapshotSource({ files: [snapFile('MF/6F3A', { fileSize: 9, fciHex: '621082024022' })] });
|
||||
const res = await profilerRunRule({ path: 'MF/6F3A', fileType: 'transparent', fileSize: 4, fciMode: 'exact', fciHex: '621082024021' }, src);
|
||||
assert.strictEqual(res.status, 'fail');
|
||||
assert.ok(res.checks.some(c => c.label === 'fileSize' && c.ok === false));
|
||||
assert.ok(res.checks.some(c => c.label === 'fci' && c.ok === false));
|
||||
});
|
||||
|
||||
test('profilerRunRule against a snapshot reports uncaptured content as an error', async () => {
|
||||
global.t = s => s;
|
||||
const src = profilerSnapshotSource({ files: [snapFile('MF/6F3A', { content: null })] });
|
||||
const res = await profilerRunRule({ path: 'MF/6F3A', fileType: 'transparent', fileSize: 4, content: { mode: 'exact', kind: 'transparent', expected: 'AABB' } }, src);
|
||||
assert.strictEqual(res.status, 'error');
|
||||
assert.strictEqual(res.error, 'Content not captured in snapshot');
|
||||
assert.ok(res.checks.some(c => c.label === 'content' && c.ok === false));
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
test('profilerVisibleResults hides passing files when mismatch-only is on', () => {
|
||||
const results = [{ status: 'pass' }, { status: 'fail' }, { status: 'error' }, { status: 'pass' }];
|
||||
assert.strictEqual(profilerVisibleResults(results, false).length, 4);
|
||||
assert.deepStrictEqual(profilerVisibleResults(results, true).map(r => r.status), ['fail', 'error']);
|
||||
assert.strictEqual(profilerVisibleResults([{ status: 'pass' }], true).length, 0);
|
||||
});
|
||||
|
||||
// --- snapshot comparison ---
|
||||
|
||||
function masterSnap(files) {
|
||||
return { files: files };
|
||||
}
|
||||
|
||||
test('profilerRulesFromSnapshot builds exact-FCI rules from the master snapshot', () => {
|
||||
const master = masterSnap([
|
||||
snapFile('MF/7F20/6F07', { name: 'EF.IMSI', fileSize: 9, content: { kind: 'transparent', data: '082905911234567890' } }),
|
||||
snapFile('MF/6F3A', { fileType: 'linear_fixed', fileSize: null, recordLen: 2, numRecords: 1, content: { kind: 'record', records: [{ num: 1, data: 'AABB' }] } }),
|
||||
snapFile('MF/6F3B', { content: null }),
|
||||
]);
|
||||
const rules = profilerRulesFromSnapshot(master, null);
|
||||
assert.strictEqual(rules.length, 3);
|
||||
assert.strictEqual(rules[0].fciMode, 'exact');
|
||||
assert.strictEqual(rules[0].fciHex, '621082024021');
|
||||
assert.deepStrictEqual(rules[0].content, { mode: 'exact', kind: 'transparent', expected: '082905911234567890' });
|
||||
assert.deepStrictEqual(rules[1].content, { mode: 'exact', kind: 'record', records: [{ num: 1, data: 'AABB' }] });
|
||||
assert.strictEqual(rules[2].content, null);
|
||||
});
|
||||
|
||||
test('profilerRulesFromSnapshot masks only the checked FIDs', () => {
|
||||
const master = masterSnap([
|
||||
snapFile('MF/7F20/6F07', { name: 'EF.IMSI', content: { kind: 'transparent', data: '082905911234567890' } }),
|
||||
snapFile('MF/2FE2', { name: 'EF.ICCID', content: { kind: 'transparent', data: '98680012345678901234' } }),
|
||||
snapFile('MF/6F3A', { content: { kind: 'transparent', data: 'AABBCCDD' } }),
|
||||
]);
|
||||
const rules = profilerRulesFromSnapshot(master, new Set(['6F07']));
|
||||
assert.deepStrictEqual(rules[0].content, { mode: 'mask', kind: 'transparent', expected: '08290591??????????' });
|
||||
assert.deepStrictEqual(rules[1].content, { mode: 'exact', kind: 'transparent', expected: '98680012345678901234' });
|
||||
assert.deepStrictEqual(rules[2].content, { mode: 'exact', kind: 'transparent', expected: 'AABBCCDD' });
|
||||
});
|
||||
|
||||
test('profilerRulesFromSnapshot falls back to the symbolic name for masking', () => {
|
||||
const master = masterSnap([snapFile('MF/CUSTOM1', { name: 'EF.ICCID', content: { kind: 'transparent', data: '98680012345678901234' } })]);
|
||||
const rules = profilerRulesFromSnapshot(master, new Set(['2FE2']));
|
||||
assert.strictEqual(rules[0].content.mode, 'mask');
|
||||
});
|
||||
|
||||
test('snapshot comparison passes on an identical snapshot', async () => {
|
||||
const files = [snapFile('MF/7F20/6F07', { name: 'EF.IMSI', fileSize: 9, content: { kind: 'transparent', data: '082905911234567890' } })];
|
||||
const rules = profilerRulesFromSnapshot(masterSnap(files), new Set(['6F07']));
|
||||
const source = profilerSnapshotSource(masterSnap(files.map(f => ({ ...f }))));
|
||||
for (const r of rules) {
|
||||
assert.strictEqual((await profilerRunRule(r, source)).status, 'pass');
|
||||
}
|
||||
});
|
||||
|
||||
test('snapshot comparison fails on a contents difference', async () => {
|
||||
const master = masterSnap([snapFile('MF/6F3A')]);
|
||||
const check = masterSnap([snapFile('MF/6F3A', { content: { kind: 'transparent', data: 'CCDD' } })]);
|
||||
const rules = profilerRulesFromSnapshot(master, null);
|
||||
const res = await profilerRunRule(rules[0], profilerSnapshotSource(check));
|
||||
assert.strictEqual(res.status, 'fail');
|
||||
assert.ok(res.checks.some(c => c.label === 'content' && c.ok === false));
|
||||
});
|
||||
|
||||
test('snapshot comparison mask ignores only the first 4 bytes', async () => {
|
||||
const master = masterSnap([snapFile('MF/7F20/6F07', { name: 'EF.IMSI', fileSize: 8, content: { kind: 'transparent', data: '0829059112345678' } })]);
|
||||
const rules = profilerRulesFromSnapshot(master, new Set(['6F07']));
|
||||
const same = masterSnap([snapFile('MF/7F20/6F07', { name: 'EF.IMSI', fileSize: 8, content: { kind: 'transparent', data: '0829059199999999' } })]);
|
||||
const other = masterSnap([snapFile('MF/7F20/6F07', { name: 'EF.IMSI', fileSize: 8, content: { kind: 'transparent', data: '0829059912345678' } })]);
|
||||
assert.strictEqual((await profilerRunRule(rules[0], profilerSnapshotSource(same))).status, 'pass');
|
||||
assert.strictEqual((await profilerRunRule(rules[0], profilerSnapshotSource(other))).status, 'fail');
|
||||
});
|
||||
|
||||
test('profilerExtraFileResults reports files missing from the master', () => {
|
||||
const master = masterSnap([snapFile('MF/6F3A')]);
|
||||
const check = masterSnap([snapFile('MF/6f3a'), snapFile('MF/6F3B')]);
|
||||
const extras = profilerExtraFileResults(master, check);
|
||||
assert.strictEqual(extras.length, 1);
|
||||
assert.strictEqual(extras[0].path, 'MF/6F3B');
|
||||
assert.strictEqual(extras[0].status, 'fail');
|
||||
assert.deepStrictEqual(extras[0].checks, [{ label: 'extra file', expected: 'absent', actual: 'present', ok: false }]);
|
||||
});
|
||||
|
||||
test('profilerScanNameKeydown starts the scan on Enter only', () => {
|
||||
const btn = { disabled: false };
|
||||
global.document = { getElementById: () => btn };
|
||||
let started = 0;
|
||||
global.profilerScanStart = () => { started++; };
|
||||
let prevented = 0;
|
||||
profilerScanNameKeydown({ key: 'Enter', preventDefault: () => prevented++ });
|
||||
profilerScanNameKeydown({ key: 'a', preventDefault: () => prevented++ });
|
||||
assert.strictEqual(started, 1);
|
||||
assert.strictEqual(prevented, 1);
|
||||
btn.disabled = true;
|
||||
profilerScanNameKeydown({ key: 'Enter', preventDefault: () => prevented++ });
|
||||
assert.strictEqual(started, 1);
|
||||
assert.strictEqual(prevented, 1);
|
||||
delete global.document;
|
||||
delete global.profilerScanStart;
|
||||
});
|
||||
|
||||
// --- snapshot command timings ---
|
||||
|
||||
test('profilerTimingStats computes min, max and average', () => {
|
||||
assert.deepStrictEqual(profilerTimingStats([10, 20, 30]), { min: 10, max: 30, avg: 20, count: 3 });
|
||||
assert.deepStrictEqual(profilerTimingStats([7, 7.5, 8]), { min: 7, max: 8, avg: 7.5, count: 3 });
|
||||
assert.strictEqual(profilerTimingStats([]), null);
|
||||
assert.strictEqual(profilerTimingStats(null), null);
|
||||
});
|
||||
|
||||
test('profilerFormatMs formats milliseconds and seconds', () => {
|
||||
assert.strictEqual(profilerFormatMs(12), '12 ms');
|
||||
assert.strictEqual(profilerFormatMs(999), '999 ms');
|
||||
assert.strictEqual(profilerFormatMs(1500), '1.50 s');
|
||||
assert.strictEqual(profilerFormatMs(null), 'n/a');
|
||||
});
|
||||
|
||||
test('profilerTimingAccumulator aggregates per command type', () => {
|
||||
const acc = profilerTimingAccumulator();
|
||||
acc.add('select', 10);
|
||||
acc.add('select', 20);
|
||||
acc.add('read_record', 5);
|
||||
acc.add('bogus', 1);
|
||||
assert.deepStrictEqual(acc.stats(), {
|
||||
select: { min: 10, max: 20, avg: 15, count: 2 },
|
||||
read_record: { min: 5, max: 5, avg: 5, count: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test('profilerBuildSnapshotFile records per-command timings', async () => {
|
||||
const acc = profilerTimingAccumulator();
|
||||
mockFetch({
|
||||
'/api/select': () => ({ exists: true, name: 'EF.ADN', file_type: 'linear_fixed', file_size: null, record_len: 2, num_of_rec: 2,
|
||||
apdu_times: [{ type: 'select', ms: 5 }, { type: 'select', ms: 7 }] }),
|
||||
'/api/read': () => ({ success: true, file_type: 'linear_fixed', records: [{ num: 1, data: 'AA' }, { num: 2, data: 'BB' }],
|
||||
apdu_times: [{ type: 'select', ms: 4 }, { type: 'read_record', ms: 11 }, { type: 'read_record', ms: 13 }] }),
|
||||
});
|
||||
const file = await profilerBuildSnapshotFile('MF/6F3A', { name: 'EF.ADN' }, acc);
|
||||
assert.deepStrictEqual(file.timing, { select_ms: 12, read_ms: 24 });
|
||||
assert.strictEqual(file.content.records[0].ms, 11);
|
||||
assert.strictEqual(file.content.records[1].ms, 13);
|
||||
const stats = acc.stats();
|
||||
assert.strictEqual(stats.select.count, 2);
|
||||
assert.strictEqual(stats.read_record.count, 2);
|
||||
});
|
||||
|
||||
test('profilerBuildSnapshotFile without apdu_times has empty timing', async () => {
|
||||
mockFetch({
|
||||
'/api/select': () => ({ exists: true, name: 'EF.ADN', file_type: 'transparent', file_size: 2, record_len: null, num_of_rec: null }),
|
||||
'/api/read': () => ({ success: true, file_type: 'transparent', data: 'AABB' }),
|
||||
});
|
||||
const file = await profilerBuildSnapshotFile('MF/6F3A', { name: 'EF.ADN' }, null);
|
||||
assert.strictEqual(file.timing, null);
|
||||
assert.deepStrictEqual(file.content, { kind: 'transparent', data: 'AABB' });
|
||||
});
|
||||
|
||||
test('profilerRenderSnapshotSummary shows counts, scan time and stats', () => {
|
||||
global.t = s => s;
|
||||
const snap = {
|
||||
files: [
|
||||
{ content: { kind: 'record', records: [{ num: 1, data: 'AA', ms: 5 }, { num: 2, data: 'BB', ms: 7 }] } },
|
||||
{ content: { kind: 'transparent', data: 'AA' } },
|
||||
],
|
||||
timing: {
|
||||
select: { min: 3, max: 9, avg: 6, count: 3 },
|
||||
read_record: { min: 5, max: 7, avg: 6, count: 2 },
|
||||
total_ms: 1500,
|
||||
},
|
||||
};
|
||||
const html = profilerRenderSnapshotSummary(snap);
|
||||
assert.ok(html.includes('Files: <b>2</b>'), html);
|
||||
assert.ok(html.includes('Records: <b>2</b>'), html);
|
||||
assert.ok(html.includes('Scan time: <b>1.50 s</b>'), html);
|
||||
assert.ok(html.includes('Select: min 3 ms'), html);
|
||||
assert.ok(html.includes('Read record: min 5 ms'), html);
|
||||
assert.ok(!html.includes('Read binary'), html);
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
test('profilerRenderSnapshotSummary notes missing timing data', () => {
|
||||
global.t = s => s;
|
||||
const html = profilerRenderSnapshotSummary({ files: [], timing: undefined });
|
||||
assert.ok(html.includes('Files: <b>0</b>'), html);
|
||||
assert.ok(html.includes('No timing data'), html);
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
test('profilerSnapshotCountLabel appends the scan time when available', () => {
|
||||
global.t = s => (s === 'scanned in' ? 'scanned in' : s);
|
||||
const withTime = profilerSnapshotCountLabel({ files: new Array(95).fill({}), timing: { total_ms: 21320 } });
|
||||
assert.strictEqual(withTime, '95 files, scanned in 21.32 sec');
|
||||
const noTime = profilerSnapshotCountLabel({ files: new Array(3).fill({}) });
|
||||
assert.strictEqual(noTime, '3 files');
|
||||
const empty = profilerSnapshotCountLabel({ files: [], timing: {} });
|
||||
assert.strictEqual(empty, '0 files');
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
test('profilerRenderReport labels mismatches with custom names', () => {
|
||||
global.t = s => s;
|
||||
global.pysimCustomFiles = [];
|
||||
const html = profilerRenderReport([{
|
||||
path: 'MF/6F3A', name: 'EF.ADN', status: 'fail',
|
||||
checks: [
|
||||
{ label: 'content', expected: 'AABB', actual: 'CCDD', ok: false },
|
||||
{ label: 'fileSize', expected: 4, actual: 9, ok: false },
|
||||
],
|
||||
}], { expected: 'Master snap', actual: 'Check snap' });
|
||||
assert.ok(html.includes('Master snap'), html);
|
||||
assert.ok(html.includes('Check snap'), html);
|
||||
assert.ok(!html.includes('>expected<'), html);
|
||||
assert.ok(!html.includes('>actual<'), html);
|
||||
delete global.t;
|
||||
delete global.pysimCustomFiles;
|
||||
});
|
||||
|
||||
test('profilerRenderReport keeps expected/actual without labels', () => {
|
||||
global.t = s => s;
|
||||
global.pysimCustomFiles = [];
|
||||
const html = profilerRenderReport([{
|
||||
path: 'MF/6F3A', status: 'fail',
|
||||
checks: [{ label: 'fileSize', expected: 4, actual: 9, ok: false }],
|
||||
}]);
|
||||
assert.ok(html.includes(' expected '), html);
|
||||
assert.ok(html.includes(' actual '), html);
|
||||
delete global.t;
|
||||
delete global.pysimCustomFiles;
|
||||
});
|
||||
|
||||
test('fcpDiffHtml headers use custom labels', () => {
|
||||
global.t = s => s;
|
||||
const diff = fcpDiffHtml(FCP_TRANSPARENT, '62128002000A8202412183026F078A0105880110', { expected: 'Master', actual: 'Candidate' });
|
||||
assert.ok(diff.includes('>Master<'), diff);
|
||||
assert.ok(diff.includes('>Candidate<'), diff);
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
test('profilerLabelText builds default, verbatim and prefixed labels', () => {
|
||||
global.t = s => 't:' + s;
|
||||
assert.strictEqual(profilerLabelText(null, 'expected'), 't:expected');
|
||||
assert.strictEqual(profilerLabelText({ expected: 'A', actual: 'B' }, 'expected'), 'A');
|
||||
assert.strictEqual(profilerLabelText({ expected: 'A', actual: 'B' }, 'actual'), 'B');
|
||||
assert.strictEqual(profilerLabelText({ expected: 'A', actual: 'B', prefix: true }, 'actual'), 't:actual (B)');
|
||||
assert.strictEqual(profilerLabelText({ expected: '', prefix: true }, 'expected'), 't:expected');
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
test('profilerRenderReport prefixes names in expected/actual labels', () => {
|
||||
global.t = s => s;
|
||||
global.pysimCustomFiles = [];
|
||||
const html = profilerRenderReport([{
|
||||
path: 'MF/6F3A', status: 'fail',
|
||||
checks: [{ label: 'fileSize', expected: 4, actual: 9, ok: false }],
|
||||
}], { expected: 'My profile', actual: 'Snap X', prefix: true });
|
||||
assert.ok(html.includes('expected (My profile)'), html);
|
||||
assert.ok(html.includes('actual (Snap X)'), html);
|
||||
delete global.t;
|
||||
delete global.pysimCustomFiles;
|
||||
});
|
||||
|
||||
test('fcpDiffHtml uses prefixed labels in headers and decode notes', () => {
|
||||
global.t = s => s;
|
||||
const diff = fcpDiffHtml(FCP_TRANSPARENT, '62128002000A8202412183026F078A0105880110', { expected: 'Prof', actual: 'Snap', prefix: true });
|
||||
assert.ok(diff.includes('expected (Prof)'), diff);
|
||||
assert.ok(diff.includes('actual (Snap)'), diff);
|
||||
const bad = fcpDiffHtml('not hex', FCP_TRANSPARENT, { expected: 'Prof', actual: 'Snap', prefix: true });
|
||||
assert.ok(bad.includes('expected (Prof)'), bad);
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
test('profilerCardIccid reads and decodes MF/2FE2, null otherwise', async () => {
|
||||
let body = null;
|
||||
global.pysimFetch = async (path, b) => { body = [path, b]; return { success: true, data: '98103254769810325476' }; };
|
||||
assert.strictEqual(await profilerCardIccid(), '89012345678901234567');
|
||||
assert.deepStrictEqual(body, ['/api/read', { path: 'MF/2FE2', mode: 'raw' }]);
|
||||
global.pysimFetch = async () => ({ success: false });
|
||||
assert.strictEqual(await profilerCardIccid(), null);
|
||||
global.pysimFetch = async () => { throw new Error('offline'); };
|
||||
assert.strictEqual(await profilerCardIccid(), null);
|
||||
delete global.pysimFetch;
|
||||
});
|
||||
|
||||
test('profilerCheck titles with the card ICCID and passes prefixed labels', async () => {
|
||||
global.t = s => s;
|
||||
global.profiles = [{ name: 'MyProfile', rules: [] }];
|
||||
global.pysimFetch = async () => ({ success: true, data: '98103254769810325476' });
|
||||
let captured = null;
|
||||
global.profilerRunProfile = async (...args) => { captured = args; };
|
||||
await profilerCheck(0);
|
||||
assert.deepStrictEqual(captured[2], { kind: 'profile', from: 'MyProfile', to: '89012345678901234567' });
|
||||
assert.deepStrictEqual(captured[4], { expected: 'MyProfile', actual: '89012345678901234567', prefix: true });
|
||||
delete global.profiles; delete global.pysimFetch; delete global.profilerRunProfile; delete global.t;
|
||||
});
|
||||
|
||||
test('profilerCheckSnapshot passes the snapshot name as the actual label', async () => {
|
||||
global.t = s => s;
|
||||
global.profiles = [{ name: 'Prof', rules: [] }];
|
||||
global.snapshots = [{ name: 'SnapX', files: [] }];
|
||||
global.document = { getElementById: () => ({ classList: { add() {} } }) };
|
||||
let captured = null;
|
||||
global.profilerRunProfile = async (...args) => { captured = args; };
|
||||
await profilerCheckSnapshot(0, 0);
|
||||
assert.deepStrictEqual(captured[2], { kind: 'profile', from: 'Prof', to: 'SnapX' });
|
||||
assert.deepStrictEqual(captured[4], { expected: 'Prof', actual: 'SnapX', prefix: true });
|
||||
delete global.profiles; delete global.snapshots; delete global.profilerRunProfile; delete global.t;
|
||||
});
|
||||
|
||||
test('profilerResultsHeaderText builds profile and snapshot headers', () => {
|
||||
global.t = s => 't:' + s;
|
||||
assert.strictEqual(profilerResultsHeaderText({ kind: 'profile', from: 'Prof', to: '8901' }), 't:Profile verification results for: Prof \u2192 8901');
|
||||
assert.strictEqual(profilerResultsHeaderText({ kind: 'snapshot', from: 'Snap1', to: 'Snap2' }), 't:Snapshot comparison results: Snap1 \u2192 Snap2');
|
||||
assert.strictEqual(profilerResultsHeaderText({ kind: 'profile', from: 'Prof', to: null }), 't:Profile verification results for: Prof');
|
||||
assert.strictEqual(profilerResultsHeaderText(null), '');
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
test('profilerRenderResultsView writes the built header into the title', () => {
|
||||
global.t = s => s;
|
||||
profilerResults = [];
|
||||
profilerResultsHeader = { kind: 'profile', from: 'Prof', to: 'Snap' };
|
||||
const els = {};
|
||||
for (const id of ['profiler-summary', 'profiler-report', 'profiler-results-title']) els[id] = { innerHTML: '', textContent: '' };
|
||||
global.document = { getElementById: id => els[id] || null };
|
||||
profilerRenderResultsView();
|
||||
assert.strictEqual(els['profiler-results-title'].textContent, 'Profile verification results for: Prof \u2192 Snap');
|
||||
profilerResultsHeader = { kind: 'snapshot', from: 'A', to: 'B' };
|
||||
profilerRenderResultsView();
|
||||
assert.strictEqual(els['profiler-results-title'].textContent, 'Snapshot comparison results: A \u2192 B');
|
||||
profilerResults = null;
|
||||
profilerResultsHeader = null;
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
test('profilerBuildFileRuleFromSnapshot builds a rule with exact contents', () => {
|
||||
const f = { path: 'MF/7F10/6F3A', name: 'EF.ADN', fileType: 'transparent', fileSize: 4, recordLen: null, numRecords: null, fciHex: '620B', content: { kind: 'transparent', data: 'AABBCCDD' } };
|
||||
const rule = profilerBuildFileRuleFromSnapshot(f, new Set(), new Set(), 'type_size', new Set());
|
||||
assert.strictEqual(rule.path, 'MF/7F10/6F3A');
|
||||
assert.strictEqual(rule.name, 'EF.ADN');
|
||||
assert.strictEqual(rule.fileType, 'transparent');
|
||||
assert.strictEqual(rule.fileSize, 4);
|
||||
assert.strictEqual(rule.fciMode, 'type_size');
|
||||
assert.strictEqual(rule.fciHex, '620B');
|
||||
assert.deepStrictEqual(rule.content, { mode: 'exact', kind: 'transparent', expected: 'AABBCCDD' });
|
||||
});
|
||||
|
||||
test('profilerBuildFileRuleFromSnapshot masks the first 4 bytes on request', () => {
|
||||
const f = { path: 'MF/6F07', name: 'EF.IMSI', fileType: 'transparent', fileSize: 9, content: { kind: 'transparent', data: '0891101234567890' } };
|
||||
assert.deepStrictEqual(profilerBuildFileRuleFromSnapshot(f, new Set(), new Set(), 'type', new Set(['6F07'])).content,
|
||||
{ mode: 'mask', kind: 'transparent', expected: '08911012????????' });
|
||||
assert.strictEqual(profilerBuildFileRuleFromSnapshot(f, new Set(), new Set(), 'type', new Set()).content.mode, 'exact');
|
||||
});
|
||||
|
||||
test('profilerBuildFileRuleFromSnapshot honors the ignore list by FID and name', () => {
|
||||
const f = { path: 'MF/7F20/6F52', name: 'EF.KcGPRS', fileType: 'transparent', fileSize: 9, content: { kind: 'transparent', data: 'AABB' } };
|
||||
assert.strictEqual(profilerBuildFileRuleFromSnapshot(f, new Set(['6F52']), new Set(), 'type_size', new Set()).content, null);
|
||||
assert.strictEqual(profilerBuildFileRuleFromSnapshot(f, new Set(), new Set(['EF.KCGPRS']), 'type_size', new Set()).content, null);
|
||||
});
|
||||
|
||||
test('profilerBuildFileRuleFromSnapshot keeps uncaptured and record contents', () => {
|
||||
const noContent = { path: 'MF/6F07', name: 'EF.IMSI', fileType: 'transparent', fileSize: 9, content: null };
|
||||
assert.strictEqual(profilerBuildFileRuleFromSnapshot(noContent, new Set(), new Set(), 'type_size', new Set()).content, null);
|
||||
const records = { path: 'MF/7F10/6F3A', name: 'EF.ADN', fileType: 'linear_fixed', fileSize: 60, recordLen: 30, numRecords: 2, content: { kind: 'record', records: [{ num: 1, data: 'AA' }, { num: 2, data: 'BB' }] } };
|
||||
const rule = profilerBuildFileRuleFromSnapshot(records, new Set(), new Set(), 'exact', new Set());
|
||||
assert.strictEqual(rule.fileSize, null);
|
||||
assert.strictEqual(rule.recordLen, 30);
|
||||
assert.strictEqual(rule.numRecords, 2);
|
||||
assert.deepStrictEqual(rule.content, { mode: 'exact', kind: 'record', records: [{ num: 1, data: 'AA' }, { num: 2, data: 'BB' }] });
|
||||
});
|
||||
|
||||
test('profilerScanSnapshot walks snapshot files with progress', async () => {
|
||||
const snapshot = { files: [
|
||||
{ path: 'MF/6F07', name: 'EF.IMSI', fileType: 'transparent', content: null },
|
||||
{ path: 'MF/2FE2', name: 'EF.ICCID', fileType: 'transparent', content: { kind: 'transparent', data: '9807' } },
|
||||
] };
|
||||
const progress = [];
|
||||
const rules = await profilerScanSnapshot(snapshot, new Set(), new Set(), 'type_size', new Set(), (d, tt, p) => progress.push([d, tt, p]));
|
||||
assert.strictEqual(rules.length, 2);
|
||||
assert.deepStrictEqual(rules.map(r => r.path), ['MF/6F07', 'MF/2FE2']);
|
||||
assert.deepStrictEqual(progress, [[0, 2, ''], [1, 2, 'MF/6F07'], [2, 2, 'MF/2FE2']]);
|
||||
});
|
||||
|
||||
test('profilerSnapshotPickListHtml wires the chosen action per snapshot', () => {
|
||||
global.t = s => s;
|
||||
global.snapshots = [
|
||||
{ name: 'SnapA', iccid: '8901', created: '2026-01-01T00:00:00Z', files: [{}, {}] },
|
||||
{ name: 'SnapB', iccid: '', created: '2026-01-02T00:00:00Z', files: [] },
|
||||
];
|
||||
const html = profilerSnapshotPickListHtml(si => 'profilerScanFromSnapshot(' + si + ')');
|
||||
assert.ok(html.includes('onclick="profilerScanFromSnapshot(0)"'), html);
|
||||
assert.ok(html.includes('onclick="profilerScanFromSnapshot(1)"'), html);
|
||||
assert.ok(html.includes('SnapA'), html);
|
||||
assert.ok(html.includes('2 files'), html);
|
||||
delete global.snapshots;
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
test('profilerScanSetTarget labels the snapshot-sourced profile form', () => {
|
||||
global.t = s => s;
|
||||
const els = {};
|
||||
for (const id of ['profiler-scan-title', 'profiler-scan-name-label', 'profiler-scan-options']) {
|
||||
els[id] = { textContent: '', attrs: {}, classList: { set: new Set(), toggle(c, on) { if (on) this.set.add(c); else this.set.delete(c); }, contains(c) { return this.set.has(c); } }, setAttribute(k, v) { this.attrs[k] = v; } };
|
||||
}
|
||||
global.document = { getElementById: id => els[id] || null };
|
||||
profilerScanSetTarget('profile-snapshot');
|
||||
assert.strictEqual(els['profiler-scan-title'].textContent, 'Profile from snapshot');
|
||||
assert.strictEqual(els['profiler-scan-name-label'].textContent, 'Profile name');
|
||||
assert.ok(!els['profiler-scan-options'].classList.contains('hidden'));
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
+161
-2
@@ -6,7 +6,7 @@ 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 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;
|
||||
@@ -22,13 +22,18 @@ function extractFunc(src, name) {
|
||||
}
|
||||
|
||||
// Extract chain builder functions and dependencies
|
||||
const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'chainInit', 'chainRamBuildRowHex'];
|
||||
const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamBuildRowHex', 'ramFmtLifecycle', 'ramFmtPrivileges', 'ramRenderExploreHtml',
|
||||
'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute'];
|
||||
let code = '';
|
||||
for (const f of FNS) {
|
||||
code += extractFunc(html, f) + '\n';
|
||||
}
|
||||
const m = html.match(/const _chains = \{\};/);
|
||||
if (m) code += m[0].replace(/^const /, 'var ') + '\n';
|
||||
const lc = html.match(/const RAM_LIFECYCLE = \{[\s\S]*?\n\};/);
|
||||
if (lc) code += lc[0].replace(/^const /, 'var ') + '\n';
|
||||
eval(code);
|
||||
code += 'var _ramCardIdx = null;\nvar _ramOpLast = null;\nvar _ramExplorerData = null;\n';
|
||||
eval(code);
|
||||
|
||||
const els = {};
|
||||
@@ -161,3 +166,157 @@ test('STORE DATA ram-enc P1 values 00/40/80/C0/E0', () => {
|
||||
assert.ok(apdu.startsWith('80E2' + p1 + '00'), enc + ' -> P1 ' + p1);
|
||||
}
|
||||
});
|
||||
|
||||
test('ramRenderExploreHtml localizes every label and button', () => {
|
||||
const seen = [];
|
||||
global.t = s => { seen.push(s); return 'XX' + s; };
|
||||
const out = ramRenderExploreHtml(
|
||||
{ appCount: 5, freeNV: 100, freeV: 50 },
|
||||
[{ aid: 'A000000151000000', lifecycle: '07', privileges: '', sdAid: 'A000000151000000' }],
|
||||
[{ aid: 'A1130001180001', lifecycle: '07', privileges: '80', implicitSel: '00', elfAid: 'ELF1' }],
|
||||
[{ aid: 'ELF1', lifecycle: '01', version: '1.0', moduleAids: ['M1'], sdAid: null }]
|
||||
);
|
||||
delete global.t;
|
||||
assert.ok(out.includes('XXDelete'), out);
|
||||
assert.ok(out.includes('XXDelete All'), out);
|
||||
assert.ok(out.includes('XXApplications:'), out);
|
||||
assert.ok(out.includes('XXFree NV:'), out);
|
||||
assert.ok(out.includes('XXFree Volatile:'), out);
|
||||
assert.ok(out.includes('XXAID:'), out);
|
||||
assert.ok(out.includes('XXLifecycle:'), out);
|
||||
assert.ok(out.includes('XXPrivileges:'), out);
|
||||
assert.ok(out.includes('XXSD AID:'), out);
|
||||
assert.ok(out.includes('XXImplicit sel:'), out);
|
||||
assert.ok(out.includes('XXVersion:'), out);
|
||||
assert.ok(seen.includes('Application / Instance AID:'));
|
||||
assert.ok(seen.includes('Load File AID / Package AID:'));
|
||||
assert.ok(seen.includes('Executable Module AIDs / Applet Class AIDs:'));
|
||||
assert.ok(!out.includes('data-l10n'), out);
|
||||
});
|
||||
|
||||
test('ramFmtPrivileges uses the translated (none) placeholder', () => {
|
||||
global.t = s => 'XX' + s;
|
||||
assert.strictEqual(ramFmtPrivileges(''), 'XX(none)');
|
||||
assert.strictEqual(ramFmtPrivileges('00'), 'XX(none)');
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
function fakeClassList() {
|
||||
const set = new Set();
|
||||
return {
|
||||
add: (...cs) => cs.forEach(c => set.add(c)),
|
||||
remove: (...cs) => cs.forEach(c => set.delete(c)),
|
||||
contains: c => set.has(c),
|
||||
toggle: (c, on) => { if (on === undefined ? !set.has(c) : on) set.add(c); else set.delete(c); },
|
||||
};
|
||||
}
|
||||
|
||||
function fakeEl(id) {
|
||||
return {
|
||||
id,
|
||||
value: '',
|
||||
innerHTML: '',
|
||||
textContent: '',
|
||||
classList: fakeClassList(),
|
||||
options: [],
|
||||
appendChild(opt) { this.options.push(opt); },
|
||||
};
|
||||
}
|
||||
|
||||
function fakeRamDocument(ids) {
|
||||
const els = {};
|
||||
for (const id of ids) els[id] = fakeEl(id);
|
||||
const sel = els['ram-card-sel'];
|
||||
if (sel) {
|
||||
Object.defineProperty(sel, 'innerHTML', {
|
||||
get() { return this._html || ''; },
|
||||
set(v) { this._html = v; this.value = ''; },
|
||||
});
|
||||
}
|
||||
globalThis.document = {
|
||||
getElementById: id => els[id] || null,
|
||||
createElement: () => fakeEl('option'),
|
||||
};
|
||||
return els;
|
||||
}
|
||||
|
||||
test('ramOpChanged clears the executed status only on a real op change', () => {
|
||||
const els = fakeRamDocument(['ram-op', 'ram-install-params', 'ram-result', 'ram-explorer', 'ram-steps', 'ram-progress']);
|
||||
_ramOpLast = null;
|
||||
els['ram-op'].value = 'explore';
|
||||
ramOpChanged();
|
||||
assert.ok(!els['ram-result'].classList.contains('hidden'));
|
||||
els['ram-result'].classList.remove('hidden');
|
||||
els['ram-steps'].classList.remove('hidden');
|
||||
ramOpChanged();
|
||||
assert.ok(!els['ram-result'].classList.contains('hidden'), 'same op must keep the result');
|
||||
els['ram-op'].value = 'install-cap';
|
||||
ramOpChanged();
|
||||
assert.ok(els['ram-result'].classList.contains('hidden'));
|
||||
assert.ok(els['ram-steps'].classList.contains('hidden'));
|
||||
assert.ok(els['ram-explorer'].classList.contains('hidden'));
|
||||
assert.ok(els['ram-progress'].classList.contains('hidden'));
|
||||
assert.ok(!els['ram-install-params'].classList.contains('hidden'));
|
||||
});
|
||||
|
||||
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' }];
|
||||
_ramCardIdx = null;
|
||||
_ramOpLast = 'explore';
|
||||
els['ram-op'].value = 'explore';
|
||||
ramRender();
|
||||
assert.strictEqual(els['ram-card-sel'].value, '');
|
||||
els['ram-card-sel'].value = '1';
|
||||
ramRender();
|
||||
assert.strictEqual(els['ram-card-sel'].value, '1');
|
||||
els['ram-card-sel'].value = '';
|
||||
_ramCardIdx = 2;
|
||||
ramRender();
|
||||
assert.strictEqual(els['ram-card-sel'].value, '2');
|
||||
globalThis.cards = [{ name: 'A' }];
|
||||
_ramCardIdx = 2;
|
||||
ramRender();
|
||||
assert.strictEqual(els['ram-card-sel'].value, '');
|
||||
delete globalThis.cards;
|
||||
});
|
||||
|
||||
test('ramApplyCard remembers a valid picked preset', () => {
|
||||
globalThis.cards = [{ name: 'A' }, { name: 'B' }];
|
||||
let applied = null;
|
||||
globalThis.cardsApply = i => { applied = i; };
|
||||
_ramCardIdx = null;
|
||||
ramApplyCard('1');
|
||||
assert.strictEqual(_ramCardIdx, 1);
|
||||
assert.strictEqual(applied, '1');
|
||||
ramApplyCard('');
|
||||
assert.strictEqual(_ramCardIdx, 1, 'invalid pick must not forget the preset');
|
||||
delete globalThis.cards;
|
||||
delete globalThis.cardsApply;
|
||||
});
|
||||
|
||||
test('ramExecute commits the dropdown selection before running', async () => {
|
||||
const els = fakeRamDocument(['ram-card-sel', 'ram-op', 'ram-install-params', 'ram-result', 'ram-explorer', 'ram-steps', 'ram-progress']);
|
||||
globalThis.cards = [{ name: 'A' }];
|
||||
globalThis.getRamSpParams = () => ({ kicKey: '11', kidKey: '22' });
|
||||
let explored = false;
|
||||
globalThis.ramExplore = async () => { explored = true; };
|
||||
globalThis.alert = () => {};
|
||||
_ramCardIdx = null;
|
||||
els['ram-card-sel'].value = '0';
|
||||
els['ram-op'].value = 'explore';
|
||||
await ramExecute();
|
||||
assert.strictEqual(_ramCardIdx, 0);
|
||||
assert.ok(explored);
|
||||
delete globalThis.cards;
|
||||
delete globalThis.getRamSpParams;
|
||||
delete globalThis.ramExplore;
|
||||
delete globalThis.alert;
|
||||
});
|
||||
|
||||
test('ramCardIdxAfterRemove keeps the remembered index aligned', () => {
|
||||
assert.strictEqual(ramCardIdxAfterRemove(2, 0), 1);
|
||||
assert.strictEqual(ramCardIdxAfterRemove(0, 0), null);
|
||||
assert.strictEqual(ramCardIdxAfterRemove(0, 2), 0);
|
||||
assert.strictEqual(ramCardIdxAfterRemove(null, 1), null);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
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, asyncFn) {
|
||||
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 (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = extractFunc(html, 'stkMenuRespond', true) + '\n';
|
||||
code += 'globalThis.esc = s => s;\n';
|
||||
eval(code);
|
||||
|
||||
function setup(response) {
|
||||
const calls = { handled: null, rendered: 0 };
|
||||
globalThis.pysimFetch = async () => response;
|
||||
globalThis.stkMenuHandleResponse = d => { calls.handled = d; };
|
||||
globalThis.stkMenuRenderItems = () => { calls.rendered++; };
|
||||
const btns = { classList: { add: () => {} } };
|
||||
const back = { style: {} };
|
||||
globalThis.document = {
|
||||
getElementById: id => (id === 'stk-menu-buttons' ? btns : id === 'stk-back-btn' ? back : { innerHTML: '' }),
|
||||
};
|
||||
return calls;
|
||||
}
|
||||
|
||||
test('back with a fetched SELECT ITEM continues the card dialogue', async () => {
|
||||
const data = { type: 'select_item', items: [{ id: 1, text: 'Info' }] };
|
||||
const calls = setup(data);
|
||||
await stkMenuRespond('back');
|
||||
assert.strictEqual(calls.handled, data);
|
||||
assert.strictEqual(calls.rendered, 0);
|
||||
});
|
||||
|
||||
test('back with a fetched DISPLAY TEXT shows it', async () => {
|
||||
const data = { type: 'display_text', text: 'hello' };
|
||||
const calls = setup(data);
|
||||
await stkMenuRespond('back');
|
||||
assert.strictEqual(calls.handled, data);
|
||||
assert.strictEqual(calls.rendered, 0);
|
||||
});
|
||||
|
||||
test('timeout with a fetched SELECT ITEM continues the card dialogue', async () => {
|
||||
const data = { type: 'select_item', items: [] };
|
||||
const calls = setup(data);
|
||||
await stkMenuRespond('timeout');
|
||||
assert.strictEqual(calls.handled, data);
|
||||
assert.strictEqual(calls.rendered, 0);
|
||||
});
|
||||
|
||||
test('back answered with SW 9000 falls back to the cached top menu', async () => {
|
||||
const calls = setup({ type: 'done', sw: '9000' });
|
||||
await stkMenuRespond('back');
|
||||
assert.strictEqual(calls.handled, null);
|
||||
assert.strictEqual(calls.rendered, 1);
|
||||
});
|
||||
|
||||
test('cancel falls back to the cached top menu', async () => {
|
||||
const calls = setup({ sw: '9000' });
|
||||
await stkMenuRespond('cancel');
|
||||
assert.strictEqual(calls.handled, null);
|
||||
assert.strictEqual(calls.rendered, 1);
|
||||
});
|
||||
|
||||
test('ok navigates with the server response', async () => {
|
||||
const data = { type: 'select_item', items: [{ id: 1, text: 'x' }] };
|
||||
const calls = setup(data);
|
||||
await stkMenuRespond('ok');
|
||||
assert.strictEqual(calls.handled, data);
|
||||
assert.strictEqual(calls.rendered, 0);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const swSource = fs.readFileSync(path.join(__dirname, '..', 'sw.js'), 'utf8');
|
||||
|
||||
class FakeResponse {
|
||||
constructor(body, init) {
|
||||
this.body = body;
|
||||
this.status = init && init.status;
|
||||
this.statusText = init && init.statusText;
|
||||
}
|
||||
clone() {
|
||||
return new FakeResponse(this.body, { status: this.status, statusText: this.statusText });
|
||||
}
|
||||
}
|
||||
|
||||
function loadSW({ fetchImpl, cacheMatch }) {
|
||||
const listeners = {};
|
||||
const puts = [];
|
||||
const sandbox = {
|
||||
self: {
|
||||
addEventListener: (type, fn) => { listeners[type] = fn; },
|
||||
skipWaiting: () => {},
|
||||
},
|
||||
caches: {
|
||||
open: async () => ({
|
||||
addAll: async () => {},
|
||||
put: async (req, res) => { puts.push([String(req && req.url || req), res]); },
|
||||
}),
|
||||
keys: async () => [],
|
||||
delete: async () => true,
|
||||
match: cacheMatch,
|
||||
},
|
||||
clients: { claim: () => {} },
|
||||
fetch: fetchImpl,
|
||||
Response: FakeResponse,
|
||||
URL,
|
||||
console,
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(swSource, sandbox);
|
||||
return { listeners, puts };
|
||||
}
|
||||
|
||||
function navigateEvent(url) {
|
||||
const event = {
|
||||
request: { url, method: 'GET', mode: 'navigate' },
|
||||
responded: null,
|
||||
};
|
||||
event.respondWith = p => { event.responded = p; };
|
||||
event.passThrough = () => { event.responded = null; };
|
||||
return event;
|
||||
}
|
||||
|
||||
test('offline navigation falls back to the cached index.html', async () => {
|
||||
const index = new FakeResponse('html');
|
||||
const { listeners } = loadSW({
|
||||
fetchImpl: async () => { throw new Error('offline'); },
|
||||
cacheMatch: async req => (String(req && req.url || req) === 'index.html' ? index : undefined),
|
||||
});
|
||||
const event = navigateEvent('http://127.0.0.1:8080/');
|
||||
listeners.fetch(event);
|
||||
const res = await event.responded;
|
||||
assert.strictEqual(res, index);
|
||||
});
|
||||
|
||||
test('offline navigation with empty cache resolves to an offline Response', async () => {
|
||||
const { listeners } = loadSW({
|
||||
fetchImpl: async () => { throw new Error('offline'); },
|
||||
cacheMatch: async () => undefined,
|
||||
});
|
||||
const event = navigateEvent('http://127.0.0.1:8080/');
|
||||
listeners.fetch(event);
|
||||
const res = await event.responded;
|
||||
assert.ok(res instanceof FakeResponse);
|
||||
assert.strictEqual(res.status, 503);
|
||||
});
|
||||
|
||||
test('a successful navigation is cached and returned', async () => {
|
||||
const page = new FakeResponse('html');
|
||||
const { listeners, puts } = loadSW({
|
||||
fetchImpl: async () => page,
|
||||
cacheMatch: async () => undefined,
|
||||
});
|
||||
const event = navigateEvent('http://127.0.0.1:8080/help.html');
|
||||
listeners.fetch(event);
|
||||
const res = await event.responded;
|
||||
assert.strictEqual(res, page);
|
||||
await new Promise(r => setImmediate(r));
|
||||
assert.strictEqual(puts.length, 1);
|
||||
assert.strictEqual(puts[0][0], 'http://127.0.0.1:8080/help.html');
|
||||
});
|
||||
|
||||
test('api requests bypass the service worker', () => {
|
||||
const { listeners } = loadSW({
|
||||
fetchImpl: async () => { throw new Error('unexpected'); },
|
||||
cacheMatch: async () => undefined,
|
||||
});
|
||||
const event = navigateEvent('http://127.0.0.1:8080/api/status');
|
||||
event.request.mode = 'cors';
|
||||
listeners.fetch(event);
|
||||
assert.strictEqual(event.responded, null);
|
||||
});
|
||||
|
||||
test('uncached asset hits the network and gets cached', async () => {
|
||||
const asset = new FakeResponse('js');
|
||||
const { listeners, puts } = loadSW({
|
||||
fetchImpl: async () => asset,
|
||||
cacheMatch: async () => undefined,
|
||||
});
|
||||
const event = navigateEvent('http://127.0.0.1:8080/des-bundle.js');
|
||||
event.request.mode = 'cors';
|
||||
listeners.fetch(event);
|
||||
const res = await event.responded;
|
||||
assert.strictEqual(res, asset);
|
||||
await new Promise(r => setImmediate(r));
|
||||
assert.strictEqual(puts.length, 1);
|
||||
});
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "pysim-otaman-server"
|
||||
version = "1.9.28"
|
||||
version = "2.1.2"
|
||||
description = "HTTP REST server wrapping pysim for the OTAMan PWA"
|
||||
requires-python = ">=3.8"
|
||||
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
|
||||
|
||||
@@ -11,7 +11,8 @@ from pySim.log import PySimLogger
|
||||
from pySim.cards import UiccCardBase
|
||||
|
||||
from .shell import load_pysim_app
|
||||
from .server import PysimHandler, StderrApduTracer, _LoggingApduTracer, VERSION, _send_terminal_profile, _DefaultProactiveHandler, _handle_proactive_chain, _send_status, _init_proactive_session
|
||||
from . import fastinit
|
||||
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
|
||||
|
||||
|
||||
_server_start = 0
|
||||
@@ -47,9 +48,24 @@ def main():
|
||||
help='Idle interval before automatic STATUS polling (1-255 seconds, default: 30). Disable with --poll-interval 0')
|
||||
parser.add_argument('--no-card-init', action='store_true', default=False,
|
||||
help='Skip pysim card initialization (preserve CAT session — no file manager)')
|
||||
parser.add_argument('--timing', action='store_true', default=False,
|
||||
help='Log phase durations, card resets and APDU counters with elapsed timestamps')
|
||||
parser.add_argument('--fast-init', action='store_true', help=argparse.SUPPRESS)
|
||||
parser.add_argument('--full-pysim-init', action='store_true', default=False,
|
||||
help="Use pysim's stock init_card/equip (multiple physical card resets) instead of the default reset-free fast init")
|
||||
parser.add_argument('--menu-timeout', type=int, default=60, metavar='SECS',
|
||||
help='Auto-send a timeout TERMINAL RESPONSE if a paused STK command is not answered (default: 60, 0 disables)')
|
||||
parser.add_argument('--no-auto-equip', action='store_true', default=False,
|
||||
help='Do not automatically initialize a card right after it is inserted (default: auto-equip on)')
|
||||
|
||||
opts = parser.parse_args()
|
||||
opts.skip_card_init = opts.no_card_init
|
||||
opts.fast_init = not opts.full_pysim_init
|
||||
if opts.timing:
|
||||
_timing_on()
|
||||
if opts.menu_timeout is not None:
|
||||
_set_menu_timeout(opts.menu_timeout)
|
||||
set_auto_equip(not opts.no_auto_equip and not opts.skip_card_init)
|
||||
sl = None
|
||||
scc = None
|
||||
card = None
|
||||
@@ -77,27 +93,44 @@ def main():
|
||||
kwargs = {}
|
||||
if opts.apdu_trace:
|
||||
kwargs['apdu_tracer'] = _LoggingApduTracer()
|
||||
t_phase = time.time()
|
||||
sl = mod.init_reader(opts, **kwargs)
|
||||
_tlog('init_reader: %.0fms' % ((time.time() - t_phase) * 1000))
|
||||
scc = SimCardCommands(sl)
|
||||
scc.cat_cla = '80' # UICC CLA default; overridden for SIM after init_card
|
||||
scc._tp.proactive_handler = _DefaultProactiveHandler()
|
||||
t_phase = time.time()
|
||||
if opts.fast_init:
|
||||
try:
|
||||
rs, card = fastinit.init_card_fast(sl, opts.skip_card_init, wait=True)
|
||||
except Exception:
|
||||
print("Warning: fast card initialization failed, falling back to pysim init:", file=sys.stderr)
|
||||
traceback.print_exc()
|
||||
rs, card = mod.init_card(sl, opts.skip_card_init)
|
||||
else:
|
||||
sl.wait_for_card(3)
|
||||
rs, card = mod.init_card(sl, opts.skip_card_init)
|
||||
_tlog('card_init: %.0fms' % ((time.time() - t_phase) * 1000))
|
||||
scc.cat_cla = '80' if isinstance(card, UiccCardBase) else 'a0'
|
||||
except Exception:
|
||||
print("Warning: reader/card initialization failed:", file=sys.stderr)
|
||||
traceback.print_exc()
|
||||
ch = CardHandler(sl) if sl else None
|
||||
t_phase = time.time()
|
||||
try:
|
||||
app = mod.PysimApp(verbose=opts.verbose, card=card, rs=rs, sl=sl, ch=ch)
|
||||
except Exception:
|
||||
print("Warning: PysimApp creation failed:", file=sys.stderr)
|
||||
traceback.print_exc()
|
||||
app = None
|
||||
if scc and hasattr(scc, '_tp'):
|
||||
_tlog('pysim_app: %.0fms' % ((time.time() - t_phase) * 1000))
|
||||
if app is not None and opts.fast_init:
|
||||
fastinit.install(app)
|
||||
if scc and card is not None and hasattr(scc, '_tp'):
|
||||
scc._tp.apdu_tracer = _LoggingApduTracer()
|
||||
try:
|
||||
_init_proactive_session()
|
||||
t_phase = time.time()
|
||||
sys.stderr.write('INIT: sending TERMINAL PROFILE %s (CLA=%s)\n' % (opts.terminal_profile, scc.cat_cla))
|
||||
sm, el = _send_terminal_profile(scc, opts.terminal_profile)
|
||||
sys.stderr.write('INIT: TP done, menu=%s events=%s\n' % ('yes' if sm else 'no', 'yes' if el else 'no'))
|
||||
@@ -109,8 +142,11 @@ def main():
|
||||
if not st_sw.startswith('91'):
|
||||
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)
|
||||
elif scc is not None:
|
||||
sys.stderr.write('INIT: card not initialized — use Equip once the card is readable\n')
|
||||
if app is not None and opts.apdu_trace:
|
||||
# PysimApp.__init__ routes PySimLogger through app.poutput() (app.stdout)
|
||||
# and drops the root level to INFO. Re-route pysim's own APDU trace logging
|
||||
@@ -146,15 +182,24 @@ def main():
|
||||
server.event_list = event_list
|
||||
server.menu_active = False
|
||||
server.stk_pending = None
|
||||
# Set server reference for polling timer and mark card as connected
|
||||
server.card_present = card is not None
|
||||
server.card_session = 1 if card is not None else 0
|
||||
server.equipping = False
|
||||
# Set server reference for polling timer and mark the card session state
|
||||
import pysim_otaman_server.server
|
||||
pysim_otaman_server.server._server_ref = server
|
||||
pysim_otaman_server.server._CARD_CONNECTED = True
|
||||
pysim_otaman_server.server._CARD_CONNECTED = card is not None
|
||||
if opts.poll_interval is not None:
|
||||
pysim_otaman_server.server._set_poll_interval(opts.poll_interval)
|
||||
# Auto-enable polling if card initialized successfully (unless interval is 0)
|
||||
if server.scc and server.card and opts.poll_interval != 0:
|
||||
pysim_otaman_server.server._poll_enable()
|
||||
# Start presence monitoring only after the startup init: pyscard reports an
|
||||
# already-present card as "added" on the first pass, and we must not
|
||||
# auto-equip over a session we just initialized. If startup init failed,
|
||||
# that event triggers auto-equip instead — the desired retry.
|
||||
if sl is not None and getattr(sl, '_reader', None) is not None:
|
||||
start_card_monitor(str(sl._reader))
|
||||
print("─" * 70)
|
||||
print(" pysim-otaman-server v%s listening on http://%s:%s" % (VERSION, opts.http_host, opts.http_port))
|
||||
print(" Open http://%s:%s in your browser for the OTAMan UI (served by this server)."
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Fast card initialization for pysim-otaman-server.
|
||||
|
||||
pySim's ``init_card()`` performs several physical card resets: one per profile
|
||||
candidate tried by ``CardProfile.pick()`` plus one at the end of
|
||||
``RuntimeState.__init__``, and ``PysimApp.equip()`` resets yet again. On common
|
||||
readers each disconnect/connect costs around a second, so the stock path spends
|
||||
most of its time re-establishing a clean state (MF selected) that can also be
|
||||
restored in software.
|
||||
|
||||
This module mirrors ``pySim.app.init_card()`` with those resets removed: all
|
||||
profile probes run back-to-back on the same connection and the runtime state
|
||||
uses a software reset. It is the default init/equip path; ``--full-pysim-init``
|
||||
restores pysim's stock behavior, and the explicit ``equip``/``reset`` commands
|
||||
keep a real reconnect/physical reset.
|
||||
"""
|
||||
|
||||
import operator
|
||||
import sys
|
||||
|
||||
from pySim.cards import CardBase, SimCardBase, UiccCardBase, card_detect
|
||||
from pySim.commands import SimCardCommands
|
||||
from pySim.exceptions import ProtocolError, SwMatchError
|
||||
from pySim.filesystem import CardApplication, CardModel
|
||||
from pySim.profile import CardProfile
|
||||
from pySim.runtime import RuntimeState
|
||||
from pySim.ts_102_221 import CardProfileUICC
|
||||
from pySim.utils import all_subclasses
|
||||
|
||||
import pySim.euicc
|
||||
|
||||
from .server import _tlog
|
||||
|
||||
|
||||
class FastRuntimeState(RuntimeState):
|
||||
"""RuntimeState whose reset() restores software state (selects MF) instead
|
||||
of power-cycling the card. Use hard_reset() for an explicit reset."""
|
||||
|
||||
def reset(self, cmd_app=None):
|
||||
try:
|
||||
return self.soft_reset(cmd_app)
|
||||
except (SwMatchError, ProtocolError) as e:
|
||||
sys.stderr.write('FAST-RESET: soft reset failed (%s), falling back to physical reset\n' % e)
|
||||
return self.hard_reset(cmd_app)
|
||||
|
||||
def soft_reset(self, cmd_app=None):
|
||||
for lchan_nr in list(self.lchan.keys()):
|
||||
self.lchan[lchan_nr].scc.scp = None
|
||||
if lchan_nr == 0:
|
||||
continue
|
||||
del self.lchan[lchan_nr]
|
||||
self.adm_verified = False
|
||||
try:
|
||||
atr = self.card._scc.get_atr()
|
||||
except Exception:
|
||||
atr = None
|
||||
if cmd_app:
|
||||
cmd_app.lchan = self.lchan[0]
|
||||
self.lchan[0].select('MF', cmd_app)
|
||||
self.lchan[0].selected_adf = None
|
||||
self.identity['ATR'] = atr
|
||||
return atr
|
||||
|
||||
def hard_reset(self, cmd_app=None):
|
||||
return super().reset(cmd_app)
|
||||
|
||||
|
||||
def pick_profile_no_reset(scc):
|
||||
"""Like CardProfile.pick(), but without a physical reset between
|
||||
candidates. Each probe selects its own discriminating file, so a reset only
|
||||
costs a reconnect without changing the outcome."""
|
||||
original_reset = scc.reset_card
|
||||
scc.reset_card = lambda: None
|
||||
try:
|
||||
profiles = sorted(all_subclasses(CardProfile), key=operator.attrgetter('ORDER'))
|
||||
for p in profiles:
|
||||
if p.match_with_card(scc):
|
||||
return p()
|
||||
return None
|
||||
finally:
|
||||
scc.reset_card = original_reset
|
||||
|
||||
|
||||
def init_card_fast(sl, skip_card_init=False, wait=True):
|
||||
"""Replacement for pySim.app.init_card() that avoids redundant resets.
|
||||
|
||||
``wait`` performs the single disconnect/connect of this init (explicit
|
||||
equip passes True; startup already connects via wait_for_card). If probing
|
||||
leaves the card in a state the software reset cannot clear, retry once
|
||||
after a physical reset."""
|
||||
try:
|
||||
return _init_card_once(sl, skip_card_init, wait)
|
||||
except (SwMatchError, ProtocolError) as e:
|
||||
sys.stderr.write('FAST-INIT: %s; retrying after physical reset\n' % e)
|
||||
sl.reset_card()
|
||||
return _init_card_once(sl, skip_card_init, wait=False)
|
||||
|
||||
|
||||
def _init_card_once(sl, skip_card_init, wait):
|
||||
scc = SimCardCommands(transport=sl)
|
||||
if wait:
|
||||
sl.wait_for_card(3)
|
||||
if skip_card_init:
|
||||
return None, CardBase(scc)
|
||||
|
||||
generic_card = False
|
||||
card = card_detect(scc)
|
||||
if card is None:
|
||||
card = SimCardBase(scc)
|
||||
generic_card = True
|
||||
|
||||
profile = pick_profile_no_reset(scc)
|
||||
if profile is None:
|
||||
return None, card
|
||||
|
||||
if generic_card and isinstance(profile, CardProfileUICC):
|
||||
card._adm_chv_num = 0x0A
|
||||
|
||||
if isinstance(profile, CardProfileUICC):
|
||||
for app_cls in all_subclasses(CardApplication):
|
||||
if hasattr(app_cls, '_' + app_cls.__name__ + '__intermediate'):
|
||||
continue
|
||||
profile.add_application(app_cls())
|
||||
if generic_card:
|
||||
card = UiccCardBase(scc)
|
||||
|
||||
rs = FastRuntimeState(card, profile)
|
||||
|
||||
CardModel.apply_matching_models(scc, rs)
|
||||
|
||||
sl.set_sw_interpreter(rs)
|
||||
|
||||
isd_r = rs.mf.applications.get(pySim.euicc.AID_ISD_R.lower(), None)
|
||||
if isd_r:
|
||||
rs.lchan[0].select_file(isd_r)
|
||||
try:
|
||||
rs.identity['EID'] = pySim.euicc.CardApplicationISDR.get_eid(scc)
|
||||
except SwMatchError:
|
||||
pass
|
||||
finally:
|
||||
rs.soft_reset()
|
||||
|
||||
return rs, card
|
||||
|
||||
|
||||
def do_equip_fast(app):
|
||||
"""Explicit equip: one real reconnect (wait_for_card) then reset-free init.
|
||||
PysimApp.equip() unregisters the old command sets itself after the new init
|
||||
succeeds, so a failed init leaves the previous card state intact."""
|
||||
rs, card = init_card_fast(app.sl, wait=True)
|
||||
app.equip(card, rs)
|
||||
|
||||
|
||||
def do_reset_fast(app):
|
||||
"""Explicit reset: always a physical card reset."""
|
||||
if app.rs is None:
|
||||
app.card._scc.reset_card()
|
||||
atr = app.card._scc.get_atr()
|
||||
else:
|
||||
atr = app.rs.hard_reset(app)
|
||||
app.poutput('Card ATR: %s' % atr)
|
||||
|
||||
|
||||
def install(app):
|
||||
"""Route the pySim-shell equip/reset commands through the fast paths."""
|
||||
def _do_equip(statement):
|
||||
_tlog('do_equip_fast: start')
|
||||
do_equip_fast(app)
|
||||
_tlog('do_equip_fast: done')
|
||||
|
||||
def _do_reset(statement):
|
||||
_tlog('do_reset_fast: start')
|
||||
do_reset_fast(app)
|
||||
_tlog('do_reset_fast: done')
|
||||
|
||||
app.do_equip = _do_equip
|
||||
app.do_reset = _do_reset
|
||||
+542
-143
@@ -11,6 +11,7 @@ from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from io import StringIO
|
||||
from pySim.transport import ApduTracer, ProactiveHandler
|
||||
from pySim.cards import UiccCardBase
|
||||
from smartcard.CardMonitoring import CardMonitor, CardObserver
|
||||
|
||||
import gsm0338 # registers 'gsm03.38' codec
|
||||
from construct import GreedyBytes
|
||||
@@ -18,7 +19,7 @@ from osmocom.construct import GsmOrUcs2Adapter
|
||||
from osmocom.tlv import BER_TLV_IE
|
||||
|
||||
|
||||
VERSION = '1.9.28'
|
||||
VERSION = '2.1.2'
|
||||
|
||||
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
||||
|
||||
@@ -41,6 +42,53 @@ _STATIC_MIME = {
|
||||
}
|
||||
|
||||
|
||||
_T0 = time.time()
|
||||
_TIMING = False
|
||||
_APDU_N = 0
|
||||
_RESET_N = 0
|
||||
|
||||
def _timing_on():
|
||||
global _TIMING
|
||||
_TIMING = True
|
||||
|
||||
def _tlog(msg):
|
||||
if not _TIMING:
|
||||
return
|
||||
sys.stderr.write('TIMING [+%7.3fs] %s\n' % (time.time() - _T0, msg))
|
||||
|
||||
|
||||
_APDU_TIMES = []
|
||||
_APDU_TIME_COLLECT = False
|
||||
|
||||
def _classify_apdu(cmd):
|
||||
"""Map a command APDU to a snapshot timing category by instruction byte."""
|
||||
if not cmd or len(cmd) < 4:
|
||||
return None
|
||||
return {'A4': 'select', 'B0': 'read_binary', 'B2': 'read_record'}.get(cmd[2:4].upper())
|
||||
|
||||
|
||||
def _collect_apdu_times():
|
||||
"""Start collecting per-command times. (Re)attaches our tracer if pySim
|
||||
nulled it (equip does). Callers hold _CARD_LOCK, so collection cannot be
|
||||
interleaved by the background poll thread."""
|
||||
global _APDU_TIME_COLLECT
|
||||
scc = getattr(_server_ref, 'scc', None) if _server_ref else None
|
||||
tp = getattr(scc, '_tp', None) if scc else None
|
||||
if tp is not None and tp.apdu_tracer is None:
|
||||
tp.apdu_tracer = _LoggingApduTracer()
|
||||
_APDU_TIMES.clear()
|
||||
_APDU_TIME_COLLECT = True
|
||||
|
||||
|
||||
def _end_apdu_time_collection():
|
||||
"""Stop collecting and return the collected [{type, ms}, ...] list."""
|
||||
global _APDU_TIME_COLLECT
|
||||
_APDU_TIME_COLLECT = False
|
||||
times = list(_APDU_TIMES)
|
||||
_APDU_TIMES.clear()
|
||||
return times
|
||||
|
||||
|
||||
class StderrApduTracer(ApduTracer):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@@ -49,8 +97,23 @@ class StderrApduTracer(ApduTracer):
|
||||
def trace_command(self, cmd):
|
||||
self._cmd_start = time.time()
|
||||
|
||||
def trace_reset(self):
|
||||
global _RESET_N
|
||||
_RESET_N += 1
|
||||
if _TIMING:
|
||||
sys.stderr.write('TIMING [+%7.3fs] RESET #%d\n' % (time.time() - _T0, _RESET_N))
|
||||
|
||||
def trace_response(self, cmd, sw, resp):
|
||||
global _APDU_N
|
||||
_APDU_N += 1
|
||||
elapsed = int((time.time() - self._cmd_start) * 1000)
|
||||
if _APDU_TIME_COLLECT:
|
||||
category = _classify_apdu(cmd)
|
||||
if category:
|
||||
_APDU_TIMES.append({'type': category, 'ms': elapsed})
|
||||
if _TIMING:
|
||||
msg = 'APDU-TRACE(+%7.3fs #%d, %dms): %s → SW: %s' % (time.time() - _T0, _APDU_N, elapsed, cmd, sw)
|
||||
else:
|
||||
msg = 'APDU-TRACE(%dms): %s → SW: %s' % (elapsed, cmd, sw)
|
||||
if resp:
|
||||
msg += ' RESP: %s' % resp
|
||||
@@ -151,39 +214,143 @@ def _get_file_type(lchan, cur_file):
|
||||
return None
|
||||
|
||||
|
||||
def _select_with_parent(lchan, name, parent_sel, app):
|
||||
if parent_sel:
|
||||
lchan.select(parent_sel, app)
|
||||
fcp = lchan.select(name, app)
|
||||
return fcp
|
||||
def _fid4(sel):
|
||||
"""True if sel is a 4-digit hex FID."""
|
||||
return bool(re.fullmatch(r'[0-9a-fA-F]{4}', str(sel or '')))
|
||||
|
||||
|
||||
def _file_by_sel(parent, sel):
|
||||
"""Resolve sel (FID or symbolic name, case-insensitive) among parent's direct children."""
|
||||
if parent is None or not sel:
|
||||
return None
|
||||
s = str(sel).strip().lower()
|
||||
for f in (getattr(parent, 'children', None) or {}).values():
|
||||
if f.fid and f.fid.lower() == s:
|
||||
return f
|
||||
if f.name and f.name.lower() == s:
|
||||
return f
|
||||
return None
|
||||
|
||||
|
||||
def _app_by_sel(rs, sel):
|
||||
"""Resolve an ADF by AID or application name (case-insensitive)."""
|
||||
if rs is None or not sel:
|
||||
return None
|
||||
s = str(sel).strip().lower()
|
||||
for aid, adf in (rs.mf.applications or {}).items():
|
||||
if aid.lower() == s or (adf.name and adf.name.lower() == s):
|
||||
return adf
|
||||
return None
|
||||
|
||||
|
||||
def _find_in_tree(root, sel):
|
||||
"""All model files matching sel (fid or name) below root; unique-match helper."""
|
||||
s = str(sel or '').strip().lower()
|
||||
found = []
|
||||
seen = set()
|
||||
stack = [root]
|
||||
while stack:
|
||||
cur = stack.pop()
|
||||
if id(cur) in seen:
|
||||
continue
|
||||
seen.add(id(cur))
|
||||
candidates = list((getattr(cur, 'children', None) or {}).values())
|
||||
candidates += list((getattr(cur, 'applications', None) or {}).values())
|
||||
for f in candidates:
|
||||
if (f.fid and f.fid.lower() == s) or (f.name and f.name.lower() == s):
|
||||
found.append(f)
|
||||
stack.append(f)
|
||||
return found
|
||||
|
||||
|
||||
def _select_with_parent(lchan, name, parent_sel, app, parent_path=None, allow_probe=False):
|
||||
"""Select name strictly within the requested parent.
|
||||
|
||||
Every model-known FID/name is resolved through the parent's children and
|
||||
selected with lchan.select_file(); pySim's global selectables and its
|
||||
probe_file() fallback are never used for model files, so a same-FID file
|
||||
under a different parent can no longer be picked and the model is not
|
||||
mutated. Model-unknown 4-hex segments (custom files) are probed only when
|
||||
allow_probe is set and are detached again via the returned cleanup.
|
||||
|
||||
Returns (selected_file, cleanup): cleanup is None unless a probe happened;
|
||||
handlers must call it in a finally block after using the selection.
|
||||
"""
|
||||
rs = app.rs
|
||||
prev = lchan.selected_file
|
||||
probes = []
|
||||
parent = rs.mf
|
||||
lchan.select_file(parent, app)
|
||||
segs = [s for s in (parent_path or ([parent_sel] if parent_sel else [])) if s]
|
||||
for seg in segs:
|
||||
if str(seg).upper() in ('MF', '3F00'):
|
||||
continue
|
||||
f = _app_by_sel(rs, seg) or _file_by_sel(parent, seg)
|
||||
if f is None and not parent_path:
|
||||
matches = _find_in_tree(rs.mf, seg)
|
||||
if len(matches) > 1:
|
||||
raise RuntimeError('Ambiguous parent selector: %s' % seg)
|
||||
if matches:
|
||||
f = matches[0]
|
||||
if f is None:
|
||||
if allow_probe and _fid4(seg):
|
||||
fid = str(seg).lower()
|
||||
probes.append((parent, fid))
|
||||
lchan.probe_file(fid, app)
|
||||
parent = lchan.selected_file
|
||||
continue
|
||||
raise RuntimeError('File not found: %s' % seg)
|
||||
lchan.select_file(f, app)
|
||||
parent = f
|
||||
target = None
|
||||
if str(name).upper() in ('MF', '3F00'):
|
||||
target = rs.mf
|
||||
if target is None:
|
||||
target = _file_by_sel(parent, name) or _app_by_sel(rs, name)
|
||||
if target is None and not parent_path and not parent_sel:
|
||||
matches = _find_in_tree(rs.mf, name)
|
||||
if len(matches) > 1:
|
||||
raise RuntimeError('Ambiguous file selector: %s' % name)
|
||||
if matches:
|
||||
target = matches[0]
|
||||
if target is not None:
|
||||
lchan.select_file(target, app)
|
||||
elif allow_probe and _fid4(name):
|
||||
fid = str(name).lower()
|
||||
probes.append((parent, fid))
|
||||
lchan.probe_file(fid, app)
|
||||
else:
|
||||
raise RuntimeError('File not found: %s' % name)
|
||||
cleanup = None
|
||||
if probes:
|
||||
def cleanup():
|
||||
for p, fid in reversed(probes):
|
||||
try:
|
||||
(getattr(p, 'children', None) or {}).pop(fid, None)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
lchan.select_file(prev, app)
|
||||
except Exception:
|
||||
try:
|
||||
lchan.select_file(rs.mf, app)
|
||||
except Exception:
|
||||
pass
|
||||
return lchan.selected_file, cleanup
|
||||
|
||||
|
||||
def _select_path(lchan, path, app):
|
||||
"""Select a file described by a full path.
|
||||
|
||||
Path is '/' separated; the first element is either 'MF' (or the MF fid
|
||||
'3F00') or an ADF AID (hex). Remaining elements are FIDs or file names.
|
||||
pySim's lchan.select() cannot select an ADF by its raw AID (selectables are
|
||||
keyed by name/fid only), so ADF roots are resolved through rs.mf.applications.
|
||||
Path is '/' separated; the first element is 'MF' (or '3F00'), an ADF AID,
|
||||
or an ADF name; remaining elements are FIDs or file names. Resolution is
|
||||
strictly parent-scoped (see _select_with_parent); unknown 4-hex segments
|
||||
are custom files and are probed without touching the model tree.
|
||||
"""
|
||||
parts = [p for p in (path or '').split('/') if p]
|
||||
if not parts:
|
||||
raise RuntimeError('Empty path')
|
||||
rs = app.rs
|
||||
first = parts[0]
|
||||
if first.upper() in ('MF', '3F00'):
|
||||
lchan.select('MF', app)
|
||||
else:
|
||||
aid = first.lower()
|
||||
adf = rs.mf.applications.get(aid)
|
||||
if not adf:
|
||||
adf = next((v for k, v in rs.mf.applications.items() if k.lower() == aid), None)
|
||||
if not adf:
|
||||
raise RuntimeError('ADF not found: %s' % first)
|
||||
lchan.select_file(adf, app)
|
||||
for seg in parts[1:]:
|
||||
lchan.select(seg, app)
|
||||
return lchan.selected_file
|
||||
return _select_with_parent(lchan, parts[-1], None, app, parent_path=parts[:-1], allow_probe=True)
|
||||
|
||||
|
||||
def _parse_tree_output(output):
|
||||
@@ -618,19 +785,19 @@ _PLI_DATA = {q: '' for q in PLI_QUALIFIER_NAMES}
|
||||
_POLL_ENABLED = False
|
||||
_POLL_INTERVAL = 30
|
||||
_POLL_TIMER = None
|
||||
_POLL_LOCK = threading.Lock()
|
||||
_CARD_LOCK = threading.RLock()
|
||||
_CARD_CONNECTED = False
|
||||
|
||||
def _set_poll_interval(seconds):
|
||||
global _POLL_INTERVAL
|
||||
_POLL_INTERVAL = max(1, min(255, int(seconds)))
|
||||
_POLL_INTERVAL = max(0, min(255, int(seconds)))
|
||||
|
||||
def _reset_poll_timer():
|
||||
global _POLL_TIMER
|
||||
if _POLL_TIMER is not None:
|
||||
_POLL_TIMER.cancel()
|
||||
_POLL_TIMER = None
|
||||
if _POLL_ENABLED:
|
||||
if _POLL_ENABLED and _POLL_INTERVAL > 0:
|
||||
_POLL_TIMER = threading.Timer(_POLL_INTERVAL, _do_status_poll)
|
||||
_POLL_TIMER.daemon = True
|
||||
_POLL_TIMER.start()
|
||||
@@ -640,7 +807,7 @@ def _do_status_poll():
|
||||
_POLL_TIMER = None
|
||||
if not _POLL_ENABLED:
|
||||
return
|
||||
with _POLL_LOCK:
|
||||
with _CARD_LOCK:
|
||||
try:
|
||||
scc = getattr(_server_ref, 'scc', None) if _server_ref else None
|
||||
if not scc:
|
||||
@@ -656,9 +823,51 @@ def _do_status_poll():
|
||||
|
||||
def _poll_enable():
|
||||
global _POLL_ENABLED
|
||||
if _POLL_INTERVAL <= 0:
|
||||
_POLL_ENABLED = False
|
||||
return
|
||||
_POLL_ENABLED = True
|
||||
_reset_poll_timer()
|
||||
|
||||
_MENU_TIMEOUT = 60
|
||||
_MENU_TIMER = None
|
||||
|
||||
def _set_menu_timeout(seconds):
|
||||
global _MENU_TIMEOUT
|
||||
_MENU_TIMEOUT = max(0, min(3600, int(seconds)))
|
||||
|
||||
def _cancel_menu_timeout():
|
||||
global _MENU_TIMER
|
||||
if _MENU_TIMER is not None:
|
||||
_MENU_TIMER.cancel()
|
||||
_MENU_TIMER = None
|
||||
|
||||
def _arm_menu_timeout():
|
||||
"""Watchdog: a paused proactive command must always get a TERMINAL RESPONSE,
|
||||
even if the user never answers. Fires 0x12 ('timeout') via the same path as
|
||||
an explicit user response."""
|
||||
global _MENU_TIMER
|
||||
_cancel_menu_timeout()
|
||||
if _MENU_TIMEOUT <= 0:
|
||||
return
|
||||
_MENU_TIMER = threading.Timer(_MENU_TIMEOUT, _menu_timeout_fire)
|
||||
_MENU_TIMER.daemon = True
|
||||
_MENU_TIMER.start()
|
||||
|
||||
def _menu_timeout_fire():
|
||||
global _MENU_TIMER
|
||||
_MENU_TIMER = None
|
||||
with _CARD_LOCK:
|
||||
server = _server_ref
|
||||
if not server or not getattr(server, 'stk_pending', None) or not getattr(server, 'scc', None):
|
||||
return
|
||||
pd = server.stk_pending
|
||||
sys.stderr.write('MENU-TIMEOUT: auto TR timeout (cmd=%02x type=%02x)\n' % (pd['cmd_num'], pd['cmd_type']))
|
||||
try:
|
||||
_menu_send_response(server, 'timeout', None)
|
||||
except Exception as e:
|
||||
sys.stderr.write('MENU-TIMEOUT error: %s\n' % e)
|
||||
|
||||
def _poll_disable():
|
||||
global _POLL_ENABLED, _POLL_TIMER
|
||||
_POLL_ENABLED = False
|
||||
@@ -954,6 +1163,7 @@ def _record_tr(entry, tr_tlv, tr_sw=None):
|
||||
def _handle_card_disconnect():
|
||||
global _CARD_CONNECTED
|
||||
_poll_disable()
|
||||
_cancel_menu_timeout()
|
||||
_CARD_CONNECTED = False
|
||||
if _server_ref:
|
||||
_server_ref.card = None
|
||||
@@ -962,9 +1172,128 @@ def _handle_card_disconnect():
|
||||
_server_ref.menu_active = False
|
||||
_server_ref.event_list = None
|
||||
_server_ref.sim_menu = None
|
||||
_server_ref.equipping = False
|
||||
_server_ref.card_session = getattr(_server_ref, 'card_session', 0) + 1
|
||||
_reset_proactive_log()
|
||||
|
||||
|
||||
def _apply_equipped_card(server):
|
||||
"""Common post-equip state refresh + TERMINAL PROFILE, shared by the
|
||||
/api/command equip branch and the auto-equip worker."""
|
||||
global _CARD_CONNECTED
|
||||
server.stk_pending = None
|
||||
server.menu_active = False
|
||||
_cancel_menu_timeout()
|
||||
server.event_list = None
|
||||
_reset_proactive_log()
|
||||
server.card = server.app.card
|
||||
server.scc = server.app.card._scc
|
||||
server.scc.cat_cla = '80' if isinstance(server.card, UiccCardBase) else 'a0'
|
||||
_CARD_CONNECTED = True
|
||||
server.card_present = True
|
||||
server.card_session = getattr(server, 'card_session', 0) + 1
|
||||
_poll_enable()
|
||||
sm, el = _send_terminal_profile(server.scc, server.terminal_profile)
|
||||
server.sim_menu = sm
|
||||
server.event_list = el
|
||||
_tlog('equip: terminal profile done')
|
||||
|
||||
|
||||
_AUTO_EQUIP = True
|
||||
_AUTO_EQUIP_BUSY = False
|
||||
|
||||
def set_auto_equip(enabled):
|
||||
global _AUTO_EQUIP
|
||||
_AUTO_EQUIP = bool(enabled)
|
||||
|
||||
def _auto_equip_trigger():
|
||||
"""Spawn a one-shot worker; never run equip in the pyscard monitor thread."""
|
||||
global _AUTO_EQUIP_BUSY
|
||||
if not _AUTO_EQUIP or _AUTO_EQUIP_BUSY:
|
||||
return
|
||||
_AUTO_EQUIP_BUSY = True
|
||||
threading.Thread(target=_auto_equip_worker, name='auto-equip', daemon=True).start()
|
||||
|
||||
def _auto_equip_worker():
|
||||
global _AUTO_EQUIP_BUSY
|
||||
try:
|
||||
with _CARD_LOCK:
|
||||
server = _server_ref
|
||||
if not server or _CARD_CONNECTED or not getattr(server, 'card_present', False):
|
||||
return
|
||||
app = server.app
|
||||
if app is None or not getattr(server, 'terminal_profile', None):
|
||||
return
|
||||
server.equipping = True
|
||||
try:
|
||||
sys.stderr.write('AUTO-EQUIP: card inserted, initializing\n')
|
||||
old_stdout, old_stderr = app.stdout, sys.stderr
|
||||
app.stdout = StringIO()
|
||||
sys.stderr = app.stdout
|
||||
try:
|
||||
app.onecmd_plus_hooks('equip')
|
||||
finally:
|
||||
app.stdout = old_stdout
|
||||
sys.stderr = old_stderr
|
||||
if not getattr(server, 'card_present', False) or server.app.card is None:
|
||||
sys.stderr.write('AUTO-EQUIP: card gone during initialization\n')
|
||||
return
|
||||
_apply_equipped_card(server)
|
||||
sys.stderr.write('AUTO-EQUIP: done\n')
|
||||
except Exception as e:
|
||||
sys.stderr.write('AUTO-EQUIP failed: %s\n' % e)
|
||||
finally:
|
||||
server.equipping = False
|
||||
finally:
|
||||
_AUTO_EQUIP_BUSY = False
|
||||
|
||||
|
||||
class _CardPresenceObserver(CardObserver):
|
||||
"""Passive PC/SC presence watcher: pyscard's CardMonitor only polls
|
||||
SCardGetStatusChange (no connection, no APDUs), so it can never interleave
|
||||
with our APDU traffic. We only update flags and tear down card state."""
|
||||
|
||||
def __init__(self, reader_name):
|
||||
self.reader_name = reader_name
|
||||
|
||||
def update(self, observable, handlers):
|
||||
addedcards, removedcards = handlers
|
||||
try:
|
||||
trigger_auto = False
|
||||
for card in removedcards:
|
||||
if str(getattr(card, 'reader', '')) == self.reader_name:
|
||||
sys.stderr.write('CARD-WATCH: card removed from %s\n' % self.reader_name)
|
||||
with _CARD_LOCK:
|
||||
if _server_ref:
|
||||
_server_ref.card_present = False
|
||||
_handle_card_disconnect()
|
||||
for card in addedcards:
|
||||
if str(getattr(card, 'reader', '')) == self.reader_name:
|
||||
sys.stderr.write('CARD-WATCH: card inserted into %s\n' % self.reader_name)
|
||||
with _CARD_LOCK:
|
||||
if _server_ref:
|
||||
_server_ref.card_present = True
|
||||
trigger_auto = True
|
||||
if trigger_auto and _AUTO_EQUIP:
|
||||
_auto_equip_trigger()
|
||||
except Exception as e:
|
||||
sys.stderr.write('CARD-WATCH error: %s\n' % e)
|
||||
|
||||
|
||||
_card_presence_observer = None
|
||||
|
||||
def start_card_monitor(reader_name):
|
||||
"""Start the process-wide pyscard monitor (one daemon thread, no process)
|
||||
and register our reader's presence observer."""
|
||||
global _card_presence_observer
|
||||
if not reader_name:
|
||||
return None
|
||||
if _card_presence_observer is None:
|
||||
_card_presence_observer = _CardPresenceObserver(reader_name)
|
||||
CardMonitor().addObserver(_card_presence_observer)
|
||||
return _card_presence_observer
|
||||
|
||||
|
||||
def _init_proactive_session():
|
||||
global _PROACTIVE_SESSION_START
|
||||
_PROACTIVE_SESSION_START = time.time()
|
||||
@@ -1303,6 +1632,94 @@ def _send_terminal_profile(scc, tp_hex):
|
||||
return sim_menu, event_list
|
||||
|
||||
|
||||
def _make_menu_fetch_handler(server, resp):
|
||||
"""on_fetch callback for the menu chain: pauses on user-interactive commands
|
||||
and stores the pending command so a TERMINAL RESPONSE can be sent later."""
|
||||
def _on_menu_fetch(raw, cmd_num, cmd_type, dev_src, dev_dst):
|
||||
if cmd_type == 0x21:
|
||||
text = _parse_display_text(raw) if raw else None
|
||||
if text:
|
||||
server.stk_pending = {'type': 'display_text',
|
||||
'cmd_num': cmd_num, 'cmd_type': cmd_type,
|
||||
'dev_src': dev_src, 'dev_dst': dev_dst, 'text': text}
|
||||
resp.update(type='display_text', text=text)
|
||||
return 'pause'
|
||||
elif cmd_type == 0x24:
|
||||
items = _parse_select_item(raw) if raw else []
|
||||
server.stk_pending = {'type': 'select_item',
|
||||
'cmd_num': cmd_num, 'cmd_type': cmd_type,
|
||||
'dev_src': dev_src, 'dev_dst': dev_dst, 'items': items}
|
||||
resp.update(type='select_item', items=items)
|
||||
return 'pause'
|
||||
elif cmd_type == 0x25:
|
||||
items = _parse_setup_menu_items(raw) if raw else []
|
||||
server.stk_pending = {'type': 'select_item',
|
||||
'cmd_num': cmd_num, 'cmd_type': cmd_type,
|
||||
'dev_src': dev_src, 'dev_dst': dev_dst, 'items': items}
|
||||
resp.update(type='select_item', items=items)
|
||||
return 'pause'
|
||||
return _on_menu_fetch
|
||||
|
||||
|
||||
def _menu_send_response(server, result, item_id=None):
|
||||
"""Send the pending command's TERMINAL RESPONSE and continue the chain.
|
||||
Shared by /api/menu-respond and the user-input timeout watchdog. Returns
|
||||
(payload, http_status)."""
|
||||
if not server.stk_pending:
|
||||
return {'error': 'no pending command'}, 400
|
||||
scc = server.scc
|
||||
RESULT_MAP = {'ok': 0x00, 'cancel': 0x10, 'back': 0x11, 'timeout': 0x12}
|
||||
gr = RESULT_MAP.get(result, 0x00)
|
||||
pd = server.stk_pending
|
||||
cd = bytes([0x81, 0x03, pd['cmd_num'], pd['cmd_type'], 0x00])
|
||||
di = bytes([0x82, 0x02, pd['dev_dst'], pd['dev_src']])
|
||||
tr_data = cd + di
|
||||
if isinstance(item_id, int) and result == 'ok' and pd['type'] == 'select_item':
|
||||
tr_data += bytes([0x90, 0x01, item_id])
|
||||
tr_data += bytes([0x83, 0x02, gr, 0x00])
|
||||
tr_hex = '%s140000%02x%s' % (scc.cat_cla, len(tr_data), tr_data.hex())
|
||||
tr_rv = scc._tp.send_apdu(tr_hex)
|
||||
sys.stderr.write('TR(menu): cmd=%02x type=%02x result=%02x -> %s\n' % (pd['cmd_num'], pd['cmd_type'], gr, tr_rv[1]))
|
||||
for entry in reversed(_PROACTIVE_LOG):
|
||||
if (entry.get('cmd_num') == pd['cmd_num']
|
||||
and entry.get('type_hex') == '%02x' % pd['cmd_type']
|
||||
and 'tr_hex' not in entry):
|
||||
_record_tr(entry, tr_data, tr_rv[1])
|
||||
break
|
||||
sw = tr_rv[1]
|
||||
resp = {'sw': sw}
|
||||
if result == 'cancel':
|
||||
server.stk_pending = None
|
||||
server.menu_active = False
|
||||
else:
|
||||
server.stk_pending = None
|
||||
if sw.startswith('91'):
|
||||
_handle_proactive_chain(scc, sw, _make_menu_fetch_handler(server, resp))
|
||||
else:
|
||||
server.menu_active = False
|
||||
resp['type'] = 'done'
|
||||
if server.stk_pending:
|
||||
_arm_menu_timeout()
|
||||
else:
|
||||
_cancel_menu_timeout()
|
||||
return resp, 200
|
||||
|
||||
|
||||
def _finish_pending_menu(server, scc):
|
||||
"""A new menu selection must never shadow a FETCHed command that awaits its
|
||||
TERMINAL RESPONSE: answer it with a cancel TR (0x10) first, then drain any
|
||||
follow-up proactive command so the card is ready for the new selection."""
|
||||
pd = server.stk_pending
|
||||
if not pd:
|
||||
return
|
||||
sys.stderr.write('MENU-SELECT: finishing pending cmd=%02x type=%02x with cancel TR\n'
|
||||
% (pd['cmd_num'], pd['cmd_type']))
|
||||
resp, _ = _menu_send_response(server, 'cancel', None)
|
||||
sw = (resp or {}).get('sw', '')
|
||||
if sw.startswith('91'):
|
||||
_handle_proactive_chain(scc, sw)
|
||||
|
||||
|
||||
class PysimHandler(BaseHTTPRequestHandler):
|
||||
def _send_json(self, data, status=200):
|
||||
self.send_response(status)
|
||||
@@ -1366,6 +1783,19 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
self.wfile.write(data)
|
||||
|
||||
def do_GET(self):
|
||||
# /api/status is pure cached state (no card I/O); keeping it out of the
|
||||
# lock lets the UI report 'initializing' while a long equip holds the
|
||||
# card lock. Result-shaping masks everything card-derived when the
|
||||
# session is not connected.
|
||||
if self.path == '/api/status':
|
||||
self._do_GET()
|
||||
return
|
||||
# Serialize all card access: the background STATUS poll runs in its own
|
||||
# thread and must never interleave with a FETCH/TERMINAL RESPONSE pair.
|
||||
with _CARD_LOCK:
|
||||
self._do_GET()
|
||||
|
||||
def _do_GET(self):
|
||||
lang = _get_lang(self.headers)
|
||||
if self.path == '/api/version':
|
||||
self._log_req()
|
||||
@@ -1378,9 +1808,23 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
lchan = rs.lchan[0] if rs else None
|
||||
cur_file = lchan.selected_file if lchan else None
|
||||
scc = app.card._scc if app and app.card else None
|
||||
card = app.card if app else None
|
||||
connected = bool(_CARD_CONNECTED and card is not None)
|
||||
if not connected:
|
||||
rs = None
|
||||
lchan = None
|
||||
cur_file = None
|
||||
scc = None
|
||||
card = None
|
||||
data = {
|
||||
'reader': str(self.server.sl) if self.server.sl else None,
|
||||
'card': app.card.name if app and app.card else None,
|
||||
'connected': connected,
|
||||
'card_present': bool(getattr(self.server, 'card_present', False)),
|
||||
'card_session': int(getattr(self.server, 'card_session', 0)),
|
||||
'proactive_seq': _PROACTIVE_ENTRY_ID,
|
||||
'equipping': bool(getattr(self.server, 'equipping', False)),
|
||||
'auto_equip': bool(_AUTO_EQUIP),
|
||||
'card': card.name if card else None,
|
||||
'profile': str(rs.profile) if rs and rs.profile else None,
|
||||
'app_ready': app is not None,
|
||||
'adm_verified': rs.adm_verified if rs else False,
|
||||
@@ -1477,8 +1921,14 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
self._serve_static()
|
||||
|
||||
def do_POST(self):
|
||||
lang = _get_lang(self.headers)
|
||||
# Serialize all card access: the background STATUS poll runs in its own
|
||||
# thread and must never interleave with a FETCH/TERMINAL RESPONSE pair.
|
||||
with _CARD_LOCK:
|
||||
_reset_poll_timer()
|
||||
self._do_POST()
|
||||
|
||||
def _do_POST(self):
|
||||
lang = _get_lang(self.headers)
|
||||
if self.path == '/api/command':
|
||||
app = self.server.app
|
||||
if not app:
|
||||
@@ -1504,20 +1954,11 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
sys.stderr = old_stderr
|
||||
elapsed = int((time.time() - t0) * 1000)
|
||||
status = 'OK' if not output or 'not a recognized command' not in output else 'ERROR'
|
||||
if str(cmd).strip().startswith('equip') and self.server.app and self.server.app.card and self.server.terminal_profile:
|
||||
global _CARD_CONNECTED
|
||||
self.server.stk_pending = None
|
||||
self.server.menu_active = False
|
||||
self.server.event_list = None
|
||||
_reset_proactive_log()
|
||||
self.server.card = self.server.app.card
|
||||
self.server.scc = self.server.app.card._scc
|
||||
self.server.scc.cat_cla = '80' if isinstance(self.server.card, UiccCardBase) else 'a0'
|
||||
_CARD_CONNECTED = True
|
||||
_poll_enable()
|
||||
sm, el = _send_terminal_profile(self.server.scc, self.server.terminal_profile)
|
||||
self.server.sim_menu = sm
|
||||
self.server.event_list = el
|
||||
is_equip = str(cmd).strip().startswith('equip')
|
||||
if is_equip:
|
||||
_tlog('equip: onecmd_plus_hooks %dms' % elapsed)
|
||||
if is_equip and self.server.app and self.server.app.card and self.server.terminal_profile:
|
||||
_apply_equipped_card(self.server)
|
||||
sys.stderr.write("CMD: %s → %s (%dms)\n" % (cmd, status, elapsed))
|
||||
resp = {'output': output, 'stop': bool(stop)}
|
||||
self._send_json(resp)
|
||||
@@ -1579,6 +2020,7 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
sys.stderr.write('RESCUE: re-sending TERMINAL PROFILE\n')
|
||||
self.server.stk_pending = None
|
||||
self.server.menu_active = False
|
||||
_cancel_menu_timeout()
|
||||
self.server.event_list = None
|
||||
_reset_proactive_log()
|
||||
sm, el = _send_terminal_profile(scc, self.server.terminal_profile)
|
||||
@@ -1623,18 +2065,25 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
fid = body.get('fid')
|
||||
name = fid if fid else body.get('name', '')
|
||||
parent_sel = body.get('parent_sel')
|
||||
parent_path = body.get('parent_path')
|
||||
allow_probe = bool(body.get('allow_probe'))
|
||||
rs = app.rs
|
||||
if not rs:
|
||||
self._send_json({'error': _err('no_card_state', lang)}, 503)
|
||||
self._log_resp({'error': _err('no_card_state', lang)})
|
||||
return
|
||||
lchan = rs.lchan[0]
|
||||
cleanup = None
|
||||
try:
|
||||
_collect_apdu_times()
|
||||
try:
|
||||
if path:
|
||||
_select_path(lchan, path, app)
|
||||
cur, cleanup = _select_path(lchan, path, app)
|
||||
else:
|
||||
_select_with_parent(lchan, name, parent_sel, app)
|
||||
cur = lchan.selected_file
|
||||
cur, cleanup = _select_with_parent(lchan, name, parent_sel, app, parent_path, allow_probe)
|
||||
finally:
|
||||
apdu_times = _end_apdu_time_collection()
|
||||
cur = cur or lchan.selected_file
|
||||
data = {
|
||||
'name': cur.name if cur else None,
|
||||
'fid': cur.fid.upper() if cur and cur.fid else None,
|
||||
@@ -1643,6 +2092,7 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
'record_len': lchan.selected_file_record_len() if lchan else None,
|
||||
'num_of_rec': lchan.selected_file_num_of_rec() if lchan else None,
|
||||
'fci_hex': (lchan.selected_file_fcp_hex or '').upper() if lchan and lchan.selected_file_fcp_hex else None,
|
||||
'apdu_times': apdu_times,
|
||||
'exists': True,
|
||||
}
|
||||
self._send_json(data)
|
||||
@@ -1651,6 +2101,9 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
err = {'error': str(e), 'exists': False}
|
||||
self._send_json(err, 404)
|
||||
self._log_resp(err)
|
||||
finally:
|
||||
if cleanup:
|
||||
cleanup()
|
||||
elif self.path == '/api/read':
|
||||
app = self.server.app
|
||||
if not app:
|
||||
@@ -1663,6 +2116,8 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
fid = body.get('fid')
|
||||
name = fid if fid else body.get('name', '')
|
||||
parent_sel = body.get('parent_sel')
|
||||
parent_path = body.get('parent_path')
|
||||
allow_probe = bool(body.get('allow_probe'))
|
||||
mode = body.get('mode', 'raw')
|
||||
rs = app.rs
|
||||
if not rs:
|
||||
@@ -1670,12 +2125,15 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
self._log_resp({'error': _err('no_card_state', lang)})
|
||||
return
|
||||
lchan = rs.lchan[0]
|
||||
cleanup = None
|
||||
try:
|
||||
_collect_apdu_times()
|
||||
try:
|
||||
sel = fid if fid else name
|
||||
if path:
|
||||
_select_path(lchan, path, app)
|
||||
_, cleanup = _select_path(lchan, path, app)
|
||||
else:
|
||||
_select_with_parent(lchan, sel, parent_sel, app)
|
||||
_, cleanup = _select_with_parent(lchan, sel, parent_sel, app, parent_path, allow_probe)
|
||||
ft = _get_file_type(lchan, lchan.selected_file)
|
||||
is_record = ft in ('linear_fixed', 'cyclic')
|
||||
if mode == 'decoded':
|
||||
@@ -1693,6 +2151,8 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
finally:
|
||||
app.stdout = old_stdout
|
||||
sys.stderr = old_stderr
|
||||
finally:
|
||||
apdu_times = _end_apdu_time_collection()
|
||||
sw_match = re.search(r'SW:\s*(\w+)', output)
|
||||
err_match = re.search(r'got (\w+)', output)
|
||||
if err_match:
|
||||
@@ -1709,24 +2169,27 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
if mode == 'decoded':
|
||||
try:
|
||||
parsed = json.loads(clean)
|
||||
resp = {'success': True, 'sw': sw, 'file_type': ft, 'decoded': parsed}
|
||||
resp = {'success': True, 'sw': sw, 'file_type': ft, 'decoded': parsed, 'apdu_times': apdu_times}
|
||||
except json.JSONDecodeError:
|
||||
resp = {'success': True, 'sw': sw, 'file_type': ft, 'data': clean}
|
||||
resp = {'success': True, 'sw': sw, 'file_type': ft, 'data': clean, 'apdu_times': apdu_times}
|
||||
elif is_record:
|
||||
records = []
|
||||
for line in clean.split('\n'):
|
||||
m = re.match(r'^(\d+)\s(.+)', line)
|
||||
if m:
|
||||
records.append({'num': int(m.group(1)), 'data': m.group(2)})
|
||||
resp = {'success': True, 'sw': sw, 'file_type': ft, 'records': records}
|
||||
resp = {'success': True, 'sw': sw, 'file_type': ft, 'records': records, 'apdu_times': apdu_times}
|
||||
else:
|
||||
resp = {'success': True, 'sw': sw, 'file_type': ft, 'data': clean}
|
||||
resp = {'success': True, 'sw': sw, 'file_type': ft, 'data': clean, 'apdu_times': apdu_times}
|
||||
self._send_json(resp)
|
||||
self._log_resp(resp)
|
||||
except Exception as e:
|
||||
err = {'success': False, 'error': str(e)}
|
||||
self._send_json(err, 500)
|
||||
self._log_resp(err)
|
||||
finally:
|
||||
if cleanup:
|
||||
cleanup()
|
||||
elif self.path == '/api/write':
|
||||
app = self.server.app
|
||||
if not app:
|
||||
@@ -1740,15 +2203,18 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
fid = body.get('fid')
|
||||
record_nr = body.get('record_nr')
|
||||
parent_sel = body.get('parent_sel')
|
||||
parent_path = body.get('parent_path')
|
||||
allow_probe = bool(body.get('allow_probe'))
|
||||
rs = app.rs
|
||||
if not rs:
|
||||
self._send_json({'error': _err('no_card_state', lang)}, 503)
|
||||
self._log_resp({'error': _err('no_card_state', lang)})
|
||||
return
|
||||
lchan = rs.lchan[0]
|
||||
cleanup = None
|
||||
try:
|
||||
sel = fid if fid else name
|
||||
_select_with_parent(lchan, sel, parent_sel, app)
|
||||
_, cleanup = _select_with_parent(lchan, sel, parent_sel, app, parent_path, allow_probe)
|
||||
ft = _get_file_type(lchan, lchan.selected_file)
|
||||
is_record = ft in ('linear_fixed', 'cyclic')
|
||||
if record_nr:
|
||||
@@ -1785,6 +2251,9 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
err = {'success': False, 'error': str(e)}
|
||||
self._send_json(err, 500)
|
||||
self._log_resp(err)
|
||||
finally:
|
||||
if cleanup:
|
||||
cleanup()
|
||||
elif self.path == '/api/tree':
|
||||
app = self.server.app
|
||||
if not app:
|
||||
@@ -1797,15 +2266,18 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
name = fid if fid else body.get('name', '')
|
||||
fid = body.get('fid')
|
||||
parent_sel = body.get('parent_sel')
|
||||
parent_path = body.get('parent_path')
|
||||
allow_probe = bool(body.get('allow_probe'))
|
||||
rs = app.rs
|
||||
if not rs:
|
||||
self._send_json({'error': _err('no_card_state', lang)}, 503)
|
||||
self._log_resp({'error': _err('no_card_state', lang)})
|
||||
return
|
||||
lchan = rs.lchan[0]
|
||||
cleanup = None
|
||||
try:
|
||||
sel = fid if fid else name
|
||||
_select_with_parent(lchan, sel, parent_sel, app)
|
||||
_, cleanup = _select_with_parent(lchan, sel, parent_sel, app, parent_path, allow_probe)
|
||||
cur = lchan.selected_file
|
||||
out = StringIO()
|
||||
old_stdout = app.stdout
|
||||
@@ -1838,9 +2310,12 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
sys.stderr.write('Handler error: %s\n' % e)
|
||||
if 'Card' in str(e) or 'Transaction' in str(e) or 'Transmit' in str(e):
|
||||
_handle_card_disconnect()
|
||||
err = {'success': False, 'error': str(e)}
|
||||
err = {'success': False, 'error': str(e), 'exists': False}
|
||||
self._send_json(err, 500)
|
||||
self._log_resp(err)
|
||||
finally:
|
||||
if cleanup:
|
||||
cleanup()
|
||||
elif self.path == '/api/menu-select':
|
||||
scc = self.server.scc
|
||||
if not scc:
|
||||
@@ -1848,6 +2323,7 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
body = self._read_body()
|
||||
self._log_req(body)
|
||||
_finish_pending_menu(self.server, scc)
|
||||
item_id = body.get('item_id', 0)
|
||||
if not isinstance(item_id, int):
|
||||
item_id = int(item_id)
|
||||
@@ -1857,31 +2333,9 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
env_hex = '%sc20000%02x%s' % (scc.cat_cla, len(menu_tlv), menu_tlv.hex())
|
||||
data, sw = scc._tp.send_apdu(env_hex)
|
||||
resp = {'type': 'done', 'sw': sw}
|
||||
on_fetch = _make_menu_fetch_handler(self.server, resp)
|
||||
if sw.startswith('91'):
|
||||
def _on_menu_fetch(raw, cmd_num, cmd_type, dev_src, dev_dst):
|
||||
if cmd_type == 0x21:
|
||||
text = _parse_display_text(raw) if raw else None
|
||||
if text:
|
||||
self.server.stk_pending = {'type': 'display_text',
|
||||
'cmd_num': cmd_num, 'cmd_type': cmd_type,
|
||||
'dev_src': dev_src, 'dev_dst': dev_dst, 'text': text}
|
||||
resp.update(type='display_text', text=text)
|
||||
return 'pause'
|
||||
elif cmd_type == 0x24:
|
||||
items = _parse_select_item(raw) if raw else []
|
||||
self.server.stk_pending = {'type': 'select_item',
|
||||
'cmd_num': cmd_num, 'cmd_type': cmd_type,
|
||||
'dev_src': dev_src, 'dev_dst': dev_dst, 'items': items}
|
||||
resp.update(type='select_item', items=items)
|
||||
return 'pause'
|
||||
elif cmd_type == 0x25:
|
||||
items = _parse_setup_menu_items(raw) if raw else []
|
||||
self.server.stk_pending = {'type': 'select_item',
|
||||
'cmd_num': cmd_num, 'cmd_type': cmd_type,
|
||||
'dev_src': dev_src, 'dev_dst': dev_dst, 'items': items}
|
||||
resp.update(type='select_item', items=items)
|
||||
return 'pause'
|
||||
_handle_proactive_chain(scc, sw, _on_menu_fetch)
|
||||
_handle_proactive_chain(scc, sw, on_fetch)
|
||||
else:
|
||||
self.server.menu_active = False
|
||||
self.server.stk_pending = None
|
||||
@@ -1890,73 +2344,18 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
st_data, st_sw = _send_status(scc)
|
||||
sys.stderr.write('STATUS -> %s\n' % st_sw)
|
||||
if st_sw.startswith('91'):
|
||||
_handle_proactive_chain(scc, st_sw, _on_menu_fetch)
|
||||
_handle_proactive_chain(scc, st_sw, on_fetch)
|
||||
if self.server.stk_pending:
|
||||
_arm_menu_timeout()
|
||||
else:
|
||||
_cancel_menu_timeout()
|
||||
self._send_json(resp)
|
||||
self._log_resp(resp)
|
||||
elif self.path == '/api/menu-respond':
|
||||
scc = self.server.scc
|
||||
if not self.server.stk_pending:
|
||||
self._send_json({'error': 'no pending command'}, 400)
|
||||
return
|
||||
body = self._read_body()
|
||||
self._log_req(body)
|
||||
result = body.get('result', 'ok')
|
||||
item_id = body.get('item_id')
|
||||
RESULT_MAP = {'ok': 0x00, 'cancel': 0x10, 'back': 0x11, 'timeout': 0x12}
|
||||
gr = RESULT_MAP.get(result, 0x00)
|
||||
pd = self.server.stk_pending
|
||||
# Build TERMINAL RESPONSE
|
||||
cd = bytes([0x81, 0x03, pd['cmd_num'], pd['cmd_type'], 0x00])
|
||||
di = bytes([0x82, 0x02, pd['dev_dst'], pd['dev_src']])
|
||||
tr_data = cd + di
|
||||
if isinstance(item_id, int) and result == 'ok' and pd['type'] == 'select_item':
|
||||
tr_data += bytes([0x90, 0x01, item_id])
|
||||
tr_data += bytes([0x83, 0x02, gr, 0x00])
|
||||
tr_hex = '%s140000%02x%s' % (scc.cat_cla, len(tr_data), tr_data.hex())
|
||||
tr_rv = scc._tp.send_apdu(tr_hex)
|
||||
sys.stderr.write('TR(menu): cmd=%02x type=%02x result=%02x -> %s\n' % (pd['cmd_num'], pd['cmd_type'], gr, tr_rv[1]))
|
||||
for entry in reversed(_PROACTIVE_LOG):
|
||||
if (entry.get('cmd_num') == pd['cmd_num']
|
||||
and entry.get('type_hex') == '%02x' % pd['cmd_type']
|
||||
and 'tr_hex' not in entry):
|
||||
_record_tr(entry, tr_data, tr_rv[1])
|
||||
break
|
||||
sw = tr_rv[1]
|
||||
resp = {'sw': sw}
|
||||
if result == 'cancel':
|
||||
self.server.stk_pending = None
|
||||
self.server.menu_active = False
|
||||
else:
|
||||
self.server.stk_pending = None
|
||||
if sw.startswith('91'):
|
||||
def _on_menu_fetch(raw, cmd_num, cmd_type, dev_src, dev_dst):
|
||||
if cmd_type == 0x21:
|
||||
text = _parse_display_text(raw) if raw else None
|
||||
if text:
|
||||
self.server.stk_pending = {'type': 'display_text',
|
||||
'cmd_num': cmd_num, 'cmd_type': cmd_type,
|
||||
'dev_src': dev_src, 'dev_dst': dev_dst, 'text': text}
|
||||
resp.update(type='display_text', text=text)
|
||||
return 'pause'
|
||||
elif cmd_type == 0x24:
|
||||
items = _parse_select_item(raw) if raw else []
|
||||
self.server.stk_pending = {'type': 'select_item',
|
||||
'cmd_num': cmd_num, 'cmd_type': cmd_type,
|
||||
'dev_src': dev_src, 'dev_dst': dev_dst, 'items': items}
|
||||
resp.update(type='select_item', items=items)
|
||||
return 'pause'
|
||||
elif cmd_type == 0x25:
|
||||
items = _parse_setup_menu_items(raw) if raw else []
|
||||
self.server.stk_pending = {'type': 'select_item',
|
||||
'cmd_num': cmd_num, 'cmd_type': cmd_type,
|
||||
'dev_src': dev_src, 'dev_dst': dev_dst, 'items': items}
|
||||
resp.update(type='select_item', items=items)
|
||||
return 'pause'
|
||||
_handle_proactive_chain(scc, sw, _on_menu_fetch)
|
||||
else:
|
||||
self.server.menu_active = False
|
||||
resp['type'] = 'done'
|
||||
self._send_json(resp)
|
||||
resp, code = _menu_send_response(self.server, body.get('result', 'ok'), body.get('item_id'))
|
||||
self._send_json(resp, code)
|
||||
self._log_resp(resp)
|
||||
elif self.path == '/api/event-send':
|
||||
scc = self.server.scc
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for per-command APDU timing collection (card snapshot measurements)."""
|
||||
|
||||
import sys
|
||||
import time
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
PROJECTS = Path(__file__).resolve().parents[2]
|
||||
PY_SIM = PROJECTS / 'pysim'
|
||||
if str(PY_SIM) not in sys.path:
|
||||
sys.path.insert(0, str(PY_SIM))
|
||||
|
||||
import pysim_otaman_server.server as S
|
||||
|
||||
|
||||
class TestClassifyApdu(unittest.TestCase):
|
||||
def test_select(self):
|
||||
self.assertEqual(S._classify_apdu('00a40004023f0000'), 'select')
|
||||
|
||||
def test_read_binary(self):
|
||||
self.assertEqual(S._classify_apdu('00b000000a'), 'read_binary')
|
||||
|
||||
def test_read_record(self):
|
||||
self.assertEqual(S._classify_apdu('00b2010428'), 'read_record')
|
||||
|
||||
def test_other_not_classified(self):
|
||||
self.assertIsNone(S._classify_apdu('80f2000c00'))
|
||||
|
||||
def test_short_input(self):
|
||||
self.assertIsNone(S._classify_apdu(''))
|
||||
self.assertIsNone(S._classify_apdu('00'))
|
||||
|
||||
|
||||
class TestApduTimeCollection(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.saved = (S._APDU_TIME_COLLECT, list(S._APDU_TIMES), S._server_ref)
|
||||
S._APDU_TIME_COLLECT = False
|
||||
S._APDU_TIMES.clear()
|
||||
S._server_ref = None
|
||||
|
||||
def tearDown(self):
|
||||
S._APDU_TIME_COLLECT, times, S._server_ref = self.saved
|
||||
S._APDU_TIMES[:] = times
|
||||
|
||||
def test_disabled_does_not_collect(self):
|
||||
tracer = S.StderrApduTracer()
|
||||
with mock.patch.object(S.os, 'write'):
|
||||
tracer.trace_command('00a40004023f0000')
|
||||
tracer.trace_response('00a40004023f0000', '9000', '')
|
||||
self.assertEqual(S._APDU_TIMES, [])
|
||||
|
||||
def test_collects_only_classified_commands_with_ms(self):
|
||||
S._collect_apdu_times()
|
||||
tracer = S.StderrApduTracer()
|
||||
with mock.patch.object(S.os, 'write'):
|
||||
tracer._cmd_start = time.time() - 0.025
|
||||
tracer.trace_response('00a40004023f0000', '9000', '')
|
||||
tracer._cmd_start = time.time() - 0.010
|
||||
tracer.trace_response('00b000000a', '9000', '')
|
||||
tracer._cmd_start = time.time() - 0.005
|
||||
tracer.trace_response('80f2000c00', '9000', '')
|
||||
times = S._end_apdu_time_collection()
|
||||
self.assertEqual([t['type'] for t in times], ['select', 'read_binary'])
|
||||
self.assertGreaterEqual(times[0]['ms'], 20)
|
||||
self.assertFalse(S._APDU_TIME_COLLECT)
|
||||
self.assertEqual(S._APDU_TIMES, [])
|
||||
|
||||
def test_collect_reattaches_tracer_when_missing(self):
|
||||
tp = types.SimpleNamespace(apdu_tracer=None)
|
||||
scc = types.SimpleNamespace(_tp=tp)
|
||||
S._server_ref = types.SimpleNamespace(scc=scc)
|
||||
S._collect_apdu_times()
|
||||
try:
|
||||
self.assertIsInstance(tp.apdu_tracer, S._LoggingApduTracer)
|
||||
finally:
|
||||
S._end_apdu_time_collection()
|
||||
|
||||
def test_collect_keeps_existing_tracer(self):
|
||||
tracer = S.StderrApduTracer()
|
||||
tp = types.SimpleNamespace(apdu_tracer=tracer)
|
||||
scc = types.SimpleNamespace(_tp=tp)
|
||||
S._server_ref = types.SimpleNamespace(scc=scc)
|
||||
S._collect_apdu_times()
|
||||
try:
|
||||
self.assertIs(tp.apdu_tracer, tracer)
|
||||
finally:
|
||||
S._end_apdu_time_collection()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the passive PC/SC card-presence observer and auto-equip state."""
|
||||
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
PROJECTS = Path(__file__).resolve().parents[2]
|
||||
PY_SIM = PROJECTS / 'pysim'
|
||||
if str(PY_SIM) not in sys.path:
|
||||
sys.path.insert(0, str(PY_SIM))
|
||||
|
||||
import pysim_otaman_server.server as S
|
||||
|
||||
|
||||
class FakeCard:
|
||||
def __init__(self, reader):
|
||||
self.reader = reader
|
||||
|
||||
|
||||
class TestCardPresenceObserver(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.observer = S._CardPresenceObserver('Test Reader 00 00')
|
||||
self.server = types.SimpleNamespace(card_present=True)
|
||||
self.saved_ref = S._server_ref
|
||||
S._server_ref = self.server
|
||||
self.disconnects = []
|
||||
self.patcher = mock.patch.object(
|
||||
S, '_handle_card_disconnect',
|
||||
side_effect=lambda: self.disconnects.append(True))
|
||||
self.patcher.start()
|
||||
self.trigger = mock.patch.object(S, '_auto_equip_trigger')
|
||||
self.trigger_mock = self.trigger.start()
|
||||
self.saved_auto = S._AUTO_EQUIP
|
||||
S._AUTO_EQUIP = True
|
||||
|
||||
def tearDown(self):
|
||||
S._AUTO_EQUIP = self.saved_auto
|
||||
self.trigger.stop()
|
||||
self.patcher.stop()
|
||||
S._server_ref = self.saved_ref
|
||||
|
||||
def test_removal_of_our_reader_disconnects(self):
|
||||
self.observer.update(None, ([], [FakeCard('Test Reader 00 00')]))
|
||||
self.assertFalse(self.server.card_present)
|
||||
self.assertEqual(len(self.disconnects), 1)
|
||||
self.trigger_mock.assert_not_called()
|
||||
|
||||
def test_removal_of_other_reader_ignored(self):
|
||||
self.observer.update(None, ([], [FakeCard('Other Reader 00 00')]))
|
||||
self.assertTrue(self.server.card_present)
|
||||
self.assertEqual(self.disconnects, [])
|
||||
self.trigger_mock.assert_not_called()
|
||||
|
||||
def test_insertion_sets_card_present_and_triggers_auto_equip(self):
|
||||
self.server.card_present = False
|
||||
self.observer.update(None, ([FakeCard('Test Reader 00 00')], []))
|
||||
self.assertTrue(self.server.card_present)
|
||||
self.assertEqual(self.disconnects, [])
|
||||
self.trigger_mock.assert_called_once()
|
||||
|
||||
def test_insertion_does_not_trigger_when_disabled(self):
|
||||
S._AUTO_EQUIP = False
|
||||
self.server.card_present = False
|
||||
self.observer.update(None, ([FakeCard('Test Reader 00 00')], []))
|
||||
self.assertTrue(self.server.card_present)
|
||||
self.trigger_mock.assert_not_called()
|
||||
|
||||
def test_missing_reader_attribute_is_ignored(self):
|
||||
self.observer.update(None, ([], [types.SimpleNamespace()]))
|
||||
self.assertTrue(self.server.card_present)
|
||||
self.assertEqual(self.disconnects, [])
|
||||
self.trigger_mock.assert_not_called()
|
||||
|
||||
|
||||
class TestAutoEquipTrigger(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
S._AUTO_EQUIP = True
|
||||
S._AUTO_EQUIP_BUSY = False
|
||||
|
||||
def test_disabled_does_not_spawn(self):
|
||||
S._AUTO_EQUIP = False
|
||||
with mock.patch.object(S.threading, 'Thread') as thread:
|
||||
S._auto_equip_trigger()
|
||||
thread.assert_not_called()
|
||||
|
||||
def test_busy_does_not_spawn_twice(self):
|
||||
S._AUTO_EQUIP = True
|
||||
S._AUTO_EQUIP_BUSY = True
|
||||
with mock.patch.object(S.threading, 'Thread') as thread:
|
||||
S._auto_equip_trigger()
|
||||
thread.assert_not_called()
|
||||
|
||||
def test_spawns_worker_once(self):
|
||||
S._AUTO_EQUIP = True
|
||||
S._AUTO_EQUIP_BUSY = False
|
||||
with mock.patch.object(S.threading, 'Thread') as thread:
|
||||
S._auto_equip_trigger()
|
||||
thread.assert_called_once_with(target=S._auto_equip_worker, name='auto-equip', daemon=True)
|
||||
|
||||
|
||||
class TestCardSession(unittest.TestCase):
|
||||
def test_disconnect_bumps_session_and_clears_equipping(self):
|
||||
server = types.SimpleNamespace(
|
||||
card_session=5, card=object(), scc=object(), stk_pending=object(),
|
||||
menu_active=True, event_list=[1], sim_menu={}, equipping=True)
|
||||
saved = S._server_ref
|
||||
S._server_ref = server
|
||||
try:
|
||||
S._handle_card_disconnect()
|
||||
finally:
|
||||
S._server_ref = saved
|
||||
self.assertEqual(server.card_session, 6)
|
||||
self.assertFalse(server.equipping)
|
||||
self.assertIsNone(server.card)
|
||||
|
||||
|
||||
class TestApplyEquippedCard(unittest.TestCase):
|
||||
def test_updates_state_and_bumps_session(self):
|
||||
scc = types.SimpleNamespace(cat_cla=None)
|
||||
card = types.SimpleNamespace(_scc=scc, name='Card')
|
||||
app = types.SimpleNamespace(card=card)
|
||||
server = types.SimpleNamespace(
|
||||
app=app, card=None, scc=None, stk_pending=object(), menu_active=True,
|
||||
event_list=[1], sim_menu={}, card_session=2, card_present=False,
|
||||
equipping=False, terminal_profile='7F')
|
||||
saved_ref, saved_conn = S._server_ref, S._CARD_CONNECTED
|
||||
S._server_ref = server
|
||||
S._CARD_CONNECTED = False
|
||||
try:
|
||||
with mock.patch.object(S, '_send_terminal_profile', return_value=('menu', ['ev'])):
|
||||
with mock.patch.object(S, '_poll_enable'):
|
||||
S._apply_equipped_card(server)
|
||||
connected_after = S._CARD_CONNECTED
|
||||
finally:
|
||||
S._server_ref = saved_ref
|
||||
S._CARD_CONNECTED = saved_conn
|
||||
self.assertTrue(connected_after)
|
||||
self.assertIs(server.card, card)
|
||||
self.assertIs(server.scc, scc)
|
||||
self.assertEqual(server.card_session, 3)
|
||||
self.assertTrue(server.card_present)
|
||||
self.assertEqual(server.sim_menu, 'menu')
|
||||
self.assertEqual(server.event_list, ['ev'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the reset-free fast initialization helpers."""
|
||||
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
PROJECTS = Path(__file__).resolve().parents[2]
|
||||
PY_SIM = PROJECTS / 'pysim'
|
||||
if str(PY_SIM) not in sys.path:
|
||||
sys.path.insert(0, str(PY_SIM))
|
||||
|
||||
from pySim.exceptions import SwMatchError
|
||||
from pySim.ts_102_221 import CardProfileUICC
|
||||
|
||||
import pysim_otaman_server.fastinit as fastinit
|
||||
from pysim_otaman_server.fastinit import (
|
||||
FastRuntimeState,
|
||||
do_reset_fast,
|
||||
pick_profile_no_reset,
|
||||
)
|
||||
|
||||
|
||||
class FakeScc:
|
||||
def __init__(self):
|
||||
self.sel_ctrl = '0004'
|
||||
self.cla_byte = '00'
|
||||
self.resets = 0
|
||||
self.selected = []
|
||||
|
||||
def reset_card(self):
|
||||
self.resets += 1
|
||||
|
||||
def select_file(self, fid):
|
||||
self.selected.append(fid)
|
||||
return ('', '9000')
|
||||
|
||||
def select_adf(self, aid):
|
||||
raise SwMatchError('6a82', '9000')
|
||||
|
||||
|
||||
class TestPickProfileNoReset(unittest.TestCase):
|
||||
def test_uicc_selected_without_any_reset(self):
|
||||
scc = FakeScc()
|
||||
profile = pick_profile_no_reset(scc)
|
||||
self.assertIsInstance(profile, CardProfileUICC)
|
||||
self.assertEqual(scc.resets, 0)
|
||||
self.assertIn('3f00', scc.selected)
|
||||
|
||||
def test_reset_card_restored_after_pick(self):
|
||||
scc = FakeScc()
|
||||
pick_profile_no_reset(scc)
|
||||
scc.reset_card()
|
||||
self.assertEqual(scc.resets, 1)
|
||||
|
||||
|
||||
class FakeLchan:
|
||||
def __init__(self):
|
||||
self.scc = types.SimpleNamespace(scp=object())
|
||||
self.selected_adf = 'SOMETHING'
|
||||
self.selected = []
|
||||
|
||||
def select(self, path, cmd_app=None):
|
||||
self.selected.append(path)
|
||||
|
||||
|
||||
class TestFastRuntimeStateSoftReset(unittest.TestCase):
|
||||
def make_rs(self):
|
||||
rs = FastRuntimeState.__new__(FastRuntimeState)
|
||||
rs.lchan = {0: FakeLchan(), 1: FakeLchan()}
|
||||
rs.adm_verified = True
|
||||
rs.card = types.SimpleNamespace(_scc=types.SimpleNamespace(get_atr=lambda: 'AABB'))
|
||||
rs.identity = {}
|
||||
return rs
|
||||
|
||||
def test_soft_reset_selects_mf_without_physical_reset(self):
|
||||
rs = self.make_rs()
|
||||
atr = rs.soft_reset()
|
||||
self.assertEqual(atr, 'AABB')
|
||||
self.assertEqual(rs.identity['ATR'], 'AABB')
|
||||
self.assertEqual(rs.lchan[0].selected, ['MF'])
|
||||
self.assertIsNone(rs.lchan[0].selected_adf)
|
||||
self.assertFalse(rs.adm_verified)
|
||||
self.assertNotIn(1, rs.lchan)
|
||||
|
||||
def test_reset_is_soft(self):
|
||||
rs = self.make_rs()
|
||||
rs.card = types.SimpleNamespace(_scc=types.SimpleNamespace(get_atr=lambda: 'EEFF'))
|
||||
self.assertEqual(rs.reset(), 'EEFF')
|
||||
self.assertEqual(rs.lchan[0].selected, ['MF'])
|
||||
|
||||
|
||||
class FakeCardScc:
|
||||
def __init__(self):
|
||||
self.resets = 0
|
||||
|
||||
def reset_card(self):
|
||||
self.resets += 1
|
||||
return 'ATR'
|
||||
|
||||
def get_atr(self):
|
||||
return 'AABB'
|
||||
|
||||
|
||||
class TestDoResetFast(unittest.TestCase):
|
||||
def test_explicit_reset_is_physical(self):
|
||||
scc = FakeCardScc()
|
||||
out = []
|
||||
app = types.SimpleNamespace(rs=None, card=types.SimpleNamespace(_scc=scc), poutput=out.append)
|
||||
do_reset_fast(app)
|
||||
self.assertEqual(scc.resets, 1)
|
||||
self.assertEqual(out, ['Card ATR: AABB'])
|
||||
|
||||
def test_explicit_reset_uses_hard_reset_with_runtime_state(self):
|
||||
calls = []
|
||||
rs = types.SimpleNamespace(hard_reset=lambda cmd_app=None: calls.append(cmd_app) or 'CCDD')
|
||||
out = []
|
||||
app = types.SimpleNamespace(rs=rs, card=None, poutput=out.append)
|
||||
do_reset_fast(app)
|
||||
self.assertEqual(calls, [app])
|
||||
self.assertEqual(out, ['Card ATR: CCDD'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
|
||||
class FlakyLchan:
|
||||
"""Lchan whose first MF select fails, as if probing left the card in a
|
||||
context the software reset cannot clear."""
|
||||
|
||||
def __init__(self):
|
||||
self.scc = types.SimpleNamespace(scp=object())
|
||||
self.selected_adf = 'SOMETHING'
|
||||
self.select_calls = 0
|
||||
self.selected = []
|
||||
|
||||
def select(self, path, cmd_app=None):
|
||||
self.select_calls += 1
|
||||
if self.select_calls == 1:
|
||||
raise SwMatchError('6d00', '9000')
|
||||
self.selected.append(path)
|
||||
|
||||
|
||||
class ResettableCard:
|
||||
def __init__(self):
|
||||
self.resets = 0
|
||||
self._scc = types.SimpleNamespace(get_atr=lambda: 'AABB')
|
||||
|
||||
def reset(self):
|
||||
self.resets += 1
|
||||
return 'AABB'
|
||||
|
||||
|
||||
class TestFastResetEscalation(unittest.TestCase):
|
||||
def make_rs(self):
|
||||
rs = FastRuntimeState.__new__(FastRuntimeState)
|
||||
rs.lchan = {0: FlakyLchan(), 1: types.SimpleNamespace(scc=types.SimpleNamespace(scp=None))}
|
||||
rs.adm_verified = True
|
||||
rs.card = ResettableCard()
|
||||
rs.identity = {}
|
||||
return rs
|
||||
|
||||
def test_soft_reset_escalates_to_physical(self):
|
||||
rs = self.make_rs()
|
||||
atr = rs.reset()
|
||||
self.assertEqual(atr, 'AABB')
|
||||
self.assertEqual(rs.card.resets, 1)
|
||||
self.assertEqual(rs.lchan[0].select_calls, 2)
|
||||
self.assertEqual(rs.lchan[0].selected, ['MF'])
|
||||
self.assertFalse(rs.adm_verified)
|
||||
self.assertNotIn(1, rs.lchan)
|
||||
|
||||
|
||||
class TestInitCardFastRetry(unittest.TestCase):
|
||||
def test_retries_once_after_physical_reset(self):
|
||||
calls = []
|
||||
|
||||
def once(sl, skip, wait):
|
||||
calls.append(wait)
|
||||
if len(calls) == 1:
|
||||
raise SwMatchError('6d00', '9000')
|
||||
return ('rs', 'card')
|
||||
|
||||
sl = types.SimpleNamespace(resets=0)
|
||||
|
||||
def reset_card():
|
||||
sl.resets += 1
|
||||
|
||||
sl.reset_card = reset_card
|
||||
with mock.patch.object(fastinit, '_init_card_once', side_effect=once):
|
||||
rs, card = fastinit.init_card_fast(sl, wait=True)
|
||||
self.assertEqual(calls, [True, False])
|
||||
self.assertEqual(sl.resets, 1)
|
||||
self.assertEqual((rs, card), ('rs', 'card'))
|
||||
|
||||
|
||||
class TestDoEquipFastFailure(unittest.TestCase):
|
||||
def test_failed_equip_keeps_previous_state(self):
|
||||
calls = []
|
||||
app = types.SimpleNamespace(
|
||||
sl=object(),
|
||||
rs=types.SimpleNamespace(profile=types.SimpleNamespace(shell_cmdsets=[object()])),
|
||||
unregister_command_set=lambda cs: calls.append('unregister'),
|
||||
equip=lambda card, rs: calls.append('equip'),
|
||||
)
|
||||
with mock.patch.object(fastinit, 'init_card_fast', side_effect=SwMatchError('6d00', '9000')):
|
||||
with self.assertRaises(SwMatchError):
|
||||
fastinit.do_equip_fast(app)
|
||||
self.assertEqual(calls, [])
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the paused-command (STK menu) timeout watchdog."""
|
||||
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
PROJECTS = Path(__file__).resolve().parents[2]
|
||||
PY_SIM = PROJECTS / 'pysim'
|
||||
if str(PY_SIM) not in sys.path:
|
||||
sys.path.insert(0, str(PY_SIM))
|
||||
|
||||
import pysim_otaman_server.server as S
|
||||
|
||||
|
||||
class TestMenuTimeout(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.saved = (S._MENU_TIMEOUT, S._MENU_TIMER)
|
||||
|
||||
def tearDown(self):
|
||||
S._cancel_menu_timeout()
|
||||
S._MENU_TIMEOUT, S._MENU_TIMER = self.saved
|
||||
|
||||
def test_clamped(self):
|
||||
S._set_menu_timeout(30)
|
||||
self.assertEqual(S._MENU_TIMEOUT, 30)
|
||||
S._set_menu_timeout(0)
|
||||
self.assertEqual(S._MENU_TIMEOUT, 0)
|
||||
S._set_menu_timeout(-1)
|
||||
self.assertEqual(S._MENU_TIMEOUT, 0)
|
||||
S._set_menu_timeout(99999)
|
||||
self.assertEqual(S._MENU_TIMEOUT, 3600)
|
||||
|
||||
def test_arm_starts_timer(self):
|
||||
S._set_menu_timeout(30)
|
||||
with mock.patch.object(S.threading, 'Timer') as timer:
|
||||
S._arm_menu_timeout()
|
||||
timer.assert_called_once_with(30, S._menu_timeout_fire)
|
||||
|
||||
def test_zero_disables_arming(self):
|
||||
S._set_menu_timeout(0)
|
||||
with mock.patch.object(S.threading, 'Timer') as timer:
|
||||
S._arm_menu_timeout()
|
||||
timer.assert_not_called()
|
||||
|
||||
def test_cancel(self):
|
||||
timer = mock.Mock()
|
||||
S._MENU_TIMER = timer
|
||||
S._cancel_menu_timeout()
|
||||
timer.cancel.assert_called_once()
|
||||
self.assertIsNone(S._MENU_TIMER)
|
||||
|
||||
|
||||
class TestMenuSendResponse(unittest.TestCase):
|
||||
def test_timeout_tr_is_flat_with_general_result(self):
|
||||
sent = []
|
||||
|
||||
def send_apdu(hexstr):
|
||||
sent.append(hexstr)
|
||||
return ('', '9000')
|
||||
|
||||
server = types.SimpleNamespace(
|
||||
stk_pending={'type': 'display_text', 'cmd_num': 1, 'cmd_type': 0x21,
|
||||
'dev_src': 0x81, 'dev_dst': 0x83},
|
||||
menu_active=True,
|
||||
scc=types.SimpleNamespace(cat_cla='80', _tp=types.SimpleNamespace(send_apdu=send_apdu)),
|
||||
)
|
||||
resp, code = S._menu_send_response(server, 'timeout', None)
|
||||
self.assertEqual(code, 200)
|
||||
self.assertEqual(resp['sw'], '9000')
|
||||
self.assertEqual(resp['type'], 'done')
|
||||
self.assertIsNone(server.stk_pending)
|
||||
self.assertFalse(server.menu_active)
|
||||
tr = sent[0]
|
||||
self.assertTrue(tr.startswith('801400000d'), tr)
|
||||
self.assertIn('8103012100', tr)
|
||||
self.assertIn('82028381', tr)
|
||||
self.assertIn('83021200', tr)
|
||||
|
||||
def test_no_pending_returns_400(self):
|
||||
resp, code = S._menu_send_response(types.SimpleNamespace(stk_pending=None), 'ok')
|
||||
self.assertEqual(code, 400)
|
||||
self.assertIn('error', resp)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestFinishPendingMenu(unittest.TestCase):
|
||||
def make_server(self):
|
||||
return types.SimpleNamespace(
|
||||
stk_pending={'type': 'select_item', 'cmd_num': 1, 'cmd_type': 0x24,
|
||||
'dev_src': 0x81, 'dev_dst': 0x83, 'items': []},
|
||||
menu_active=True, scc=None)
|
||||
|
||||
def make_scc(self, sent, sw='9000'):
|
||||
return types.SimpleNamespace(
|
||||
cat_cla='80',
|
||||
_tp=types.SimpleNamespace(send_apdu=lambda h: (sent.append(h) or ('', sw))))
|
||||
|
||||
def test_no_pending_is_noop(self):
|
||||
sent = []
|
||||
S._finish_pending_menu(types.SimpleNamespace(stk_pending=None), self.make_scc(sent))
|
||||
self.assertEqual(sent, [])
|
||||
|
||||
def test_pending_finished_with_cancel_tr(self):
|
||||
server = self.make_server()
|
||||
sent = []
|
||||
scc = self.make_scc(sent)
|
||||
server.scc = scc
|
||||
S._finish_pending_menu(server, scc)
|
||||
self.assertEqual(len(sent), 1)
|
||||
tr = sent[0]
|
||||
self.assertTrue(tr.startswith('801400000d'), tr)
|
||||
self.assertIn('83021000', tr) # general result 0x10 = cancel
|
||||
self.assertIsNone(server.stk_pending)
|
||||
self.assertFalse(server.menu_active)
|
||||
|
||||
def test_91xx_answer_drains_chain(self):
|
||||
server = self.make_server()
|
||||
scc = self.make_scc([], sw='9120')
|
||||
server.scc = scc
|
||||
with mock.patch.object(S, '_handle_proactive_chain') as chain:
|
||||
S._finish_pending_menu(server, scc)
|
||||
chain.assert_called_once_with(scc, '9120')
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the background STATUS polling interval semantics."""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
PROJECTS = Path(__file__).resolve().parents[2]
|
||||
PY_SIM = PROJECTS / 'pysim'
|
||||
if str(PY_SIM) not in sys.path:
|
||||
sys.path.insert(0, str(PY_SIM))
|
||||
|
||||
import pysim_otaman_server.server as S
|
||||
|
||||
|
||||
class TestPollInterval(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.saved = (S._POLL_ENABLED, S._POLL_INTERVAL, S._POLL_TIMER)
|
||||
|
||||
def tearDown(self):
|
||||
S._poll_disable()
|
||||
S._POLL_ENABLED, S._POLL_INTERVAL, S._POLL_TIMER = self.saved
|
||||
|
||||
def test_zero_interval_disables_polling(self):
|
||||
S._set_poll_interval(0)
|
||||
self.assertEqual(S._POLL_INTERVAL, 0)
|
||||
with mock.patch.object(S.threading, 'Timer') as timer:
|
||||
S._poll_enable()
|
||||
timer.assert_not_called()
|
||||
self.assertFalse(S._POLL_ENABLED)
|
||||
self.assertIsNone(S._POLL_TIMER)
|
||||
|
||||
def test_negative_interval_clamped_to_zero(self):
|
||||
S._set_poll_interval(-5)
|
||||
self.assertEqual(S._POLL_INTERVAL, 0)
|
||||
|
||||
def test_positive_interval_starts_timer(self):
|
||||
S._set_poll_interval(30)
|
||||
with mock.patch.object(S.threading, 'Timer') as timer:
|
||||
S._poll_enable()
|
||||
self.assertTrue(S._POLL_ENABLED)
|
||||
timer.assert_called_once_with(30, S._do_status_poll)
|
||||
|
||||
def test_reset_timer_skipped_when_disabled(self):
|
||||
S._set_poll_interval(0)
|
||||
S._POLL_ENABLED = True
|
||||
with mock.patch.object(S.threading, 'Timer') as timer:
|
||||
S._reset_poll_timer()
|
||||
timer.assert_not_called()
|
||||
self.assertIsNone(S._POLL_TIMER)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for the parent-scoped select helpers in pysim_otaman_server.server.
|
||||
|
||||
The helpers must resolve every model-known file strictly within the requested
|
||||
parent (no pySim global selectables, no probe_file model injection) and must
|
||||
detach any model-unknown file that had to be probed for a custom file.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
PROJECTS = Path(__file__).resolve().parents[2]
|
||||
PY_SIM = PROJECTS / 'pysim'
|
||||
if str(PY_SIM) not in sys.path:
|
||||
sys.path.insert(0, str(PY_SIM))
|
||||
|
||||
from pysim_otaman_server.server import (
|
||||
_app_by_sel,
|
||||
_fid4,
|
||||
_file_by_sel,
|
||||
_find_in_tree,
|
||||
_select_path,
|
||||
_select_with_parent,
|
||||
)
|
||||
|
||||
|
||||
class FakeFile:
|
||||
def __init__(self, fid=None, name=None, parent=None, aid=None):
|
||||
self.fid = fid
|
||||
self.name = name
|
||||
self.parent = parent
|
||||
self.aid = aid
|
||||
self.sfid = None
|
||||
self.children = {}
|
||||
|
||||
def add_files(self, files):
|
||||
for f in files:
|
||||
f.parent = self
|
||||
self.children[f.fid] = f
|
||||
|
||||
|
||||
class FakeMF(FakeFile):
|
||||
def __init__(self):
|
||||
super().__init__(fid='3f00', name='MF')
|
||||
self.applications = {}
|
||||
|
||||
|
||||
class FakeLchan:
|
||||
"""No .select() on purpose: any global-resolution call would fail loudly."""
|
||||
|
||||
def __init__(self, mf):
|
||||
self.selected_file = mf
|
||||
self.selects = []
|
||||
self.probes = []
|
||||
|
||||
def select_file(self, f, app=None):
|
||||
self.selected_file = f
|
||||
self.selects.append(f)
|
||||
|
||||
def probe_file(self, fid, app=None):
|
||||
self.probes.append(fid)
|
||||
f = FakeFile(fid=fid, name='EF.' + fid.upper(), parent=self.selected_file)
|
||||
self.selected_file.add_files([f])
|
||||
self.selected_file = f
|
||||
|
||||
|
||||
def build_model():
|
||||
mf = FakeMF()
|
||||
gsm = FakeFile('7f20', 'DF.GSM', mf)
|
||||
mf.children['7f20'] = gsm
|
||||
spn = FakeFile('6f46', 'EF.SPN', gsm)
|
||||
gsm.children['6f46'] = spn
|
||||
telecom = FakeFile('7f10', 'DF.TELECOM', mf)
|
||||
mf.children['7f10'] = telecom
|
||||
tel_ph = FakeFile('5f3a', 'DF.PHONEBOOK', telecom)
|
||||
telecom.children['5f3a'] = tel_ph
|
||||
usim = FakeFile(None, 'ADF.USIM', mf, aid='A0000000871002')
|
||||
mf.applications['a0000000871002'] = usim
|
||||
imsi = FakeFile('6f07', 'EF.IMSI', usim)
|
||||
usim.children['6f07'] = imsi
|
||||
usim_ph = FakeFile('5f3a', 'DF.PHONEBOOK', usim)
|
||||
usim.children['5f3a'] = usim_ph
|
||||
return mf, usim, usim_ph, telecom, tel_ph, gsm, spn
|
||||
|
||||
|
||||
def setup():
|
||||
mf, usim, usim_ph, telecom, tel_ph, gsm, spn = build_model()
|
||||
app = SimpleNamespace(rs=SimpleNamespace(mf=mf))
|
||||
lchan = FakeLchan(mf)
|
||||
return app, lchan, mf, usim, usim_ph, gsm, spn
|
||||
|
||||
|
||||
class FidHelpersTest(unittest.TestCase):
|
||||
def test_fid4(self):
|
||||
self.assertTrue(_fid4('6F07'))
|
||||
self.assertFalse(_fid4('EF.IMSI'))
|
||||
self.assertFalse(_fid4('6F0'))
|
||||
|
||||
def test_file_by_sel_matches_fid_and_name(self):
|
||||
_, _, _, _, _, gsm, spn = setup()
|
||||
self.assertIs(_file_by_sel(gsm, '6f46'), spn)
|
||||
self.assertIs(_file_by_sel(gsm, 'EF.SPN'), spn)
|
||||
self.assertIsNone(_file_by_sel(gsm, '6f07'))
|
||||
|
||||
def test_find_in_tree_reports_duplicates(self):
|
||||
app, _, mf, _, _, _, _ = setup()
|
||||
self.assertEqual(len(_find_in_tree(mf, '5f3a')), 2)
|
||||
self.assertEqual(len(_find_in_tree(mf, 'EF.IMSI')), 1)
|
||||
|
||||
|
||||
class ParentScopedSelectTest(unittest.TestCase):
|
||||
def test_duplicate_fid_is_resolved_under_the_walked_parent(self):
|
||||
app, lchan, mf, usim, usim_ph, _, _ = setup()
|
||||
target, cleanup = _select_with_parent(lchan, '5f3a', None, app, parent_path=['MF', 'A0000000871002'])
|
||||
self.assertIs(target, usim_ph)
|
||||
self.assertIsNone(cleanup)
|
||||
self.assertEqual(lchan.selects, [mf, usim, usim_ph])
|
||||
self.assertEqual(lchan.probes, [])
|
||||
|
||||
def test_path_with_fids_selects_exactly(self):
|
||||
app, lchan, mf, _, _, gsm, spn = setup()
|
||||
target, cleanup = _select_path(lchan, 'MF/7F20/6F46', app)
|
||||
self.assertIs(target, spn)
|
||||
self.assertIsNone(cleanup)
|
||||
self.assertEqual(lchan.selects, [mf, gsm, spn])
|
||||
|
||||
def test_path_with_aid_root_selects_application(self):
|
||||
app, lchan, _, usim, _, _, _ = setup()
|
||||
target, _ = _select_path(lchan, 'A0000000871002/6F07', app)
|
||||
self.assertEqual(target.fid, '6f07')
|
||||
self.assertEqual(lchan.selected_file.parent, usim)
|
||||
|
||||
def test_ambiguous_legacy_parent_selector_is_rejected(self):
|
||||
app, lchan, _, _, _, _, _ = setup()
|
||||
with self.assertRaisesRegex(RuntimeError, 'Ambiguous'):
|
||||
_select_with_parent(lchan, '6f07', '5f3a', app)
|
||||
|
||||
def test_unknown_name_is_not_probed(self):
|
||||
app, lchan, _, _, _, gsm, _ = setup()
|
||||
with self.assertRaisesRegex(RuntimeError, 'File not found'):
|
||||
_select_with_parent(lchan, 'NOSUCH', None, app, parent_path=['MF', '7F20'])
|
||||
self.assertEqual(lchan.probes, [])
|
||||
|
||||
def test_unknown_fid_without_allow_probe_does_not_touch_the_card(self):
|
||||
app, lchan, _, _, _, gsm, _ = setup()
|
||||
with self.assertRaisesRegex(RuntimeError, 'File not found'):
|
||||
_select_with_parent(lchan, '6f99', None, app, parent_path=['MF', '7F20'])
|
||||
self.assertEqual(lchan.probes, [])
|
||||
|
||||
def test_allow_probe_detaches_the_temporary_file_and_restores_selection(self):
|
||||
app, lchan, mf, _, _, gsm, _ = setup()
|
||||
before = set(gsm.children)
|
||||
target, cleanup = _select_with_parent(lchan, '6f99', None, app, parent_path=['MF', '7F20'], allow_probe=True)
|
||||
self.assertEqual(lchan.probes, ['6f99'])
|
||||
self.assertEqual(target.fid, '6f99')
|
||||
self.assertIsNotNone(cleanup)
|
||||
self.assertIn('6f99', gsm.children)
|
||||
cleanup()
|
||||
self.assertEqual(set(gsm.children), before)
|
||||
self.assertIs(lchan.selected_file, mf)
|
||||
|
||||
def test_custom_path_segments_are_probed_and_detached(self):
|
||||
app, lchan, mf, _, _, gsm, _ = setup()
|
||||
before = set(gsm.children)
|
||||
target, cleanup = _select_path(lchan, 'MF/7F20/A0B1/6F01', app)
|
||||
self.assertEqual(lchan.probes, ['a0b1', '6f01'])
|
||||
self.assertEqual(target.fid, '6f01')
|
||||
cleanup()
|
||||
self.assertEqual(set(gsm.children), before)
|
||||
self.assertIs(lchan.selected_file, mf)
|
||||
|
||||
def test_model_known_selection_never_mutates_the_tree(self):
|
||||
app, lchan, _, _, _, gsm, spn = setup()
|
||||
before = {id(k): k for k in gsm.children}
|
||||
_select_with_parent(lchan, '6f46', None, app, parent_path=['MF', '7F20'])
|
||||
self.assertEqual({id(k): k for k in gsm.children}, before)
|
||||
self.assertEqual(lchan.probes, [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user