v3.21.0: the test-runner tolerance batch + the proactive cleanup action

Five reported issues, plus the action that makes an explicit drain possible:

- on_fail is honoured by every step failure: an expectation with no pending
  command (or a FETCH that returns nothing) fails as a Command/Fetch check at
  the step's level instead of raising past it; the default still stops the
  run, on_fail "warning" continues (the tolerant consume-if-pending form).
- the run cleanup answers ok (0x00) and keeps draining while the TR answers
  91XX (bounded); a cancel is only sent when a script asks for it - it tells
  the card the user aborted the session and pushes assertive applets onto an
  error path.
- new `proactive` action: STATUS rounds consume every announced command
  (respond/first TERMINAL RESPONSE results, attempts/interval_ms bound) until
  the card answers 9000; an optional require asserts at least one drained
  command (an empty drain fails it), the final SW is the step's SW check and
  a still-pending command is handed to the next step.  PWA editor + summary +
  validation, EN/RU help, docs, action-kind drift test.
- the PoR response form follows the command's TAR: _tar_is_rm (preset role
  entries + the TS 101 220 Annex D ISD/RFM allocations) decides; a non-RM
  TAR's reply is always raw (TS 102 226 4) - no fabricated status word even
  when the bytes happen to parse (01 00 0B 91 ... used to read as SW 000B).
- a secured data too short for the compact structure (a 2-byte applet reply)
  is recovered by _por_header_parse (the decode minus the compact parse; a
  malformed packet or a failed RC/CC/DS check still yields no por) and
  reported raw, so short applet replies are assertable via por.data.

Docs: test-scripts.md, api.md, help EN/RU, repo AGENTS.  sw cache simple-v396.
Tests: 825 frontend + 755 Python green.
This commit is contained in:
2026-10-05 23:36:47 +03:00
parent 82c5733906
commit e6ced47323
12 changed files with 788 additions and 100 deletions
+32 -11
View File
@@ -592,12 +592,16 @@ response carries them too, plus an `error`).
fetched via a proactive command (FETCH). The response contains the fetched via a proactive command (FETCH). The response contains the
same `por` structure if decoding succeeds. same `por` structure if decoding succeeds.
When the secured data cannot be the TS 102 226 compact remote response (its The response form follows the command's TAR (v3.21.0): a remote-management
command count exceeds the command script that was sent - a third-party TAR (the preset's role entries and the standard ISD/RFM allocations of
applet's own TAR answers with application bytes), `por.response_type` is TS 101 220 Annex D) is decoded as an RM response; any other TAR's secured
`raw`, the whole secured data is reported as `decoded.last_response_data` data is the receiving application's own (TS 102 226 4) and is reported with
and no status word is decoded; `secured_data` carries the response bytes in `por.response_type` `raw` - the whole secured data in
every case. `decoded.last_response_data` and no status word (`secured_data` carries the
response bytes in every case). The same `raw` classification covers a
secured data that cannot be the compact structure (its command count exceeds
the command script that was sent, or it is too short for Table 5.1), while a
genuinely malformed packet or a failed RC/CC/DS check still yields no `por`.
The SPI2 `por_in_submit` bit (0x20) selects submit-mode PoR. The SPI2 `por_in_submit` bit (0x20) selects submit-mode PoR.
@@ -722,20 +726,29 @@ number 1-15), `tar`/`spi1`/`spi2` overrides and `format` (`compact` default /
`expanded` / `expanded-ae` - the TS 102 226 5.2.1 Command Scripting template `expanded` / `expanded-ae` - the TS 102 226 5.2.1 Command Scripting template
around the C-APDU) - KIc/KID and the counter always around the C-APDU) - KIc/KID and the counter always
come from the resolved preset, which must match the equipped card and be come from the resolved preset, which must match the equipped card and be
complete) or `status` complete), `status`
(`attempts`, `interval_ms` - when `attempts > 1` the default SW check is the (`attempts`, `interval_ms` - when `attempts > 1` the default SW check is the
mask `91??`, i.e. poll until the card announces a command). mask `91??`, i.e. poll until the card announces a command) or `proactive`
(the proactive cleanup: poll STATUS and consume every announced command -
`respond`/`first` TERMINAL RESPONSE results, `attempts`/`interval_ms` bound,
optional `require` `{command, qualifier?}` asserting at least one drained
command; the final SW is the step's `check.sw`, so a still-pending card fails
and hands the command to the next step - an empty drain passes unless
`require` was given).
`check` is `{"sw": ..., "data": ...}` (exact or `{"mode": "mask", "value": `check` is `{"sw": ..., "data": ...}` (exact or `{"mode": "mask", "value":
"91??"}`, `?` = per-nibble wildcard) plus `"por"` for SCP80 - "91??"}`, `?` = per-nibble wildcard) plus `"por"` for SCP80 -
`"none"`/`"ok"`/`"any"` or an object `{"status"?, "sw"?, "data"?}` asserting `"none"`/`"ok"`/`"any"` or an object `{"status"?, "sw"?, "data"?}` asserting
the decoded PoR of this exchange (inline or SEND SHORT MESSAGE; at least one the decoded PoR of this exchange (inline or SEND SHORT MESSAGE; at least one
field). `on_fail` is `error` (terminates the script) or `warning` (continues). field). `on_fail` is `error` (terminates the script) or `warning` (continues)
and applies to every step failure - including an expectation that finds no
pending command (v3.21.0; `warning` is the tolerant consume-if-pending form).
**Expectation steps** (`type: "expect"`) require a command pending from the **Expectation steps** (`type: "expect"`) require a command pending from the
previous step (`91XX`); they never poll - a `9000` response means no command previous step (`91XX`); they never poll - a `9000` response means no command
and is an error (TS 102 221 7.4.2.1 / TS 102 223 6.3; add a `status` action and fails the step at its `on_fail` level (TS 102 221 7.4.2.1 / TS 102 223
if the card delivers on poll). `command` is a name or type code; `checks` 6.3; a `status` action with `attempts > 1` polls when the card delivers on
poll). `command` is a name or type code; `checks`
may be `text` (contains/exact), `alpha` (the command's Alpha identifier may be `text` (contains/exact), `alpha` (the command's Alpha identifier
`05`/`85`, contains/exact - distinct from the `8D` text string), `item` `05`/`85`, contains/exact - distinct from the `8D` text string), `item`
(`id`/`text` for SELECT ITEM / SET UP MENU), `raw` (mask), `por` (a SEND (`id`/`text` for SELECT ITEM / SET UP MENU), `raw` (mask), `por` (a SEND
@@ -756,6 +769,14 @@ The response is the initial state (`running: true`), the final counter
(`scp80_counter`) and the step list; poll `/api/test/status`. The PWA writes (`scp80_counter`) and the step list; poll `/api/test/status`. The PWA writes
`scp80_counter` back to the card preset after the run. `scp80_counter` back to the card preset after the run.
A run that ends (an error, a stop, an unexpected pending command) with a
command still announced **drains it with `ok` (0x00)** - fetching while the
TERMINAL RESPONSE answers `91XX` (bounded); a cancel is never sent
automatically (it tells the card the user aborted the session and can push
assertive applications onto an error path). The cleanup is reported as a
`cleanup` step; a script that wants the refusal path answers explicitly (an
expectation's `respond`, or the `proactive` action's `first`).
### `GET /api/test/scripts` and the script store ### `GET /api/test/scripts` and the script store
Test scripts live in a server-side store (`~/.pysim-simple-server/test_scripts.json`, Test scripts live in a server-side store (`~/.pysim-simple-server/test_scripts.json`,
+74 -3
View File
@@ -54,6 +54,13 @@ secured data is exposed as `por.data`. Assert such a response with `data`
and leave `sw` empty; the step's `check.sw` still asserts the envelope and leave `sw` empty; the step's `check.sw` still asserts the envelope
exchange. exchange.
The form is decided by the command's **TAR** (v3.21.0): the preset's role
TARs and the standard ISD/RFM allocations (TS 101 220 Annex D) are decoded as
RM responses; any other TAR - an applet's own - is reported `raw` regardless
of whether its bytes happen to parse as a compact response, so no status word
is ever fabricated (a 2-byte reply cannot be one: Table 5.1 needs the command
count plus two status bytes - it is reported raw too).
A `62XX`/`63XX` envelope answer does not guarantee a response packet: an A `62XX`/`63XX` envelope answer does not guarantee a response packet: an
application that refuses at the envelope level (e.g. an applet answering application that refuses at the envelope level (e.g. an applet answering
`6200` to an unknown command) sends none, while other warning paths still `6200` to an unknown command) sends none, while other warning paths still
@@ -172,6 +179,9 @@ TEST-RUN step 4: MENU-SELECT ENVELOPE=80c2000009d30702020181900101 item=1 ('One'
- Other actions log their send/response pair too (`APDU TX=… -> SW=…`, - Other actions log their send/response pair too (`APDU TX=… -> SW=…`,
`ENVELOPE(Event Download) … -> SW=…`, `READ BINARY <path>`, `ENVELOPE(Event Download) … -> SW=…`, `READ BINARY <path>`,
`READ RECORD <n> <path>`, `UPDATE BINARY <path>`, each with `-> SW=…`). `READ RECORD <n> <path>`, `UPDATE BINARY <path>`, each with `-> SW=…`).
- A `proactive` action logs its rounds (`STATUS i/N -> …`, `FETCH=…`,
`CMD 0x…`, `TR=… -> SW=…`); the run cleanup logs
`TEST-RUN drain: <CMD> (0xNN) TR=… -> SW=…`.
- A failing step adds `TEST-RUN step N failed: …` with the reason. - A failing step adds `TEST-RUN step N failed: …` with the reason.
- The shared secured-packet sender logs each segment's answer - The shared secured-packet sender logs each segment's answer
(`OTA SEND: ENVELOPE i/N -> <SW>`). (`OTA SEND: ENVELOPE i/N -> <SW>`).
@@ -193,7 +203,11 @@ TEST-RUN step 4: MENU-SELECT ENVELOPE=80c2000009d30702020181900101 item=1 ('One'
Every step may carry `on_fail`: `"error"` (default - stops the run) or Every step may carry `on_fail`: `"error"` (default - stops the run) or
`"warning"` (continues, the report shows ⚠). Each entry of `checks` may `"warning"` (continues, the report shows ⚠). Each entry of `checks` may
override it with its own `on_fail`. override it with its own `on_fail`. The level applies to **every** step
failure, including an expectation that finds no pending command (v3.21.0) -
the tolerant "consume a command if there is one" form is an expectation with
`on_fail: "warning"`; to drain and confirm the card is idle, use the
`proactive` action.
## Presets: where the keys, TARs and counters come from ## Presets: where the keys, TARs and counters come from
@@ -233,6 +247,7 @@ consecutive runs continue correctly.
| `apdu` | `apdu` hex | raw transport, no auto-handler | | `apdu` | `apdu` hex | raw transport, no auto-handler |
| `scp80` | `apdu` **xor** `sp` (optional `source`), `kvn`, `tar`, `spi1`, `spi2`, `format` | secured packet to the TAR; `format`: `compact` (default - the C-APDU verbatim), `expanded` (the `AA`-prefixed Command Scripting template) or `expanded-ae` (the `AE 80 … 00 00` form), TS 102 226 §5.2.1 | | `scp80` | `apdu` **xor** `sp` (optional `source`), `kvn`, `tar`, `spi1`, `spi2`, `format` | secured packet to the TAR; `format`: `compact` (default - the C-APDU verbatim), `expanded` (the `AA`-prefixed Command Scripting template) or `expanded-ae` (the `AE 80 … 00 00` form), TS 102 226 §5.2.1 |
| `status` | `attempts` 1–1000, `interval_ms` 0–10000 | with `attempts > 1` the default SW check is `91??` (poll until a command) | | `status` | `attempts` 1–1000, `interval_ms` 0–10000 | with `attempts > 1` the default SW check is `91??` (poll until a command) |
| `proactive` | `respond`, `first`, `attempts` 1–1000 (default 3), `interval_ms` (default 200), `require` | drain the card's announced proactive commands and confirm it is idle (see below) |
`path` is `/`-separated: `MF` (or `3F00`) or an ADF name/AID first, then FIDs `path` is `/`-separated: `MF` (or `3F00`) or an ADF name/AID first, then FIDs
or file names - e.g. `MF/7F20/6F07`, `ADF.USIM/EF.TEST`. or file names - e.g. `MF/7F20/6F07`, `ADF.USIM/EF.TEST`.
@@ -266,6 +281,37 @@ actions take the same optional `src` device-identities override.
`src` overrides the device-identities source (`82` terminal, `83` network); `src` overrides the device-identities source (`82` terminal, `83` network);
the default is the terminal, matching the Phone tab's forms. the default is the terminal, matching the Phone tab's forms.
### Proactive cleanup (`kind: "proactive"`)
Consume whatever the card announces and leave it idle - at the start, in the
middle or at the end of a script (a leftover command from a previous run must
not derail the next step). Each round polls STATUS; a `91XX` answer fetches
the command and answers it (the first command with `first`, the rest with
`respond` - both `{"result": …}` like an expectation's response). The loop
ends when STATUS answers `9000` (idle), a non-`91XX` SW appears or `attempts`
rounds are spent; the **final SW is the step's `check.sw`** (default `9000`),
so a still-pending card fails the step and hands the pending command to the
next step.
```json
{"type": "action", "kind": "proactive",
"params": {"respond": {"result": "ok"},
"first": {"result": "cancel"},
"attempts": 5, "interval_ms": 200,
"require": {"command": "REFRESH", "qualifier": "00"}},
"check": {"sw": "9000"},
"label": "serve whatever is pending, leave the card idle"}
```
- An **empty drain passes** (that is the point) - unless `require` is given:
the step then fails with "nothing was pending".
- `require` asserts **at least one** consumed command matches (`command` name
or hex type, `ANY` allowed; optional `qualifier`, exact/mask).
- `first` lets a script test the card's refusal path for the first command
(e.g. `{"result": "cancel"}`) while the rest are answered `ok`.
- Every consumed command is reported (type, qualifier, the TR's SW) and
logged (`STATUS`/`FETCH`/`CMD`/`TR` lines under the step).
### `check` on actions ### `check` on actions
```json ```json
@@ -303,6 +349,12 @@ runner never polls by itself. `command` is a name (as shown in the proactive
log) or a hex type; `ANY`/`*` skips the type check. `qualifier` is the log) or a hex type; `ANY`/`*` skips the type check. `qualifier` is the
command-details byte 3 (one byte, exact/mask). command-details byte 3 (one byte, exact/mask).
When nothing is pending the step fails at its own `on_fail` level (the
default stops the run; `on_fail: "warning"` warns and continues - the
tolerant consume-if-pending form, v3.21.0). "Add a `status` action" applies
when the card delivers on poll: `status` with `attempts > 1` polls until a
command appears; it cannot make one appear when the card has none.
### Content checks ### Content checks
| kind | fields | checks | | kind | fields | checks |
@@ -365,6 +417,10 @@ UCS2 answer to a GET INPUT:
"respond": {"result": "ok", "text": "Пароль", "dcs": "08"}} "respond": {"result": "ok", "text": "Пароль", "dcs": "08"}}
``` ```
The `proactive` action's `respond`/`first` use the same shape (the command
type varies per drained command, so `text`/`item_id` apply where the command
carries them).
## The two PoR transports (SPI2) ## The two PoR transports (SPI2)
1. **Inline** - the card answers the ENVELOPE `9000` with the PoR in the 1. **Inline** - the card answers the ENVELOPE `9000` with the PoR in the
@@ -487,14 +543,29 @@ PoR-in-submit (with the transport asserted by the expectation):
is outside the preset's keysets. is outside the preset's keysets.
- `no MSL for TAR … - set it in the Cards tab or pass spi1` - the step's TAR - `no MSL for TAR … - set it in the Cards tab or pass spi1` - the step's TAR
has no entry in the preset and the step carries no `spi1`. has no entry in the preset and the step carries no `spi1`.
- `no proactive command pending …` - an expectation ran without a `91XX` from - `no proactive command pending` - an expectation ran without a `91XX` from
the previous step (add a `status` action when the card delivers on poll). the previous step; it fails at the step's `on_fail` level (the check row
names it). A `status` action with `attempts > 1` polls until a command
appears when the card delivers on poll; `on_fail: "warning"` tolerates the
empty case, and the `proactive` action drains and confirms idle.
- `menu-select: no menu item matches …` - the error lists the cached menu; a - `menu-select: no menu item matches …` - the error lists the cached menu; a
miss on an explicit `case_sensitive: true` names the case-insensitive miss on an explicit `case_sensitive: true` names the case-insensitive
candidates. candidates.
- `card verdict: no PoR (the card sent none for SPI2 …)` - an SCP80 operation - `card verdict: no PoR (the card sent none for SPI2 …)` - an SCP80 operation
expected a PoR that never arrived (check SPI2 and the transport). expected a PoR that never arrived (check SPI2 and the transport).
## Run cleanup
When a run ends (an error, a stop request, or an unexpected pending command)
with a command still announced, the runner fetches it and answers **`ok`
(0x00)** and keeps draining while the TERMINAL RESPONSE answers `91XX`
(bounded) - a cancel (`0x10`) is not sent automatically: it tells the card
the user aborted the proactive session, which can push assertive applications
onto an error path and make them queue a further command. The cleanup is
reported like a normal step (`TR ok …`, `pending REFRESH (0x01) answered with
ok`); a script that wants to test the refusal path answers explicitly (an
expectation's `respond`, or the proactive action's `first`).
## Known limitations ## Known limitations
- The runner executes one command at a time and never polls by itself; a - The runner executes one command at a time and never polls by itself; a
+4 -4
View File
@@ -563,12 +563,12 @@
<h3 id="test-script" class="text-lg font-medium mb-2">8.9 Тест-скрипт</h3> <h3 id="test-script" class="text-lg font-medium mb-2">8.9 Тест-скрипт</h3>
<p class="text-sm mb-2"><strong>Тест-скрипт</strong> &mdash; упорядоченный список <strong>действий</strong> и <strong>ожиданий</strong> проактивных команд, выполняемый на подключённой карте на стороне сервера. Пока запуск активен, карта принадлежит скрипту: остальные операции с картой отклоняются (HTTP&nbsp;409), фоновый опрос STATUS приостанавливается; отчёт о запуске хранится в памяти (в том числе после перезагрузки страницы) до нажатия <strong>Очистить отчёт</strong>. Скрипты хранятся в <strong>хранилище на сервере</strong> (<code class="font-mono text-sm">~/.pysim-simple-server/test_scripts.json</code>, <code class="font-mono text-sm">--test-scripts</code>), поэтому все браузеры работают с одним набором; изменения записываются в хранилище по мере правки (кнопки &laquo;Сохранить&raquo; нет &mdash; черновик без валидного шага остаётся локальным); экспорт/импорт JSON остаётся для резервных копий и переноса скриптов между стендами (старый экспорт из localStorage импортируется как есть).</p> <p class="text-sm mb-2"><strong>Тест-скрипт</strong> &mdash; упорядоченный список <strong>действий</strong> и <strong>ожиданий</strong> проактивных команд, выполняемый на подключённой карте на стороне сервера. Пока запуск активен, карта принадлежит скрипту: остальные операции с картой отклоняются (HTTP&nbsp;409), фоновый опрос STATUS приостанавливается; отчёт о запуске хранится в памяти (в том числе после перезагрузки страницы) до нажатия <strong>Очистить отчёт</strong>. Скрипты хранятся в <strong>хранилище на сервере</strong> (<code class="font-mono text-sm">~/.pysim-simple-server/test_scripts.json</code>, <code class="font-mono text-sm">--test-scripts</code>), поэтому все браузеры работают с одним набором; изменения записываются в хранилище по мере правки (кнопки &laquo;Сохранить&raquo; нет &mdash; черновик без валидного шага остаётся локальным); экспорт/импорт JSON остаётся для резервных копий и переноса скриптов между стендами (старый экспорт из localStorage импортируется как есть).</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3"> <ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Действия</strong> &mdash; <em>Envelope</em> (Event Download: сырой тип события + данные, с опциональным переопределением источника &mdash; 82 терминал / 83 сеть) или <em>Event</em> (семантические формы: Location status, Access technology change, Network rejection, Data connection status change &mdash; поля редактируются как объект JSON в редакторе шагов, а для остальных событий форма события на вкладке «Телефон» показывает готовый hex), <em>Menu Selection</em> (item id 1&ndash;255 или текст пункта с точным/частичным совпадением, по умолчанию без учёта регистра &mdash; выбор <em>«Учёт регистра»</em> в редакторе шагов включает точное совпадение; текст подбирается по меню карты, которое следует за параметрами установки апплета), <em>UPDATE/READ файла</em> (путь + режим; чтение проверяет данные файла), <em>сырой APDU</em> (без авто-обработчика), <em>SCP80</em> (C-APDU или готовый защищённый пакет, опциональные переопределения номера keyset (kvn) / TAR / SPI1 / SPI2 и формат команды &mdash; компактный или обёрнутый в шаблон Command Scripting TS 102 226 &sect;5.2.1 <em>expanded</em> (AA) / <em>expanded AE&nbsp;80</em>; ключи и счётчик берутся из пресета карты, подобранного по ICCID подключённой карты &mdash; запуск называет пресет в хранилище, поэтому всегда используются его keysets, TAR и счётчик &mdash; а счётчик записывается обратно после запуска) и <em>STATUS</em> (при <em>попытках</em> &gt; 1 опрашивает, пока карта не объявит команду). У каждого действия есть проверка ответа: ожидаемые SW и данные (точно или маска с подстановкой <code class="font-mono text-sm">?</code> &mdash; <code class="font-mono text-sm">91??</code>) и проверка PoR для SCP80 (<em>любой / без PoR / por_ok</em> или своя проверка декодированного PoR &mdash; статус / SW / данные; транспорт через SEND SHORT MESSAGE работает так же).</li> <li><strong>Действия</strong> &mdash; <em>Envelope</em> (Event Download: сырой тип события + данные, с опциональным переопределением источника &mdash; 82 терминал / 83 сеть) или <em>Event</em> (семантические формы: Location status, Access technology change, Network rejection, Data connection status change &mdash; поля редактируются как объект JSON в редакторе шагов, а для остальных событий форма события на вкладке «Телефон» показывает готовый hex), <em>Menu Selection</em> (item id 1&ndash;255 или текст пункта с точным/частичным совпадением, по умолчанию без учёта регистра &mdash; выбор <em>«Учёт регистра»</em> в редакторе шагов включает точное совпадение; текст подбирается по меню карты, которое следует за параметрами установки апплета), <em>UPDATE/READ файла</em> (путь + режим; чтение проверяет данные файла), <em>сырой APDU</em> (без авто-обработчика), <em>SCP80</em> (C-APDU или готовый защищённый пакет, опциональные переопределения номера keyset (kvn) / TAR / SPI1 / SPI2 и формат команды &mdash; компактный или обёрнутый в шаблон Command Scripting TS 102 226 &sect;5.2.1 <em>expanded</em> (AA) / <em>expanded AE&nbsp;80</em>; ключи и счётчик берутся из пресета карты, подобранного по ICCID подключённой карты &mdash; запуск называет пресет в хранилище, поэтому всегда используются его keysets, TAR и счётчик &mdash; а счётчик записывается обратно после запуска), <em>STATUS</em> (при <em>попытках</em> &gt; 1 опрашивает, пока карта не объявит команду) и <em>Очистка proactive</em> (опрос STATUS и поглощение всех объявленных команд &mdash; результат TERMINAL RESPONSE для каждой, опционально другой результат для первой; граница попыток/интервала; опциональная проверка <em>require</em> по поглощённому. Пустой опрос проходит, финальный STATUS должен быть idle, а оставшаяся команда передаётся следующему шагу &mdash; используйте в начале, середине или конце скрипта, чтобы убедиться, что карта перестала слать команды). У каждого действия есть проверка ответа: ожидаемые SW и данные (точно или маска с подстановкой <code class="font-mono text-sm">?</code> &mdash; <code class="font-mono text-sm">91??</code>) и проверка PoR для SCP80 (<em>любой / без PoR / por_ok</em> или своя проверка декодированного PoR &mdash; статус / SW / данные; транспорт через SEND SHORT MESSAGE работает так же).</li>
<li><strong>Ожидания</strong> &mdash; карта должна объявить проактивную команду на предыдущем шаге (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 &sect;7.4.2.1 / TS 102 223 &sect;6.3); <code class="font-mono text-sm">9000</code> означает &laquo;команды нет&raquo; и считается ошибкой &mdash; добавьте действие STATUS, если апплет выдаёт команду при опросе. Проверяются тип команды, квалификатор и содержимое: текст / пункт / raw, проверка <em>Alpha</em> (идентификатор Alpha команды, тег <code class="font-mono text-sm">05</code>/<code class="font-mono text-sm">85</code> &mdash; отличается от текстовой строки <code class="font-mono text-sm">8D</code>), проверка <em>SMS TPDU</em> (submit-TPDU команды SEND SHORT MESSAGE: получатель / PID / DCS / UDL / данные &mdash; для собственного SMS апплета), проверка <em>PoR</em> (SEND SHORT MESSAGE несёт PoR, когда пакет запросил PoR-in-submit, бит SPI2 <code class="font-mono text-sm">20</code>: статус ответа, SW R-APDU и данные ответа; перед этим должен выполниться шаг SCP80 &mdash; SW R-APDU находится <em>внутри</em> полезной нагрузки ответа: собственный TAR апплета возвращает данные приложения, поэтому проверяйте данные, а SW оставляйте пустым; отклонённый ENVELOPE (<code class="font-mono text-sm">62XX</code>/<code class="font-mono text-sm">63XX</code>) может вовсе не нести пакет ответа &mdash; для такого шага оставляйте проверку PoR «любой» или ожидайте «без PoR») и проверка <em>Files</em> (File List команды REFRESH, например квалификатор <code class="font-mono text-sm">00</code> = NAA initialization with full file change notification). <strong>TERMINAL RESPONSE</strong> (результат, идентификатор пункта, текст, доп. TLV) является частью инструкции.</li> <li><strong>Ожидания</strong> &mdash; карта должна объявить проактивную команду на предыдущем шаге (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 &sect;7.4.2.1 / TS 102 223 &sect;6.3); <code class="font-mono text-sm">9000</code> означает &laquo;команды нет&raquo;: шаг падает на своём уровне ошибок &mdash; Ошибка останавливает скрипт, Предупреждение продолжает (терпимая форма &laquo;поглотить команду, если она есть&raquo;) &mdash; а действие STATUS с <em>попытками</em> &gt; 1 опрашивает, когда апплет выдаёт команду при опросе. Проверяются тип команды, квалификатор и содержимое: текст / пункт / raw, проверка <em>Alpha</em> (идентификатор Alpha команды, тег <code class="font-mono text-sm">05</code>/<code class="font-mono text-sm">85</code> &mdash; отличается от текстовой строки <code class="font-mono text-sm">8D</code>), проверка <em>SMS TPDU</em> (submit-TPDU команды SEND SHORT MESSAGE: получатель / PID / DCS / UDL / данные &mdash; для собственного SMS апплета), проверка <em>PoR</em> (SEND SHORT MESSAGE несёт PoR, когда пакет запросил PoR-in-submit, бит SPI2 <code class="font-mono text-sm">20</code>: статус ответа, SW R-APDU и данные ответа; перед этим должен выполниться шаг SCP80 &mdash; SW R-APDU находится <em>внутри</em> полезной нагрузки ответа: собственный TAR апплета возвращает данные приложения, поэтому проверяйте данные, а SW оставляйте пустым; отклонённый ENVELOPE (<code class="font-mono text-sm">62XX</code>/<code class="font-mono text-sm">63XX</code>) может вовсе не нести пакет ответа &mdash; для такого шага оставляйте проверку PoR «любой» или ожидайте «без PoR») и проверка <em>Files</em> (File List команды REFRESH, например квалификатор <code class="font-mono text-sm">00</code> = NAA initialization with full file change notification). <strong>TERMINAL RESPONSE</strong> (результат, идентификатор пункта, текст, доп. TLV) является частью инструкции.</li>
<li><strong>Уровни ошибок</strong> &mdash; несовпадение помечает шаг как <em>Ошибка</em> (скрипт останавливается) или <em>Предупреждение</em> (продолжает); каждая проверка показывается с ожидаемым и фактическим значением.</li> <li><strong>Уровни ошибок</strong> &mdash; несовпадение помечает шаг как <em>Ошибка</em> (скрипт останавливается) или <em>Предупреждение</em> (продолжает); уровень применяется к любой ошибке шага, включая ожидание без объявленной команды; каждая проверка показывается с ожидаемым и фактическим значением.</li>
<li><strong>Шаблоны</strong> &mdash; Empty, <em>Обзор меню STK</em>, <em>Апплет через SCP80</em>; редактор шагов — формный (поля действия Event вводятся объектом JSON), отдельные скрипты экспортируются/импортируются как JSON.</li> <li><strong>Шаблоны</strong> &mdash; Empty, <em>Обзор меню STK</em>, <em>Апплет через SCP80</em>; редактор шагов — формный (поля действия Event вводятся объектом JSON), отдельные скрипты экспортируются/импортируются как JSON.</li>
</ul> </ul>
<p class="text-sm mb-3">Каждый шаг пишет семантическую строку в журнал сервера под своим номером (<code class="font-mono text-sm">TEST-RUN step N: …</code>), запрос перед ответом: отправленный APDU/данные и полученный SW, для SCP80 — открытый C-APDU, собранный защищённый пакет и R-APDU из PoR, для ожидания — тройка FETCH / команда / TERMINAL RESPONSE, а для выбора пункта меню — полный ENVELOPE с определённым пунктом. Запустите сервер с <code class="font-mono text-sm">--apdu-trace</code>, чтобы добавить и журнал сырых транспортных APDU.</p> <p class="text-sm mb-3">Каждый шаг пишет семантическую строку в журнал сервера под своим номером (<code class="font-mono text-sm">TEST-RUN step N: …</code>), запрос перед ответом: отправленный APDU/данные и полученный SW, для SCP80 — открытый C-APDU, собранный защищённый пакет и R-APDU из PoR, для ожидания — тройка FETCH / команда / TERMINAL RESPONSE, очистка proactive — каждый раунд STATUS/FETCH/команда/TR, а очистка в конце запуска — поглощённые команды (отвечаются <code class="font-mono text-sm">ok</code> &mdash; cancel отправляется только по явному запросу скрипта), а для выбора пункта меню — полный ENVELOPE с определённым пунктом. Запустите сервер с <code class="font-mono text-sm">--apdu-trace</code>, чтобы добавить и журнал сырых транспортных APDU.</p>
<h3 id="bip" class="text-lg font-medium mb-2">8.10 BIP-терминал (обычный TCP)</h3> <h3 id="bip" class="text-lg font-medium mb-2">8.10 BIP-терминал (обычный TCP)</h3>
<p class="text-sm mb-2">Пилюля <strong>BIP</strong> запускает терминальную сторону Bearer Independent Protocol для карт, использующих TCP без HTTP OTA (SCP81): апплет или пуш-триггер может открыть канал и обменяться сырыми данными. Одновременно работает только одна BIP-сессия &mdash; запуск здесь останавливает слушатель SCP81, а запуск слушателя SCP81 останавливает эту сессию (в обоих случаях сообщается, что было заменено).</p> <p class="text-sm mb-2">Пилюля <strong>BIP</strong> запускает терминальную сторону Bearer Independent Protocol для карт, использующих TCP без HTTP OTA (SCP81): апплет или пуш-триггер может открыть канал и обменяться сырыми данными. Одновременно работает только одна BIP-сессия &mdash; запуск здесь останавливает слушатель SCP81, а запуск слушателя SCP81 останавливает эту сессию (в обоих случаях сообщается, что было заменено).</p>
+4 -4
View File
@@ -562,12 +562,12 @@
<h3 id="test-script" class="text-lg font-medium mb-2">8.9 Test script</h3> <h3 id="test-script" class="text-lg font-medium mb-2">8.9 Test script</h3>
<p class="text-sm mb-2">A <strong>test script</strong> is an ordered list of <strong>actions</strong> and proactive-command <strong>expectations</strong> executed server-side on the equipped card. While a run is active the card belongs to the script: other card actions are refused (HTTP&nbsp;409) and background STATUS polling is paused; the run report is kept in memory (also after reloading the page) until <strong>Clear report</strong>. Scripts live in the <strong>server-side store</strong> (<code class="font-mono text-sm">~/.pysim-simple-server/test_scripts.json</code>, <code class="font-mono text-sm">--test-scripts</code>), so every browser manages the same set; edits are written to the store as you change them (there is no Save button &mdash; a draft without a valid step stays local until it is valid); JSON export/import remains for backup and for moving scripts between benches (the old localStorage export imports as-is).</p> <p class="text-sm mb-2">A <strong>test script</strong> is an ordered list of <strong>actions</strong> and proactive-command <strong>expectations</strong> executed server-side on the equipped card. While a run is active the card belongs to the script: other card actions are refused (HTTP&nbsp;409) and background STATUS polling is paused; the run report is kept in memory (also after reloading the page) until <strong>Clear report</strong>. Scripts live in the <strong>server-side store</strong> (<code class="font-mono text-sm">~/.pysim-simple-server/test_scripts.json</code>, <code class="font-mono text-sm">--test-scripts</code>), so every browser manages the same set; edits are written to the store as you change them (there is no Save button &mdash; a draft without a valid step stays local until it is valid); JSON export/import remains for backup and for moving scripts between benches (the old localStorage export imports as-is).</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3"> <ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Actions</strong> &mdash; <em>Envelope</em> (Event Download: raw event type + data, with an optional source override &mdash; 82 terminal / 83 network) or <em>Event</em> (the semantic forms: Location status, Access technology change, Network rejection, Data connection status change &mdash; the fields are edited as a JSON object in the step editor, and the Phone tab's event form shows the built hex for the other events), <em>Menu Selection</em> (item id 1&ndash;255, or the item's text with an exact/contains match, case-insensitive by default &mdash; the step editor's <em>Match case</em> choice switches to an exact-case match; the text is resolved against the card's menu, which follows the applet's install parameters), <em>UPDATE/READ file</em> (path + mode; reads verify the file data), <em>raw APDU</em> (no auto-handler), <em>SCP80</em> (C-APDU or a pre-built secured packet, optional keyset number (kvn) / TAR / SPI1 / SPI2 overrides and a command format &mdash; compact, or the TS 102 226 &sect;5.2.1 Command Scripting template <em>expanded</em> (AA) / <em>expanded AE&nbsp;80</em> around the C-APDU; the keys and the counter come from the card preset matched by the equipped card's ICCID &mdash; the run names the stored preset, so it always uses its keysets, TARs and counter &mdash; and the counter is written back to it after the run) and <em>STATUS</em> (with <em>attempts</em> &gt; 1 it polls until the card announces a command). Every action has a response check: expected SW and data (exact, or a mask with <code class="font-mono text-sm">?</code> nibble wildcards &mdash; <code class="font-mono text-sm">91??</code>) plus a PoR check for SCP80 (<em>any / no PoR / por_ok</em>, or a custom assertion of the decoded PoR &mdash; status / SW / data; the SEND SHORT MESSAGE transport works the same).</li> <li><strong>Actions</strong> &mdash; <em>Envelope</em> (Event Download: raw event type + data, with an optional source override &mdash; 82 terminal / 83 network) or <em>Event</em> (the semantic forms: Location status, Access technology change, Network rejection, Data connection status change &mdash; the fields are edited as a JSON object in the step editor, and the Phone tab's event form shows the built hex for the other events), <em>Menu Selection</em> (item id 1&ndash;255, or the item's text with an exact/contains match, case-insensitive by default &mdash; the step editor's <em>Match case</em> choice switches to an exact-case match; the text is resolved against the card's menu, which follows the applet's install parameters), <em>UPDATE/READ file</em> (path + mode; reads verify the file data), <em>raw APDU</em> (no auto-handler), <em>SCP80</em> (C-APDU or a pre-built secured packet, optional keyset number (kvn) / TAR / SPI1 / SPI2 overrides and a command format &mdash; compact, or the TS 102 226 &sect;5.2.1 Command Scripting template <em>expanded</em> (AA) / <em>expanded AE&nbsp;80</em> around the C-APDU; the keys and the counter come from the card preset matched by the equipped card's ICCID &mdash; the run names the stored preset, so it always uses its keysets, TARs and counter &mdash; and the counter is written back to it after the run), <em>STATUS</em> (with <em>attempts</em> &gt; 1 it polls until the card announces a command) and <em>Proactive cleanup</em> (poll STATUS and consume every announced command &mdash; the TERMINAL RESPONSE result for each, optionally a different result for the first; the attempt/interval bound; an optional <em>require</em> assertion over what was drained. An empty poll passes, the final STATUS must be idle, and a still-pending command is handed to the next step &mdash; use it at the start, in the middle or at the end of a script to make sure the card has stopped sending). Every action has a response check: expected SW and data (exact, or a mask with <code class="font-mono text-sm">?</code> nibble wildcards &mdash; <code class="font-mono text-sm">91??</code>) plus a PoR check for SCP80 (<em>any / no PoR / por_ok</em>, or a custom assertion of the decoded PoR &mdash; status / SW / data; the SEND SHORT MESSAGE transport works the same).</li>
<li><strong>Expectations</strong> &mdash; the card must have announced a proactive command in the previous step (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 &sect;7.4.2.1 / TS 102 223 &sect;6.3); a <code class="font-mono text-sm">9000</code> means &ldquo;no command&rdquo; and is an error &mdash; add a STATUS action when the applet delivers on poll. The command type, qualifier and contents are verified: text / item / raw checks, an <em>Alpha</em> check (the command's Alpha identifier, tag <code class="font-mono text-sm">05</code>/<code class="font-mono text-sm">85</code> &mdash; distinct from the <code class="font-mono text-sm">8D</code> text string), an <em>SMS TPDU</em> check (the submit TPDU of a SEND SHORT MESSAGE: destination / PID / DCS / UDL / user data &mdash; for an applet's own SMS), a <em>PoR</em> check (a SEND SHORT MESSAGE carries the PoR when the packet requested PoR-in-submit, SPI2 bit <code class="font-mono text-sm">20</code>: the response status, the R-APDU SW and the response data; make sure an SCP80 step ran first &mdash; the R-APDU SW lives <em>inside</em> the response payload: an applet's own TAR returns application data, so check its data and leave the SW empty; a refused ENVELOPE (<code class="font-mono text-sm">62XX</code>/<code class="font-mono text-sm">63XX</code>) may carry no response packet at all &mdash; leave the PoR check at <em>any</em> or expect <em>no PoR</em> for such a step) and a <em>Files</em> check (the File List of a REFRESH, e.g. qualifier <code class="font-mono text-sm">00</code> = NAA initialization with full file change notification). The scripted <strong>TERMINAL RESPONSE</strong> (result, item identifier, text, extra TLVs) is part of the instruction.</li> <li><strong>Expectations</strong> &mdash; the card must have announced a proactive command in the previous step (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 &sect;7.4.2.1 / TS 102 223 &sect;6.3); a <code class="font-mono text-sm">9000</code> means &ldquo;no command&rdquo;: the step fails at its fail level &mdash; Error stops the script, Warning continues (the tolerant &ldquo;consume a command if there is one&rdquo; form) &mdash; and a STATUS action with <em>attempts</em> &gt; 1 polls when the applet delivers on poll. The command type, qualifier and contents are verified: text / item / raw checks, an <em>Alpha</em> check (the command's Alpha identifier, tag <code class="font-mono text-sm">05</code>/<code class="font-mono text-sm">85</code> &mdash; distinct from the <code class="font-mono text-sm">8D</code> text string), an <em>SMS TPDU</em> check (the submit TPDU of a SEND SHORT MESSAGE: destination / PID / DCS / UDL / user data &mdash; for an applet's own SMS), a <em>PoR</em> check (a SEND SHORT MESSAGE carries the PoR when the packet requested PoR-in-submit, SPI2 bit <code class="font-mono text-sm">20</code>: the response status, the R-APDU SW and the response data; make sure an SCP80 step ran first &mdash; the R-APDU SW lives <em>inside</em> the response payload: an applet's own TAR returns application data, so check its data and leave the SW empty; a refused ENVELOPE (<code class="font-mono text-sm">62XX</code>/<code class="font-mono text-sm">63XX</code>) may carry no response packet at all &mdash; leave the PoR check at <em>any</em> or expect <em>no PoR</em> for such a step) and a <em>Files</em> check (the File List of a REFRESH, e.g. qualifier <code class="font-mono text-sm">00</code> = NAA initialization with full file change notification). The scripted <strong>TERMINAL RESPONSE</strong> (result, item identifier, text, extra TLVs) is part of the instruction.</li>
<li><strong>Fail levels</strong> &mdash; a mismatch flags the step as <em>Error</em> (terminates the script) or <em>Warning</em> (continues); each check is reported with expected vs actual values.</li> <li><strong>Fail levels</strong> &mdash; a mismatch flags the step as <em>Error</em> (terminates the script) or <em>Warning</em> (continues); every step failure honours it, including an expectation that finds no pending command; each check is reported with expected vs actual values.</li>
<li><strong>Templates</strong> &mdash; Empty, <em>STK menu browsing</em>, <em>Applet via SCP80</em>; the step editor is form-based (the event action's fields are a JSON object) and individual scripts export/import as JSON.</li> <li><strong>Templates</strong> &mdash; Empty, <em>STK menu browsing</em>, <em>Applet via SCP80</em>; the step editor is form-based (the event action's fields are a JSON object) and individual scripts export/import as JSON.</li>
</ul> </ul>
<p class="text-sm mb-3">Every step logs a semantic line to the server log under its step number (<code class="font-mono text-sm">TEST-RUN step N: …</code>), request before response: the APDU/data sent and the SW received, for SCP80 the plaintext C-APDU, the built secured packet and the PoR's R-APDU, for an expectation the FETCH / command / TERMINAL RESPONSE trio, and for a menu selection the complete ENVELOPE with the resolved item. Start the server with <code class="font-mono text-sm">--apdu-trace</code> to add the raw transport APDU log as well.</p> <p class="text-sm mb-3">Every step logs a semantic line to the server log under its step number (<code class="font-mono text-sm">TEST-RUN step N: …</code>), request before response: the APDU/data sent and the SW received, for SCP80 the plaintext C-APDU, the built secured packet and the PoR's R-APDU, for an expectation the FETCH / command / TERMINAL RESPONSE trio, a proactive cleanup every STATUS/FETCH/command/TR round and the run cleanup the commands it drained (answered <code class="font-mono text-sm">ok</code> &mdash; a cancel is only sent when a script asks for it), and for a menu selection the complete ENVELOPE with the resolved item. Start the server with <code class="font-mono text-sm">--apdu-trace</code> to add the raw transport APDU log as well.</p>
<h3 id="bip" class="text-lg font-medium mb-2">8.10 BIP terminal (generic TCP)</h3> <h3 id="bip" class="text-lg font-medium mb-2">8.10 BIP terminal (generic TCP)</h3>
<p class="text-sm mb-2">The <strong>BIP</strong> pill runs the terminal side of the Bearer Independent Protocol for cards that use TCP without HTTP OTA (SCP81): an applet or a push trigger can open a channel and exchange raw data. Only one BIP session runs at a time &mdash; starting here stops a running SCP81 listener, and starting the SCP81 listener stops this session (both report what was replaced).</p> <p class="text-sm mb-2">The <strong>BIP</strong> pill runs the terminal side of the Bearer Independent Protocol for cards that use TCP without HTTP OTA (SCP81): an applet or a push trigger can open a channel and exchange raw data. Only one BIP session runs at a time &mdash; starting here stops a running SCP81 listener, and starting the SCP81 listener stops this session (both report what was replaced).</p>
+52 -2
View File
@@ -1877,7 +1877,7 @@
// ===== Version ===== // ===== Version =====
// Single source of truth for the PWA version: shown in the header and used // Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect(). // by the server version check in pysimConnect().
const SIMPLE_VERSION = '3.20.0'; const SIMPLE_VERSION = '3.21.0';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching ===== // ===== Tab switching =====
@@ -6913,7 +6913,7 @@ function decodeResponse() {
// through /api/test/status. This UI edits the script and monitors the run; // through /api/test/status. This UI edits the script and monitors the run;
// SCP80 steps use the card preset matched by the equipped card's ICCID, and // SCP80 steps use the card preset matched by the equipped card's ICCID, and
// the final SCP80 counter is written back to it. // the final SCP80 counter is written back to it.
const TEST_ACTION_KINDS = ['envelope', 'event', 'menu-select', 'file-write', 'file-read', 'apdu', 'scp80', 'status']; const TEST_ACTION_KINDS = ['envelope', 'event', 'menu-select', 'file-write', 'file-read', 'apdu', 'scp80', 'status', 'proactive'];
let _testScripts = null; let _testScripts = null;
let _testCurrentIdx = -1; let _testCurrentIdx = -1;
let _testRunState = null; let _testRunState = null;
@@ -7072,6 +7072,8 @@ function testScriptProblem(script) {
} }
} }
if ((s.kind === 'file-write' || s.kind === 'file-read') && !String(p.path || '').trim()) return at + t('file path is empty'); if ((s.kind === 'file-write' || s.kind === 'file-read') && !String(p.path || '').trim()) return at + t('file path is empty');
if (s.kind === 'proactive' && String(p.attempts === undefined ? '' : p.attempts).trim() !== ''
&& !(parseInt(p.attempts, 10) >= 1)) return at + t('attempts must be at least 1');
} else if (s.type === 'expect') { } else if (s.type === 'expect') {
if (!String(s.command || '').trim()) return at + t('command is required'); if (!String(s.command || '').trim()) return at + t('command is required');
} else { } else {
@@ -7147,6 +7149,15 @@ function testStepSummary(step) {
else if (step.kind === 'file-write') s = 'UPDATE ' + (p.path || '?') + (p.mode && p.mode !== 'auto' ? ' (' + p.mode + (p.record ? ' ' + p.record : '') + ')' : '') + ' = ' + (p.data || ''); else if (step.kind === 'file-write') s = 'UPDATE ' + (p.path || '?') + (p.mode && p.mode !== 'auto' ? ' (' + p.mode + (p.record ? ' ' + p.record : '') + ')' : '') + ' = ' + (p.data || '');
else if (step.kind === 'file-read') s = 'READ ' + (p.path || '?') + (p.mode && p.mode !== 'auto' ? ' (' + p.mode + (p.record ? ' ' + p.record : '') + ')' : ''); else if (step.kind === 'file-read') s = 'READ ' + (p.path || '?') + (p.mode && p.mode !== 'auto' ? ' (' + p.mode + (p.record ? ' ' + p.record : '') + ')' : '');
else if (step.kind === 'status') s = 'STATUS' + ((p.attempts || 1) > 1 ? ' x' + p.attempts + ' (poll)' : ''); else if (step.kind === 'status') s = 'STATUS' + ((p.attempts || 1) > 1 ? ' x' + p.attempts + ' (poll)' : '');
else if (step.kind === 'proactive') {
const parts = ['PROACTIVE x' + (p.attempts === undefined ? 3 : p.attempts)];
const rq = p.require || {};
if (rq.command) parts.push('require ' + rq.command + (rq.qualifier ? ' q=' + (rq.qualifier.value || rq.qualifier) : ''));
const rr = p.respond || {};
parts.push('TR ' + (rr.result === undefined ? 'ok' : rr.result));
if (p.first && p.first.result) parts.push('first ' + p.first.result);
s = parts.join(' ');
}
else s = String(step.kind || '?'); else s = String(step.kind || '?');
const c = step.check || {}; const c = step.check || {};
const ck = []; const ck = [];
@@ -7464,6 +7475,24 @@ function testStepRender() {
} else if (step.kind === 'status') { } else if (step.kind === 'status') {
html += testFormRow('Attempts (1 = single STATUS)', testFormInput('test-f-attempts', p.attempts === undefined ? 1 : p.attempts, '1..1000')); html += testFormRow('Attempts (1 = single STATUS)', testFormInput('test-f-attempts', p.attempts === undefined ? 1 : p.attempts, '1..1000'));
html += testFormRow('Interval (ms, when polling)', testFormInput('test-f-interval', p.interval_ms === undefined ? 200 : p.interval_ms, '0..10000')); html += testFormRow('Interval (ms, when polling)', testFormInput('test-f-interval', p.interval_ms === undefined ? 200 : p.interval_ms, '0..10000'));
} else if (step.kind === 'proactive') {
const pr = p.respond || {};
html += testFormRow('TERMINAL RESPONSE result (each command)',
testFormSelect('test-f-presult', pr.result === undefined ? 'ok' : String(pr.result), [
{v: 'ok', l: t('ok')}, {v: 'cancel', l: t('cancel')}, {v: 'back', l: t('back')},
{v: 'timeout', l: t('timeout')}, {v: 'no_response', l: t('no response')}]));
const pf = p.first || {};
html += testFormRow('First command only (optional)',
testFormSelect('test-f-pfirst', pf.result === undefined ? '' : String(pf.result), [
{v: '', l: t('same as above')}, {v: 'ok', l: t('ok')}, {v: 'cancel', l: t('cancel')},
{v: 'back', l: t('back')}, {v: 'timeout', l: t('timeout')},
{v: 'no_response', l: t('no response')}]));
html += testFormRow('Attempts (STATUS rounds)', testFormInput('test-f-pattempts', p.attempts === undefined ? 3 : p.attempts, '1..1000'));
html += testFormRow('Interval (ms, between rounds)', testFormInput('test-f-pinterval', p.interval_ms === undefined ? 200 : p.interval_ms, '0..10000'));
const rq = p.require || {};
html += testFormRow('Require a command (optional)', '<div class="flex gap-2">'
+ testFormInput('test-f-prequire', rq.command, 'REFRESH')
+ testFormInput('test-f-prequal', rq.qualifier && rq.qualifier.value, '00') + '</div>');
} }
const c = step.check || {}; const c = step.check || {};
const sw = c.sw || {}; const sw = c.sw || {};
@@ -7720,6 +7749,20 @@ function testStepCollect() {
if (fmt && fmt !== 'compact') p.format = fmt; if (fmt && fmt !== 'compact') p.format = fmt;
} }
else if (kind === 'status') { p.attempts = num('test-f-attempts', 1); p.interval_ms = num('test-f-interval', 200); } else if (kind === 'status') { p.attempts = num('test-f-attempts', 1); p.interval_ms = num('test-f-interval', 200); }
else if (kind === 'proactive') {
p.respond = {result: val('test-f-presult') || 'ok'};
const first = val('test-f-pfirst');
if (first) p.first = {result: first};
p.attempts = num('test-f-pattempts', 3);
p.interval_ms = num('test-f-pinterval', 200);
const requireCmd = val('test-f-prequire');
if (requireCmd) {
const rq = {command: requireCmd.toUpperCase()};
const rqHex = hex('test-f-prequal');
if (rqHex) rq.qualifier = rqHex;
p.require = rq;
}
}
step.params = p; step.params = p;
const check = {}; const check = {};
const swVal = hex('test-f-sw'); const swVal = hex('test-f-sw');
@@ -7793,6 +7836,7 @@ function testStepFormError() {
} }
if ((step.kind === 'file-write' || step.kind === 'file-read') && !p.path) return t('file path is empty'); if ((step.kind === 'file-write' || step.kind === 'file-read') && !p.path) return t('file path is empty');
if (step.kind === 'status' && !(p.attempts >= 1)) return t('attempts must be at least 1'); if (step.kind === 'status' && !(p.attempts >= 1)) return t('attempts must be at least 1');
if (step.kind === 'proactive' && !(p.attempts >= 1)) return t('attempts must be at least 1');
} else if (!step.command) { } else if (!step.command) {
return t('command is required'); return t('command is required');
} }
@@ -21276,6 +21320,12 @@ const LANG_RU = {
'SPI2 override (hex, optional)': 'Переопределение SPI2 (hex, опционально)', 'SPI2 override (hex, optional)': 'Переопределение SPI2 (hex, опционально)',
'Attempts (1 = single STATUS)': 'Попытки (1 = один STATUS)', 'Attempts (1 = single STATUS)': 'Попытки (1 = один STATUS)',
'Interval (ms, when polling)': 'Интервал (мс, при опросе)', 'Interval (ms, when polling)': 'Интервал (мс, при опросе)',
'Attempts (STATUS rounds)': 'Попытки (раунды STATUS)',
'Interval (ms, between rounds)': 'Интервал (мс, между раундами)',
'TERMINAL RESPONSE result (each command)': 'Результат TERMINAL RESPONSE (для каждой команды)',
'First command only (optional)': 'Только для первой команды (необязательно)',
'same as above': 'как выше',
'Require a command (optional)': 'Требовать команду (необязательно)',
'attempts must be at least 1': 'число попыток должно быть ≥ 1', 'attempts must be at least 1': 'число попыток должно быть ≥ 1',
'Expected SW': 'Ожидаемое SW', 'Expected SW': 'Ожидаемое SW',
'default: 9000 (91?? when polling)': 'по умолчанию: 9000 (91?? при опросе)', 'default: 9000 (91?? when polling)': 'по умолчанию: 9000 (91?? при опросе)',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v395'; const CACHE = 'simple-v396';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+36
View File
@@ -107,6 +107,10 @@ test('testScriptProblem accepts good scripts and names bad ones', () => {
assert.match(testScriptProblem({ assert.match(testScriptProblem({
steps: [{ type: 'action', kind: 'file-read', params: {} }] }), /file path/); steps: [{ type: 'action', kind: 'file-read', params: {} }] }), /file path/);
assert.match(testScriptProblem({ steps: [{ type: 'expect' }] }), /command is required/); assert.match(testScriptProblem({ steps: [{ type: 'expect' }] }), /command is required/);
assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'proactive',
params: { attempts: 0 } }] }), /attempts/);
assert.strictEqual(testScriptProblem({ steps: [{ type: 'action', kind: 'proactive',
params: {} }] }), '');
// the selected SCP80 source decides which value is required // the selected SCP80 source decides which value is required
assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'scp80', assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'scp80',
params: { source: 'apdu', sp: 'AA' } }] }), /SCP80/); params: { source: 'apdu', sp: 'AA' } }] }), /SCP80/);
@@ -116,6 +120,29 @@ test('testScriptProblem accepts good scripts and names bad ones', () => {
params: { source: 'sp', sp: 'AA', apdu: '80E2' } }] }), ''); params: { source: 'sp', sp: 'AA', apdu: '80E2' } }] }), '');
}); });
test('the proactive cleanup step collects its fields', () => {
_testEditStep = { type: 'action', kind: 'proactive', params: {} };
_testEditChecks = [];
_testEditStepIndex = 0;
fakeForm({ 'test-step-kind': 'proactive', 'test-f-presult': 'ok', 'test-f-pfirst': 'cancel',
'test-f-pattempts': '5', 'test-f-pinterval': '0', 'test-f-prequire': 'refresh',
'test-f-prequal': '00', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
testStepCollect();
const p = _testEditStep.params;
assert.strictEqual(p.respond.result, 'ok');
assert.strictEqual(p.first.result, 'cancel');
assert.strictEqual(p.attempts, 5);
assert.strictEqual(p.interval_ms, 0);
assert.deepStrictEqual(p.require, { command: 'REFRESH', qualifier: '00' });
// an empty require field drops the assertion
fakeForm({ 'test-step-kind': 'proactive', 'test-f-presult': 'ok', 'test-f-pfirst': '',
'test-f-pattempts': '3', 'test-f-pinterval': '200', 'test-f-prequire': '',
'test-f-prequal': '', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
testStepCollect();
assert.ok(!('first' in _testEditStep.params));
assert.ok(!('require' in _testEditStep.params));
});
test('testStepSummary renders actions', () => { test('testStepSummary renders actions', () => {
assert.strictEqual( assert.strictEqual(
testStepSummary({ type: 'action', kind: 'menu-select', params: { item_id: 3 }, testStepSummary({ type: 'action', kind: 'menu-select', params: { item_id: 3 },
@@ -135,6 +162,15 @@ test('testStepSummary renders actions', () => {
assert.strictEqual( assert.strictEqual(
testStepSummary({ type: 'action', kind: 'status', params: { attempts: 5 } }), testStepSummary({ type: 'action', kind: 'status', params: { attempts: 5 } }),
'STATUS x5 (poll)'); 'STATUS x5 (poll)');
assert.strictEqual(
testStepSummary({ type: 'action', kind: 'proactive',
params: { attempts: 5, respond: { result: 'ok' },
require: { command: 'REFRESH', qualifier: '00' } } }),
'PROACTIVE x5 require REFRESH q=00 TR ok');
assert.strictEqual(
testStepSummary({ type: 'action', kind: 'proactive',
params: { attempts: 3, first: { result: 'cancel' } } }),
'PROACTIVE x3 TR ok first cancel');
assert.strictEqual( assert.strictEqual(
testStepSummary({ type: 'action', kind: 'file-read', params: { path: 'MF/2FE2' } }), testStepSummary({ type: 'action', kind: 'file-read', params: { path: 'MF/2FE2' } }),
'READ MF/2FE2'); 'READ MF/2FE2');
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "pysim-simple-server" name = "pysim-simple-server"
version = "3.20.0" version = "3.21.0"
description = "HTTP REST server wrapping pysim for the SIMple PWA" description = "HTTP REST server wrapping pysim for the SIMple PWA"
requires-python = ">=3.8" requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
+288 -48
View File
@@ -34,7 +34,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad from osmocom.utils import rpad
VERSION = '3.20.0' VERSION = '3.21.0'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -1910,7 +1910,8 @@ def _counter_probe(server, scc, preset, body, send_fn=None):
por_hex = submit_hex por_hex = submit_hex
por = _decode_por(p['spi1'], p['spi2'], p['kic'], p['kid'], cntr, por = _decode_por(p['spi1'], p['spi2'], p['kic'], p['kid'], cntr,
p['kic_key'], p['kid_key'], por_hex, p['kic_key'], p['kid_key'], por_hex,
cmd_len=len(p['apdu']) // 2) if por_hex else None cmd_len=len(p['apdu']) // 2,
rm=_tar_is_rm(preset, p['tar'])) if por_hex else None
pstatus = str((por or {}).get('response_status') or '') pstatus = str((por or {}).get('response_status') or '')
out['packets'] += 1 out['packets'] += 1
attempt = {'cntr': cntr, 'por_status': pstatus or None, attempt = {'cntr': cntr, 'por_status': pstatus or None,
@@ -2121,7 +2122,9 @@ def _ram_send_gp_apdu(server, scc, sp, state, step_name, apdu_hex, silent=False)
por_src = 'sms-submit' por_src = 'sms-submit'
por = _decode_por(spi1, spi2, sp['kic'], sp['kid'], state['cntr'], por = _decode_por(spi1, spi2, sp['kic'], sp['kid'], state['cntr'],
sp['kic_key'], sp['kid_key'], por_hex, sp['kic_key'], sp['kid_key'], por_hex,
cmd_len=len(apdu_hex) // 2) cmd_len=len(apdu_hex) // 2,
rm=_tar_is_rm(_preset_by_id(server, state.get('preset_id')),
sp.get('tar')))
step, step_error = _ram_step_result(step_name, last_sw, por, por_hex, step, step_error = _ram_step_result(step_name, last_sw, por, por_hex,
result['bytes'], result['segments']) result['bytes'], result['segments'])
state['last_step'] = step state['last_step'] = step
@@ -2262,33 +2265,94 @@ def _sms_submit_por(submit_handler):
return '027100' + ud_hex return '027100' + ud_hex
def _por_header_parse(data, otak, spi):
"""Header-only parse of an SMS response packet (TS 102 225 5.2 Table 3).
Everything `OtaDialectSms.decode_resp` validates *before* the compact
response parse: the UDH/RPI check, the response header, deciphering, the
PCNTR padding and the RC/CC/DS verification. Used when the secured data
is not a compact/scripting RM response (TS 102 226 4: the receiving
application's own format) - a valid packet is then reported raw instead
of "undecodable". Returns the parsed response or None."""
from pySim.ota import OtaDialectSms
from pySim.sms import UserDataHeader
try:
if not data or data[0] != 0x02:
return None
udhd, remainder = UserDataHeader.from_bytes(data)
if not udhd.has_ie(0x71):
return None
res = OtaDialectSms.SmsResponsePacket.parse(remainder)
if spi['por_shall_be_ciphered']:
deciph = otak.crypt.decrypt(remainder[6:])
temp_data = remainder[:6] + deciph
res = OtaDialectSms.SmsResponsePacket.parse(temp_data)
if res['pcntr'] != 0:
res['secured_data'] = res['secured_data'][:-res['pcntr']]
remainder = temp_data
len_sig = res['rhl'] - 10
if spi['por_rc_cc_ds'] == 'no_rc_cc_ds':
if len_sig:
return None
elif spi['por_rc_cc_ds'] == 'cc':
# UDH + RPL/RHL/TAR/CNTR/PCNTR/STS are part of the CC input
udh = data[:3]
header = remainder[:13]
otak.auth.check_sig(udh + header + remainder[13 + len_sig:],
res['cc_rc'])
else:
return None
except Exception:
# a malformed packet or a failed check is not a response packet
return None
return res
def _decode_por(spi1, spi2, kic, kid, cntr_hex, kic_key_hex, kid_key_hex, def _decode_por(spi1, spi2, kic, kid, cntr_hex, kic_key_hex, kid_key_hex,
response_hex, cmd_len=None): response_hex, cmd_len=None, rm=None):
"""Decode a response packet (PoR). `cmd_len` is the length in bytes of the """Decode a response packet (PoR).
plaintext command script the packet carried (None when unknown): the
TS 102 226 5.1.2 compact response's command count cannot exceed it, so a `rm` tells whether the command was addressed to a remote-management TAR
larger count means the data is the application's own (a third-party (TS 102 226 4: only those answer with the compact/scripting structures of
applet's TAR) and is reported as `raw` - no status word is invented.""" 5.1.2/5.2.2):
from pySim.ota import OtaDialectSms, CompactRemoteResp
from osmocom.utils import h2b, b2h - ``True``: decode as an RM response;
- ``False``: any other TAR - the secured data is the receiving
application's own (application specific, "not defined" in the spec) and
is reported raw; no status word is invented;
- ``None`` (unknown): decode, falling back to raw when the compact parse
cannot apply.
`cmd_len` is the length in bytes of the plaintext command script the
packet carried (None when unknown): the TS 102 226 5.1.2 compact
response's command count cannot exceed it, so a larger count means the
data is the application's own and is reported as `raw`."""
from pySim.ota import OtaDialectSms
from osmocom.utils import h2b
if not response_hex: if not response_hex:
return None return None
try: try:
otak = _ota_keyset(spi1, spi2, kic, kid, cntr_hex, kic_key_hex, kid_key_hex) otak = _ota_keyset(spi1, spi2, kic, kid, cntr_hex, kic_key_hex, kid_key_hex)
spi = _spi_from_bytes(int(spi1, 16), int(spi2, 16)) spi = _spi_from_bytes(int(spi1, 16), int(spi2, 16))
except Exception:
# a malformed counter/key (e.g. a hand-made request) is not a PoR
# either - the same rule as the decode below: never raise here.
return None
try:
data = h2b(response_hex) data = h2b(response_hex)
if not data or data[0] != 0x02:
return None
res, dec = OtaDialectSms().decode_resp(otak, spi, data)
except Exception: except Exception:
# any malformed/garbage PoR (non-hex, bad UDL, truncated fields, # a malformed counter/key/hex (e.g. a hand-made request) is not a PoR
# bad CC) is not a POR; never let decoding crash the request handler.
return None return None
dec = None
if rm is False:
res = _por_header_parse(data, otak, spi)
if res is None:
return None
else:
try:
res, dec = OtaDialectSms().decode_resp(otak, spi, data)
except Exception:
# pySim's decode fails when the secured data is not a compact
# response (e.g. a third-party applet's short reply): fall back
# to the header parse and report the data raw, not lost.
res = _por_header_parse(data, otak, spi)
if res is None:
return None
out = { out = {
'response_status': str(res['response_status']), 'response_status': str(res['response_status']),
'tar': res['tar'].hex().upper(), 'tar': res['tar'].hex().upper(),
@@ -2315,9 +2379,11 @@ def _decode_por(spi1, spi2, kic, kid, cntr_hex, kic_key_hex, kid_key_hex,
# R-APDU(s) of the executed remote command(s) this way instead of the # R-APDU(s) of the executed remote command(s) this way instead of the
# plain compact response. The R-APDU's own SW and data are the useful # plain compact response. The R-APDU's own SW and data are the useful
# result (a bare CompactRemoteResp parse would read `AB` as the command # result (a bare CompactRemoteResp parse would read `AB` as the command
# count and produce garbage). # count and produce garbage). A non-RM TAR's payload is application data
# (rm False): never parse it as an RM structure.
if res.response_status == 'por_ok' and len(res['secured_data']): if res.response_status == 'por_ok' and len(res['secured_data']):
scripted = _parse_response_scripting(bytes(res['secured_data'])) scripted = (_parse_response_scripting(bytes(res['secured_data']))
if rm is not False else None)
if scripted is not None: if scripted is not None:
out['response_type'] = 'scripting' out['response_type'] = 'scripting'
out['decoded'] = { out['decoded'] = {
@@ -5761,6 +5827,7 @@ _TEST_KIND_LABELS = {
'menu-select': 'ENVELOPE(Menu Selection)', 'menu-select': 'ENVELOPE(Menu Selection)',
'file-write': 'UPDATE FILE', 'file-read': 'READ FILE', 'apdu': 'APDU', 'file-write': 'UPDATE FILE', 'file-read': 'READ FILE', 'apdu': 'APDU',
'scp80': 'SCP80', 'status': 'STATUS', 'cleanup': 'CLEANUP', 'scp80': 'SCP80', 'status': 'STATUS', 'cleanup': 'CLEANUP',
'proactive': 'PROACTIVE',
} }
# Card-touching endpoints refused while a script owns the card. # Card-touching endpoints refused while a script owns the card.
_TEST_BLOCKED_PATHS = frozenset([ _TEST_BLOCKED_PATHS = frozenset([
@@ -5813,6 +5880,28 @@ TAR_PROBE_TARS = [
] ]
TAR_PROBE_DEFAULT_APDU = '00A40000023F00' # SELECT MF: harmless, applet-visible TAR_PROBE_DEFAULT_APDU = '00A40000023F00' # SELECT MF: harmless, applet-visible
# The TARs the tool treats as remote-management targets: the preset's role
# entries (checked at call time - a card may use non-standard values) plus
# the ISD/RFM allocations of TS 101 220 V18.3.0 Annex D, Table D.1. A command
# sent to any other TAR goes to a receiving application whose data format is
# application-specific (TS 102 226 4), so its response is reported raw -
# never decoded as an RM response with a fabricated status word.
RM_TAR_ALLOCATIONS = ('000000', 'B20100', 'B00000', 'B00001', 'B00010',
'B00120', 'B00130', 'B00140')
def _tar_is_rm(preset, tar):
"""True when `tar` is a remote-management TAR (a preset role entry or a
standard ISD/RFM allocation), False for any other TAR; None when the TAR
is empty (unknown). Drives the response-form decision of `_decode_por`."""
t = str(tar or '').strip().upper()
if not t:
return None
for entry in (preset or {}).get('tars') or []:
if entry.get('role') and str(entry.get('tar') or '').strip().upper() == t:
return True
return t in RM_TAR_ALLOCATIONS
def _tar_probe_verdict(step): def _tar_probe_verdict(step):
"""Classify one TAR probe step: the card's ENVELOPE verdict plus the PoR """Classify one TAR probe step: the card's ENVELOPE verdict plus the PoR
@@ -5935,7 +6024,8 @@ def _test_finish_entry(entry, result):
fields = {'status': result.get('status', 'ok'), fields = {'status': result.get('status', 'ok'),
'checks': result.get('checks', []), 'checks': result.get('checks', []),
'ms': int((time.time() - (entry.get('started') or time.time())) * 1000)} 'ms': int((time.time() - (entry.get('started') or time.time())) * 1000)}
for key in ('sent', 'sw', 'data', 'note', 'por', 'sms', 'counter', 'command'): for key in ('sent', 'sw', 'data', 'note', 'por', 'sms', 'counter',
'command', 'drained'):
if result.get(key) is not None: if result.get(key) is not None:
fields[key] = result[key] fields[key] = result[key]
_test_entry_update(entry, fields) _test_entry_update(entry, fields)
@@ -6029,6 +6119,102 @@ def _test_run_status(scc, step):
return data or '', sw, ('STATUS x%d' % used if used > 1 else 'STATUS') return data or '', sw, ('STATUS x%d' % used if used > 1 else 'STATUS')
def _proactive_require_match(req, consumed):
"""Whether one consumed command satisfies a proactive action's `require`
spec: the command type/name (ANY matches all) and an optional qualifier
(exact/mask)."""
if req.get('type') is not None and consumed.get('type_hex') != '%02X' % req['type']:
return False
q = req.get('qualifier')
if q and not testscript.match_value(q, consumed.get('qualifier')):
return False
return True
def _proactive_require_label(req):
"""The `require` spec as the report's expected string."""
if req.get('type') is not None:
name = req.get('name') or '0x%02X' % req['type']
else:
name = 'ANY'
q = req.get('qualifier')
return name + ((' q=%s' % q['value']) if q else '')
def _test_run_proactive(server, step):
"""The proactive cleanup action: poll STATUS and consume every announced
command - the TERMINAL RESPONSE is `first` for the first one, `respond`
for the rest - until the card answers 9000 (idle), a non-91XX SW appears
or the attempt budget is spent. The final SW is the step's SW check; an
optional `require` asserts what was drained (at least one match; an empty
drain fails it)."""
scc = server.scc
p = step['params']
consumed = []
sw = ''
polls = 0
for n in range(p['attempts']):
data, sw = _send_status(scc)
polls = n + 1
_test_log('STATUS %d/%d -> %s' % (polls, p['attempts'], sw))
if not (sw or '').startswith('91'):
break
fetch_apdu = '%s120000%02x' % (scc.cat_cla, int(sw[2:], 16))
fdata, fetch_sw = scc._tp.send_apdu(fetch_apdu)
if not fdata:
_test_log('FETCH=%s -> SW=%s (no data)' % (fetch_apdu, fetch_sw))
sw = fetch_sw
break
raw = bytes.fromhex(fdata)
_test_log('FETCH=%s -> SW=%s %s' % (fetch_apdu, fetch_sw, raw.hex().upper()))
cmd_num, cmd_type, dev_src, dev_dst, cmd_qual = _parse_proactive_header(raw)
name = PROACTIVE_TYPE_NAMES.get(cmd_type, 'UNKNOWN')
_test_log('CMD 0x%02X %s qual=%s' % (
cmd_type, name, ('%02X' % cmd_qual) if cmd_qual is not None else '-'))
if cmd_type == 0x25:
# keep the cached menu (text-based selection) current
menu = _parse_setup_menu_command(raw)
if menu:
server.sim_menu = menu
respond = p['first'] if (not consumed and p.get('first')) else p['respond']
tr = testscript.build_tr(cmd_num, cmd_type, dev_dst, dev_src, respond)
tr_rv = scc._tp.send_apdu('%s140000%02x%s' % (scc.cat_cla, len(tr), tr.hex()))
_test_log('TR=%s -> SW=%s' % (tr.hex().upper(), tr_rv[1]))
consumed.append({'type_hex': '%02X' % cmd_type, 'type_name': name,
'qualifier': ('%02X' % cmd_qual) if cmd_qual is not None else None,
'tr_sw': tr_rv[1], 'raw': raw.hex().upper()})
sw = tr_rv[1]
if not (sw or '').startswith('91') and sw != '9000':
# a TERMINAL RESPONSE the card rejected: stop and let the SW
# check name it
break
if n + 1 < p['attempts'] and p['interval_ms']:
time.sleep(p['interval_ms'] / 1000.0)
checks = [_test_check_result('SW', testscript.match_value(step['check']['sw'], sw),
step['check']['sw']['value'], sw or '(none)',
step['on_fail'])]
req = p.get('require')
if req:
matched = any(_proactive_require_match(req, c) for c in consumed)
if consumed:
actual = ', '.join(
'%s%s' % (c['type_name'],
(' q=%s' % c['qualifier']) if c['qualifier'] else '')
for c in consumed)
else:
actual = '(nothing was pending)'
checks.append(_test_check_result('Require', matched,
_proactive_require_label(req), actual,
step['on_fail']))
status = testscript.combine_levels([c['level'] if not c['ok'] else 'ok'
for c in checks])
result = {'status': status, 'sw': sw or '', 'data': '',
'sent': 'STATUS x%d; TR x%d' % (polls, len(consumed)),
'checks': checks, 'drained': consumed}
pending = int(sw[2:], 16) if (sw or '').startswith('91') else None
return result, pending
def _test_run_file(server, step): def _test_run_file(server, step):
"""UPDATE/READ a file by path (extends the network simulator's writes to """UPDATE/READ a file by path (extends the network simulator's writes to
arbitrary files); returns (data, sw, sent).""" arbitrary files); returns (data, sw, sent)."""
@@ -6107,7 +6293,8 @@ def _test_run_scp80(server, step, ctx):
cmd_len = len(p.get('apdu') or '') // 2 or None cmd_len = len(p.get('apdu') or '') // 2 or None
ctx['last_scp80'] = {'spi1': spi1, 'spi2': spi2, 'kic': kic, 'kid': kid, ctx['last_scp80'] = {'spi1': spi1, 'spi2': spi2, 'kic': kic, 'kid': kid,
'counter': counter, 'kicKey': kic_key, 'kidKey': kid_key, 'counter': counter, 'kicKey': kic_key, 'kidKey': kid_key,
'tar': tar, 'cmd_len': cmd_len} 'tar': tar, 'cmd_len': cmd_len,
'rm': _tar_is_rm(preset, tar)}
fmt = p.get('format') or 'compact' fmt = p.get('format') or 'compact'
apdu_hex = p.get('apdu') or '' apdu_hex = p.get('apdu') or ''
if p.get('sp'): if p.get('sp'):
@@ -6139,7 +6326,7 @@ def _test_run_scp80(server, step, ctx):
por = None por = None
if data: if data:
por = _decode_por(spi1, spi2, kic, kid, counter, kic_key, kid_key, data, por = _decode_por(spi1, spi2, kic, kid, counter, kic_key, kid_key, data,
cmd_len=cmd_len) cmd_len=cmd_len, rm=_tar_is_rm(preset, tar))
if por: if por:
dec = por.get('decoded') or {} dec = por.get('decoded') or {}
extra = '' extra = ''
@@ -6273,6 +6460,8 @@ def _test_run_action(server, step, ctx):
(' RESP=%dB' % (len(data) // 2)) if data else '')) (' RESP=%dB' % (len(data) // 2)) if data else ''))
elif kind == 'scp80': elif kind == 'scp80':
data, sw, sent, por, counter = _test_run_scp80(server, step, ctx) data, sw, sent, por, counter = _test_run_scp80(server, step, ctx)
elif kind == 'proactive':
return _test_run_proactive(server, step)
else: else:
raise testscript.ScriptError('unknown action kind %r' % kind) raise testscript.ScriptError('unknown action kind %r' % kind)
checks = _test_action_checks(step, sw, data, por, kind) checks = _test_action_checks(step, sw, data, por, kind)
@@ -6375,21 +6564,37 @@ def _test_expect_por(ctx, raw):
por_hex = '027100' + ud_hex por_hex = '027100' + ud_hex
return _decode_por(last['spi1'], last['spi2'], last['kic'], last['kid'], return _decode_por(last['spi1'], last['spi2'], last['kic'], last['kid'],
last['counter'], last['kicKey'], last['kidKey'], por_hex, last['counter'], last['kicKey'], last['kidKey'], por_hex,
cmd_len=last.get('cmd_len')) cmd_len=last.get('cmd_len'), rm=last.get('rm'))
def _test_run_expect(server, step, pending, ctx=None): def _test_run_expect(server, step, pending, ctx=None):
scc = server.scc scc = server.scc
if not pending: if not pending:
raise testscript.ScriptError( # Nothing to fetch: the step fails at its own on_fail level. The
'no proactive command pending - the previous step must end with SW 91XX ' # tolerant "consume a pending command if there is one" form is an
'(or add a status action); the UICC announces pending commands in the ' # expectation with on_fail "warning" (or the proactive cleanup action,
'response to a command (TS 102 221 7.4.2.1)') # which tolerates an empty card by design). The UICC announces
# pending commands in the response to a command (TS 102 221 7.4.2.1).
want = step['command'].get('name')
if not want and step['command'].get('type') is not None:
want = '0x%02X' % step['command']['type']
return {'status': step['on_fail'], 'checks': [_test_check_result(
'Command', False, want or 'a pending command', '(none)',
step['on_fail'],
detail='no proactive command pending - the previous step '
'did not end with SW 91XX (the card announces '
'pending commands in the response to a command, '
'TS 102 221 7.4.2.1); a status action with '
'attempts > 1 polls until one appears')]}, None
fetch_apdu = '%s120000%02x' % (scc.cat_cla, pending) fetch_apdu = '%s120000%02x' % (scc.cat_cla, pending)
fdata, fetch_sw = scc._tp.send_apdu(fetch_apdu) fdata, fetch_sw = scc._tp.send_apdu(fetch_apdu)
if not fdata: if not fdata:
_test_log('FETCH=%s -> SW=%s (no data)' % (fetch_apdu, fetch_sw)) _test_log('FETCH=%s -> SW=%s (no data)' % (fetch_apdu, fetch_sw))
raise testscript.ScriptError('FETCH returned no data (SW %s)' % fetch_sw) return {'status': step['on_fail'], 'checks': [_test_check_result(
'Fetch', False, 'the announced command',
'no data (SW %s)' % fetch_sw, step['on_fail'],
detail='the card announced a command (SW 91XX) but FETCH '
'returned no data')]}, None
raw = bytes.fromhex(fdata) raw = bytes.fromhex(fdata)
_test_log('FETCH=%s -> SW=%s %s' % (fetch_apdu, fetch_sw, raw.hex().upper())) _test_log('FETCH=%s -> SW=%s %s' % (fetch_apdu, fetch_sw, raw.hex().upper()))
cmd_num, cmd_type, dev_src, dev_dst, cmd_qual = _parse_proactive_header(raw) cmd_num, cmd_type, dev_src, dev_dst, cmd_qual = _parse_proactive_header(raw)
@@ -6533,23 +6738,50 @@ def _test_run_expect(server, step, pending, ctx=None):
return result, pending_next return result, pending_next
def _test_drain_pending(scc, fetch_len): def _test_drain_pending(scc, fetch_len, result=0x00, max_commands=3):
"""Fetch an announced command the script did not expect and answer it """Fetch the commands the script did not expect and answer them, so the
with a cancel TR, so the card is not left re-announcing it (6.3).""" card is not left re-announcing one (TS 102 223 6.3).
The answer defaults to 'ok' (0x00, command performed successfully): a
cancel (0x10) tells the card the user terminated the proactive session,
which makes assertive applications take an error path - a REFRESH
answered cancel can queue a further command. Answering one command can
make the application emit another, so the drain keeps fetching while the
TERMINAL RESPONSE answers 91XX (bounded by `max_commands`). A script that
wants to test the refusal path answers explicitly (an expectation's
`respond`, or the proactive action's `first`)."""
answered = []
try: try:
fdata, sw = scc._tp.send_apdu('%s120000%02x' % (scc.cat_cla, fetch_len or 0x100)) while len(answered) < max_commands:
if not fdata: fdata, sw = scc._tp.send_apdu('%s120000%02x' % (scc.cat_cla,
return {'note': 'nothing to drain (FETCH SW %s)' % sw} fetch_len or 0x100))
raw = bytes.fromhex(fdata) if not fdata:
cmd_num, cmd_type, dev_src, dev_dst, cmd_qual = _parse_proactive_header(raw) break
tr = testscript.build_tr(cmd_num, cmd_type, dev_dst, dev_src, {'result': 0x10}) raw = bytes.fromhex(fdata)
rv = scc._tp.send_apdu('%s140000%02x%s' % (scc.cat_cla, len(tr), tr.hex())) cmd_num, cmd_type, dev_src, dev_dst, cmd_qual = _parse_proactive_header(raw)
return {'sent': 'TR cancel ' + tr.hex().upper(), 'sw': rv[1], tr = testscript.build_tr(cmd_num, cmd_type, dev_dst, dev_src, {'result': result})
'data': raw.hex().upper(), rv = scc._tp.send_apdu('%s140000%02x%s' % (scc.cat_cla, len(tr), tr.hex()))
'note': 'pending %s (0x%02X) answered with cancel' name = PROACTIVE_TYPE_NAMES.get(cmd_type, '?')
% (PROACTIVE_TYPE_NAMES.get(cmd_type, '?'), cmd_type)} answered.append({'name': name, 'type': cmd_type,
'raw': raw.hex().upper(), 'tr': tr.hex().upper(),
'sw': rv[1]})
sys.stderr.write('TEST-RUN drain: %s (0x%02X) TR=%s -> SW=%s\n'
% (name, cmd_type, tr.hex().upper(), rv[1]))
if not (rv[1] or '').startswith('91'):
break
fetch_len = int(rv[1][2:], 16)
except Exception as e: except Exception as e:
return {'note': 'drain failed: %s' % e} return {'note': 'drain failed: %s' % e}
if not answered:
return {'note': 'nothing to drain'}
result_name = {0x00: 'ok', 0x10: 'cancel', 0x11: 'back',
0x12: 'timeout', 0x22: 'no response'}.get(result,
'0x%02X' % result)
return {'sent': '; '.join('TR %s %s' % (result_name, a['tr']) for a in answered),
'sw': answered[-1]['sw'], 'data': answered[0]['raw'],
'note': '; '.join('pending %s (0x%02X) answered with %s'
% (a['name'], a['type'], result_name)
for a in answered)}
def _test_run_execute(server, script, preset): def _test_run_execute(server, script, preset):
@@ -7903,6 +8135,12 @@ class PysimHandler(BaseHTTPRequestHandler):
# no counter is advanced or persisted. # no counter is advanced or persisted.
counter_tracked = _counter_tracked(spi1) counter_tracked = _counter_tracked(spi1)
include_cpi = body.get('includeCpi', True) include_cpi = body.get('includeCpi', True)
# The response form follows the command's TAR (TS 102 226 4): only
# a remote-management TAR answers with the RM structures; a hand
# send may name any TAR (the pre-built `sp` path may name none -
# unknown, decode with the structural fallback).
rm = _tar_is_rm(_preset_by_id(self.server, body.get('preset_id')),
body.get('tar') or ('000000' if apdu else ''))
ram_format = None ram_format = None
try: try:
if apdu: if apdu:
@@ -7986,7 +8224,8 @@ class PysimHandler(BaseHTTPRequestHandler):
body.get('kic', ''), body.get('kid', ''), body.get('kic', ''), body.get('kid', ''),
cntr, body.get('kicKey', ''), cntr, body.get('kicKey', ''),
body.get('kidKey', ''), failed_por_hex, body.get('kidKey', ''), failed_por_hex,
cmd_len=len(body.get('apdu') or '') // 2 or None) cmd_len=len(body.get('apdu') or '') // 2 or None,
rm=rm)
if failed_por: if failed_por:
resp['por'] = failed_por resp['por'] = failed_por
low = _cntr_low_fields(failed_por.get('response_status')) low = _cntr_low_fields(failed_por.get('response_status'))
@@ -8024,7 +8263,8 @@ class PysimHandler(BaseHTTPRequestHandler):
por = _decode_por(spi1, spi2, body.get('kic', ''), por = _decode_por(spi1, spi2, body.get('kic', ''),
body.get('kid', ''), cntr, body.get('kicKey', ''), body.get('kid', ''), cntr, body.get('kicKey', ''),
body.get('kidKey', ''), por_hex, body.get('kidKey', ''), por_hex,
cmd_len=len(body.get('apdu') or '') // 2 or None) cmd_len=len(body.get('apdu') or '') // 2 or None,
rm=rm)
# Check for SPI2=0x21 (PoR required) but got 9000 with no PoR → card refuses PoR # Check for SPI2=0x21 (PoR required) but got 9000 with no PoR → card refuses PoR
por_required = bool(spi2_val & 0x01) por_required = bool(spi2_val & 0x01)
no_por_received = not por_hex and not (submit_handler and submit_handler.submit_tpdu_hex) no_por_received = not por_hex and not (submit_handler and submit_handler.submit_tpdu_hex)
+51 -23
View File
@@ -33,7 +33,7 @@ __all__ = [
] ]
ACTION_KINDS = ('envelope', 'event', 'menu-select', 'file-write', 'file-read', ACTION_KINDS = ('envelope', 'event', 'menu-select', 'file-write', 'file-read',
'apdu', 'scp80', 'status') 'apdu', 'scp80', 'status', 'proactive')
FAIL_LEVELS = ('error', 'warning') FAIL_LEVELS = ('error', 'warning')
POR_CHECKS = ('none', 'ok', 'any') POR_CHECKS = ('none', 'ok', 'any')
@@ -207,17 +207,17 @@ def normalise_step(step, command_resolver=None):
raise ScriptError('each step must be an object') raise ScriptError('each step must be an object')
typ = step.get('type') typ = step.get('type')
if typ == 'action': if typ == 'action':
return _normalise_action(step) return _normalise_action(step, command_resolver)
if typ == 'expect': if typ == 'expect':
return _normalise_expect(step, command_resolver) return _normalise_expect(step, command_resolver)
raise ScriptError("step type must be 'action' or 'expect'") raise ScriptError("step type must be 'action' or 'expect'")
def _normalise_action(step): def _normalise_action(step, command_resolver=None):
kind = str(step.get('kind') or '').lower() kind = str(step.get('kind') or '').lower()
if kind not in ACTION_KINDS: if kind not in ACTION_KINDS:
raise ScriptError("unknown action kind %r" % step.get('kind')) raise ScriptError("unknown action kind %r" % step.get('kind'))
params = _normalise_params(kind, step.get('params') or {}) params = _normalise_params(kind, step.get('params') or {}, command_resolver)
on_fail = _fail_level(step.get('on_fail')) on_fail = _fail_level(step.get('on_fail'))
check = _normalise_check(step.get('check'), kind, params) check = _normalise_check(step.get('check'), kind, params)
out = {'type': 'action', 'kind': kind, 'params': params, out = {'type': 'action', 'kind': kind, 'params': params,
@@ -227,7 +227,7 @@ def _normalise_action(step):
return out return out
def _normalise_params(kind, p): def _normalise_params(kind, p, command_resolver=None):
if not isinstance(p, dict): if not isinstance(p, dict):
raise ScriptError('%s: params must be an object' % kind) raise ScriptError('%s: params must be an object' % kind)
if kind == 'envelope': if kind == 'envelope':
@@ -346,6 +346,30 @@ def _normalise_params(kind, p):
interval = p.get('interval_ms') interval = p.get('interval_ms')
interval = _int(200 if interval is None else interval, 'status interval_ms', 0, 10000) interval = _int(200 if interval is None else interval, 'status interval_ms', 0, 10000)
return {'attempts': attempts, 'interval_ms': interval} return {'attempts': attempts, 'interval_ms': interval}
if kind == 'proactive':
# The proactive cleanup: consume whatever the card announces (the
# TERMINAL RESPONSE is `first` for the first command, `respond` for
# the rest) and confirm the card is idle; `require` asserts what was
# drained (at least one match - an empty drain fails it).
out = {'respond': normalise_respond(p.get('respond') or {}),
'attempts': _int(3 if p.get('attempts') is None else p.get('attempts'),
'proactive attempts', 1, 1000),
'interval_ms': _int(200 if p.get('interval_ms') is None
else p.get('interval_ms'),
'proactive interval_ms', 0, 10000)}
if p.get('first') is not None:
out['first'] = normalise_respond(p['first'])
req = p.get('require')
if req is not None:
if not isinstance(req, dict):
raise ScriptError('proactive require must be an object')
ctype, cname = _resolve_command(req.get('command'), 'proactive require',
command_resolver)
qualifier = _check_spec(req.get('qualifier'), 'proactive require.qualifier')
if qualifier and '?' not in qualifier['value'] and len(qualifier['value']) != 2:
raise ScriptError('proactive require.qualifier must be one byte')
out['require'] = {'type': ctype, 'name': cname, 'qualifier': qualifier}
return out
raise ScriptError('unknown action kind %r' % kind) raise ScriptError('unknown action kind %r' % kind)
@@ -381,26 +405,30 @@ def _normalise_check(check, kind, params):
return {'sw': sw, 'data': data, 'por': por} return {'sw': sw, 'data': data, 'por': por}
def _normalise_expect(step, command_resolver=None): def _resolve_command(cmd, what, command_resolver=None):
cmd = step.get('command') """A proactive command spec: a name (resolved to a type code through
``command_resolver``), a hex type code or ANY. Returns (type|None,
name|None)."""
if cmd is None or isinstance(cmd, bool): if cmd is None or isinstance(cmd, bool):
raise ScriptError('expect: command is required') raise ScriptError('%s: command is required' % what)
if isinstance(cmd, int): if isinstance(cmd, int):
ctype, cname = cmd, None return cmd, None
else: s = str(cmd).strip()
s = str(cmd).strip() up = s.upper()
up = s.upper() if up in ('ANY', '*'):
if up in ('ANY', '*'): return None, 'ANY'
ctype, cname = None, 'ANY' if re.fullmatch(r'(0X)?[0-9A-F]{2}', up):
elif re.fullmatch(r'(0X)?[0-9A-F]{2}', up): return int(up.replace('0X', ''), 16), None
ctype, cname = int(up.replace('0X', ''), 16), None if command_resolver is not None:
elif command_resolver is not None: ctype = command_resolver(up)
ctype = command_resolver(up) if ctype is None:
if ctype is None: raise ScriptError('%s: unknown proactive command %r' % (what, s))
raise ScriptError('expect: unknown proactive command %r' % s) return ctype, up
cname = up raise ScriptError('%s: command must be a hex type code' % what)
else:
raise ScriptError('expect: command must be a hex type code')
def _normalise_expect(step, command_resolver=None):
ctype, cname = _resolve_command(step.get('command'), 'expect', command_resolver)
qualifier = _check_spec(step.get('qualifier'), 'qualifier') qualifier = _check_spec(step.get('qualifier'), 'qualifier')
if qualifier and '?' not in qualifier['value'] and len(qualifier['value']) != 2: if qualifier and '?' not in qualifier['value'] and len(qualifier['value']) != 2:
raise ScriptError('qualifier must be one byte') raise ScriptError('qualifier must be one byte')
+54
View File
@@ -25,6 +25,7 @@ from pysim_simple_server.server import (
_build_tr, _build_tr,
_decode_cmd, _decode_cmd,
_decode_por, _decode_por,
_tar_is_rm,
_decode_tr, _decode_tr,
_log_proactive, _log_proactive,
_ota_reference, _ota_reference,
@@ -535,6 +536,59 @@ class TestDecodePor(unittest.TestCase):
self.assertEqual(r['decoded']['last_status_word'], '6a86') self.assertEqual(r['decoded']['last_status_word'], '6a86')
self.assertEqual(r['secured_data'], '016A86') self.assertEqual(r['secured_data'], '016A86')
def test_non_rm_tar_is_application_data(self):
# v3.21.0: the response form follows the command's TAR. A non-RM TAR
# answers with application data (TS 102 226 4); `01 00 0B 91 ...`
# happens to parse as a compact response (count 1, SW 000B) and must
# not be split into a fabricated status word (live 2026-10-05).
raw = '027100000e0aaf4d010000000001000001000B919733525088F4'
r = _decode_por('16', '01', '15', '15', '0000000001', K, K, raw, rm=False)
self.assertEqual(r['response_type'], 'raw')
self.assertEqual(r['response_status'], 'por_ok')
self.assertEqual(r['secured_data'], '01000B919733525088F4')
self.assertEqual(r['decoded']['last_response_data'], '01000B919733525088F4')
self.assertEqual(r['decoded']['last_status_word'], '')
self.assertIsNone(r['decoded']['number_of_commands'])
# without the TAR context the same bytes still parse as compact
r = _decode_por('16', '01', '15', '15', '0000000001', K, K, raw)
self.assertEqual(r['response_type'], 'compact')
self.assertEqual(r['decoded']['last_status_word'], '000b')
def test_short_applet_reply_falls_back_to_raw(self):
# a 2-byte secured data cannot be a compact response (TS 102 226 5.1.2
# Table 5.1 needs a count plus two status bytes): pySim's decode raises
# and the packet is reported raw instead of "undecodable"
raw = '027100000e0aaf4d01000000000100000400'
r = _decode_por('16', '01', '15', '15', '0000000001', K, K, raw)
self.assertEqual(r['response_type'], 'raw')
self.assertEqual(r['response_status'], 'por_ok')
self.assertEqual(r['secured_data'], '0400')
self.assertEqual(r['decoded']['last_response_data'], '0400')
class TestTarIsRm(unittest.TestCase):
"""v3.21.0: the response form follows the command's TAR - the preset's
role entries plus the standard ISD/RFM allocations of TS 101 220 Annex D."""
def test_standard_allocations(self):
for tar in ('000000', 'B00000', 'B00001', 'B00010', 'B20100',
'B00120', 'B00130', 'B00140'):
self.assertTrue(_tar_is_rm(None, tar), tar)
def test_application_allocations_are_not_rm(self):
# the Annex D application TARs (USAT interpreter, multiplexing, CASD,
# OMA) and any custom TAR: application-specific data (TS 102 226 4)
for tar in ('AF4D01', 'B20000', 'B20200', 'B20201', 'B20202',
'B20203', 'B00200'):
self.assertFalse(_tar_is_rm(None, tar), tar)
def test_preset_roles_win(self):
preset = {'tars': [{'role': 'isd', 'tar': 'A11300', 'msl': '16'},
{'role': '', 'tar': 'AF4D01', 'msl': '16'}]}
self.assertTrue(_tar_is_rm(preset, 'A11300'))
self.assertFalse(_tar_is_rm(preset, 'AF4D01'))
self.assertIsNone(_tar_is_rm(preset, ''))
class TestPresetCounterSeed(unittest.TestCase): class TestPresetCounterSeed(unittest.TestCase):
"""v3.20.0: the effective counter of an operation - explicit `cntr` wins, """v3.20.0: the effective counter of an operation - explicit `cntr` wins,
+191 -3
View File
@@ -85,6 +85,39 @@ class TestValidation(unittest.TestCase):
self.assertEqual(check['sw'], {'mode': 'mask', 'value': '91??'}) self.assertEqual(check['sw'], {'mode': 'mask', 'value': '91??'})
self.assertEqual(check['data'], {'mode': 'mask', 'value': 'AA??'}) self.assertEqual(check['data'], {'mode': 'mask', 'value': 'AA??'})
def test_proactive_action_normalises(self):
script = T.normalise_script({'steps': [
{'type': 'action', 'kind': 'proactive',
'params': {'respond': {'result': 'ok'}, 'first': {'result': 'cancel'},
'attempts': 5, 'interval_ms': 0,
'require': {'command': 'REFRESH', 'qualifier': '00'}}},
]}, _resolver)
p = script['steps'][0]['params']
self.assertEqual(p['respond'], {'result': 0x00})
self.assertEqual(p['first'], {'result': 0x10})
self.assertEqual(p['attempts'], 5)
self.assertEqual(p['interval_ms'], 0)
self.assertEqual(p['require']['type'], 0x01)
self.assertEqual(p['require']['name'], 'REFRESH')
self.assertEqual(p['require']['qualifier'], {'mode': 'exact', 'value': '00'})
# the defaults: ok / 3 attempts / 200 ms, no first, no require
script = T.normalise_script({'steps': [
{'type': 'action', 'kind': 'proactive'}]}, _resolver)
p = script['steps'][0]['params']
self.assertEqual(p['respond'], {'result': 0x00})
self.assertNotIn('first', p)
self.assertNotIn('require', p)
self.assertEqual(p['attempts'], 3)
self.assertEqual(p['interval_ms'], 200)
# a bad require is refused
for bad in ({'require': {'qualifier': '00'}},
{'require': 'REFRESH'},
{'require': {'command': 'NOT A COMMAND'}},
{'attempts': 0}):
with self.assertRaises(T.ScriptError, msg=repr(bad)):
T.normalise_script({'steps': [{'type': 'action', 'kind': 'proactive',
'params': bad}]}, _resolver)
def test_integer_fields_accept_decimals_with_leading_zeros_and_hex(self): def test_integer_fields_accept_decimals_with_leading_zeros_and_hex(self):
script = T.normalise_script({'steps': [ script = T.normalise_script({'steps': [
{'type': 'action', 'kind': 'status', {'type': 'action', 'kind': 'status',
@@ -440,7 +473,29 @@ class TestRunnerDialogue(RunnerTestCase):
'checks': [{'kind': 'text', 'value': 'hello'}], 'respond': {}}, 'checks': [{'kind': 'text', 'value': 'hello'}], 'respond': {}},
]) ])
self.assertEqual(run['status'], 'error') self.assertEqual(run['status'], 'error')
self.assertIn('no proactive command pending', run['steps'][0]['note']) self.assertEqual(run['steps'][0]['status'], 'error')
check = run['steps'][0]['checks'][0]
self.assertEqual(check['label'], 'Command')
self.assertFalse(check['ok'])
self.assertEqual(check['expected'], 'DISPLAY TEXT')
self.assertEqual(check['actual'], '(none)')
self.assertIn('no proactive command pending', check['detail'])
def test_expectation_warning_without_pending_continues(self):
# on_fail "warning" is the tolerant consume-if-pending form (v3.21.0):
# the empty poll warns and the script continues
scc = FakeScc()
scc.push('80F2', '', '9000')
run = self.run_script(FakeServer(scc), [
{'type': 'expect', 'command': 'ANY', 'respond': {},
'on_fail': 'warning'},
{'type': 'action', 'kind': 'status', 'params': {}},
])
self.assertEqual(run['status'], 'warning')
self.assertEqual(len(run['steps']), 2)
self.assertEqual(run['steps'][0]['status'], 'warning')
self.assertIn('no proactive command pending',
run['steps'][0]['checks'][0]['detail'])
def test_text_mismatch_fails_the_expectation(self): def test_text_mismatch_fails_the_expectation(self):
scc = FakeScc() scc = FakeScc()
@@ -471,9 +526,31 @@ class TestRunnerDialogue(RunnerTestCase):
]) ])
self.assertEqual(run['status'], 'error') self.assertEqual(run['status'], 'error')
self.assertIn('unexpected proactive command pending', run['steps'][1]['note']) self.assertIn('unexpected proactive command pending', run['steps'][1]['note'])
# the drain FETCHed the command and answered with a cancel TR (0x10) # the drain FETCHed the command and answered with an ok TR (0x00,
# v3.21.0: a cancel told the card the user aborted the session)
self.assertTrue(any(a.startswith('8012') for a in scc.sent[1:])) self.assertTrue(any(a.startswith('8012') for a in scc.sent[1:]))
self.assertTrue(any('83021000' in a for a in scc.sent)) self.assertTrue(any('83020000' in a for a in scc.sent))
self.assertIn('TR ok', run['steps'][1]['sent'])
def test_drain_answers_ok_and_follows_the_next_command(self):
# answering one command can make the application emit another; the
# drain keeps fetching while the TR answers 91XX (bounded, v3.21.0)
scc = FakeScc()
scc.push('80C2', '', '9103') # menu selection
scc.push('8012', SELECT_ITEM_CMD, '9000') # FETCH #1
scc.push('8014', '', '9102') # TR -> another pending
scc.push('8012', DISPLAY_TEXT_CMD, '9000') # FETCH #2
scc.push('8014', '', '9000') # TR -> idle
run = self.run_script(FakeServer(scc), [
{'type': 'action', 'kind': 'menu-select', 'params': {'item_id': 1},
'check': {'sw': '91??'}},
{'type': 'action', 'kind': 'apdu', 'params': {'apdu': '00A40000023F00'}},
])
self.assertEqual(run['status'], 'error') # the unexpected pending stops the script
entry = run['steps'][1]
self.assertEqual(entry['sent'].count('TR ok'), 2)
self.assertEqual(entry['sw'], '9000')
self.assertIn(SELECT_ITEM_CMD, entry['data'])
def test_sw_mismatch_error_stops_the_script_warning_continues(self): def test_sw_mismatch_error_stops_the_script_warning_continues(self):
scc = FakeScc() scc = FakeScc()
@@ -532,6 +609,117 @@ class TestRunnerDialogue(RunnerTestCase):
self.assertEqual(run['steps'], []) self.assertEqual(run['steps'], [])
class TestRunnerProactive(RunnerTestCase):
"""The proactive cleanup action (v3.21.0): consume whatever the card
announces, answer it and confirm the card is idle."""
def test_empty_drain_passes(self):
# the point of the action: an empty first poll is fine
scc = FakeScc()
scc.push('80F2', '', '9000')
run = self.run_script(FakeServer(scc), [
{'type': 'action', 'kind': 'proactive', 'params': {}},
])
self.assertEqual(run['status'], 'ok', run['steps'])
entry = run['steps'][0]
self.assertEqual(entry['status'], 'ok')
self.assertEqual(entry['sent'], 'STATUS x1; TR x0')
self.assertEqual(entry['drained'], [])
self.assertEqual(entry['checks'][0]['label'], 'SW')
self.assertTrue(entry['checks'][0]['ok'])
def test_drains_a_pending_command_and_confirms_idle(self):
scc = FakeScc()
scc.push('80F2', '', '9102') # STATUS: DISPLAY TEXT pending
scc.push('8012', DISPLAY_TEXT_CMD, '9000')
scc.push('8014', '', '9000') # TR ok
scc.push('80F2', '', '9000') # confirming STATUS: idle
run = self.run_script(FakeServer(scc), [
{'type': 'action', 'kind': 'proactive', 'params': {'attempts': 3, 'interval_ms': 0}},
])
self.assertEqual(run['status'], 'ok', run['steps'])
entry = run['steps'][0]
self.assertEqual(entry['sent'], 'STATUS x2; TR x1')
self.assertEqual(len(entry['drained']), 1)
self.assertEqual(entry['drained'][0]['type_name'], 'DISPLAY TEXT')
self.assertEqual(entry['drained'][0]['qualifier'], '00')
self.assertTrue(any('83020000' in a for a in scc.sent))
def test_require_matches_a_drained_command(self):
scc = FakeScc()
scc.push('80F2', '', '9102')
scc.push('8012', DISPLAY_TEXT_CMD, '9000')
scc.push('8014', '', '9000')
scc.push('80F2', '', '9000')
run = self.run_script(FakeServer(scc), [
{'type': 'action', 'kind': 'proactive',
'params': {'attempts': 3, 'interval_ms': 0, 'require': {'command': 'DISPLAY TEXT'}}},
])
self.assertEqual(run['status'], 'ok', run['steps'])
req = [c for c in run['steps'][0]['checks'] if c['label'] == 'Require'][0]
self.assertTrue(req['ok'])
def test_require_fails_on_a_mismatch(self):
scc = FakeScc()
scc.push('80F2', '', '9102')
scc.push('8012', DISPLAY_TEXT_CMD, '9000')
scc.push('8014', '', '9000')
scc.push('80F2', '', '9000')
run = self.run_script(FakeServer(scc), [
{'type': 'action', 'kind': 'proactive',
'params': {'attempts': 3, 'interval_ms': 0,
'require': {'command': 'REFRESH', 'qualifier': '00'}}},
])
self.assertEqual(run['status'], 'error')
req = [c for c in run['steps'][0]['checks'] if c['label'] == 'Require'][0]
self.assertFalse(req['ok'])
self.assertIn('DISPLAY TEXT', req['actual'])
def test_require_fails_on_an_empty_drain(self):
scc = FakeScc()
scc.push('80F2', '', '9000')
run = self.run_script(FakeServer(scc), [
{'type': 'action', 'kind': 'proactive',
'params': {'require': {'command': 'REFRESH'}}},
])
self.assertEqual(run['status'], 'error')
req = [c for c in run['steps'][0]['checks'] if c['label'] == 'Require'][0]
self.assertEqual(req['actual'], '(nothing was pending)')
def test_first_uses_a_different_result(self):
scc = FakeScc()
scc.push('80F2', '', '9102') # STATUS: DISPLAY TEXT pending
scc.push('8012', DISPLAY_TEXT_CMD, '9000')
scc.push('8014', '', '9102') # TR cancel -> another pending
scc.push('80F2', '', '9102') # STATUS: SELECT ITEM pending
scc.push('8012', SELECT_ITEM_CMD, '9000')
scc.push('8014', '', '9000') # TR ok
scc.push('80F2', '', '9000') # confirming STATUS
run = self.run_script(FakeServer(scc), [
{'type': 'action', 'kind': 'proactive',
'params': {'attempts': 4, 'interval_ms': 0, 'respond': {'result': 'ok'},
'first': {'result': 'cancel'}}},
])
self.assertEqual(run['status'], 'ok', run['steps'])
self.assertEqual(len(run['steps'][0]['drained']), 2)
self.assertTrue(any('83021000' in a for a in scc.sent)) # the first TR
self.assertTrue(any('83020000' in a for a in scc.sent)) # the second
def test_bound_leaves_the_pending_for_the_next_step(self):
scc = FakeScc()
scc.push('80F2', '', '9102')
scc.push('8012', DISPLAY_TEXT_CMD, '9000')
scc.push('8014', '', '9102') # another command pending
run = self.run_script(FakeServer(scc), [
{'type': 'action', 'kind': 'proactive', 'params': {'attempts': 1}},
])
# the bound stopped with 91XX: the SW check fails and the pending is
# handed to the next step
self.assertEqual(run['status'], 'error')
self.assertEqual(run['steps'][0]['sw'], '9102')
self.assertFalse(run['steps'][0]['checks'][0]['ok'])
class TestRunnerActions(RunnerTestCase): class TestRunnerActions(RunnerTestCase):
def test_script_menu_select_mirrors_menu_active(self): def test_script_menu_select_mirrors_menu_active(self):
# 9000 -> the menu dialogue is over # 9000 -> the menu dialogue is over