2 Commits

Author SHA1 Message Date
catarrh 8a08f642d4 v3.32.2: the Windows card-swap auto-equip loop
release / release (push) Waiting to run
After a card swap (or the first insertion after startup) on Windows the
auto-equip looped forever: "card inserted, initializing" / "card gone
during initialization", with no "retry 2/3" lines.

An equip with the card still present produced no card (app.card None),
and that branch reported "card gone" and returned True - a no-op success
- so the three retries never ran and the watchdog re-armed every ~5 s.

- The card-gone no-op is now only for a real removal (card_present
  False); a card-present init that produced no card raises a failure.
- Any failed attempt marks the transport stale, so the next attempt
  rebuilds the PC/SC link (a Windows swap can leave the old link unable
  to connect; _ensure_transport releases the old link before building
  the new one, so the 2026-09-24 stale-handle bug cannot recur).
- The error pySim swallowed (cmd2) is printed as "AUTO-EQUIP: init
  output:" with its traceback - the failure is diagnosable from the log.
- Docs: the READMEs' troubleshooting bullet.
- Version strings + the PWA cache bumped together (simple-v460).

Tests: a card-present init without a card is a failure, a mid-init
removal stays a silent no-op, any failure marks the transport, the
stale-handle traceback reaches the log, and an end-to-end retry: a
failed init, the old link released, a fresh one built, the retry
succeeds.  1012 Python + 953 frontend green.
2026-10-21 14:35:44 +03:00
catarrh cdd3820064 v3.32.1: "Start counter value" in the outfile import
release / release (push) Waiting to run
The import created presets with the store's default counter (1).  Cards
whose counters are already past it had to be edited preset by preset
afterwards.

- The outfile preview form gains a "Start counter value" field (default
  1, the store's value).  The value is written into every created keyset
  of every imported card - parse_outfile's `start_counter`, 1-10 hex
  digits, whitespace stripped and uppercased; a bad value raises and the
  endpoint answers 400.  Without it the store's default still applies
  (the field is omitted), so the API keeps its old behaviour.
- The preview shows the counter; an import stores the protocol's
  10-digit form (counter_hex).
- Docs: api.md (the field), help EN/RU (the form).
- Version strings + the PWA cache bumped together (simple-v459).

Tests: the parser (every keyset, normalisation, unset, the bad values),
the endpoint (the preview's keysets, the 400, the stored padded form),
the PWA (the field's default, the controls' normalisation, both bodies).
1008 Python + 953 frontend green.
2026-10-21 13:20:28 +03:00
15 changed files with 232 additions and 31 deletions
+21
View File
@@ -9,6 +9,27 @@ every change carries a descriptive commit message.
## [Unreleased] ## [Unreleased]
## [3.32.2] — 2026-10-21
### Fixed
- **The auto-equip loop after a card swap on Windows.** A failed initialization with
the card still present was reported as "card gone during initialization" and treated
as a no-op success — so the three retries never ran and the watchdog re-armed every
few seconds forever. It is a failure now, and **every failed attempt marks the
PC/SC transport for a rebuild**, so the retry starts from a fresh link (the release
of the old link happens first, so it cannot inherit a stale card handle). The error
pySim swallowed is printed as `AUTO-EQUIP: init output:` with its traceback.
## [3.32.1] — 2026-10-21
### Added
- **"Start counter value"** in the outfile import: the counter every created keyset
starts from. It defaults to the store's value (`1`); raise it for cards whose
counters are already past it. The API takes `start_counter` (1–10 hex digits;
a bad value is a 400), and the preview carries the value.
## [3.32.0] — 2026-10-21 ## [3.32.0] — 2026-10-21
### Added ### Added
+1 -1
View File
@@ -799,7 +799,7 @@ its MSVC build chain) is not pulled in.
- **"Failed to establish context: Access denied"** — `pcscd` isn't running or the user lacks permission: `sudo systemctl enable --now pcscd && sudo usermod -a -G pcscd $USER`. - **"Failed to establish context: Access denied"** — `pcscd` isn't running or the user lacks permission: `sudo systemctl enable --now pcscd && sudo usermod -a -G pcscd $USER`.
- **"device file /dev/ttyUSB0 does not exist"** — no serial reader; connect a USB reader or pass `-d` explicitly. The server still starts without a reader. - **"device file /dev/ttyUSB0 does not exist"** — no serial reader; connect a USB reader or pass `-d` explicitly. The server still starts without a reader.
- **"Service not available" (0x8010001D) / reader gone after a `pcscd` restart** — the server recovers by itself: a watchdog revives pyscard's presence monitor and the next equip (automatic after re-insertion, or the **Equip** button) recreates the PC/SC transport (releasing the old link first). No server restart needed. - **"Service not available" (0x8010001D) / reader gone after a `pcscd` restart** — the server recovers by itself: a watchdog revives pyscard's presence monitor and the next equip (automatic after re-insertion, or the **Equip** button) recreates the PC/SC transport (releasing the old link first). No server restart needed.
- **"Card was removed" errors / auto-equip fails after a card swap** — a normal card swap reconnects on the existing transport (card-level PC/SC errors no longer force a transport rebuild), a failed auto-equip is retried up to 3 times, and the watchdog re-arms it every 5 s while a card is present. A removed card also clears the card state completely, so handlers answer "no card" instead of transmitting over the dead card. - **"Card was removed" errors / auto-equip fails after a card swap** — the auto-equip retries up to 3 times, and **every failed attempt rebuilds the PC/SC transport** before the next one: after a card swap on Windows the old link can stay unable to connect, which used to fail every retry and re-arm forever. The swallowed init error (with its traceback) is now printed as `AUTO-EQUIP: init output:`, and a card that is present but produced no card is a failure, not a silent "card gone". The watchdog re-arms with a doubling delay (5 s up to 60 s) while a card is present; a removed card clears the card state completely, so handlers answer "no card" instead of transmitting over the dead card.
### API reference ### API reference
+1 -1
View File
@@ -783,7 +783,7 @@ pysim-simple-server --http-port 8080
- **"Failed to establish context: Access denied"** — `pcscd` не запущен или нет прав: `sudo systemctl enable --now pcscd && sudo usermod -a -G pcscd $USER`. - **"Failed to establish context: Access denied"** — `pcscd` не запущен или нет прав: `sudo systemctl enable --now pcscd && sudo usermod -a -G pcscd $USER`.
- **"device file /dev/ttyUSB0 does not exist"** — нет serial-ридера; подключите USB-ридер или укажите `-d`. Сервер всё равно стартует без ридера. - **"device file /dev/ttyUSB0 does not exist"** — нет serial-ридера; подключите USB-ридер или укажите `-d`. Сервер всё равно стартует без ридера.
- **"Service not available" (0x8010001D) / ридер пропал после перезапуска `pcscd`** — сервер восстанавливается сам: сторож перезапускает монитор присутствия pyscard, а следующий equip (автоматически после повторной вставки карты или кнопкой **Equip**) пересоздаёт транспорт PC/SC (сначала освобождая старый линк). Перезапуск сервера не нужен. - **"Service not available" (0x8010001D) / ридер пропал после перезапуска `pcscd`** — сервер восстанавливается сам: сторож перезапускает монитор присутствия pyscard, а следующий equip (автоматически после повторной вставки карты или кнопкой **Equip**) пересоздаёт транспорт PC/SC (сначала освобождая старый линк). Перезапуск сервера не нужен.
- **Ошибки "Card was removed" / equip не срабатывает после смены карты** — обычная смена карты переподключается на существующем транспорте (карточные ошибки PC/SC больше не вызывают пересоздание транспорта), неудавшийся авто-equip повторяется до 3 раз, а сторож перезапускает его каждые 5 с, пока карта в ридере. Снятие карты теперь полностью очищает состояние, поэтому обработчики отвечают «нет карты», а не шлют APDU мёртвой карте. - **Ошибки "Card was removed" / equip не срабатывает после смены карты** — авто-equip повторяется до 3 раз, и **каждая неудачная попытка пересоздаёт транспорт PC/SC** перед следующей: после смены карты в Windows старое соединение может остаться неспособным подключиться, из-за чего раньше все повторы падали, а сторож перезапускал попытки бесконечно. Проглоченная ошибка инициализации (с трассировкой) теперь печатается как `AUTO-EQUIP: init output:`, а «карта есть, но карта не создана» — это ошибка, а не тихое «карта пропала». Сторож повторяет с удвоением задержки (5 с … 60 с), пока карта в ридере; снятие карты полностью очищает состояние, поэтому обработчики отвечают «нет карты», а не шлют APDU мёртвой карте.
### Справочник API ### Справочник API
+4
View File
@@ -351,6 +351,10 @@ into a preview.
- `tar_keyset` — an optional **global default keyset** (1–15) for the TAR - `tar_keyset` — an optional **global default keyset** (1–15) for the TAR
entries that do not name one (the API's shorthand; the PWA sends per-row entries that do not name one (the API's shorthand; the PWA sends per-row
keysets instead). keysets instead).
- `start_counter` — the counter **every created keyset starts from** (1–10 hex
digits; whitespace stripped, uppercased). Without it the store's default
(`1`) applies. A bad value is a `400`; the import stores the protocol's
10-digit form.
Recognised columns (case-insensitive): `ICCID` (the digits + odd-nibble F Recognised columns (case-insensitive): `ICCID` (the digits + odd-nibble F
pad, the plain digits, spaced digits or the raw nibble-swapped EF hex all pad, the plain digits, spaced digits or the raw nibble-swapped EF hex all
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -371,7 +371,7 @@
</tbody> </tbody>
</table> </table>
<p class="text-sm mb-3">The <strong>SCP81</strong> column shows whether the preset supplies a usable PSK pair: <strong>&#10003;</strong> (green) — identity and key set; <strong>&#9888;</strong> (amber) — only one of the two, so the listener ignores the preset; <strong>&mdash;</strong> — no PSK. PSK identity and key must be set together. <strong>Add preset</strong> opens the editor with a fresh preset; <strong>Edit</strong> loads the row's preset into it so fields can be changed without re-entering the card; <strong>Save</strong> stores it and returns to the list, <strong>Cancel</strong> discards the edits; <strong>Remove</strong> deletes the row (list only). Presets can be shared with <strong>Export to file</strong> and restored with <strong>Import from file</strong>. The selected card preset auto-fills the Secured Packet form and sets the TAR for the operation (RAM/GP &rarr; ISD TAR, SIM RFM &rarr; UICC RFM TAR, USIM RFM &rarr; ADF RFM TAR); a TAR typed into the Secured Packet form by hand is kept when sending - the counter, keys and the TAR's MSL are still re-read from the preset; the SPI1 is seeded from the TAR's MSL and a lower value warns (the server returns it as a <code class="font-mono text-sm">warning</code> too); edits are pushed into a running SCP81 listener automatically.</p> <p class="text-sm mb-3">The <strong>SCP81</strong> column shows whether the preset supplies a usable PSK pair: <strong>&#10003;</strong> (green) — identity and key set; <strong>&#9888;</strong> (amber) — only one of the two, so the listener ignores the preset; <strong>&mdash;</strong> — no PSK. PSK identity and key must be set together. <strong>Add preset</strong> opens the editor with a fresh preset; <strong>Edit</strong> loads the row's preset into it so fields can be changed without re-entering the card; <strong>Save</strong> stores it and returns to the list, <strong>Cancel</strong> discards the edits; <strong>Remove</strong> deletes the row (list only). Presets can be shared with <strong>Export to file</strong> and restored with <strong>Import from file</strong>. The selected card preset auto-fills the Secured Packet form and sets the TAR for the operation (RAM/GP &rarr; ISD TAR, SIM RFM &rarr; UICC RFM TAR, USIM RFM &rarr; ADF RFM TAR); a TAR typed into the Secured Packet form by hand is kept when sending - the counter, keys and the TAR's MSL are still re-read from the preset; the SPI1 is seeded from the TAR's MSL and a lower value warns (the server returns it as a <code class="font-mono text-sm">warning</code> too); edits are pushed into a running SCP81 listener automatically.</p>
<p class="text-sm mb-3"><strong>Import outfile</strong> imports many cards at once from a card vendor's <em>outfile</em> — the personalization tool's plain-text dump: a banner and a configuration header, then a whitespace-separated <strong>column line</strong> (the tokens include <code class="font-mono text-sm">ICCID</code>; the first token may carry a <code class="font-mono text-sm">var_out:</code>-style prefix) and one row per card. Everything before the column line and every unused column is ignored. Every data row must carry a value for <strong>every declared column</strong> &mdash; the format is positional, so an empty value is indistinguishable from a missing one &mdash; and a <code class="font-mono text-sm">KICn</code>/<code class="font-mono text-sm">KIDn</code> pair named in the header must have a usable value in every row; a violation makes the whole file invalid (the preview reports the line and the import is refused). The parser reads <code class="font-mono text-sm">ICCID</code> (digits, the digits with an odd-nibble <code class="font-mono text-sm">F</code> pad, spaced digits or the raw EF hex — all normalised to one form), <code class="font-mono text-sm">PIN1</code>/<code class="font-mono text-sm">PIN2</code>/<code class="font-mono text-sm">PUK1</code>/<code class="font-mono text-sm">PUK2</code>, <code class="font-mono text-sm">ADM</code> (the key's hex value, 4&ndash;16 hex digits in byte pairs — e.g. <code class="font-mono text-sm">3632333239463337</code>), <code class="font-mono text-sm">PSK</code> (16 bytes; an optional <code class="font-mono text-sm">PSKID</code>/<code class="font-mono text-sm">PSK_ID</code> names its identity, defaulting to the card's ICCID) and the numbered key pairs <code class="font-mono text-sm">KICn</code>/<code class="font-mono text-sm">KIDn</code> — one keyset per pair, up to 15. The <strong>KIc/KID bytes are never in the file</strong>: the keyset number is the column number (<code class="font-mono text-sm">KIC2</code> &rarr; keyset 2) and the algorithm nibble follows the key size — 8 bytes DES, 24 bytes 3DES3, and for <strong>16 bytes</strong> the preview's choice, <strong>3DES2</strong> (default) or <strong>AES</strong>; any other size skips that keyset with a note. The TARs never come from the file either: the preview shows the TAR table the imported cards get (the three role entries with their spec TARs and MSL 16 by default, editable, with <strong>+ TAR</strong> for extra entries) and <strong>every row carries its own keyset</strong> — a select over the keysets found in the file (the first one is preselected; a non-zero MSL needs one, marked amber), so each TAR's packets use exactly the keyset you choose. A <strong>common name</strong> is applied to every imported card (empty falls back to the ICCID); <strong>Import mode</strong> chooses <strong>Merge</strong> (default — existing presets are kept, duplicate ICCIDs are skipped and reported) or <strong>Replace</strong> (the store is wiped first). The preview lists the parsed cards (ICCID, keysets, the codes found) and every skipped row/key with its reason before anything is stored.</p> <p class="text-sm mb-3"><strong>Import outfile</strong> imports many cards at once from a card vendor's <em>outfile</em> — the personalization tool's plain-text dump: a banner and a configuration header, then a whitespace-separated <strong>column line</strong> (the tokens include <code class="font-mono text-sm">ICCID</code>; the first token may carry a <code class="font-mono text-sm">var_out:</code>-style prefix) and one row per card. Everything before the column line and every unused column is ignored. Every data row must carry a value for <strong>every declared column</strong> &mdash; the format is positional, so an empty value is indistinguishable from a missing one &mdash; and a <code class="font-mono text-sm">KICn</code>/<code class="font-mono text-sm">KIDn</code> pair named in the header must have a usable value in every row; a violation makes the whole file invalid (the preview reports the line and the import is refused). The parser reads <code class="font-mono text-sm">ICCID</code> (digits, the digits with an odd-nibble <code class="font-mono text-sm">F</code> pad, spaced digits or the raw EF hex — all normalised to one form), <code class="font-mono text-sm">PIN1</code>/<code class="font-mono text-sm">PIN2</code>/<code class="font-mono text-sm">PUK1</code>/<code class="font-mono text-sm">PUK2</code>, <code class="font-mono text-sm">ADM</code> (the key's hex value, 4&ndash;16 hex digits in byte pairs — e.g. <code class="font-mono text-sm">3632333239463337</code>), <code class="font-mono text-sm">PSK</code> (16 bytes; an optional <code class="font-mono text-sm">PSKID</code>/<code class="font-mono text-sm">PSK_ID</code> names its identity, defaulting to the card's ICCID) and the numbered key pairs <code class="font-mono text-sm">KICn</code>/<code class="font-mono text-sm">KIDn</code> — one keyset per pair, up to 15. The <strong>KIc/KID bytes are never in the file</strong>: the keyset number is the column number (<code class="font-mono text-sm">KIC2</code> &rarr; keyset 2) and the algorithm nibble follows the key size — 8 bytes DES, 24 bytes 3DES3, and for <strong>16 bytes</strong> the preview's choice, <strong>3DES2</strong> (default) or <strong>AES</strong>; any other size skips that keyset with a note. The TARs never come from the file either: the preview shows the TAR table the imported cards get (the three role entries with their spec TARs and MSL 16 by default, editable, with <strong>+ TAR</strong> for extra entries) and <strong>every row carries its own keyset</strong> — a select over the keysets found in the file (the first one is preselected; a non-zero MSL needs one, marked amber), so each TAR's packets use exactly the keyset you choose. <strong>Start counter value</strong> sets the counter every created keyset starts from (default <code class="font-mono text-sm">1</code> &mdash; the store's value; raise it for cards already past it). A <strong>common name</strong> is applied to every imported card (empty falls back to the ICCID); <strong>Import mode</strong> chooses <strong>Merge</strong> (default — existing presets are kept, duplicate ICCIDs are skipped and reported) or <strong>Replace</strong> (the store is wiped first). The preview lists the parsed cards (ICCID, keysets, the codes found) and every skipped row/key with its reason before anything is stored.</p>
</section> </section>
<section class="mb-10"> <section class="mb-10">
+14 -4
View File
@@ -1943,7 +1943,7 @@
// ===== Version ===== // ===== Version =====
// Single source of truth for the PWA version: shown in the header and used // Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect(). // by the server version check in pysimConnect().
const SIMPLE_VERSION = '3.32.0'; const SIMPLE_VERSION = '3.32.2';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== view history (the browser Back/Forward buttons) ===== // ===== view history (the browser Back/Forward buttons) =====
@@ -15154,7 +15154,7 @@ async function cardsOutfileFile(input) {
} }
_cardsOutfile = { text: text, file: String(file.name || ''), _cardsOutfile = { text: text, file: String(file.name || ''),
name: String(file.name || '').replace(/\.[^.]+$/, ''), name: String(file.name || '').replace(/\.[^.]+$/, ''),
algorithm16: '3des2', mode: 'merge', preview: null }; algorithm16: '3des2', cntr: '1', mode: 'merge', preview: null };
_cardsOutfileTars = 0; _cardsOutfileTars = 0;
cardsOutfileRender(); cardsOutfileRender();
await cardsOutfilePreview(); await cardsOutfilePreview();
@@ -15177,6 +15177,7 @@ function cardsOutfileControls() {
const val = id => { const el = document.getElementById(id); return el ? String(el.value) : ''; }; const val = id => { const el = document.getElementById(id); return el ? String(el.value) : ''; };
if (document.getElementById('cards-of-name')) o.name = val('cards-of-name').trim(); if (document.getElementById('cards-of-name')) o.name = val('cards-of-name').trim();
if (document.getElementById('cards-of-algo')) o.algorithm16 = val('cards-of-algo') || '3des2'; if (document.getElementById('cards-of-algo')) o.algorithm16 = val('cards-of-algo') || '3des2';
if (document.getElementById('cards-of-cntr')) o.cntr = val('cards-of-cntr').trim().toUpperCase();
if (document.getElementById('cards-of-mode')) { if (document.getElementById('cards-of-mode')) {
o.mode = val('cards-of-mode') === 'replace' ? 'replace' : 'merge'; o.mode = val('cards-of-mode') === 'replace' ? 'replace' : 'merge';
} }
@@ -15371,6 +15372,13 @@ function cardsOutfileRender() {
'<select id="cards-of-mode" class="block mt-0.5 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">' + '<select id="cards-of-mode" class="block mt-0.5 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="merge"' + (o.mode === 'replace' ? '' : ' selected') + '>' + esc(t('Merge')) + '</option>' + '<option value="merge"' + (o.mode === 'replace' ? '' : ' selected') + '>' + esc(t('Merge')) + '</option>' +
'<option value="replace"' + (o.mode === 'replace' ? ' selected' : '') + '>' + esc(t('Replace')) + '</option></select></label>'; '<option value="replace"' + (o.mode === 'replace' ? ' selected' : '') + '>' + esc(t('Replace')) + '</option></select></label>';
// The counter every created keyset starts from (v3.32.1): the store's
// default, editable for cards whose counter is already past it.
html += '<label class="text-xs text-gray-600 dark:text-slate-400" title="' +
esc(t('The counter every created keyset starts from (1-10 hex digits); the store default is 1')) + '">' +
esc(t('Start counter value')) +
'<input id="cards-of-cntr" class="block mt-0.5 w-24 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800 font-mono" maxlength="10" value="' +
escHtml(o.cntr || '1') + '" onchange="cardsOutfilePreview()"></label>';
html += '</div>'; html += '</div>';
html += '<div class="text-xs text-gray-600 dark:text-slate-400 mb-1">' + esc(t('TAR table')) + '</div>'; html += '<div class="text-xs text-gray-600 dark:text-slate-400 mb-1">' + esc(t('TAR table')) + '</div>';
html += '<div id="cards-of-tars">' + cardsOutfileTarRowsHtml() + '</div>'; html += '<div id="cards-of-tars">' + cardsOutfileTarRowsHtml() + '</div>';
@@ -15393,7 +15401,7 @@ async function cardsOutfilePreview() {
cardsOutfileControls(); cardsOutfileControls();
const body = { const body = {
text: o.text, name: o.name, algorithm16: o.algorithm16, text: o.text, name: o.name, algorithm16: o.algorithm16,
tars: cardsOutfileTarsFromForm(), tars: cardsOutfileTarsFromForm(), start_counter: o.cntr,
}; };
try { try {
o.preview = await pysimFetchOk('/api/presets/import-outfile', body); o.preview = await pysimFetchOk('/api/presets/import-outfile', body);
@@ -15418,7 +15426,7 @@ async function cardsOutfileImport() {
if (btn) btn.disabled = true; if (btn) btn.disabled = true;
const body = { const body = {
text: o.text, name: o.name, algorithm16: o.algorithm16, text: o.text, name: o.name, algorithm16: o.algorithm16,
tars: cardsOutfileTarsFromForm(), mode: o.mode, tars: cardsOutfileTarsFromForm(), start_counter: o.cntr, mode: o.mode,
}; };
try { try {
const res = await pysimFetchOk('/api/presets/import-outfile', body); const res = await pysimFetchOk('/api/presets/import-outfile', body);
@@ -24012,6 +24020,8 @@ const LANG_RU = {
'Common name': 'Общее имя', 'Common name': 'Общее имя',
'16-byte keys': '16-байтные ключи', '16-byte keys': '16-байтные ключи',
'This TAR needs a keyset (its MSL requires security)': 'Этому TAR нужен keyset (его MSL требует защиты)', 'This TAR needs a keyset (its MSL requires security)': 'Этому TAR нужен keyset (его MSL требует защиты)',
'Start counter value': 'Начальное значение счётчика',
'The counter every created keyset starts from (1-10 hex digits); the store default is 1': 'Счётчик, с которого начинается каждый созданный keyset (1–10 hex-цифр); по умолчанию в хранилище — 1',
'The file is invalid - fix it and refresh the preview': 'Файл некорректен — исправьте его и обновите предпросмотр', 'The file is invalid - fix it and refresh the preview': 'Файл некорректен — исправьте его и обновите предпросмотр',
'Import mode': 'Режим импорта', 'Import mode': 'Режим импорта',
'Merge': 'Дополнить', 'Merge': 'Дополнить',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v458'; const CACHE = 'simple-v460';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+8 -2
View File
@@ -113,8 +113,8 @@ test('the preview and the import both post the outfile endpoint', () => {
// the keysets travel per TAR row (v3.29.1): no global tar_keyset // the keysets travel per TAR row (v3.29.1): no global tar_keyset
assert.ok(!preview.includes('tar_keyset'), 'the preview must not send a global keyset'); assert.ok(!preview.includes('tar_keyset'), 'the preview must not send a global keyset');
assert.ok(!imp.includes('tar_keyset'), 'the import must not send a global keyset'); assert.ok(!imp.includes('tar_keyset'), 'the import must not send a global keyset');
assert.match(preview, /tars: cardsOutfileTarsFromForm\(\)/); assert.match(preview, /tars: cardsOutfileTarsFromForm\(\), start_counter: o\.cntr/);
assert.match(imp, /tars: cardsOutfileTarsFromForm\(\), mode: o\.mode/); assert.match(imp, /tars: cardsOutfileTarsFromForm\(\), start_counter: o\.cntr, mode: o\.mode/);
}); });
test('the 16-byte algorithm values match the server map', () => { test('the 16-byte algorithm values match the server map', () => {
@@ -225,10 +225,12 @@ test('cardsOutfileControls reads the panel controls back into the state', () =>
setEl('cards-of-name', ' Batch 7 '); setEl('cards-of-name', ' Batch 7 ');
setEl('cards-of-algo', 'aes'); setEl('cards-of-algo', 'aes');
setEl('cards-of-mode', 'replace'); setEl('cards-of-mode', 'replace');
setEl('cards-of-cntr', ' 1a ');
app.cardsOutfileControls(); app.cardsOutfileControls();
assert.strictEqual(app.state.name, 'Batch 7'); assert.strictEqual(app.state.name, 'Batch 7');
assert.strictEqual(app.state.algorithm16, 'aes'); assert.strictEqual(app.state.algorithm16, 'aes');
assert.strictEqual(app.state.mode, 'replace'); assert.strictEqual(app.state.mode, 'replace');
assert.strictEqual(app.state.cntr, '1A'); // the store's uppercase hex form
}); });
test('picking a file previews it with the file name and the TAR defaults', async () => { test('picking a file previews it with the file name and the TAR defaults', async () => {
@@ -254,6 +256,10 @@ test('picking a file previews it with the file name and the TAR defaults', async
assert.match(els['cards-outfile-panel'].innerHTML, /id="cards-of-tar-0-keyset"/); assert.match(els['cards-outfile-panel'].innerHTML, /id="cards-of-tar-0-keyset"/);
assert.match(els['cards-outfile-panel'].innerHTML, /<option value="1" selected>Keyset 1 — 15\/15<\/option>/); assert.match(els['cards-outfile-panel'].innerHTML, /<option value="1" selected>Keyset 1 — 15\/15<\/option>/);
assert.match(els['cards-outfile-panel'].innerHTML, /8970119000004600098/); assert.match(els['cards-outfile-panel'].innerHTML, /8970119000004600098/);
// the start counter field defaults to the store's value and travels in the body
assert.match(els['cards-outfile-panel'].innerHTML, /id="cards-of-cntr"/);
assert.match(els['cards-outfile-panel'].innerHTML, /value="1"/);
assert.strictEqual(body.start_counter, '1');
}); });
test('a failing preview shows the server message in the panel', async () => { test('a failing preview shows the server message in the panel', async () => {
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "pysim-simple-server" name = "pysim-simple-server"
version = "3.32.0" version = "3.32.2"
description = "HTTP REST server wrapping pysim for the SIMple PWA" description = "HTTP REST server wrapping pysim for the SIMple PWA"
license = { text = "GPL-2.0-or-later" } license = { text = "GPL-2.0-or-later" }
requires-python = ">=3.9" requires-python = ">=3.9"
+19 -6
View File
@@ -184,13 +184,23 @@ def _hex_value(tok):
return v if v and len(v) % 2 == 0 else '' return v if v and len(v) % 2 == 0 else ''
def parse_outfile(text, name='', algorithm16='3des2', tars=None, tar_keyset=None): def parse_outfile(text, name='', algorithm16='3des2', tars=None, tar_keyset=None,
start_counter=None):
"""Parse an outfile into preset dicts (the store's shape). """Parse an outfile into preset dicts (the store's shape).
Returns ``{'presets': [...], 'report': {...}}``. Every skipped row or key ``start_counter`` (1-10 hex digits) is written into every created keyset -
is named in the report (up to ``SKIP_REPORT_MAX`` lines, then counted) - the counter an operation against that keyset starts from; without it the
nothing is silently dropped. Raises ValueError on a malformed TAR table. store's default ('1') applies. Returns
``{'presets': [...], 'report': {...}}``. Every skipped row or key is named
in the report (up to ``SKIP_REPORT_MAX`` lines, then counted) - nothing is
silently dropped. Raises ValueError on a malformed TAR table or counter.
""" """
counter = None
if start_counter not in (None, ''):
counter = re.sub(r'\s', '', str(start_counter)).upper()
if not re.fullmatch(r'[0-9A-F]{1,10}', counter):
raise ValueError('start_counter must be 1-10 hex digits: %r'
% (start_counter,))
columns, line_index = find_columns(text) columns, line_index = find_columns(text)
report = {'columns': columns or [], 'header_lines': max(line_index, 0), report = {'columns': columns or [], 'header_lines': max(line_index, 0),
'rows': 0, 'cards': 0, 'keysets': [], 'tars': 0, 'rows': 0, 'cards': 0, 'keysets': [], 'tars': 0,
@@ -256,8 +266,11 @@ def parse_outfile(text, name='', algorithm16='3des2', tars=None, tar_keyset=None
% (row_no, n, len(kic_key) // 2)) % (row_no, n, len(kic_key) // 2))
continue continue
byte = '%02X' % ((n << 4) | algo) byte = '%02X' % ((n << 4) | algo)
keysets.append({'kic': byte, 'kid': byte, keyset = {'kic': byte, 'kid': byte,
'kicKey': kic_key, 'kidKey': kid_key}) 'kicKey': kic_key, 'kidKey': kid_key}
if counter is not None:
keyset['cntr'] = counter
keysets.append(keyset)
if not keysets: if not keysets:
skip('line %d: no usable keyset' % row_no) skip('line %d: no usable keyset' % row_no)
continue continue
+31 -10
View File
@@ -37,7 +37,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad from osmocom.utils import rpad
VERSION = '3.32.0' VERSION = '3.32.2'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -5701,8 +5701,8 @@ def _auto_equip_attempt(server):
if not _ensure_transport(server): if not _ensure_transport(server):
return False return False
server.equipping = True server.equipping = True
out = StringIO()
try: try:
out = StringIO()
old_stdout, old_stderr = app.stdout, sys.stderr old_stdout, old_stderr = app.stdout, sys.stderr
old_debug = getattr(app, 'debug', None) old_debug = getattr(app, 'debug', None)
app.stdout = out app.stdout = out
@@ -5716,9 +5716,17 @@ def _auto_equip_attempt(server):
app.debug = old_debug app.debug = old_debug
app.stdout = old_stdout app.stdout = old_stdout
sys.stderr = old_stderr sys.stderr = old_stderr
if not getattr(server, 'card_present', False) or server.app.card is None: if not getattr(server, 'card_present', False):
# A real removal: _handle_card_disconnect already cleared the
# session, nothing to retry.
sys.stderr.write('AUTO-EQUIP: card gone during initialization\n') sys.stderr.write('AUTO-EQUIP: card gone during initialization\n')
return True return True
if server.app.card is None:
# The presence monitor still reports the card, so this is a
# failed initialization, not a swap - never a silent no-op
# (a Windows card swap showed an endless "card gone"
# re-arm loop because this returned True: 2026-10-21).
raise RuntimeError('the card is present but the init produced no card')
if 'Traceback (most recent call last)' in out.getvalue(): if 'Traceback (most recent call last)' in out.getvalue():
raise RuntimeError('equip reported an error (see the captured output)') raise RuntimeError('equip reported an error (see the captured output)')
if not _app_equip_complete(app): if not _app_equip_complete(app):
@@ -5729,12 +5737,24 @@ def _auto_equip_attempt(server):
return True return True
except Exception as e: except Exception as e:
sys.stderr.write('AUTO-EQUIP failed: %s\n' % e) sys.stderr.write('AUTO-EQUIP failed: %s\n' % e)
# A dead transport must be rebuilt before the next attempt; # Show what pySim swallowed (cmd2 printed it into the capture):
# card-level failures reconnect on the existing link. Unequip the # without it the failure is undiagnosable in the run log.
# half-initialized shell as well: a failed init can leave command captured = out.getvalue().strip()
# sets registered that the next equip would refuse to re-register. if 'Traceback (most recent call last)' in captured:
if _is_transport_fatal(e): lines = captured.splitlines()
_handle_card_disconnect(stale=True) start = next(i for i, l in enumerate(lines)
if 'Traceback (most recent call last)' in l)
sys.stderr.write('AUTO-EQUIP: init output:\n%s\n'
% '\n'.join(lines[start:start + 12]))
# ANY failed attempt leaves the PC/SC card handle suspect: after a
# card swap on Windows the link can stay unable to connect, so
# every retry failed and the watchdog re-armed forever (live
# 2026-10-21). Mark the transport stale so the retry rebuilds it -
# _ensure_transport releases the old link first, so the new one
# never inherits a stale handle (the 2026-09-24 bug).
_handle_card_disconnect(stale=True)
# Unequip the half-initialized shell too: a failed init can leave
# command sets registered that the next equip would refuse.
_clear_app_card_state(app) _clear_app_card_state(app)
return False return False
finally: finally:
@@ -11610,7 +11630,8 @@ class PysimHandler(BaseHTTPRequestHandler):
name=str(body.get('name') or ''), name=str(body.get('name') or ''),
algorithm16=algorithm16, algorithm16=algorithm16,
tars=tars, tars=tars,
tar_keyset=tar_keyset) tar_keyset=tar_keyset,
start_counter=body.get('start_counter'))
except ValueError as e: except ValueError as e:
resp = {'error': str(e)} resp = {'error': str(e)}
self._send_json(resp, 400) self._send_json(resp, 400)
+89 -3
View File
@@ -269,9 +269,50 @@ class AutoEquipTests(unittest.TestCase):
self.assertTrue(server._auto_equip_attempts(srv, 3, lambda s: None)) self.assertTrue(server._auto_equip_attempts(srv, 3, lambda s: None))
self.assertEqual(state['calls'], 2) self.assertEqual(state['calls'], 2)
self.assertEqual(applied, [srv]) self.assertEqual(applied, [srv])
# the failed attempt left the handle suspect; the retry's
# _ensure_transport (patched here) would rebuild and clear the flag
self.assertTrue(server._TRANSPORT_STALE)
def test_a_failed_init_rebuilds_the_transport_before_the_retry(self):
# The Windows swap end to end: the first init fails with the card
# present, the retry starts from a fresh PC/SC link and succeeds -
# and the old link is released before the new one is built.
srv = self.make_server(None)
released = []
built = []
class Link:
def disconnect(self):
released.append(self)
old = Link()
srv.sl = old
srv.transport_factory = lambda: built.append(Link()) or built[-1]
calls = {'n': 0}
def onecmd(cmd):
calls['n'] += 1
if calls['n'] == 1:
raise RuntimeError('ProtocolError: Connection failed')
# a completed equip installs both (the failure unequipped the shell)
srv.app.card = SimpleNamespace(_scc='scc')
srv.app.lchan = 'lchan'
srv.app.onecmd_plus_hooks = onecmd
with mock.patch.object(server, '_apply_equipped_card', lambda s: None):
self.assertTrue(server._auto_equip_attempts(srv, 3, lambda s: None))
self.assertEqual(calls['n'], 2)
self.assertEqual(len(built), 1)
self.assertEqual(released, [old])
self.assertIs(srv.sl, built[0])
self.assertFalse(server._TRANSPORT_STALE) self.assertFalse(server._TRANSPORT_STALE)
def test_card_level_failure_keeps_the_transport(self): def test_a_failed_attempt_marks_the_transport_for_the_rebuild(self):
# A card-level error used to keep the link: after a Windows card swap
# the link stayed unable to connect, every retry failed and the
# watchdog re-armed forever (live 2026-10-21). Any failed attempt now
# marks the transport stale so the retry rebuilds it (which releases
# the old link first - the 2026-09-24 bug).
srv = self.make_server(None) srv = self.make_server(None)
def onecmd(cmd): def onecmd(cmd):
@@ -282,7 +323,7 @@ class AutoEquipTests(unittest.TestCase):
srv.app.onecmd_plus_hooks = onecmd srv.app.onecmd_plus_hooks = onecmd
with mock.patch.object(server, '_ensure_transport', lambda s: True): with mock.patch.object(server, '_ensure_transport', lambda s: True):
self.assertFalse(server._auto_equip_attempt(srv)) self.assertFalse(server._auto_equip_attempt(srv))
self.assertFalse(server._TRANSPORT_STALE) self.assertTrue(server._TRANSPORT_STALE)
def test_transport_fatal_failure_marks_the_transport(self): def test_transport_fatal_failure_marks_the_transport(self):
srv = self.make_server(None) srv = self.make_server(None)
@@ -341,7 +382,7 @@ class AutoEquipTests(unittest.TestCase):
mock.patch.object(server, '_apply_equipped_card', mock.patch.object(server, '_apply_equipped_card',
lambda s: self.fail('_apply_equipped_card must not run')): lambda s: self.fail('_apply_equipped_card must not run')):
self.assertFalse(server._auto_equip_attempt(srv)) self.assertFalse(server._auto_equip_attempt(srv))
self.assertFalse(server._TRANSPORT_STALE) self.assertTrue(server._TRANSPORT_STALE)
def test_captured_equip_traceback_is_a_failure(self): def test_captured_equip_traceback_is_a_failure(self):
# cmd2 prints swallowed exceptions (with cmd2 debug: a traceback) to # cmd2 prints swallowed exceptions (with cmd2 debug: a traceback) to
@@ -358,6 +399,51 @@ class AutoEquipTests(unittest.TestCase):
lambda s: self.fail('_apply_equipped_card must not run')): lambda s: self.fail('_apply_equipped_card must not run')):
self.assertFalse(server._auto_equip_attempt(srv)) self.assertFalse(server._auto_equip_attempt(srv))
def test_a_card_present_init_without_a_card_is_a_failure(self):
# The Windows loop: the presence monitor still reports the card, the
# init produced nothing - it must retry (and rebuild), never report a
# silent "card gone" success that re-arms forever (2026-10-21).
srv = self.make_server(None)
def onecmd(cmd):
pass # cmd2 swallowed the real error: no card built
srv.app.onecmd_plus_hooks = onecmd
with mock.patch.object(server, '_ensure_transport', lambda s: True):
self.assertFalse(server._auto_equip_attempt(srv))
self.assertTrue(server._TRANSPORT_STALE)
def test_a_card_removed_during_the_init_is_still_a_silent_no_op(self):
srv = self.make_server(None)
def onecmd(cmd):
srv.card_present = False # the monitor saw the removal mid-init
srv.app.onecmd_plus_hooks = onecmd
with mock.patch.object(server, '_ensure_transport', lambda s: True), \
mock.patch('sys.stderr', new=StringIO()) as err:
self.assertTrue(server._auto_equip_attempt(srv))
self.assertIn('card gone during initialization', err.getvalue())
self.assertFalse(server._TRANSPORT_STALE)
def test_the_swallowed_traceback_reaches_the_log(self):
srv = self.make_server(None)
def onecmd(cmd):
srv.app.card = SimpleNamespace(_scc='scc')
sys.stderr.write('Traceback (most recent call last):\n'
' File "x", line 1, in equip\n'
'ProtocolError: Connection failed\n')
srv.app.onecmd_plus_hooks = onecmd
with mock.patch.object(server, '_ensure_transport', lambda s: True), \
mock.patch.object(server, '_apply_equipped_card', lambda s: None), \
mock.patch('sys.stderr', new=StringIO()) as err:
self.assertFalse(server._auto_equip_attempt(srv))
out = err.getvalue()
self.assertIn('AUTO-EQUIP: init output:', out)
self.assertIn('ProtocolError: Connection failed', out)
def test_failed_attempt_unequips_the_half_initialized_shell(self): def test_failed_attempt_unequips_the_half_initialized_shell(self):
calls = [] calls = []
srv = self.make_server(None) srv = self.make_server(None)
+20
View File
@@ -979,6 +979,26 @@ class PresetStoreHttpTests(unittest.TestCase):
self.assertEqual(status, 400, resp) self.assertEqual(status, 400, resp)
self.assertIn('the file is invalid', resp['error']) self.assertIn('the file is invalid', resp['error'])
self.assertEqual(len(self.store.list()), before) self.assertEqual(len(self.store.list()), before)
# the import form's start counter (v3.32.1): every created keyset
# carries it; an unusable value is a 400
status, resp = self._post('/api/presets/import-outfile', {
'text': text, 'start_counter': '1A'})
self.assertEqual(status, 200, resp)
self.assertEqual([k.get('cntr') for k in resp['presets'][0]['keysets']],
['1A'] * len(resp['presets'][0]['keysets']))
status, resp = self._post('/api/presets/import-outfile', {
'text': text, 'start_counter': 'XYZ'})
self.assertEqual(status, 400, resp)
self.assertIn('start_counter', resp['error'])
# ...and an import stores the protocol's padded form (a fresh ICCID:
# merge mode skips a duplicate)
fresh = text.replace('8970119000004600098', '8970119000004600999')
status, resp = self._post('/api/presets/import-outfile', {
'text': fresh, 'start_counter': '1A', 'mode': 'merge'})
self.assertEqual(status, 200, resp)
got = [p for p in self.store.list()
if p['iccid'] == '8970119000004600999']
self.assertEqual([k['cntr'] for k in got[0]['keysets']], ['000000001A'])
# a non-list TAR table is the caller's error, never a silent fallback # a non-list TAR table is the caller's error, never a silent fallback
status, resp = self._post('/api/presets/import-outfile', { status, resp = self._post('/api/presets/import-outfile', {
'text': text, 'tars': 'nope'}) 'text': text, 'tars': 'nope'})
+20
View File
@@ -219,6 +219,26 @@ class OptionTests(unittest.TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
outfile.parse_outfile(text, tars=[{'tar': '000000', 'msl': '16', 'keyset': 'x'}]) outfile.parse_outfile(text, tars=[{'tar': '000000', 'msl': '16', 'keyset': 'x'}])
def test_the_start_counter_is_written_into_every_keyset(self):
"""The import form's "Start counter value": every created keyset
carries it (the store pads it to the protocol's 10-digit form); without
it the store's default ('1') applies. An unusable value is the
caller's error, never a silent default (v3.32.1)."""
res = outfile.parse_outfile(alfa_text(), start_counter='1a')
ks = res['presets'][0]['keysets']
self.assertGreaterEqual(len(ks), 2)
self.assertEqual([k.get('cntr') for k in ks], ['1A'] * len(ks))
# normalised: whitespace stripped, uppercase
res = outfile.parse_outfile(alfa_text(), start_counter=' a1 ')
self.assertEqual(res['presets'][0]['keysets'][0]['cntr'], 'A1')
# unset: no field at all - the store fills its default
res = outfile.parse_outfile(alfa_text())
self.assertNotIn('cntr', res['presets'][0]['keysets'][0])
# an unusable value refuses the whole parse
for bad in ('XYZ', '1G', '1234567890A', ' '):
with self.assertRaises(ValueError):
outfile.parse_outfile(alfa_text(), start_counter=bad)
def test_a_malformed_tar_table_raises(self): def test_a_malformed_tar_table_raises(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
outfile.parse_outfile(alfa_text(), tars=[{'tar': 'ZZ', 'msl': '16'}]) outfile.parse_outfile(alfa_text(), tars=[{'tar': 'ZZ', 'msl': '16'}])