v3.28.0: the batch CAP install

The bulk workflow: one .cap and one set of install parameters applied to
card after card - swap cards and each one installs as soon as it is
equipped.  The server owns the loop, so closing the tab or reloading the
page does not interrupt it.

The install chain is now shared: _ram_install_plan(body) builds the
card-independent part (the parsed .cap, the INSTALL/LOAD sequence, the
block size, the transport knobs) and _ram_install_run(server, body, plan,
scc) runs one chain (the per-card preset/keyset/counter resolution
included); the /api/ram-install handler is the plan + run + response, and
the batch worker calls the same pair.  The plan is built once at Start,
so a bad .cap or parameter combination is a 400 there (a malformed
archive included - a zip parse error is the caller's, never a dropped
connection), not a per-card failure.  _op_keyset_resolve() is the module
form of the handler's _op_keyset() (the dead _keyset_guard method is
gone).

The worker (_ram_batch_worker, state _RAM_BATCH) polls the card state
every 0.5 s and acts on a new card session while connected; a card
already equipped at Start installs immediately.  Per card the preset is
matched by ICCID and its ISD role TAR supplies the keyset (v3.26.0) and
the counter seed (v3.20.0); a card with no readable ICCID, no matching
preset or no usable ISD keyset is recorded as skipped with its reason.
A card is attempted once per run (any outcome): re-equipping it records
"already processed in this batch (<outcome>)" and never installs again -
restarting the batch is the retry.  Failures carry the failing step and
the remote SW, are never retried and never stop the run; an unexpected
card-handling error is recorded and the loop keeps waiting.

Endpoints: POST /api/ram-batch/start (no card needed; 400/409), GET
/api/ram-batch/status (card-free - the panel polls it during an install),
POST /api/ram-batch/stop (the first _CARD_FREE_POST: it only sets a flag,
so the button works while the worker holds the card lock; a running
install always finishes first) and POST /api/ram-batch/clear (409 while
active).  /api/status carries the ram_batch summary (the detailed state
minus the attempts list), so the panel's headline - the live phase, the
current card, the counts - rides the 2 s poll; the attempts report (the
last 500 kept, the total counted) is fetched only when it changed.  The
batch and a test run exclude each other (409 both ways); the state is in
memory - a server restart ends a run.

PWA: the Batch install (.cap file, per card) RAM operation shares the
install form (ramInstallCapFields(); the single install uses it too and
now sends ram_format, and the block-size validation moved before the
modal), hides the preset selector + Execute and the preset-mismatch
banner, and renders Start/Stop/Clear, the phase headline (equip the next
card / installing card + live step / stopping after the current card),
the counts and the attempts table (newest first).  The panel only works
inside the visible SCP80 RAM pill.

Docs: help EN/RU, api.md (a section + the four table rows), the repo
AGENTS.  Tests: the state/report helpers, the per-card decisions, the
worker loop (a real thread + a fake install), the lifecycle and guards,
the HTTP endpoints (the malformed .cap, the stop under a held card lock,
the test-run refusal) and the PWA panel helpers/wiring.  914 frontend +
919 Python; sw cache simple-v438.
This commit is contained in:
2026-10-14 15:00:03 +03:00
parent 21d7f58f61
commit ab373aff30
13 changed files with 1527 additions and 236 deletions
+66
View File
@@ -46,6 +46,10 @@ a 3.x PWA).
| `/api/help` | POST | pySim help for a given command |
| `/api/send-ota` | POST | SCP80 OTA secured packet delivery |
| `/api/ram-install` | POST | Install a Java Card `.cap` file via SCP80 (INSTALL[for load] → LOAD ×N → INSTALL[for install]) |
| `/api/ram-batch/start` | POST | Start the **batch CAP install** (v3.28.0): the server installs the same `.cap` on every card that gets equipped, using each card's preset (matched by ICCID) |
| `/api/ram-batch/status` | GET | The batch state: phase, current card, counts and the attempts report (card-free) |
| `/api/ram-batch/stop` | POST | Stop the batch — a running install finishes first (card-free, so it works while the install holds the card lock) |
| `/api/ram-batch/clear` | POST | Drop the finished batch's report (`409` while a batch is active) |
| `/api/ram-install-app` | POST | Single INSTALL [for install] / [for make selectable] for an already loaded package (no re-load) |
| `/api/cap-info` | POST | Validate a `.cap` archive and estimate its code/NVRAM/RAM requirements (read-only) |
| `/api/cap-compat` | POST | CAP compatibility test: LOAD only to the block completing the Import component (nothing committed) |
@@ -1126,6 +1130,68 @@ sets `success: false`, `failed_step` and a detailed `error` such as
The `steps` array contains one entry per GP command. `final_cntr` is the counter value after all successful steps (use it to update the card preset). The response is not streamed — all steps run server-side before the JSON is returned.
### `POST /api/ram-batch/start`, `GET /api/ram-batch/status`, `POST /api/ram-batch/stop`, `POST /api/ram-batch/clear`
The **batch CAP install** (v3.28.0): one `.cap` and one set of install
parameters applied to card after card, for bulk provisioning. The loop runs
**in the server** (a worker thread), so a page reload or a closed tab does not
interrupt it; the state lives in memory and a server restart ends the run.
- `POST /api/ram-batch/start` — the body is the `/api/ram-install` payload
**without** the per-card fields (`preset_id`, `tar`, `spi1`, `cntr`,
`kic`/`kid`/`kicKey`/`kidKey`): the plan is card independent and is built
once, so a bad `.cap` or an invalid parameter combination is a `400` at
Start, not a per-card failure. **No card is needed** — the worker waits for
one. `400` for a bad request, `409` when another batch or a test run is
active. The response is the status payload below plus `ok: true`.
- `GET /api/ram-batch/status` — **card-free** (the panel polls it while an
install holds the card lock):
```json
{"active": true, "phase": "waiting_card",
"attempts": [{"ts": 1760000000.0, "iccid": "8970119000004600098",
"preset": "Batch 0002", "result": "installed",
"duration_ms": 41230, "final_cntr": "0000000005",
"load_file_aid": "F0414C46416101",
"module_aid": "F0414C4641610101", "nv_delta": 3120}],
"attempts_total": 3,
"current": null, "notice": "Card 8970119000004600098 installed.",
"counts": {"installed": 2, "failed": 1, "skipped": 0},
"started": 1760000000.0, "stopped": null, "progress": null}
```
`phase` is `idle` (no run), `waiting_card`, `installing`, `stopping`,
`stopped` or `finished`. A failed attempt carries `failed_step` (e.g.
`LOAD (3/7)`), `por_sw` (the card's own status word), `por_status`,
`por_error` and `sw`; a skipped one carries `reason`. `current` names the
card being installed (`{iccid, preset, started}`) and `progress` mirrors the
RAM progress payload while a chain runs. The report keeps the last 500
attempts (`attempts_total` counts them all).
- `POST /api/ram-batch/stop` — sets the stop flag: a running install finishes
first (an interrupted LOAD chain can leave a half-loaded package), then the
run ends. It touches no card state, so it answers while the card lock is
held (a `_CARD_FREE_POST` exception); a repeated stop is idempotent, `409`
when no run exists.
- `POST /api/ram-batch/clear` — drops the finished run's report (`409` while a
batch is active).
**Per-card resolution.** Every equipped card's **preset is matched by
ICCID**; the preset's ISD role TAR supplies the keyset (v3.26.0), the keys and
the counter seed (v3.20.0) — the same rules as `/api/ram-install`. A card
with no readable ICCID, no matching preset or no usable ISD keyset is recorded
as `skipped` with its reason and the batch keeps waiting. A card is attempted
**once per run** (any outcome): re-equipping it is recorded as
`already processed in this batch (<outcome>)`, never installed again —
restart the batch to retry it. Failures never stop the run and are never
retried automatically.
`/api/status` carries a small summary in `ram_batch` — the detailed state
**minus the attempts list** (`{active, phase, current, notice,
attempts_total, counts, started, stopped}`), so the panel's headline (the
phase, the current card, the counts) rides the existing 2 s status poll; the
attempts report is fetched from `/api/ram-batch/status` only when
`attempts_total` (or the active flag) changed.
### `POST /api/ram-install-app`
Run the single **INSTALL [for install]** (or **INSTALL [for make selectable]**) APDU for an *already loaded* package — one APDU, so the install parameters can be iterated without deleting and re-loading the `.cap`. The APDU builders are shared with `/api/ram-install` (`_cap_install_apdu` / `_cap_make_selectable_apdu`; both case 3 — no trailing `Le`, which the card's SCP80 layer counts as a phantom command) and the step verdict/counter logic is the same (`final_cntr` is returned on success **and** on failure).
+1
View File
@@ -323,6 +323,7 @@
<tbody>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Обзор карты (все данные GP)</td><td class="py-1 px-2">Запрос GET STATUS для ISD, приложений, ELF и модулей ELF, а также GET DATA FF21 (расширенные ресурсы карты: число установленных приложений и свободная энергонезависимая/энергозависимая память). Компактный листинг содержит только AID, состояние жизненного цикла и привилегии (версии в устаревшем формате нет); если карта отвергает его (<code class="font-mono text-sm">P2.b2=0</code>, устаревший по GP), запросы повторяются в расширенном формате <code class="font-mono text-sm">E3</code>, а флажок <strong>Запрашивать расширенные данные реестра (связи load-файла и SD)</strong> дополнительно запрашивает их и объединяет расширенные поля: версию исполняемого load-файла, AID load-файла, AID Security Domain, списки модулей, пункты меню STK апплета (SCP Registry Data, TS 102 226 8.2.1.6) и любые другие объекты реестра, которые вернула карта (показываются как есть, без потерь) &mdash; он стоит несколько дополнительных защищённых пакетов на каждый обзор и <strong>включён по умолчанию</strong> (запоминается в браузере; снимите флажок, чтобы пропустить). Карты различаются в том, что принимают, поэтому расширенный запрос пробуется в трёх формах &mdash; эталонный список тегов (с <code class="font-mono text-sm">EA</code> по TS 102 226), тот же список без <code class="font-mono text-sm">EA</code> и вовсе без списка тегов (форма GlobalPlatformPro) &mdash; а обзор сообщает, какая форма сработала и где выполнен откат к компактному списку (версии пакетов тогда недоступны). Если строки ELF всё ещё без версий, формы со списком тегов повторяются для обоих P1=10 и P1=20, пропуская уже отклонённые комбинации, и обход останавливается, как только версия найдена. Результаты отображаются в обзоре (строка успеха минимальна, счётчики — в заголовках разделов) с кнопками <strong>Удалить</strong> для каждого элемента и определённым форматом команд RAM (подсказка при наведении объясняет compact и expanded).</td></tr>
<tr><td class="py-1 px-2">Установка пакета (.cap файл)</td><td class="py-1 px-2">Отправка <code class="font-mono text-sm">.cap</code> файла на карту через сервер: INSTALL[for load] &rarr; LOAD &times;N &rarr; INSTALL[for install (+make selectable)]. Load-файл делится на LOAD APDU размера <strong>размер блока LOAD</strong> (1&ndash;240 байт полезной нагрузки, по умолчанию 240); каждый защищённый пакет доставляется одним SMS или, если он больше, конкатенированной SMS-PP загрузкой до 5 сегментов, так что большой <code>.cap</code> просто занимает несколько SMS. Сразу после выбора файл проверяется и в форме показывается <strong>оценка требований к памяти</strong>: <strong>требование NVRAM</strong> (образ кода + постоянные данные, сумма в стиле C6+C8 из GP Card Spec) и оценка RAM. Там же показаны <strong>библиотеки, с которыми слинкован CAP</strong> (его компонент Import: имя библиотеки, AID и минимальная версия <code class="font-mono text-sm">≥ x.y</code> — имена стандартных библиотек и семейства подставляются, а где известно, даётся подсказка о версии Java Card), идентификаторы пакета и апплетов и разбивка по компонентам (размеры и доли в порядке load-файла) — всё сгруппировано по разделам <strong>Package</strong>, <strong>Requires</strong>, <strong>Memory</strong> и <strong>Components</strong>. Строка <strong>Platform</strong> показывает минимальный уровень Java Card, который должна предоставить карта (выводится из импорта <code class="font-mono text-sm">javacard.framework</code>; флаг <code class="font-mono text-sm">int</code> в заголовке дополнительно требует поддержки int — Java Card 2.2+), и <strong>релиз ETSI</strong>, из которого пришли импорты UICC API (версия <code class="font-mono text-sm">uicc.toolkit</code> &rarr; REL-N по корпусу <code class="font-mono text-sm">sim-tools</code> в рабочем каталоге; основание показано в скобках): JCRE проверяет каждый импорт по своим пакетам при верификации load-файла (JC VM spec 4.5.2), поэтому карта со старой платформой отклоняет LOAD сразу после завершения компонента <strong>Import</strong> (в строке шага виден remote SW карты и компонент, который завершил блок, например <em>завершает Import</em>): подсказка при ошибке и попап предлагают <strong>тест совместимости CAP</strong> (в один клик — тот же <code class="font-mono text-sm">.cap</code> уже выбран), чтобы найти отсутствующий пакет и версию; меньший размер блока LOAD помогает только если блок превышает буфер карты. Повреждённый файл не проходит анализ, и <strong>Execute</strong> остаётся недоступной до успешного анализа. Оценка носит справочный характер и никогда не используется при установке: NVRAM — это образ кода плюс постоянные данные (сумма в стиле C6+C8 по GP Table 11-48; управление памятью карты, округление и запись в реестре не учитываются, а образ статических полей входит в обе части); рекомендуемый <strong>C6</strong> следует за load-файлом, а рекомендуемые квоты — это десятичные значения в байтах (шестнадцатеричное значение в скобках — это кодирование параметров установки C7/C8: C6 — энергонезависимый код, C7 — энергозависимая RAM, C8 — энергонезависимые данные).</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Пакетная установка (.cap, по картам)</td><td class="py-1 px-2">Массовый сценарий: один <code class="font-mono text-sm">.cap</code> и один набор параметров установки, применяемые карта за картой. <strong>Запустить пакетную установку</strong> не требует карты &mdash; цикл ведёт сервер: он ждёт карту, устанавливает сразу после завершения equip и затем ждёт следующую. Для каждой карты <strong>пресет находится по ICCID</strong> и задаёт keyset (по умолчанию из ролевой записи ISD TAR, v3.26.0), ключи и счётчик; карта без подходящего пресета или без пригодного keyset ISD TAR отмечается и пропускается. Каждая попытка попадает в список со временем, ICCID, пресетом, вердиктом и длительностью; при ошибке показаны шаг, на котором она произошла, и удалённый статусный байт &mdash; пакетная установка никогда не повторяет попытку и не останавливается при ошибке (ошибки остаются на ваше усмотрение). Карта обрабатывается <strong>один раз за запуск</strong>: повторная установка карты даёт «уже обработана» &mdash; для повтора перезапустите пакетную установку. <strong>Остановить</strong> завершает текущую карту (прерванная цепочка LOAD может оставить полузагруженный пакет), а затем завершает запуск; <strong>Очистить</strong> удаляет отчёт завершённого запуска. Цикл работает на сервере, поэтому закрытие вкладки, переключение представлений или перезагрузка страницы его не прерывают; перезапуск сервера завершает запуск. Пакетная установка и тест-скрипт исключают друг друга.</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Проверка совместимости CAP</td><td class="py-1 px-2">Отвечает на один вопрос, ничего не устанавливая: <strong>может ли эта карта удовлетворить список импортов CAP?</strong> Сервер выполняет проверку формата, INSTALL[for load] и блоки LOAD только до блока, завершающего компонент <strong>Import</strong> &mdash; именно там JCRE проверяет список импортов &mdash; и останавливается: без флага последнего блока и без INSTALL[for install], поэтому ничего не фиксируется и удалять после этого ничего не нужно. Список <strong>«Версии импортов для проверки»</strong> предзаполняется из анализа CAP: <code class="font-mono text-sm">AID=version</code> переписывает запрошенную версию импорта, <code class="font-mono text-sm">+AID=version</code> добавляет синтетический импорт (запрос возможностей карты: LOAD проходит, только если карта экспортирует этот пакет версии &ge; указанной), а <strong>«Все 0.0»</strong> снимает все требования. Использование в два шага: нажмите «Все 0.0» &mdash; если проверка проходит, причиной был список импортов; затем возвращайте по одной строке, чтобы найти пакет и версию, которых не хватает карте. Вердикт явно указывает, что проверяется только <strong>этап LOAD/импортов</strong>: этап связывания (наличие реально вызываемых API) проверяет настоящая установка на INSTALL[for install]. Запуск и результат показываются в окне (как при установке): строки шагов, вердикт и блок деталей по каждому AID &mdash; при отказе он называет раздел CAP, который покрывает неудачный блок (диапазон байтов load-файла и компоненты, и завершает ли этот блок <strong>Import</strong>), SW команды с его значением, список импортов ровно в том виде, в каком он проверялся (версии CAP, правки пробы, добавленные синтетические записи, строки без совпадений) и что означает состояние пробы; при успехе — список импортов, который прошёл проверку.</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">INSTALL [for install] (загруженный пакет)</td><td class="py-1 px-2">Один APDU INSTALL [for install] для уже загруженного на карту пакета (его Load File AID и Module AID, привилегии, параметры установки/STK и необязательное <em>make selectable</em>) &mdash; параметры установки можно перебирать, не удаляя и не загружая <code class="font-mono text-sm">.cap</code> заново.</td></tr>
<tr><td class="py-1 px-2">INSTALL [for make selectable]</td><td class="py-1 px-2">Один APDU INSTALL [for make selectable] (AID экземпляра) для установленного, но невыбираемого экземпляра апплета.</td></tr>
+1
View File
@@ -322,6 +322,7 @@
<tbody>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Explore Card (all GP data)</td><td class="py-1 px-2">Queries GET STATUS for ISD, Applications, ELFs, and ELF Modules, plus GET DATA FF21 (Extended Card Resources: the number of installed applications and the free non-volatile/volatile memory). The compact listing carries the AID, life cycle and privileges (the legacy format has no version); if a card rejects it (<code class="font-mono text-sm">P2.b2=0</code>, deprecated by GP) the queries are retried in the expanded <code class="font-mono text-sm">E3</code> form, and the <strong>Fetch expanded registry details (load-file / SD associations)</strong> checkbox also requests it as an extra pass and merges the expanded-only fields: the Executable Load File version, Load File AID, Security Domain AID, module lists, the applet STK menu entries (SCP Registry Data, TS 102 226 8.2.1.6) and any other registry data objects the card returns (shown raw, never dropped) &mdash; it costs a few extra secured packets per Explore and is <strong>on by default</strong> (remembered per browser; untick to skip). Cards differ in what they accept, so the expanded query is tried in three forms &mdash; the reference tag list (with the TS 102 226 <code class="font-mono text-sm">EA</code>), the same list without <code class="font-mono text-sm">EA</code>, and no tag list at all (the GlobalPlatformPro form) &mdash; and the explorer reports which form worked and where the run fell back to the compact listing (package versions are then unavailable). When the ELF rows still carry no version, the tag-list forms are retried for both P1=10 and P1=20, skipping the combinations already refused, and the walk stops as soon as a version appears. Results appear in an explorer view (the success line stays minimal; the counts are in the section headings) with per-item <strong>Delete</strong> buttons and the detected RAM command format (hover for what compact vs expanded means).</td></tr>
<tr><td class="py-1 px-2">Install Package (.cap file)</td><td class="py-1 px-2">Sends a <code class="font-mono text-sm">.cap</code> file to the card via the server: INSTALL[for load] &rarr; LOAD &times;N &rarr; INSTALL[for install (+make selectable)]. The load file is split into LOAD APDUs of the <strong>LOAD block size</strong> (1&ndash;240 bytes of payload, 240 by default); each secured packet is delivered as a single SMS or, when larger, as a concatenated SMS-PP download of up to 5 segments, so a large <code class="font-mono text-sm">.cap</code> simply takes several SMS. As soon as the file is selected it is validated and its <strong>memory requirements are estimated</strong> in the form: the <strong>NVRAM requirement</strong> (the code image + persistent data, the GlobalPlatform Card Spec C6+C8 style total) and the RAM estimate. The same box reports the <strong>libraries the CAP is linked against</strong> (its Import component: library name, AID and the minimum version <code class="font-mono text-sm">≥ x.y</code>, with standard names and families resolved and a Java Card SDK hint where known), the package/applet identity and the component breakdown (sizes and shares in load-file order) &mdash; grouped under the <strong>Package</strong>, <strong>Requires</strong>, <strong>Memory</strong> and <strong>Components</strong> headings. The <strong>Platform</strong> line names the minimum Java Card level the card must provide (derived from the <code class="font-mono text-sm">javacard.framework</code> import; the header <code class="font-mono text-sm">int</code> flag additionally requires int support, Java Card 2.2+) and the <strong>ETSI release</strong> the UICC API imports come from (<code class="font-mono text-sm">uicc.toolkit</code> version &rarr; REL-N, per the workspace <code class="font-mono text-sm">sim-tools</code> corpus; shown with its basis in brackets): the JCRE checks every import against its own packages when it verifies the load file (JC VM spec 4.5.2), so a card whose platform is older rejects the LOAD as soon as the <strong>Import</strong> component is complete (the step line carries the card's remote SW and names the component the block completed, e.g. <em>completes Import</em>): the failure hint and the popup then point at the <strong>CAP compatibility test</strong> (one click &mdash; the same <code class="font-mono text-sm">.cap</code> is still selected) to find the missing package and version; a smaller LOAD block only helps when the block exceeds the card's buffer. A corrupt or wrong-format file fails the analysis, and <strong>Execute</strong> stays disabled until a successful analysis. The estimate is informational only and is never used for installation: NVRAM is the code image plus the persistent data (the GlobalPlatform C6+C8 style total, Table 11-48 &mdash; card memory management, rounding and the registry entry are not included, and the static field image is counted in both parts); the suggested <strong>C6</strong> follows the load file, and the suggested quotas are decimal byte counts (the hex in parentheses is how the C7/C8 install parameters encode them: C6 = non-volatile code, C7 = volatile RAM, C8 = non-volatile data).</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Batch install (.cap file, per card)</td><td class="py-1 px-2">The bulk workflow: one <code class="font-mono text-sm">.cap</code> and one set of install parameters, applied to card after card. <strong>Start batch</strong> needs no card &mdash; the server owns the loop: it waits for a card, installs the moment the equip finishes, then waits for the next one. Every card's <strong>preset is matched by ICCID</strong> and supplies the keyset (the ISD role TAR's default, v3.26.0), the keys and the counter; a card with no matching preset or no usable ISD keyset is reported and skipped. Each attempt lands in the list with its time, ICCID, preset, verdict and duration; a failure shows the failing step and the remote status word &mdash; the batch never retries and never stops on a failure (failures are yours to manage afterwards). A card is attempted <strong>once per run</strong>: re-equipping it reports &ldquo;already processed&rdquo; &mdash; restart the batch to retry it. <strong>Stop</strong> finishes the current card first (an interrupted LOAD chain can leave a half-loaded package) and then ends the run; <strong>Clear</strong> drops the finished report. The loop runs in the server, so closing the tab, switching views or reloading the page does not interrupt it; a server restart ends it. A batch and a test run exclude each other.</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">CAP compatibility test</td><td class="py-1 px-2">Answers one question without installing anything: <strong>can this card satisfy the CAP's import list?</strong> The server runs the format check, INSTALL[for load] and the LOAD blocks only up to the block that completes the <strong>Import</strong> component &mdash; the point where the JCRE verifies the import list &mdash; then stops: no last-block flag and no INSTALL[for install], so nothing is committed and nothing has to be deleted afterwards. The <strong>Import versions to test</strong> list is pre-filled from the CAP analysis: <code class="font-mono text-sm">AID=version</code> rewrites an import's requested version, <code class="font-mono text-sm">+AID=version</code> appends a synthetic import (a card-capability query: the LOAD passes only if the card exports that package at &ge; that version), and <strong>Set all 0.0</strong> drops every requirement. Two-step use: press Set all 0.0 &mdash; if the test passes, the import list was the blocker; then restore one line at a time to find the package and version the card lacks. The verdict states it covers the <strong>LOAD/import gate only</strong>: the link gate (whether the applet's actual API calls exist) is what a real install checks at INSTALL[for install]. The run and its result appear in a popup (like the install): the step lines, the verdict and a per-AID detail block &mdash; on a rejection it names the CAP section the failing block covers (load-file byte range and components, and whether that block completes <strong>Import</strong>), the remote status word with its meaning, the import list exactly as tested (CAP versions, probe rewrites, appended synthetic entries, unmatched lines) and what the probe state implies; on success it lists the import list that passed.</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">INSTALL [for install] (loaded package)</td><td class="py-1 px-2">One INSTALL [for install] APDU for a package that is already loaded on the card (its Load File AID and Module AID, privileges, install/STK parameters and an optional <em>make selectable</em>) &mdash; iterate the install parameters without deleting and re-loading the <code class="font-mono text-sm">.cap</code> every time.</td></tr>
<tr><td class="py-1 px-2">INSTALL [for make selectable]</td><td class="py-1 px-2">One INSTALL [for make selectable] APDU (the instance AID) for an INSTALLED, non-selectable applet instance.</td></tr>
+265 -32
View File
@@ -689,7 +689,7 @@
<p class="text-xs text-gray-500 dark:text-slate-400 mb-3" data-l10n="RAM operations perform atomic GlobalPlatform commands over SCP80. Card keys are taken from the saved preset.">RAM operations perform atomic GlobalPlatform commands over SCP80. Card keys are taken from the saved preset.</p>
<div class="mb-3 flex gap-2 items-end">
<div class="flex-1">
<div id="ram-preset-cell" class="flex-1">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Card preset">Card preset</label>
<select id="ram-card-sel" onchange="ramApplyCard(this.value)" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
<option value="" data-l10n="— Select card —">— Select card —</option>
@@ -700,6 +700,7 @@
<select id="ram-op" onchange="ramOpChanged()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
<option value="explore" data-l10n="Explore Card (all GP data)">Explore Card (all GP data)</option>
<option value="install-cap" data-l10n="Install Package (.cap file)">Install Package (.cap file)</option>
<option value="batch" data-l10n="Batch install (.cap file, per card)">Batch install (.cap file, per card)</option>
<option value="compat" data-l10n="CAP compatibility test">CAP compatibility test</option>
<option value="install-app" data-l10n="INSTALL [for install] (loaded package)">INSTALL [for install] (loaded package)</option>
<option value="make-selectable" data-l10n="INSTALL [for make selectable]">INSTALL [for make selectable]</option>
@@ -714,7 +715,7 @@
<option value="expanded-ae" data-l10n="Expanded (AE 80, indefinite length)">Expanded (AE 80, indefinite length)</option>
</select>
</div>
<button data-needs="card" data-needs-keyset="ram" data-cap-gate="ram" onclick="ramExecute()" class="px-5 py-1.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Execute">Execute</button>
<button id="ram-exec-btn" data-needs="card" data-needs-keyset="ram" data-cap-gate="ram" onclick="ramExecute()" class="px-5 py-1.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Execute">Execute</button>
</div>
<div id="ram-preset-warning" class="hidden mb-3 px-3 py-2 rounded border border-amber-400 dark:border-amber-600 bg-amber-50 dark:bg-amber-900/20 text-sm text-amber-700 dark:text-amber-300"></div>
@@ -912,6 +913,19 @@
</div>
</div>
<div id="ram-batch-panel" class="hidden mb-3 border border-gray-200 dark:border-slate-700 rounded p-3">
<div class="flex flex-wrap items-center gap-2 mb-2">
<button type="button" id="ram-batch-start" data-needs="server" data-cap-gate="ram" onclick="ramBatchStart()" class="px-5 py-1.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Start batch">Start batch</button>
<button type="button" id="ram-batch-stop" onclick="ramBatchStop()" class="hidden px-5 py-1.5 bg-red-600 text-white text-sm font-medium rounded hover:bg-red-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Stop">Stop</button>
<button type="button" id="ram-batch-clear" onclick="ramBatchClear()" class="px-3 py-1.5 text-sm rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Clear">Clear</button>
<span id="ram-batch-state" class="text-sm font-medium text-gray-700 dark:text-slate-300"></span>
</div>
<div id="ram-batch-notice" class="hidden mb-2 px-3 py-2 rounded border border-blue-300 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20 text-sm text-blue-700 dark:text-blue-300"></div>
<div id="ram-batch-counts" class="text-xs text-gray-500 dark:text-slate-400 mb-2"></div>
<div id="ram-batch-attempts" class="max-h-64 overflow-y-auto text-xs"></div>
<div class="text-xs text-gray-400 dark:text-slate-500 mt-2" data-l10n="The preset, keyset and counter come from each card's own preset (matched by ICCID). A card is attempted once per run: re-equipping it reports it as already processed - restart the batch to retry. Stop finishes the current card first.">The preset, keyset and counter come from each card's own preset (matched by ICCID). A card is attempted once per run: re-equipping it reports it as already processed - restart the batch to retry. Stop finishes the current card first.</div>
</div>
<div id="ram-result" class="mt-2 text-base font-semibold font-mono hidden"></div>
<div id="ram-cntr-low" class="mt-2 hidden"></div>
<div id="ram-explorer" class="mt-2 text-xs font-mono hidden"></div>
@@ -1895,7 +1909,7 @@
// ===== Version =====
// Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect().
const SIMPLE_VERSION = '3.27.0';
const SIMPLE_VERSION = '3.28.0';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== view history (the browser Back/Forward buttons) =====
@@ -10099,7 +10113,8 @@ function ramOpChanged() {
const op = document.getElementById('ram-op').value;
if (_ramOpLast !== null && op !== _ramOpLast) ramClearResults();
_ramOpLast = op;
const isCap = op === 'install-cap';
const isBatch = op === 'batch';
const isCap = op === 'install-cap' || isBatch;
const isCompat = op === 'compat';
const isApp = op === 'install-app' || op === 'make-selectable';
const setHidden = (id, hidden) => {
@@ -10110,6 +10125,13 @@ function ramOpChanged() {
setHidden('ram-cap-rows', !(isCap || isCompat));
setHidden('ram-compat-rows', !isCompat);
setHidden('ram-app-rows', !isApp);
// The batch resolves the preset per card (by ICCID): its selector and the
// single-card Execute button do not apply, and the batch panel takes over.
setHidden('ram-preset-cell', isBatch);
setHidden('ram-preset-warning', isBatch);
setHidden('ram-exec-btn', isBatch);
setHidden('ram-batch-panel', !isBatch);
if (isBatch) ramBatchFetch();
// The Execute button's .cap gate applies to the install-cap and compat ops.
if (typeof pysimApplyAvailability === 'function') pysimApplyAvailability();
}
@@ -10124,6 +10146,217 @@ function ramHideProgress() {
document.getElementById('ram-progress').classList.add('hidden');
}
// ===== batch CAP install (v3.28.0) =====
// The bulk workflow: the server owns the loop (a worker installs on every card
// that gets equipped, using each card's own preset) and this panel is a thin
// view over /api/ram-batch/status. The live headline comes from the 2 s
// /api/status poll (the batch summary + the RAM progress); the detailed status
// (the attempts list) is fetched only when the summary says it changed.
let _ramBatchLast = null; // the last detailed /api/ram-batch/status
let _ramBatchFetching = false;
let _ramBatchSummary = null; // /api/status.ram_batch
let _ramBatchProgress = null; // /api/status.ram_progress
function ramBatchPanelVisible() {
const op = document.getElementById('ram-op');
return isViewVisible('tab-scp80') && isViewVisible('scp80-sub-ram')
&& !!op && op.value === 'batch';
}
async function ramBatchFetch() {
if (_ramBatchFetching) return;
_ramBatchFetching = true;
try {
_ramBatchLast = await pysimFetch('/api/ram-batch/status');
} catch (e) { /* the next poll retries */ }
_ramBatchFetching = false;
ramBatchRender();
}
// Called from the 2 s status poll: the summary drives the headline, a change
// in the attempts count (or the active flag) refreshes the detailed view.
function ramBatchApplyStatus(status) {
if (!status) return;
_ramBatchSummary = status.ram_batch || null;
_ramBatchProgress = status.ram_progress || null;
if (!ramBatchPanelVisible()) return;
const total = _ramBatchSummary ? (_ramBatchSummary.attempts_total || 0) : 0;
const active = _ramBatchSummary ? !!_ramBatchSummary.active : false;
if (!_ramBatchLast || (_ramBatchLast.attempts_total || 0) !== total
|| !!_ramBatchLast.active !== active) {
ramBatchFetch();
return;
}
ramBatchRender();
}
function ramBatchRender() {
const panel = document.getElementById('ram-batch-panel');
if (!panel) return;
if (!ramBatchPanelVisible()) {
panel.classList.add('hidden');
return;
}
panel.classList.remove('hidden');
// The summary (the 2 s status poll) is fresher than the last detailed
// fetch: it carries the phase/current/notice/counts, the attempts list
// comes from the detailed status.
const st = Object.assign({ active: false, phase: 'idle', attempts: [], counts: {} },
_ramBatchLast || {}, _ramBatchSummary || {});
const active = !!st.active;
const phase = st.phase || (active ? 'waiting_card' : 'idle');
const startBtn = document.getElementById('ram-batch-start');
if (startBtn) startBtn.classList.toggle('hidden', active);
const stopBtn = document.getElementById('ram-batch-stop');
if (stopBtn) {
stopBtn.classList.toggle('hidden', !active);
stopBtn.textContent = phase === 'stopping' ? t('Stopping...') : t('Stop');
stopBtn.disabled = phase === 'stopping';
}
const stateEl = document.getElementById('ram-batch-state');
if (stateEl) {
if (!active) {
stateEl.textContent = phase === 'stopped' ? t('Batch stopped')
: (phase === 'finished' ? t('Batch finished') : '');
} else if (phase === 'installing') {
const cur = st.current || {};
stateEl.textContent = t('Installing...') + ' ' + (cur.iccid || '')
+ (cur.preset ? ' \u2014 ' + cur.preset : '');
} else if (phase === 'stopping') {
stateEl.textContent = t('Stopping after the current card...');
} else {
stateEl.textContent = t('Waiting for a card...');
}
}
const notice = document.getElementById('ram-batch-notice');
if (notice) {
notice.textContent = st.notice || '';
notice.classList.toggle('hidden', !st.notice);
}
const counts = document.getElementById('ram-batch-counts');
if (counts) {
const c = st.counts || {};
let text = t('Installed') + ': ' + (c.installed || 0)
+ ' \u00b7 ' + t('Failed') + ': ' + (c.failed || 0)
+ ' \u00b7 ' + t('Skipped') + ': ' + (c.skipped || 0);
if (st.attempts_total) text += ' \u00b7 ' + st.attempts_total + ' ' + t('attempts');
if (active && _ramBatchProgress && _ramBatchProgress.active && _ramBatchProgress.name) {
// the step name already carries its index for the LOAD steps
text += ' \u00b7 ' + _ramBatchProgress.name;
}
counts.textContent = text;
}
const box = document.getElementById('ram-batch-attempts');
if (box) box.innerHTML = ramBatchAttemptsHtml(st.attempts || []);
}
function ramBatchAttemptsHtml(attempts) {
if (!attempts.length) return '<div class="text-gray-400 dark:text-slate-500">' + esc(t('No cards processed yet.')) + '</div>';
let html = '<table class="w-full"><tbody>';
for (let i = attempts.length - 1; i >= 0; i--) {
const a = attempts[i];
const when = a.ts ? new Date(a.ts * 1000).toLocaleTimeString() : '';
const verdict = a.result === 'installed' ? t('OK')
: (a.result === 'failed' ? t('Failed') : t('Skipped'));
const tone = a.result === 'installed' ? 'text-emerald-600 dark:text-emerald-400'
: (a.result === 'failed' ? 'text-red-600 dark:text-red-400' : 'text-amber-600 dark:text-amber-400');
const detail = a.result === 'failed'
? [a.failed_step, a.por_sw || a.sw].filter(Boolean).join(' \u2014 ')
: (a.reason || '');
html += '<tr class="border-b border-gray-100 dark:border-slate-800">'
+ '<td class="py-0.5 pr-2 text-gray-400 dark:text-slate-500 whitespace-nowrap">' + esc(when) + '</td>'
+ '<td class="py-0.5 pr-2 font-mono whitespace-nowrap">' + esc(a.iccid || '') + '</td>'
+ '<td class="py-0.5 pr-2">' + esc(a.preset || '') + '</td>'
+ '<td class="py-0.5 pr-2 font-medium whitespace-nowrap ' + tone + '">' + esc(verdict) + '</td>'
+ '<td class="py-0.5 pr-2 text-gray-500 dark:text-slate-400">' + esc(detail) + '</td>'
+ '<td class="py-0.5 text-gray-400 dark:text-slate-500 whitespace-nowrap text-right">'
+ (a.duration_ms ? (a.duration_ms / 1000).toFixed(1) + ' s' : '') + '</td>'
+ '</tr>';
}
return html + '</tbody></table>';
}
// The .cap + install parameters shared by the single install and the batch:
// everything except the per-card preset/keyset/counter fields, which the
// server resolves from each card's own preset. Returns null after alerting
// when a value is unusable (the caller stops before opening a modal).
function ramInstallCapFields() {
// The STK hex is regenerated on every toolkit field edit; recompute it
// once more at send time unless the user pasted a custom hex directly
// (dataset.manual), so the sent parameters always match the fields.
const stkEl = document.getElementById('ram-stk-params');
if (document.getElementById('rc-toolkit-enable').checked && !(stkEl.dataset && stkEl.dataset.manual)) {
updateStkParamsHex();
}
// The privileges are an aggregate of the checkboxes: derive them now, so a
// value restored by the browser or left from an earlier experiment can
// never be sent silently.
updateRcPriv();
const composed = ramInstallParamsCompose();
const body = {
sd_aid: (document.getElementById('ram-sd-aid').value || '').replace(/[^0-9a-fA-F]/g, ''),
privileges: (document.getElementById('rc-priv').value || '00').replace(/[^0-9a-fA-F]/g, ''),
install_params: composed.install_params,
stk_params: composed.stk_params,
make_selectable: document.getElementById('ram-make-sel').checked,
ram_format: (document.getElementById('ram-format') || {}).value || 'auto',
};
const loadSize = (document.getElementById('ram-load-size').value || '').trim();
if (loadSize !== '') {
const n = parseInt(loadSize, 10);
if (!(n >= 1 && n <= 240)) { alert(t('LOAD block size must be 1..240')); return null; }
body.load_block_size = n;
}
return body;
}
async function ramBatchStart() {
if (!capGateOk('ram')) { alert(t('Analyze the CAP file first')); return; }
const fileInput = document.getElementById('ram-cap-file');
const file = fileInput.files[0];
if (!file) { alert(t('Select a .cap file')); return; }
if (file.size > 48 * 1024) { alert(t('CAP file exceeds 48 kB limit')); return; }
const fields = ramInstallCapFields();
if (!fields) return;
const startBtn = document.getElementById('ram-batch-start');
if (startBtn) startBtn.disabled = true;
try {
fields.cap_hex = await ramReadFileHex(file);
_ramBatchLast = await pysimFetchOk('/api/ram-batch/start', fields);
_ramBatchSummary = null;
} catch (e) {
alert(t('Error') + ': ' + e.message);
} finally {
if (startBtn) startBtn.disabled = false;
if (typeof pysimApplyAvailability === 'function') pysimApplyAvailability();
ramBatchRender();
}
}
async function ramBatchStop() {
const stopBtn = document.getElementById('ram-batch-stop');
if (stopBtn) stopBtn.disabled = true;
try {
_ramBatchLast = await pysimFetchOk('/api/ram-batch/stop', {});
_ramBatchSummary = null; // the poll refreshes it; the fresh detail wins
} catch (e) {
alert(t('Error') + ': ' + e.message);
} finally {
ramBatchRender();
}
}
async function ramBatchClear() {
try {
await pysimFetchOk('/api/ram-batch/clear', {});
_ramBatchLast = null;
_ramBatchSummary = null;
ramBatchFetch();
} catch (e) {
alert(t('Error') + ': ' + e.message);
}
}
// ===== RAM operation modal (v3.6.34) =====
// Long RAM operations (install chains, Explore) run behind a modal that blocks
// everything else - an interrupted install can leave a half-loaded package.
@@ -12441,6 +12674,10 @@ async function ramInstallCap(sp) {
const file = fileInput.files[0];
if (!file) { alert(t('Select a .cap file')); return; }
if (file.size > 48 * 1024) { alert(t('CAP file exceeds 48 kB limit')); return; }
// The parameters are validated before the modal opens (an unusable block
// size must not leave a modal whose Close is disabled).
const fields = ramInstallCapFields();
if (!fields) return;
const capPkg = (_capAnalysis.ram.memory && _capAnalysis.ram.memory.package_aid) || '';
ramOpModalBegin(t('Installing package') + (capPkg ? ' \u2014 ' + capPkg : ''));
@@ -12448,36 +12685,12 @@ async function ramInstallCap(sp) {
const capHex = await ramReadFileHex(file);
ramOpModalStatus(t('Sending to server for install...'));
// The STK hex is regenerated on every toolkit field edit; recompute it
// once more at send time unless the user pasted a custom hex directly
// (dataset.manual), so the sent parameters always match the fields.
const stkEl = document.getElementById('ram-stk-params');
if (document.getElementById('rc-toolkit-enable').checked && !(stkEl.dataset && stkEl.dataset.manual)) {
updateStkParamsHex();
}
// The privileges are an aggregate of the checkboxes: derive them now, so a
// value restored by the browser or left from an earlier experiment can
// never be sent silently.
updateRcPriv();
const composed = ramInstallParamsCompose();
const body = {
const body = Object.assign({
cap_hex: capHex,
preset_id: sp.preset_id,
sd_aid: (document.getElementById('ram-sd-aid').value || '').replace(/[^0-9a-fA-F]/g, ''),
privileges: (document.getElementById('rc-priv').value || '00').replace(/[^0-9a-fA-F]/g, ''),
install_params: composed.install_params,
stk_params: composed.stk_params,
make_selectable: document.getElementById('ram-make-sel').checked,
spi1: sp.spi1, spi2: '01', kic: sp.kic, kid: sp.kid,
tar: sp.tar, cntr: sp.cntr, kicKey: sp.kicKey, kidKey: sp.kidKey,
};
const loadSize = (document.getElementById('ram-load-size').value || '').trim();
if (loadSize !== '') {
const n = parseInt(loadSize, 10);
if (!(n >= 1 && n <= 240)) { alert(t('LOAD block size must be 1..240')); return; }
body.load_block_size = n;
}
}, fields);
const data = await pysimFetch('/api/ram-install', body);
@@ -13810,7 +14023,11 @@ function spPresetWarningRender() {
el.classList.toggle('hidden', !text);
};
show('sp-preset-warning', spPresetWarning(iccid, cards, spPresetIdx()));
show('ram-preset-warning', spPresetWarning(iccid, cards, ramPresetIdx()));
// The batch resolves the preset per card, so the RAM view's warning does
// not apply there (v3.28.0).
const ramOp = document.getElementById('ram-op');
const batchMode = !!ramOp && ramOp.value === 'batch';
show('ram-preset-warning', batchMode ? '' : spPresetWarning(iccid, cards, ramPresetIdx()));
show('tar-preset-warning', spPresetWarning(iccid, cards, tarPresetIdx()));
}
@@ -17130,7 +17347,10 @@ function pysimStartBackendPoll() {
_pysimCardSession = null;
_pysimStatusTimer = setInterval(async () => {
try {
pysimCardStateUpdate(await pysimFetch('/api/status'));
const status = await pysimFetch('/api/status');
// The batch panel's live headline rides this poll (v3.28.0).
ramBatchApplyStatus(status);
pysimCardStateUpdate(status);
} catch (e) {
pysimSetServerAvailable(false);
}
@@ -24182,6 +24402,19 @@ const LANG_RU = {
'Loading...': 'Загрузка...',
'Close': 'Закрыть',
'Installing package': 'Установка пакета',
'Batch install (.cap file, per card)': 'Пакетная установка (.cap, по картам)',
'Start batch': 'Запустить пакетную установку',
'Stopping...': 'Остановка…',
'Batch stopped': 'Пакетная установка остановлена',
'Batch finished': 'Пакетная установка завершена',
'Installing...': 'Установка…',
'Stopping after the current card...': 'Остановка после текущей карты…',
'Waiting for a card...': 'Ожидание карты…',
'Installed': 'Установлено',
'Skipped': 'Пропущено',
'attempts': 'попыток',
'No cards processed yet.': 'Карты ещё не обрабатывались.',
'The preset, keyset and counter come from each card\'s own preset (matched by ICCID). A card is attempted once per run: re-equipping it reports it as already processed - restart the batch to retry. Stop finishes the current card first.': 'Пресет, keyset и счётчик берутся из пресета каждой карты (совпадение по ICCID). Карта обрабатывается один раз за запуск: повторная установка карты отмечается как уже обработанная — для повтора перезапустите пакетную установку. «Остановить» завершает текущую карту.',
'Do not close or reload this window while the operation is running.': 'Не закрывайте и не перезагружайте окно, пока операция выполняется.',
'Working...': 'Выполняется...',
'APDU hex': 'APDU (hex)',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v437';
const CACHE = 'simple-v438';
const URLS = [
'index.html',
'help.html',
+3 -1
View File
@@ -591,8 +591,10 @@ test('every tab-content lives inside the max-w-7xl page container', () => {
});
test('the RAM form shows Card preset and Operation in one row', () => {
const row = /<div class="mb-3 flex gap-2 items-end">\s*<div class="flex-1">\s*<label[^>]*data-l10n="Card preset"[\s\S]*?id="ram-card-sel"[\s\S]*?data-l10n="Operation"[\s\S]*?id="ram-op"[\s\S]*?data-l10n="Execute"/.exec(html);
const row = /<div class="mb-3 flex gap-2 items-end">\s*<div id="ram-preset-cell" class="flex-1">\s*<label[^>]*data-l10n="Card preset"[\s\S]*?id="ram-card-sel"[\s\S]*?data-l10n="Operation"[\s\S]*?id="ram-op"[\s\S]*?data-l10n="Execute"/.exec(html);
assert.ok(row, 'Card preset + Operation + Execute must share one row');
// the batch mode hides the preset cell and Execute (per-card resolution)
assert.match(html, /setHidden\('ram-preset-cell', isBatch\)/);
});
test('the RAM operation modal is wide enough for the step lines', () => {
+2 -1
View File
@@ -79,5 +79,6 @@ test('both SCP80 views carry the warning banner', () => {
// the preset selection
assert.ok(html.includes('spPresetWarningRender();'));
assert.ok(html.includes("show('sp-preset-warning', spPresetWarning(iccid, cards, spPresetIdx()));"));
assert.ok(html.includes("show('ram-preset-warning', spPresetWarning(iccid, cards, ramPresetIdx()));"));
// the batch resolves the preset per card: the RAM banner is suppressed there
assert.ok(html.includes("batchMode ? '' : spPresetWarning(iccid, cards, ramPresetIdx())"));
});
+251
View File
@@ -0,0 +1,251 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name, asyncFn) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
}
const ASYNC_FNS = ['ramBatchFetch', 'ramBatchStart', 'ramBatchStop', 'ramBatchClear'];
let code = '';
for (const fn of ['ramBatchPanelVisible', 'ramBatchAttemptsHtml', 'ramBatchRender',
'ramBatchApplyStatus', 'ramBatchFetch', 'ramBatchStart', 'ramBatchStop',
'ramBatchClear', 'esc']) {
code += extractFunc(html, fn, ASYNC_FNS.indexOf(fn) >= 0) + '\n';
}
const els = {};
function setEl(id, value) {
els[id] = {
id: id, value: value === undefined ? '' : value, textContent: '', innerHTML: '',
disabled: false,
classes: {},
classList: {
add(c) { els[id].classes[c] = true; },
remove(c) { delete els[id].classes[c]; },
toggle(c, on) { if (on) els[id].classes[c] = true; else delete els[id].classes[c]; },
contains(c) { return !!els[id].classes[c]; },
},
};
return els[id];
}
function clearEls() {
for (const k of Object.keys(els)) delete els[k];
}
const app = new Function('els', `
var sandbox = { fetched: [], replies: {}, fetchError: null, alerts: [], visibility: true };
var document = { getElementById: id => els[id] || null };
var t = k => k;
var isViewVisible = () => sandbox.visibility;
var alert = m => sandbox.alerts.push(String(m));
var pysimFetch = async (p) => { sandbox.fetched.push(p); return sandbox.replies[p] || null; };
var pysimFetchOk = async (p, body) => {
sandbox.fetched.push(p);
sandbox.bodies = sandbox.bodies || {};
sandbox.bodies[p] = body;
if (sandbox.fetchError) throw new Error(sandbox.fetchError);
return sandbox.replies[p] || { ok: true };
};
var capGateOk = () => sandbox.capGate !== false;
var ramReadFileHex = async () => 'AABB';
var ramInstallCapFields = () => ({ sd_aid: '', install_params: '', ram_format: 'auto' });
var pysimApplyAvailability = () => {};
var _ramBatchLast = null;
var _ramBatchFetching = false;
var _ramBatchSummary = null;
var _ramBatchProgress = null;
${code}
sandbox.getLast = () => _ramBatchLast;
sandbox.setLast = v => { _ramBatchLast = v; };
sandbox.getSummary = () => _ramBatchSummary;
sandbox.getProgress = () => _ramBatchProgress;
sandbox.ramBatchPanelVisible = ramBatchPanelVisible;
sandbox.ramBatchAttemptsHtml = ramBatchAttemptsHtml;
sandbox.ramBatchRender = ramBatchRender;
sandbox.ramBatchApplyStatus = ramBatchApplyStatus;
sandbox.ramBatchFetch = ramBatchFetch;
sandbox.ramBatchStart = ramBatchStart;
sandbox.ramBatchStop = ramBatchStop;
sandbox.ramBatchClear = ramBatchClear;
return sandbox;
`)(els);
test('the RAM view wires the batch operation and the panel', () => {
assert.match(html, /<option value="batch" data-l10n="Batch install \(\.cap file, per card\)">/);
assert.match(html, /<div id="ram-batch-panel"/);
for (const id of ['ram-batch-start', 'ram-batch-stop', 'ram-batch-clear',
'ram-batch-state', 'ram-batch-notice', 'ram-batch-counts', 'ram-batch-attempts']) {
assert.match(html, new RegExp('id="' + id + '"'));
}
// the batch start needs the server (not a card) and the CAP analysis
assert.match(html, /id="ram-batch-start" data-needs="server" data-cap-gate="ram"/);
assert.match(html, /'Batch install \(\.cap file, per card\)': /);
});
test('ramOpChanged hides the preset cell and Execute in batch mode', () => {
const src = extractFunc(html, 'ramOpChanged');
assert.match(src, /const isBatch = op === 'batch';/);
assert.match(src, /const isCap = op === 'install-cap' \|\| isBatch;/);
assert.match(src, /setHidden\('ram-preset-cell', isBatch\)/);
assert.match(src, /setHidden\('ram-exec-btn', isBatch\)/);
assert.match(src, /setHidden\('ram-batch-panel', !isBatch\)/);
assert.match(src, /if \(isBatch\) ramBatchFetch\(\);/);
// the preset warning does not apply to the per-card resolution
const warn = extractFunc(html, 'spPresetWarningRender');
assert.match(warn, /batchMode \? '' : spPresetWarning/);
});
test('the status poll drives the batch panel', () => {
const poll = extractFunc(html, 'pysimStartBackendPoll');
assert.match(poll, /const status = await pysimFetch\('\/api\/status'\);/);
assert.match(poll, /ramBatchApplyStatus\(status\);/);
// the single install and the batch share the field builder
const inst = extractFunc(html, 'ramInstallCap', true);
assert.match(inst, /const fields = ramInstallCapFields\(\);/);
assert.match(inst, /Object\.assign\(\{/);
const start = extractFunc(html, 'ramBatchStart', true);
assert.match(start, /ramInstallCapFields\(\)/);
assert.match(start, /pysimFetchOk\('\/api\/ram-batch\/start', fields\)/);
});
test('ramBatchAttemptsHtml renders every verdict with its detail', () => {
const out = app.ramBatchAttemptsHtml([
{ ts: 1700000000, iccid: '8970119000004600098', preset: 'Alfa', result: 'installed', duration_ms: 12000 },
{ ts: 1700000001, iccid: '8970119000004600106', preset: 'Alfa', result: 'failed',
failed_step: 'LOAD (3/7)', por_sw: '6A80', duration_ms: 9000 },
{ ts: 1700000002, iccid: '8970119000004600114', result: 'skipped',
reason: 'no preset matches this ICCID' },
]);
assert.match(out, /8970119000004600098/);
assert.match(out, /OK/);
assert.match(out, /LOAD \(3\/7\) — 6A80/);
assert.match(out, /no preset matches this ICCID/);
assert.match(out, /12\.0 s/);
// newest first
assert.ok(out.indexOf('8970119000004600114') < out.indexOf('8970119000004600098'));
assert.match(app.ramBatchAttemptsHtml([]), /No cards processed yet\./);
});
test('ramBatchRender shows the phase, the counts and the stop button', () => {
clearEls();
setEl('ram-op', 'batch');
setEl('ram-batch-panel', '');
setEl('ram-batch-start', '');
setEl('ram-batch-stop', '');
setEl('ram-batch-state', '');
setEl('ram-batch-notice', '');
setEl('ram-batch-counts', '');
setEl('ram-batch-attempts', '');
// waiting for a card
app.setLast({ active: true, phase: 'waiting_card', attempts: [], counts: {}, attempts_total: 0, notice: '' });
app.ramBatchRender();
assert.strictEqual(els['ram-batch-state'].textContent, 'Waiting for a card...');
assert.ok(els['ram-batch-stop'].classes['hidden'] === undefined);
assert.ok(els['ram-batch-start'].classes['hidden']);
// installing card X with the live step
app.setLast({ active: true, phase: 'installing', attempts: [], counts: { installed: 2, failed: 1, skipped: 0 },
attempts_total: 3, notice: 'Card 897… installed.', current: { iccid: '8970119000004600098', preset: 'Alfa' } });
app.ramBatchRender();
assert.match(els['ram-batch-state'].textContent, /Installing\.\.\. 8970119000004600098 — Alfa/);
assert.match(els['ram-batch-counts'].textContent, /Installed: 2 · Failed: 1 · Skipped: 0 · 3 attempts/);
assert.strictEqual(els['ram-batch-notice'].textContent, 'Card 897… installed.');
// stopping
app.setLast({ active: true, phase: 'stopping', attempts: [], counts: {}, attempts_total: 0 });
app.ramBatchRender();
assert.match(els['ram-batch-state'].textContent, /Stopping after the current card\.\.\./);
assert.strictEqual(els['ram-batch-stop'].textContent, 'Stopping...');
assert.ok(els['ram-batch-stop'].disabled);
// stopped
app.setLast({ active: false, phase: 'stopped', attempts: [], counts: {}, attempts_total: 0 });
app.ramBatchRender();
assert.strictEqual(els['ram-batch-state'].textContent, 'Batch stopped');
assert.ok(els['ram-batch-start'].classes['hidden'] === undefined);
assert.ok(els['ram-batch-stop'].classes['hidden']);
});
test('ramBatchApplyStatus fetches only when the summary changed', async () => {
clearEls();
setEl('ram-op', 'batch');
setEl('ram-batch-panel', '');
setEl('ram-batch-start', '');
setEl('ram-batch-stop', '');
setEl('ram-batch-state', '');
setEl('ram-batch-notice', '');
setEl('ram-batch-counts', '');
setEl('ram-batch-attempts', '');
app.fetched = [];
app.replies['/api/ram-batch/status'] = { active: true, phase: 'waiting_card', attempts: [], counts: {}, attempts_total: 1 };
app.ramBatchApplyStatus({ ram_batch: { active: true, phase: 'waiting_card', attempts_total: 1, installed: 0, failed: 0, skipped: 0 } });
await new Promise(r => setImmediate(r));
assert.deepStrictEqual(app.fetched, ['/api/ram-batch/status']);
// an unchanged summary does not refetch
app.ramBatchApplyStatus({ ram_batch: { active: true, phase: 'waiting_card', attempts_total: 1 } });
await new Promise(r => setImmediate(r));
assert.strictEqual(app.fetched.length, 1);
// a new attempt refetches
app.ramBatchApplyStatus({ ram_batch: { active: true, phase: 'waiting_card', attempts_total: 2 } });
await new Promise(r => setImmediate(r));
assert.strictEqual(app.fetched.length, 2);
// the panel is not visible -> no fetch, no render
clearEls();
app.fetched = [];
app.ramBatchApplyStatus({ ram_batch: { active: true, attempts_total: 5 } });
await new Promise(r => setImmediate(r));
assert.deepStrictEqual(app.fetched, []);
});
test('the panel headline follows the live summary (phase and step)', () => {
clearEls();
setEl('ram-op', 'batch');
setEl('ram-batch-panel', '');
setEl('ram-batch-start', '');
setEl('ram-batch-stop', '');
setEl('ram-batch-state', '');
setEl('ram-batch-notice', '');
setEl('ram-batch-counts', '');
setEl('ram-batch-attempts', '');
// the detailed status is stale (waiting), the 2 s summary says installing
app.setLast({ active: true, phase: 'waiting_card', attempts: [], counts: {}, attempts_total: 0 });
app.ramBatchApplyStatus({
ram_batch: { active: true, phase: 'installing', attempts_total: 0,
current: { iccid: '8970119000004600098', preset: 'Alfa' },
counts: { installed: 0, failed: 0, skipped: 0 } },
ram_progress: { active: true, step: 3, total: 8, name: 'LOAD (3/7)' },
});
assert.match(els['ram-batch-state'].textContent, /Installing\.\.\. 8970119000004600098 — Alfa/);
assert.match(els['ram-batch-counts'].textContent, /LOAD \(3\/7\)/);
// ...and back to waiting when the card is gone
app.ramBatchApplyStatus({ ram_batch: { active: true, phase: 'waiting_card', attempts_total: 0,
counts: { installed: 0, failed: 0, skipped: 0 } } });
assert.strictEqual(els['ram-batch-state'].textContent, 'Waiting for a card...');
});
test('the stop and clear calls hit their endpoints', async () => {
clearEls();
app.fetched = [];
app.replies['/api/ram-batch/stop'] = { active: true, phase: 'stopping', attempts: [], counts: {}, attempts_total: 0 };
app.replies['/api/ram-batch/clear'] = { ok: true };
app.replies['/api/ram-batch/status'] = { active: false, phase: 'idle', attempts: [], counts: {}, attempts_total: 0 };
setEl('ram-op', 'batch');
setEl('ram-batch-panel', '');
await app.ramBatchStop();
await app.ramBatchClear();
await new Promise(r => setImmediate(r));
assert.deepStrictEqual(app.fetched, ['/api/ram-batch/stop', '/api/ram-batch/clear', '/api/ram-batch/status']);
});
+10 -3
View File
@@ -57,12 +57,19 @@ test('updateRcPriv aggregates the checked privilege boxes', () => {
});
test('the install derives the privileges at send time', () => {
const fn = extractFunc(html, 'ramInstallCap');
// v3.28.0: the shared field builder (the single install and the batch) does
// the derivation before it returns the body
const fn = extractFunc(html, 'ramInstallCapFields');
const iPriv = fn.indexOf('updateRcPriv();');
const iBody = fn.indexOf('const body = {');
assert.ok(iPriv >= 0, 'ramInstallCap must call updateRcPriv()');
assert.ok(iBody >= 0, 'ramInstallCap body not found');
assert.ok(iPriv >= 0, 'ramInstallCapFields must call updateRcPriv()');
assert.ok(iBody >= 0, 'ramInstallCapFields body not found');
assert.ok(iPriv < iBody, 'the privileges must be derived before the request body');
// both callers go through it
assert.ok(/ramInstallCapFields\(\)/.test(extractFunc(html, 'ramInstallCap')),
'ramInstallCap must use the shared field builder');
assert.ok(/ramInstallCapFields\(\)/.test(extractFunc(html, 'ramBatchStart')),
'ramBatchStart must use the shared field builder');
});
test('the form resets the restored grants on load', () => {
+2 -1
View File
@@ -207,7 +207,8 @@ test('updateStkParamsHex names the rejection reason', () => {
});
test('the install body composes the C9/quota prefix', () => {
const fn = extractFunc(html, 'ramInstallCap');
// v3.28.0: the shared field builder (the single install and the batch)
const fn = extractFunc(html, 'ramInstallCapFields');
assert.ok(/const composed = ramInstallParamsCompose\(\)/.test(fn), fn);
assert.ok(/install_params: composed\.install_params/.test(fn), fn);
assert.ok(/stk_params: composed\.stk_params/.test(fn), fn);
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
version = "3.27.0"
version = "3.28.0"
description = "HTTP REST server wrapping pysim for the SIMple PWA"
requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
+531 -196
View File
@@ -37,7 +37,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
VERSION = '3.27.0'
VERSION = '3.28.0'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -1752,6 +1752,31 @@ def _resolve_operation_keyset(server, body, tar):
return kvn, None
def _op_keyset_resolve(server, body, tar=None):
"""Resolve an operation's keyset: the preset's TAR table supplies it
(v3.26.0 - the RAM operations default to the ISD role's TAR), then the
KIc/KID checks run: the numbers must agree (TS 102 225 A.2 - the card
rejects a mismatch) and a named preset must define the number ("the keyset
must exist"). An explicit request KIc/KID stays an API override.
Returns ``(kvn, kic, kid, error)`` with the effective values; kvn is None
when the packet carries no number ('00'/'00' = no security, legal per
A.2). Shared by the HTTP handlers and the batch worker."""
t = str(tar if tar is not None else (body.get('tar') or '')).strip().upper()
if not t and body.get('preset_id'):
preset = _preset_by_id(server, body.get('preset_id'))
t = presets.role_tar(preset, 'isd') if preset else ''
_kvn, err = _resolve_operation_keyset(server, body, t)
if err:
return None, '', '', err
kic = str(body.get('kic') or '').strip().upper()
kid = str(body.get('kid') or '').strip().upper()
kvn, err = _kvn_of(kic, kid)
if err:
return None, '', '', err
err = _preset_keyset_check(server, body.get('preset_id'), kic, kid)
return kvn, kic, kid, err
def _preset_counter_persist(server, preset_id, cntr, source, kvn=None):
"""Persist the counter an operation accepted into the server-side preset
store (v3.8.0), into the keyset it used (each keyset has its own dedicated
@@ -2365,6 +2390,420 @@ def _ram_nv_fields(nv_before, nv_after):
return {'nv_before': nv_before, 'nv_after': nv_after, 'nv_delta': delta}
def _ram_install_plan(body):
"""The card-independent part of a CAP install: parse the .cap, build the
INSTALL [for load] -> LOAD xN -> INSTALL [for install] chain and read the
transport knobs. Nothing here touches the card, so the batch builds the
plan once and reuses it for every card. Raises ValueError with the
caller's message (the endpoint and the batch answer 400)."""
cap_hex = str(body.get('cap_hex') or '').replace(' ', '')
if not cap_hex:
raise ValueError('No cap_hex provided')
loadfile_aid, module_aid, loadfile_data = _cap_parse(cap_hex)
# LOAD block size: the default 240-byte payload fits the GP limit; an
# explicit override is capped to it (SCP80 concatenates the secured packet
# over up to MAX_ENVELOPE_SEGMENTS SMs, so a block no longer has to fit
# into a single SMS).
block_size_req = body.get('load_block_size')
if block_size_req in (None, ''):
block_size_req = None
else:
try:
block_size_req = int(block_size_req)
except (TypeError, ValueError):
block_size_req = -1
if not 1 <= block_size_req <= 240:
raise ValueError('load_block_size must be 1..240')
block_size = block_size_req or 240
# The install parameters are composed per the spec: an EF-form STK part
# shares one System Specific Parameters EF with the quotas (TS 102 226
# 8.2.1.3.2.1; v3.20.0) - an invalid combination is refused before
# anything is sent.
seq = _cap_apdu_sequence(
loadfile_aid, module_aid, loadfile_data,
sd_aid=str(body.get('sd_aid') or '').replace(' ', ''),
privileges=str(body.get('privileges') or '').replace(' ', '') or '00',
install_params=str(body.get('install_params') or '').replace(' ', ''),
stk_params=str(body.get('stk_params') or '').replace(' ', ''),
make_selectable=body.get('make_selectable', True),
block_size=block_size,
nv_quota=body.get('nv_quota'),
volatile_quota=body.get('volatile_quota'))
return {'loadfile_aid': loadfile_aid, 'module_aid': module_aid, 'seq': seq,
'block_size': block_size, 'block_size_req': block_size_req,
'spi2': str(body.get('spi2') or '01'),
'include_cpi': body.get('includeCpi', True),
'ram_format': _ram_normalize_format(body.get('ram_format'))}
def _ram_install_run(server, body, plan, scc):
"""Run one CAP install chain on `scc` - the caller holds the card lock.
The per-card part: the preset's ISD TAR supplies the keyset (v3.26.0) and
the counter is seeded from the store (v3.20.0). Raises ValueError for a
request error (the endpoint and the batch answer 400 / record the skip); a
chain failure comes back as ``{'success': False, ...}``. Returns the
response dict. Shared by /api/ram-install and the batch worker."""
preset = _preset_by_id(server, body.get('preset_id'))
tar = str(body.get('tar') or presets.role_tar(preset, 'isd')
or '000000').strip().upper()
spi1, _ = _spi1_for_tar(preset, tar, body.get('spi1'), prefer_msl=True)
kvn, kic, kid, keyset_err = _op_keyset_resolve(server, body, tar)
if keyset_err:
raise ValueError(keyset_err)
kic_key = body.get('kicKey', '')
kid_key = body.get('kidKey', '')
# The counter: explicit `cntr` wins, else the preset's value for this
# keyset (the server owns the counters, v3.20.0).
cntr = _preset_counter_seed(server, body, kvn) or body.get('cntr', '00000000')
steps = []
state = {'steps': steps, 'encode_error': None, 'failure': {},
'cntr': cntr, 'preset_id': body.get('preset_id'), 'kvn': kvn}
sp_state = {'spi1': spi1, 'spi2': plan['spi2'], 'kic': kic, 'kid': kid,
'tar': tar, 'kic_key': kic_key, 'kid_key': kid_key,
'include_cpi': plan['include_cpi']}
seq = plan['seq']
sys.stderr.write('RAM-INSTALL: LOAD block size %d bytes\n' % plan['block_size'])
# Live progress for the PWA's operation modal (the request holds _CARD_LOCK
# until the chain is done; /api/status carries this)
_ram_progress_begin('install-cap', len(seq) + 1)
nv_before = None
nv_after = None
try:
# RAM command format: detected per operation (a read-only probe step)
# unless the caller pinned compact/expanded.
ram_format = plan['ram_format']
if ram_format == 'auto':
_ram_progress_step(0, 'FORMAT CHECK')
ram_format = _ram_detect_format(server, scc, sp_state, state)
sys.stderr.write('RAM-INSTALL: %d APDUs (INSTALL / %d x LOAD / INSTALL, %s) loadfile_aid=%s\n' % (
len(seq), len(seq) - 2, ram_format, plan['loadfile_aid']))
# NV footprint: free non-volatile memory before the chain (best effort
# - a card without FF21 skips it).
nv_before = _ram_read_ff21(server, scc, sp_state, state, ram_format, 'NV before')
for apdu_idx, gp_apdu in enumerate(seq):
if apdu_idx == 0:
step_name = 'INSTALL [for load]'
elif apdu_idx == len(seq) - 1:
step_name = 'INSTALL [for install]'
else:
step_name = 'LOAD (%d/%d)' % (apdu_idx, len(seq) - 2)
_ram_progress_step(apdu_idx + 1, step_name)
if not _ram_send_gp_apdu(server, scc, sp_state, state, step_name,
_ram_format_apdu(gp_apdu, ram_format)):
# What the failed attempt consumed (a rejected load may keep or
# discard its partial file - the delta tells which).
nv_after = _ram_read_ff21(server, scc, sp_state, state,
ram_format, 'NV after')
resp = {'success': False, 'steps': steps, 'failed_step': len(steps),
'error': (state['encode_error'] or state['failure'].get('error')
or ('%s failed' % step_name)),
'final_cntr': state['cntr'], 'ram_format': ram_format,
'load_file_aid': plan['loadfile_aid'],
'module_aid': plan['module_aid'],
'load_block_size': plan['block_size'],
'load_block_size_requested': plan['block_size_req'],
'load_block_size_auto': not plan['block_size_req']}
resp.update(_ram_nv_fields(nv_before, nv_after))
failed_rec = steps[-1] if steps else {}
resp.update(_cntr_low_fields(failed_rec.get('por_status')))
if resp.get('cntr_low'):
sys.stderr.write('RAM-INSTALL: low counter - the packet '
'counter is not above the card\'s\n')
return resp
# NV footprint: what the successful chain consumed.
nv_after = _ram_read_ff21(server, scc, sp_state, state, ram_format, 'NV after')
finally:
_ram_progress_end()
resp = {'success': True, 'steps': steps, 'load_file_aid': plan['loadfile_aid'],
'module_aid': plan['module_aid'], 'final_cntr': state['cntr'],
'ram_format': ram_format, 'load_block_size': plan['block_size'],
'load_block_size_requested': plan['block_size_req'],
'load_block_size_auto': not plan['block_size_req']}
resp.update(_ram_nv_fields(nv_before, nv_after))
sys.stderr.write('RAM-INSTALL: Complete — loadfile_aid=%s module_aid=%s cntr=%s\n' % (
plan['loadfile_aid'], plan['module_aid'], state['cntr']))
return resp
# ===== batch CAP install (v3.28.0) =====
# The bulk workflow: one worker thread owns a CAP install plan and applies it
# to every card that gets equipped, resolving the keys from that card's own
# preset (matched by ICCID). A card the run already attempted - any outcome -
# is reported, never installed again; restarting the batch (Stop, then Start)
# is the deliberate retry. The PWA is a thin panel over
# /api/ram-batch/status; the state lives in memory, so a server restart ends a
# run (the report goes with it).
_RAM_BATCH = None # the current (or last finished) batch state
_RAM_BATCH_LOCK = threading.RLock()
_RAM_BATCH_POLL = 0.5 # the worker's card-state poll interval
_RAM_BATCH_ATTEMPTS_MAX = 500 # the report keeps the last N attempts
def _ram_batch_active():
"""True while a batch runs (the test-runner guard asks)."""
with _RAM_BATCH_LOCK:
return bool(_RAM_BATCH and _RAM_BATCH.get('active'))
def _ram_batch_new_state(plan):
"""A fresh run's state. The plan holds everything card independent; the
per-card preset/keyset/counter are resolved at each card."""
return {'active': True, 'phase': 'waiting_card', 'stop': False,
'started': time.time(), 'stopped': None, 'plan': plan,
'session': None, 'seen': {}, 'attempts': [], 'attempts_total': 0,
'counts': {'installed': 0, 'failed': 0, 'skipped': 0},
'current': None, 'notice': '', 'thread': None}
def _ram_batch_record(state, rec):
"""Record one attempt: the report keeps the last _RAM_BATCH_ATTEMPTS_MAX
entries (the total keeps counting), the counts update and the notice names
the outcome for the panel's headline."""
with _RAM_BATCH_LOCK:
rec.setdefault('ts', time.time())
state['attempts'].append(rec)
if len(state['attempts']) > _RAM_BATCH_ATTEMPTS_MAX:
del state['attempts'][:-_RAM_BATCH_ATTEMPTS_MAX]
state['attempts_total'] += 1
result = rec.get('result')
if result in state['counts']:
state['counts'][result] += 1
who = rec.get('iccid') or 'the card'
if result == 'installed':
state['notice'] = 'Card %s installed.' % who
elif result == 'failed':
step = rec.get('failed_step') or ''
sw = rec.get('por_sw') or rec.get('sw') or ''
state['notice'] = ('Card %s failed%s%s.' % (
who, (' at ' + step) if step else '', (' (' + sw + ')') if sw else ''))
else:
state['notice'] = 'Card %s skipped: %s.' % (who, rec.get('reason') or '')
detail = rec.get('reason') or rec.get('failed_step') or ''
sys.stderr.write('RAM-BATCH: %s%s%s\n' % (
result, (' ' + who) if rec.get('iccid') else '', (' - ' + detail) if detail else ''))
def _ram_batch_failure_fields(result):
"""The failing step's name and status words for the batch report (the panel
shows e.g. 'LOAD (3/7) - 6A80'): the remote SW (`por_sw`) is the card's own
answer, `sw` the transport status."""
steps = result.get('steps') or []
idx = result.get('failed_step')
rec = steps[idx - 1] if isinstance(idx, int) and 0 < idx <= len(steps) \
else (steps[-1] if steps else {})
return {'failed_step': rec.get('name') or '',
'por_sw': rec.get('por_sw') or '',
'por_status': rec.get('por_status') or '',
'por_error': rec.get('por_error') or '',
'sw': rec.get('sw') or ''}
def _ram_batch_handle_card(state, server):
"""One equipped card: match the preset by ICCID, check its ISD TAR keyset
and run the install under the card lock. A card already attempted in this
run (any outcome) is reported, never re-installed."""
iccid = presets.normalize_iccid(getattr(server, 'iccid', None) or '')
if not iccid:
_ram_batch_record(state, {'result': 'skipped',
'reason': 'no ICCID readable'})
return
with _RAM_BATCH_LOCK:
if iccid in state['seen']:
_ram_batch_record(state, {'iccid': iccid, 'result': 'skipped',
'reason': 'already processed in this batch (%s)'
% state['seen'][iccid]})
return
store = getattr(server, 'card_presets', None)
preset = store.find_by_iccid(iccid) if store is not None else None
if preset is None:
with _RAM_BATCH_LOCK:
state['seen'][iccid] = 'no preset'
_ram_batch_record(state, {'iccid': iccid, 'result': 'skipped',
'reason': 'no preset matches this ICCID'})
return
name = preset.get('name') or ''
body = {'preset_id': preset.get('id') or '',
'tar': presets.role_tar(preset, 'isd')}
with _RAM_BATCH_LOCK:
state['seen'][iccid] = 'pending'
state['current'] = {'iccid': iccid, 'preset': name, 'started': time.time()}
state['phase'] = 'installing'
started = time.time()
result = None
error = None
try:
with _CARD_LOCK:
result = _ram_install_run(server, body, state['plan'], server.scc)
except Exception as e:
# A refused keyset, a lost card, a transport error: the card's outcome
# is a failure - the batch never retries it.
error = str(e)
finally:
with _RAM_BATCH_LOCK:
state['current'] = None
if not state['stop']:
state['phase'] = 'waiting_card'
duration = int((time.time() - started) * 1000)
if result is not None and result.get('success'):
outcome = 'installed'
rec = {'iccid': iccid, 'preset': name, 'result': outcome,
'duration_ms': duration, 'final_cntr': result.get('final_cntr'),
'load_file_aid': result.get('load_file_aid'),
'module_aid': result.get('module_aid'),
'nv_delta': result.get('nv_delta')}
elif result is not None:
outcome = 'failed'
rec = {'iccid': iccid, 'preset': name, 'result': outcome,
'duration_ms': duration, 'final_cntr': result.get('final_cntr'),
'cntr_low': bool(result.get('cntr_low')),
'error': result.get('error') or ''}
rec.update(_ram_batch_failure_fields(result))
else:
outcome = 'skipped'
rec = {'iccid': iccid, 'preset': name, 'result': outcome,
'duration_ms': duration,
'reason': error or 'the install could not start'}
with _RAM_BATCH_LOCK:
state['seen'][iccid] = outcome
_ram_batch_record(state, rec)
def _ram_batch_worker(state):
"""The batch loop: wait for an equipped card (a new card session), install
the plan on it with the card's own preset, record the attempt, repeat until
the stop flag is set - a running install always finishes first."""
try:
while True:
with _RAM_BATCH_LOCK:
if state['stop']:
break
server = _server_ref
session = getattr(server, 'card_session', 0) if server is not None else None
connected = bool(server is not None
and getattr(server, 'connected', False))
with _RAM_BATCH_LOCK:
if state['stop']:
break
fresh = connected and session != state['session']
if fresh:
state['session'] = session
elif state['phase'] != 'installing':
state['phase'] = 'waiting_card'
if not fresh:
time.sleep(_RAM_BATCH_POLL)
continue
try:
_ram_batch_handle_card(state, server)
except Exception as e:
# An unattended bulk run must survive a surprise: the card is
# recorded and the loop keeps waiting for the next one.
sys.stderr.write('RAM-BATCH: card handling error: %s\n' % e)
_ram_batch_record(state, {'result': 'skipped',
'reason': 'unexpected error: %s' % e})
finally:
with _RAM_BATCH_LOCK:
state['active'] = False
state['current'] = None
state['phase'] = 'stopped' if state['stop'] else 'finished'
state['stopped'] = time.time()
state['thread'] = None
phase, total = state['phase'], state['attempts_total']
sys.stderr.write('RAM-BATCH: %s after %d attempts\n' % (phase, total))
def _ram_batch_start(server, body):
"""Validate the CAP/parameters (the plan is card independent, so it is built
once) and spawn the worker. Raises ValueError (400) for a bad request and
RuntimeError (409) when another batch or a test run is active."""
global _RAM_BATCH
with _RAM_BATCH_LOCK:
if _RAM_BATCH and _RAM_BATCH.get('active'):
raise RuntimeError('a batch is already running - stop it first')
if _TEST_RUNNING:
raise RuntimeError('a test run is active - stop it first')
plan = _ram_install_plan(body)
state = _ram_batch_new_state(plan)
with _RAM_BATCH_LOCK:
_RAM_BATCH = state
thread = threading.Thread(target=_ram_batch_worker, args=(state,),
name='ram-batch', daemon=True)
state['thread'] = thread
thread.start()
sys.stderr.write('RAM-BATCH: started (%d APDUs, loadfile_aid=%s)\n'
% (len(plan['seq']), plan['loadfile_aid']))
return state
def _ram_batch_stop():
"""Request a stop: a running install finishes first (an interrupted LOAD
chain can leave a half-loaded package), then the run ends. Returns False
when no batch is running."""
with _RAM_BATCH_LOCK:
if _RAM_BATCH is None or not _RAM_BATCH.get('active'):
return False
_RAM_BATCH['stop'] = True
_RAM_BATCH['phase'] = 'stopping'
sys.stderr.write('RAM-BATCH: stop requested\n')
return True
def _ram_batch_clear():
"""Drop the finished run's report. Refused while a batch is active."""
global _RAM_BATCH
with _RAM_BATCH_LOCK:
if _RAM_BATCH and _RAM_BATCH.get('active'):
return False
_RAM_BATCH = None
return True
def _ram_batch_status():
"""The panel's status payload (also the summary /api/status carries)."""
with _RAM_BATCH_LOCK:
if _RAM_BATCH is None:
return {'active': False, 'phase': 'idle', 'attempts': [],
'attempts_total': 0, 'current': None, 'notice': '',
'counts': {'installed': 0, 'failed': 0, 'skipped': 0},
'started': None, 'stopped': None, 'progress': None}
return {'active': bool(_RAM_BATCH.get('active')),
'phase': _RAM_BATCH.get('phase'),
'attempts': list(_RAM_BATCH['attempts']),
'attempts_total': _RAM_BATCH['attempts_total'],
'current': dict(_RAM_BATCH['current']) if _RAM_BATCH.get('current') else None,
'notice': _RAM_BATCH.get('notice') or '',
'counts': dict(_RAM_BATCH['counts']),
'started': _RAM_BATCH.get('started'),
'stopped': _RAM_BATCH.get('stopped'),
'progress': _ram_progress_payload() if _RAM_BATCH.get('current') else None}
def _ram_batch_summary():
"""The small /api/status summary the panel's headline rides (None when no
run exists): the detailed state minus the attempts list, so the PWA can
render the phase/counts live and fetch the report only when it changed."""
with _RAM_BATCH_LOCK:
if _RAM_BATCH is None:
return None
cur = _RAM_BATCH.get('current')
return {'active': bool(_RAM_BATCH.get('active')),
'phase': _RAM_BATCH.get('phase'),
'current': dict(cur) if cur else None,
'notice': _RAM_BATCH.get('notice') or '',
'attempts_total': _RAM_BATCH['attempts_total'],
'counts': dict(_RAM_BATCH['counts']),
'started': _RAM_BATCH.get('started'),
'stopped': _RAM_BATCH.get('stopped')}
_BAD_FORMAT_NAMES = {
'01': 'unknown tag',
'02': 'wrong length',
@@ -6181,6 +6620,7 @@ _TEST_BLOCKED_PATHS = frozenset([
# (see do_GET). Card-touching GETs are deliberately absent: /api/cardinfo
# (runs cardinfo on the card) and /api/esim/* (ES10 selection + STORE DATA).
_CARD_FREE_GET = frozenset([
'/api/ram-batch/status',
'/api/status', '/api/test/status', '/api/version',
'/api/stk-status', '/api/poll-status', '/api/proactive-log',
'/api/menu', '/api/events', '/api/terminal-profile',
@@ -6191,6 +6631,11 @@ _CARD_FREE_GET = frozenset([
'/api/scp81/log', '/api/bip/log',
])
# POSTs that must answer while the card lock is held: the batch stop only sets
# a flag (no card I/O), and the panel's Stop must be effective while the batch
# worker holds the lock for an install.
_CARD_FREE_POST = frozenset(['/api/ram-batch/stop'])
TAR_PROBE_DEFAULT_APDU = '00A40000023F00' # SELECT MF: harmless, applet-visible
# The TARs the tool treats as remote-management targets: the preset's role
@@ -8103,35 +8548,10 @@ class PysimHandler(BaseHTTPRequestHandler):
self._log_resp({'suite': suite.get('name'), 'members': len(members),
'start_reset': bool(reset_info)})
def _keyset_guard(self, body, kic, kid):
"""The keyset-number checks shared by the RAM operations: the KIc/KID
numbers must agree (TS 102 225 A.2 - the card rejects a mismatch) and a
named preset must define the number ("the keyset must exist").
Returns ``(kvn, error)``: kvn is None when the packet carries no number
('00'/'00' = no security, legal per A.2), error an error string
the caller answers with 400."""
kvn, err = _kvn_of(kic, kid)
if err:
return None, err
err = _preset_keyset_check(self.server, body.get('preset_id'), kic, kid)
return (kvn or None), err
def _op_keyset(self, body, tar=None):
"""v3.26.0: resolve an operation's keyset from the preset's TAR table
(the RAM operations default to the ISD role's TAR), then run the
KIc/KID checks. An explicit request KIc/KID stays an API override.
Returns ``(kvn, kic, kid, error)`` with the effective values."""
t = str(tar if tar is not None else (body.get('tar') or '')).strip().upper()
if not t and body.get('preset_id'):
preset = _preset_by_id(self.server, body.get('preset_id'))
t = presets.role_tar(preset, 'isd') if preset else ''
_kvn, err = _resolve_operation_keyset(self.server, body, t)
if err:
return None, '', '', err
kic = str(body.get('kic') or '').strip().upper()
kid = str(body.get('kid') or '').strip().upper()
kvn, err = self._keyset_guard(body, kic, kid)
return kvn, kic, kid, err
"""The handler-side wrapper of ``_op_keyset_resolve()`` (the batch
worker calls the module function directly)."""
return _op_keyset_resolve(self.server, body, tar)
def _log_req(self, body=None):
if self.server.log_requests:
@@ -8237,6 +8657,13 @@ class PysimHandler(BaseHTTPRequestHandler):
resp = _test_state_snapshot()
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/ram-batch/status':
# Card-free (it must answer while the batch worker holds the card
# lock for an install): the panel polls it live.
self._log_req()
resp = _ram_batch_status()
self._send_json(resp)
self._log_resp(resp)
elif self.path.startswith('/api/mcc-mnc'):
self._log_req()
q = ''
@@ -8307,6 +8734,7 @@ class PysimHandler(BaseHTTPRequestHandler):
'proactive_seq': _PROACTIVE_ENTRY_ID,
'equipping': bool(getattr(self.server, 'equipping', False)),
'ram_progress': _ram_progress_payload(),
'ram_batch': _ram_batch_summary(),
'auto_equip': bool(_AUTO_EQUIP),
'card': card.name if card else None,
'profile': str(rs.profile) if rs and rs.profile else None,
@@ -8549,6 +8977,10 @@ class PysimHandler(BaseHTTPRequestHandler):
self._send_json({'error': 'test script running - card commands are '
'blocked until it finishes'}, 409)
return
path = self.path.split('?', 1)[0]
if path in _CARD_FREE_POST:
self._do_POST()
return
# Serialize all card access: the background STATUS poll runs in its own
# thread and must never interleave with a FETCH/TERMINAL RESPONSE pair.
with _CARD_LOCK:
@@ -9571,178 +10003,76 @@ class PysimHandler(BaseHTTPRequestHandler):
self._log_resp({'error': _err('reader_not_init', lang)})
return
try:
cap_hex = body.get('cap_hex', '').replace(' ', '')
if not cap_hex:
self._send_json({'error': 'No cap_hex provided'}, 400)
return
# Parse .cap file
loadfile_aid, module_aid, loadfile_data = _cap_parse(cap_hex)
# SCP80 params: the SPI1 comes from the MSL of the packet's
# TAR (the operation uses the preset's table); a caller-supplied
# value is only the fallback for a TAR the preset does not carry.
preset = _preset_by_id(self.server, body.get('preset_id'))
tar = str(body.get('tar') or presets.role_tar(preset, 'isd')
or '000000').strip().upper()
try:
spi1, _ = _spi1_for_tar(preset, tar, body.get('spi1'),
prefer_msl=True)
except ValueError as e:
err = {'success': False, 'error': str(e)}
self._send_json(err, 400)
self._log_resp(err)
return
spi2 = body.get('spi2', '01')
cntr = body.get('cntr', '00000000')
sd_aid = body.get('sd_aid', '').replace(' ', '')
install_params_hex = body.get('install_params', '').replace(' ', '')
stk_params_hex = body.get('stk_params', '').replace(' ', '')
make_selectable = body.get('make_selectable', True)
privileges_hex = body.get('privileges', '').replace(' ', '') or '00'
# The keyset: the preset's default for the operation's TAR (the
# ISD role entry; v3.26.0) unless the request names KIc/KID
# itself - then the number must be consistent and defined in
# the preset (TS 102 225 A.2 / the "keyset must exist" rule),
# checked before the format probe consumes a counter.
kvn, kic, kid, keyset_err = self._op_keyset(body)
if keyset_err:
err = {'success': False, 'error': keyset_err}
self._send_json(err, 400)
self._log_resp(err)
return
kic_key = body.get('kicKey', '')
kid_key = body.get('kidKey', '')
# The counter: explicit `cntr` wins, else the preset's value
# for this keyset (the server owns the counters, v3.20.0).
cntr = _preset_counter_seed(self.server, body, kvn) or cntr
# LOAD block size: the default 240-byte payload cannot be sent
# over SCP80 (pySim refuses a secured packet above one SMS).
# Fit it automatically, or honour an explicit override capped
# to what still encodes into a single SMS.
block_size_req = body.get('load_block_size')
if block_size_req in (None, ''):
block_size_req = None
else:
try:
block_size_req = int(block_size_req)
except (TypeError, ValueError):
block_size_req = -1
if not 1 <= block_size_req <= 240:
err = {'success': False,
'error': 'load_block_size must be 1..240'}
self._send_json(err, 400)
self._log_resp(err)
return
# The GP LOAD payload limit is 240 bytes per APDU; SCP80
# concatenates the secured packet over up to SCP80_MAX_SEGMENTS
# SMs, so a block no longer has to fit into a single SMS.
block_size = block_size_req or 240
sys.stderr.write('RAM-INSTALL: LOAD block size %d bytes\n' % block_size)
steps = []
state = {'steps': steps, 'encode_error': None, 'failure': {},
'cntr': cntr, 'preset_id': body.get('preset_id'),
'kvn': kvn}
sp_state = {'spi1': spi1, 'spi2': spi2, 'kic': kic, 'kid': kid,
'tar': tar, 'kic_key': kic_key, 'kid_key': kid_key,
'include_cpi': body.get('includeCpi', True)}
# INSTALL [for load] -> LOAD blocks -> INSTALL [for install].
# The install parameters are composed per the spec: an
# EF-form STK part shares one System Specific Parameters EF
# with the quotas (TS 102 226 8.2.1.3.2.1; v3.20.0) - an
# invalid combination is refused before anything is sent.
try:
seq = _cap_apdu_sequence(
loadfile_aid, module_aid, loadfile_data, sd_aid=sd_aid,
privileges=privileges_hex,
install_params=install_params_hex, stk_params=stk_params_hex,
make_selectable=make_selectable, block_size=block_size,
nv_quota=body.get('nv_quota'),
volatile_quota=body.get('volatile_quota'))
except ValueError as e:
err = {'success': False, 'error': str(e)}
self._send_json(err, 400)
self._log_resp(err)
return
# Live progress for the PWA's operation modal (the request holds
# _CARD_LOCK until the chain is done; /api/status carries this)
_ram_progress_begin('install-cap', len(seq) + 1)
nv_before = None
nv_after = None
try:
# RAM command format: detected per operation (a read-only
# probe step) unless the caller pinned compact/expanded.
ram_format = _ram_normalize_format(body.get('ram_format'))
if ram_format == 'auto':
_ram_progress_step(0, 'FORMAT CHECK')
ram_format = _ram_detect_format(self.server, scc, sp_state, state)
sys.stderr.write('RAM-INSTALL: %d APDUs (INSTALL / %d x LOAD / INSTALL, %s) loadfile_aid=%s\n' % (
len(seq), len(seq) - 2, ram_format, loadfile_aid))
# NV footprint: free non-volatile memory before the chain
# (best effort - a card without FF21 skips it).
nv_before = _ram_read_ff21(self.server, scc, sp_state, state,
ram_format, 'NV before')
for apdu_idx, gp_apdu in enumerate(seq):
if apdu_idx == 0:
step_name = 'INSTALL [for load]'
elif apdu_idx == len(seq) - 1:
step_name = 'INSTALL [for install]'
else:
step_name = 'LOAD (%d/%d)' % (apdu_idx, len(seq) - 2)
_ram_progress_step(apdu_idx + 1, step_name)
if not _ram_send_gp_apdu(self.server, scc, sp_state, state,
step_name,
_ram_format_apdu(gp_apdu, ram_format)):
# What the failed attempt consumed (a rejected load
# may keep or discard its partial file - the delta
# tells which).
nv_after = _ram_read_ff21(self.server, scc, sp_state, state,
ram_format, 'NV after')
resp = {'success': False, 'steps': steps, 'failed_step': len(steps),
'error': (state['encode_error'] or state['failure'].get('error')
or ('%s failed' % step_name)),
'final_cntr': state['cntr'], 'ram_format': ram_format,
'load_file_aid': loadfile_aid, 'module_aid': module_aid,
'load_block_size': block_size,
'load_block_size_requested': block_size_req,
'load_block_size_auto': not block_size_req}
resp.update(_ram_nv_fields(nv_before, nv_after))
failed_rec = steps[-1] if steps else {}
resp.update(_cntr_low_fields(failed_rec.get('por_status')))
if resp.get('cntr_low'):
sys.stderr.write('RAM-INSTALL: low counter - the packet '
'counter is not above the card\'s\n')
self._send_json(resp)
self._log_resp(resp)
return
# NV footprint: what the successful chain consumed.
nv_after = _ram_read_ff21(self.server, scc, sp_state, state,
ram_format, 'NV after')
finally:
_ram_progress_end()
resp = {'success': True, 'steps': steps, 'load_file_aid': loadfile_aid,
'module_aid': module_aid, 'final_cntr': state['cntr'],
'ram_format': ram_format, 'load_block_size': block_size,
'load_block_size_requested': block_size_req,
'load_block_size_auto': not block_size_req}
resp.update(_ram_nv_fields(nv_before, nv_after))
sys.stderr.write('RAM-INSTALL: Complete — loadfile_aid=%s module_aid=%s cntr=%s\n' % (
loadfile_aid, module_aid, state['cntr']))
self._send_json(resp)
self._log_resp(resp)
# The card-independent plan (.cap + the INSTALL/LOAD chain),
# then one run on the equipped card - both shared with the
# batch worker (v3.28.0).
plan = _ram_install_plan(body)
resp = _ram_install_run(self.server, body, plan, scc)
except ValueError as e:
err = {'success': False, 'error': str(e)}
self._send_json(err, 400)
self._log_resp(err)
return
except Exception as e:
sys.stderr.write('RAM-INSTALL error: %s\n' % e)
err = {'success': False, 'error': str(e)}
self._send_json(err, 500)
self._log_resp(err)
return
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/ram-batch/start':
# Start the bulk install: no card is needed yet - the worker waits
# for one and installs on every card that gets equipped, using the
# card's own preset (matched by ICCID).
body = self._read_body()
self._log_req(body)
try:
_ram_batch_start(self.server, body)
except ValueError as e:
resp = {'error': str(e)}
self._send_json(resp, 400)
self._log_resp(resp)
return
except RuntimeError as e:
resp = {'error': str(e)}
self._send_json(resp, 409)
self._log_resp(resp)
return
except Exception as e:
# A malformed .cap (a zip parse error) and any other bad input
# is the caller's: answer 400, never a dropped connection.
resp = {'error': str(e)}
self._send_json(resp, 400)
self._log_resp(resp)
return
resp = _ram_batch_status()
resp['ok'] = True
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/ram-batch/stop':
# Sets the flag; card-free so the button works while an install
# holds the card lock (_CARD_FREE_POST).
self._log_req()
if not _ram_batch_stop():
resp = {'error': 'no batch is running'}
self._send_json(resp, 409)
self._log_resp(resp)
return
resp = _ram_batch_status()
resp['ok'] = True
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/ram-batch/clear':
self._log_req()
if not _ram_batch_clear():
resp = {'error': 'a batch is running - stop it first'}
self._send_json(resp, 409)
self._log_resp(resp)
return
resp = {'ok': True}
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/ram-install-app':
# INSTALL [for install] / [for make selectable] for an already
# loaded package (iterate the final step without re-loading).
@@ -10503,6 +10833,11 @@ class PysimHandler(BaseHTTPRequestHandler):
self._send_json(resp, 409)
self._log_resp(resp)
return
if _ram_batch_active():
resp = {'error': 'a batch install is running - stop it first'}
self._send_json(resp, 409)
self._log_resp(resp)
return
suite_id = str(body.get('suite_id') or '').strip().lower()
if suite_id:
self._test_suite_run_request(suite_id, body)
+393
View File
@@ -0,0 +1,393 @@
#!/usr/bin/env python3
"""Batch CAP install tests (v3.28.0).
The batch worker applies one install plan to every card that gets equipped,
resolving the keys from each card's own preset (matched by ICCID). A card is
attempted once per run - re-equipping it is reported, never re-installed - a
failure never stops the batch, and the stop flag finishes a running install
first.
"""
import json
import sys
import threading
import time
import unittest
import urllib.error
import urllib.request
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
PROJECTS = Path(__file__).resolve().parents[2]
PY_SIM = PROJECTS / 'pysim'
if str(PY_SIM) not in sys.path:
sys.path.insert(0, str(PY_SIM))
from pysim_simple_server.__main__ import _build_http_server
from pysim_simple_server import presets
from pysim_simple_server import server as srv
from pysim_simple_server.server import PysimHandler, _CARD_LOCK
def _plan():
return {'seq': ['80E60200', '80E80000', '80E60000'], 'loadfile_aid': 'F0414C46416101',
'module_aid': 'F0414C4641610101', 'block_size': 240, 'block_size_req': None,
'spi2': '01', 'include_cpi': True, 'ram_format': 'compact'}
class _FakeStore:
def __init__(self, entries):
self.entries = entries
def find_by_iccid(self, iccid):
want = presets.normalize_iccid(iccid)
for p in self.entries:
if presets.normalize_iccid(p.get('iccid')) == want:
return p
return None
def _preset(iccid, name='Card', keyset=1):
byte = '%02X' % ((keyset << 4) | 0x05)
ks = {'kic': byte, 'kid': byte, 'kicKey': '00' * 16, 'kidKey': '11' * 16,
'cntr': '0000000001'}
tar = {'role': 'isd', 'tar': '000000', 'msl': '16', 'desc': '', 'keyset': keyset}
return {'id': 'p-' + iccid, 'name': name, 'iccid': iccid, 'keysets': [ks],
'tars': [tar]}
def _fake_server(iccid, store, connected=True, session=1):
return SimpleNamespace(iccid=iccid, card_presets=store, connected=connected,
card_session=session, scc=object())
class BatchStateTests(unittest.TestCase):
def setUp(self):
srv._RAM_BATCH = None
def tearDown(self):
srv._RAM_BATCH = None
def test_the_report_counts_and_caps_the_attempts(self):
st = srv._ram_batch_new_state(_plan())
srv._ram_batch_record(st, {'iccid': '8970119000004600098', 'result': 'installed'})
srv._ram_batch_record(st, {'iccid': '8970119000004600106', 'result': 'failed',
'failed_step': 'LOAD (3/7)', 'por_sw': '6A80'})
srv._ram_batch_record(st, {'iccid': '8970119000004600114', 'result': 'skipped',
'reason': 'no preset matches this ICCID'})
self.assertEqual(st['counts'], {'installed': 1, 'failed': 1, 'skipped': 1})
self.assertEqual(st['attempts_total'], 3)
# the notice names the last attempt's outcome
self.assertIn('skipped: no preset matches this ICCID', st['notice'])
srv._ram_batch_record(st, {'iccid': '8970119000004600122', 'result': 'failed',
'failed_step': 'LOAD (3/7)', 'por_sw': '6A80'})
self.assertIn('failed at LOAD (3/7) (6A80)', st['notice'])
# the report keeps the last _RAM_BATCH_ATTEMPTS_MAX entries
for _ in range(srv._RAM_BATCH_ATTEMPTS_MAX + 10):
srv._ram_batch_record(st, {'result': 'skipped', 'reason': 'x'})
self.assertEqual(len(st['attempts']), srv._RAM_BATCH_ATTEMPTS_MAX)
self.assertEqual(st['attempts_total'], 4 + srv._RAM_BATCH_ATTEMPTS_MAX + 10)
def test_the_failure_fields_name_the_failing_step(self):
result = {'failed_step': 2, 'steps': [
{'name': 'INSTALL [for load]', 'sw': '9000', 'por_status': 'por_ok'},
{'name': 'LOAD (1/2)', 'sw': '9000', 'por_status': 'por_error',
'por_sw': '6A80', 'por_error': 'remote SW 6A80'},
]}
fields = srv._ram_batch_failure_fields(result)
self.assertEqual(fields['failed_step'], 'LOAD (1/2)')
self.assertEqual(fields['por_sw'], '6A80')
self.assertEqual(fields['por_error'], 'remote SW 6A80')
# a result without steps stays quiet
self.assertEqual(srv._ram_batch_failure_fields({})['failed_step'], '')
class BatchCardTests(unittest.TestCase):
"""The per-card decision and one run (the install chain itself is mocked)."""
def setUp(self):
srv._RAM_BATCH = None
def tearDown(self):
srv._RAM_BATCH = None
def test_a_card_without_a_preset_is_skipped(self):
st = srv._ram_batch_new_state(_plan())
srv._ram_batch_handle_card(st, _fake_server('8970119000004600098', _FakeStore([])))
self.assertEqual(st['attempts'][0]['result'], 'skipped')
self.assertIn('no preset', st['attempts'][0]['reason'])
self.assertEqual(st['seen']['8970119000004600098'], 'no preset')
def test_a_card_without_a_readable_iccid_is_skipped(self):
st = srv._ram_batch_new_state(_plan())
srv._ram_batch_handle_card(st, _fake_server(None, _FakeStore([])))
self.assertIn('no ICCID', st['attempts'][0]['reason'])
def test_the_preset_is_matched_by_iccid_and_the_install_runs(self):
st = srv._ram_batch_new_state(_plan())
preset = _preset('8970119000004600098', 'Alfa')
calls = []
def fake_run(server, body, plan, scc):
calls.append(body)
return {'success': True, 'final_cntr': '0000000002',
'load_file_aid': 'F0414C46416101', 'module_aid': 'F0414C4641610101'}
with mock.patch.object(srv, '_ram_install_run', fake_run):
srv._ram_batch_handle_card(st, _fake_server('8970119000004600098',
_FakeStore([preset])))
self.assertEqual(len(calls), 1)
self.assertEqual(calls[0]['preset_id'], 'p-8970119000004600098')
self.assertEqual(calls[0]['tar'], '000000') # the ISD role TAR
rec = st['attempts'][0]
self.assertEqual(rec['result'], 'installed')
self.assertEqual(rec['preset'], 'Alfa')
self.assertEqual(st['counts']['installed'], 1)
def test_a_failed_install_is_recorded_with_the_step_and_sw(self):
st = srv._ram_batch_new_state(_plan())
preset = _preset('8970119000004600098')
def fake_run(server, body, plan, scc):
return {'success': False, 'error': 'remote SW 6A80', 'failed_step': 2,
'steps': [{'name': 'INSTALL [for load]', 'sw': '9000'},
{'name': 'LOAD (1/1)', 'sw': '9000', 'por_sw': '6A80'}],
'final_cntr': '0000000002'}
with mock.patch.object(srv, '_ram_install_run', fake_run):
srv._ram_batch_handle_card(st, _fake_server('8970119000004600098',
_FakeStore([preset])))
rec = st['attempts'][0]
self.assertEqual(rec['result'], 'failed')
self.assertEqual(rec['failed_step'], 'LOAD (1/1)')
self.assertEqual(rec['por_sw'], '6A80')
self.assertEqual(st['counts']['failed'], 1)
def test_a_raised_error_is_a_skip_and_the_card_stays_seen(self):
st = srv._ram_batch_new_state(_plan())
preset = _preset('8970119000004600098')
def fake_run(*a, **k):
raise ValueError('TAR 000000 needs a default keyset (its MSL 16 '
'requires security)')
with mock.patch.object(srv, '_ram_install_run', fake_run):
srv._ram_batch_handle_card(st, _fake_server('8970119000004600098',
_FakeStore([preset])))
rec = st['attempts'][0]
self.assertEqual(rec['result'], 'skipped')
self.assertIn('needs a default keyset', rec['reason'])
self.assertEqual(st['seen']['8970119000004600098'], 'skipped')
def test_a_card_already_processed_is_reported_not_reinstalled(self):
st = srv._ram_batch_new_state(_plan())
preset = _preset('8970119000004600098', 'Alfa')
calls = []
with mock.patch.object(srv, '_ram_install_run',
lambda *a, **k: calls.append(1) or {'success': True}):
srv._ram_batch_handle_card(st, _fake_server('8970119000004600098',
_FakeStore([preset]), session=1))
srv._ram_batch_handle_card(st, _fake_server('8970119000004600098',
_FakeStore([preset]), session=2))
self.assertEqual(len(calls), 1) # the second card never installs
self.assertEqual(st['attempts'][1]['result'], 'skipped')
self.assertIn('already processed in this batch (installed)',
st['attempts'][1]['reason'])
self.assertEqual(st['attempts_total'], 2)
def test_the_worker_waits_for_a_card_and_installs(self):
"""The loop reacts to a new card session, records the attempt and ends
on the stop flag."""
st = srv._ram_batch_new_state(_plan())
preset = _preset('8970119000004600098', 'Alfa')
srv._RAM_BATCH = st
srv._server_ref = _fake_server('8970119000004600098', _FakeStore([preset]),
connected=True, session=7)
calls = []
with mock.patch.object(srv, '_ram_install_run',
lambda *a, **k: calls.append(1) or {'success': True}):
t = threading.Thread(target=srv._ram_batch_worker, args=(st,), daemon=True)
t.start()
for _ in range(60):
if st['attempts']:
break
time.sleep(0.05)
self.assertTrue(srv._ram_batch_stop())
t.join(timeout=3)
srv._server_ref = None
self.assertEqual(len(calls), 1)
self.assertEqual(st['attempts'][0]['result'], 'installed')
self.assertFalse(st['active'])
self.assertEqual(st['phase'], 'stopped')
# the summary /api/status carries
self.assertEqual(srv._ram_batch_summary()['counts']['installed'], 1)
self.assertFalse(srv._ram_batch_summary()['active'])
class BatchLifecycleTests(unittest.TestCase):
def setUp(self):
srv._RAM_BATCH = None
def tearDown(self):
srv._RAM_BATCH = None
def test_start_refuses_a_second_batch_and_a_test_run(self):
with mock.patch.object(srv, '_ram_install_plan', lambda body: _plan()), \
mock.patch.object(srv, '_ram_batch_worker', lambda state: None):
st = srv._ram_batch_start(None, {'cap_hex': 'AA'})
self.assertTrue(st['active'])
with self.assertRaises(RuntimeError):
srv._ram_batch_start(None, {'cap_hex': 'AA'})
srv._ram_batch_stop()
# a test run blocks the start
srv._RAM_BATCH = None
with mock.patch.object(srv, '_ram_install_plan', lambda body: _plan()), \
mock.patch.object(srv, '_TEST_RUNNING', True):
with self.assertRaises(RuntimeError):
srv._ram_batch_start(None, {'cap_hex': 'AA'})
def test_the_status_payload_shape(self):
idle = srv._ram_batch_status()
self.assertFalse(idle['active'])
self.assertEqual(idle['phase'], 'idle')
self.assertEqual(idle['attempts'], [])
self.assertIsNone(srv._ram_batch_summary())
with mock.patch.object(srv, '_ram_install_plan', lambda body: _plan()), \
mock.patch.object(srv, '_ram_batch_worker', lambda state: None):
srv._ram_batch_start(None, {'cap_hex': 'AA'})
srv._ram_batch_record(srv._RAM_BATCH, {'iccid': '8970119000004600098',
'result': 'installed'})
st = srv._ram_batch_status()
self.assertTrue(st['active'])
self.assertEqual(st['phase'], 'waiting_card')
self.assertEqual(len(st['attempts']), 1)
self.assertEqual(st['counts']['installed'], 1)
self.assertEqual(srv._ram_batch_summary()['counts']['installed'], 1)
# a stop request flips the phase; clear is refused while active
self.assertTrue(srv._ram_batch_stop())
self.assertEqual(srv._ram_batch_status()['phase'], 'stopping')
self.assertFalse(srv._ram_batch_clear())
srv._RAM_BATCH['active'] = False
self.assertTrue(srv._ram_batch_clear())
self.assertIsNone(srv._RAM_BATCH)
class BatchHttpTests(unittest.TestCase):
"""The endpoints: start needs no card, status is card-free (the panel polls
it during an install), stop answers while the card lock is held."""
def setUp(self):
srv._RAM_BATCH = None
self.server = _build_http_server('127.0.0.1', 0, PysimHandler)
self.server.log_requests = False
self.server.app = None
self.server.sl = None
self.server.scc = None
self.server.card_presets = None
threading.Thread(target=self.server.serve_forever, daemon=True).start()
self.port = self.server.server_address[1]
def tearDown(self):
self.server.shutdown()
self.server.server_close()
srv._RAM_BATCH = None
def _post(self, path, body, timeout=5):
req = urllib.request.Request(
'http://127.0.0.1:%d%s' % (self.port, path),
data=json.dumps(body).encode(),
headers={'Content-Type': 'application/json'}, method='POST')
try:
with urllib.request.urlopen(req, timeout=timeout) as res:
return res.status, json.loads(res.read() or b'{}')
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read() or b'{}')
def _get(self, path, timeout=5):
try:
with urllib.request.urlopen(
'http://127.0.0.1:%d%s' % (self.port, path), timeout=timeout) as res:
return res.status, json.loads(res.read() or b'{}')
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read() or b'{}')
def test_the_batch_endpoints(self):
with mock.patch.object(srv, '_ram_install_plan', lambda body: _plan()), \
mock.patch.object(srv, '_ram_batch_worker', lambda state: None):
status, resp = self._post('/api/ram-batch/start', {'cap_hex': 'AA'})
self.assertEqual(status, 200, resp)
self.assertTrue(resp['active'])
self.assertEqual(resp['phase'], 'waiting_card')
# a second start is refused
status, resp = self._post('/api/ram-batch/start', {'cap_hex': 'AA'})
self.assertEqual(status, 409, resp)
# the status is card-free
status, resp = self._get('/api/ram-batch/status')
self.assertEqual(status, 200, resp)
self.assertTrue(resp['active'])
# clear is refused while active
status, resp = self._post('/api/ram-batch/clear', {})
self.assertEqual(status, 409, resp)
# the stop answers and flips the phase
status, resp = self._post('/api/ram-batch/stop', {})
self.assertEqual(status, 200, resp)
self.assertEqual(resp['phase'], 'stopping')
# a repeated stop is idempotent (the panel disables the button)
status, resp = self._post('/api/ram-batch/stop', {})
self.assertEqual(status, 200, resp)
srv._RAM_BATCH['active'] = False
status, resp = self._post('/api/ram-batch/clear', {})
self.assertEqual(status, 200, resp)
# with no run left, a stop is a 409
status, resp = self._post('/api/ram-batch/stop', {})
self.assertEqual(status, 409, resp)
status, resp = self._get('/api/ram-batch/status')
self.assertEqual(status, 200, resp)
self.assertFalse(resp['active'])
self.assertEqual(resp['phase'], 'idle')
def test_a_bad_start_is_a_400_and_a_test_run_blocks_it(self):
def boom(body):
raise ValueError('No cap_hex provided')
with mock.patch.object(srv, '_ram_install_plan', boom):
status, resp = self._post('/api/ram-batch/start', {})
self.assertEqual(status, 400, resp)
self.assertIn('cap_hex', resp['error'])
# a malformed .cap (a zip parse error) is a 400 too, never a dropped
# connection
status, resp = self._post('/api/ram-batch/start', {'cap_hex': '00'})
self.assertEqual(status, 400, resp)
self.assertIn('zip', resp['error'].lower())
with mock.patch.object(srv, '_ram_install_plan', lambda body: _plan()), \
mock.patch.object(srv, '_TEST_RUNNING', True):
status, resp = self._post('/api/ram-batch/start', {'cap_hex': 'AA'})
self.assertEqual(status, 409, resp)
def test_a_test_run_is_refused_while_a_batch_runs(self):
with mock.patch.object(srv, '_ram_install_plan', lambda body: _plan()), \
mock.patch.object(srv, '_ram_batch_worker', lambda state: None):
status, resp = self._post('/api/ram-batch/start', {'cap_hex': 'AA'})
self.assertEqual(status, 200, resp)
status, resp = self._post('/api/test/run', {})
self.assertEqual(status, 409, resp)
self.assertIn('batch install', resp['error'])
def test_stop_answers_while_the_card_lock_is_held(self):
"""The panel's Stop must be effective during an install (the worker
holds _CARD_LOCK for the whole chain)."""
with mock.patch.object(srv, '_ram_install_plan', lambda body: _plan()), \
mock.patch.object(srv, '_ram_batch_worker', lambda state: None):
status, resp = self._post('/api/ram-batch/start', {'cap_hex': 'AA'})
self.assertEqual(status, 200, resp)
with _CARD_LOCK:
status, resp = self._post('/api/ram-batch/stop', {}, timeout=2)
self.assertEqual(status, 200, resp)
self.assertEqual(resp['phase'], 'stopping')
status, resp = self._get('/api/ram-batch/status', timeout=2)
self.assertEqual(status, 200, resp)
if __name__ == '__main__':
unittest.main()