From ee747003f25c13ae523033bf0166576f92fa82e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Wed, 30 Sep 2026 09:22:22 +0300 Subject: [PATCH] feat: CAP compatibility test in the popup + per-AID failure analysis (v3.6.52) The compatibility test now runs in the same operation modal as the installs (live progress from the server's ram_progress, Close disabled until done) and its result gains a per-AID detail block instead of the comma list: - the probe summary (probe: 2 imports -> 0.0 . +1 appended); - the import list as tested (ramCompatImportRows): the CAP's imports with the version the card was asked for and the CAP's own version when the probe lowered it, appended synthetic entries and unmatched lines - each with the resolved library name (jcAidName), so a raw AID list becomes readable rows; - on a rejection, the failure analysis (ramCompatFailureHtml): which CAP section the failing block covers (load-file byte range and component names, 'completes Import'), what the card checks at that point (JC VM spec 4.5.2) or that the import list already passed, the remote status word with its name, and what the probe state implies (every import at 0.0 -> a missing package or another load-file check is the cause; otherwise lower the versions and bisect). ramCompatVerdict names the remote SW; the inline result keeps the verdict + short probe note and the modal carries the escaped step lines + the detail HTML (ramOpModalFinish gained an asHtml mode); the server reports probe_unmatched as [{'aid', 'version'}] (_probe_unmatched), documented in docs/api.md. The same failure block for the real install is the next step. sw cache simple-v325; tests: 550 Python / 703 Node. --- docs/api.md | 16 +++ frontend/help-ru.html | 2 +- frontend/help.html | 2 +- frontend/index.html | 225 ++++++++++++++++++++++++++++++---- frontend/sw.js | 2 +- frontend/tests/ram.test.js | 157 ++++++++++++++++++++++-- pyproject.toml | 2 +- pysim_simple_server/server.py | 26 +++- tests/test_cap_memory.py | 19 ++- tests/test_fetch_routes.py | 25 ++++ 10 files changed, 434 insertions(+), 42 deletions(-) diff --git a/docs/api.md b/docs/api.md index 8199d44..b9ef666 100644 --- a/docs/api.md +++ b/docs/api.md @@ -40,6 +40,7 @@ a 3.x PWA). | `/api/ram-install` | POST | Install a Java Card `.cap` file via SCP80 (INSTALL[for load] → LOAD ×N → INSTALL[for install]) | | `/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) | | `/api/test/run` | POST | Start a test script (actions + proactive expectations) | | `/api/test/status` | GET | Test script run state and per-step results | | `/api/test/stop` | POST | Request a running test script to stop | @@ -622,6 +623,21 @@ Run the single **INSTALL [for install]** (or **INSTALL [for make selectable]**) **Response:** `{"success": bool, "steps": [...], "final_cntr": "...", "ram_format": "...", "error": "...", "failed_step": N}` — the same step records as `/api/ram-install`. +### `POST /api/cap-compat` + +Run the **CAP compatibility test**: the format check, `INSTALL [for load]` and the LOAD blocks only up to the block that completes the **Import** component (the point where the JCRE verifies the import list, JC VM spec 4.5.2), then stop — no last-block flag and no `INSTALL [for install]`, so nothing is committed and nothing has to be deleted afterwards. The verdict (`imports_ok`) covers the **LOAD/import gate only**; the link gate is what a real install checks at `INSTALL [for install]`. + +**Request body:** as `/api/ram-install` (`cap_hex`, the SCP80 fields, optional `load_block_size` and `ram_format`), plus: + +| Field | Req | Description | +|---|---|---| +| `probe_imports` | no | `{"all": "x.y"}` or `{aid_hex: "x.y"}`: rewrite the Import component's requested versions (diagnostic) | +| `probe_additions` | no | `[{"aid": hex, "version": "x.y"}]`: append synthetic imports (card-capability queries; an AID already present becomes a version override) | + +**Response:** `{"success": bool, "imports_ok": bool, "steps": [...], "boundary_block": N, "total_blocks": M, "load_file_aid": "...", "module_aid": "...", "ram_format": "...", "probe_imports": [...], "probe_unmatched": [...], "final_cntr": "...", "load_block_size": N, "error": "...", "failed_step": N}`. + +`probe_imports` lists the entries changed or appended (`{"aid": "...", "from": "1.3", "to": "0.0"}`; `from: null` = an appended synthetic entry). `probe_unmatched` lists override AIDs that matched no Import entry — the CAP does not import them, so the card is never asked about them — as `[{"aid": "...", "version": "0.0"}]` with the version the probe requested. The PWA renders both as a per-AID list in the result popup, together with the failing block's CAP section and the remote status word's meaning. + ### `POST /api/sp-verify` Cross-check a secured packet against pySim's `OtaDialectSms.encode_cmd` diff --git a/frontend/help-ru.html b/frontend/help-ru.html index 7ee1373..8d8ac0d 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -322,7 +322,7 @@ Обзор карты (все данные GP)Запрос GET STATUS для ISD, приложений, ELF и модулей ELF, а также GET DATA FF21 (расширенные ресурсы карты: число установленных приложений и свободная энергонезависимая/энергозависимая память). Компактный листинг содержит только AID, состояние жизненного цикла и привилегии (версии в устаревшем формате нет); если карта отвергает его (P2.b2=0, устаревший по GP), запросы повторяются в расширенном формате E3, а флажок Запрашивать расширенные данные реестра (связи load-файла и SD) дополнительно запрашивает их и объединяет расширенные поля: версию исполняемого load-файла, AID load-файла, AID Security Domain, списки модулей, пункты меню STK апплета (SCP Registry Data, TS 102 226 8.2.1.6) и любые другие объекты реестра, которые вернула карта (показываются как есть, без потерь) — он расходует дополнительные защищённые пакеты, и не все карты поддерживают расширенный запрос, поэтому по умолчанию выключен и запоминается в браузере. Результаты отображаются в обзоре (строка успеха минимальна, счётчики — в заголовках разделов) с кнопками Удалить для каждого элемента и определённым форматом команд RAM (подсказка при наведении объясняет compact и expanded). Установка пакета (.cap файл)Отправка .cap файла на карту через сервер: INSTALL[for load] → LOAD ×N → INSTALL[for install (+make selectable)]. Load-файл делится на LOAD APDU размера размер блока LOAD (1–240 байт полезной нагрузки, по умолчанию 240); каждый защищённый пакет доставляется одним SMS или, если он больше, конкатенированной SMS-PP загрузкой до 5 сегментов, так что большой .cap просто занимает несколько SMS. Сразу после выбора файл проверяется и в форме показывается оценка требований к памяти: требование NVRAM (образ кода + постоянные данные, сумма в стиле C6+C8 из GP Card Spec) и оценка RAM. Там же показаны библиотеки, с которыми слинкован CAP (его компонент Import: имя библиотеки, AID и минимальная версия ≥ x.y — имена стандартных библиотек и семейства подставляются, а где известно, даётся подсказка о версии Java Card), идентификаторы пакета и апплетов и разбивка по компонентам (размеры и доли в порядке load-файла) — всё сгруппировано по разделам Package, Requires, Memory и Components. Строка Platform показывает минимальный уровень Java Card, который должна предоставить карта (выводится из импорта javacard.framework; флаг int в заголовке дополнительно требует поддержки int — Java Card 2.2+), и релиз ETSI, из которого пришли импорты UICC API (версия uicc.toolkit → REL-N по корпусу sim-tools в рабочем каталоге; основание показано в скобках): JCRE проверяет каждый импорт по своим пакетам при верификации load-файла (JC VM spec 4.5.2), поэтому карта со старой платформой отклоняет LOAD сразу после завершения компонента Import (в строке шага виден remote SW карты и компонент, который завершил блок, например завершает Import) — меньший размер блока LOAD помогает только если блок превышает буфер карты. Повреждённый файл не проходит анализ, и Execute остаётся недоступной до успешного анализа. Оценка носит справочный характер и никогда не используется при установке: NVRAM — это образ кода плюс постоянные данные (сумма в стиле C6+C8 по GP Table 11-48; управление памятью карты, округление и запись в реестре не учитываются, а образ статических полей входит в обе части); рекомендуемый C6 следует за load-файлом, а рекомендуемые квоты — это десятичные значения в байтах (шестнадцатеричное значение в скобках — это кодирование параметров установки C7/C8: C6 — энергонезависимый код, C7 — энергозависимая RAM, C8 — энергонезависимые данные). - Проверка совместимости CAPОтвечает на один вопрос, ничего не устанавливая: может ли эта карта удовлетворить список импортов CAP? Сервер выполняет проверку формата, INSTALL[for load] и блоки LOAD только до блока, завершающего компонент Import — именно там JCRE проверяет список импортов — и останавливается: без флага последнего блока и без INSTALL[for install], поэтому ничего не фиксируется и удалять после этого ничего не нужно. Список «Версии импортов для проверки» предзаполняется из анализа CAP: AID=version переписывает запрошенную версию импорта, +AID=version добавляет синтетический импорт (запрос возможностей карты: LOAD проходит, только если карта экспортирует этот пакет версии ≥ указанной), а «Все 0.0» снимает все требования. Использование в два шага: нажмите «Все 0.0» — если проверка проходит, причиной был список импортов; затем возвращайте по одной строке, чтобы найти пакет и версию, которых не хватает карте. Вердикт явно указывает, что проверяется только этап LOAD/импортов: этап связывания (наличие реально вызываемых API) проверяет настоящая установка на INSTALL[for install]. + Проверка совместимости CAPОтвечает на один вопрос, ничего не устанавливая: может ли эта карта удовлетворить список импортов CAP? Сервер выполняет проверку формата, INSTALL[for load] и блоки LOAD только до блока, завершающего компонент Import — именно там JCRE проверяет список импортов — и останавливается: без флага последнего блока и без INSTALL[for install], поэтому ничего не фиксируется и удалять после этого ничего не нужно. Список «Версии импортов для проверки» предзаполняется из анализа CAP: AID=version переписывает запрошенную версию импорта, +AID=version добавляет синтетический импорт (запрос возможностей карты: LOAD проходит, только если карта экспортирует этот пакет версии ≥ указанной), а «Все 0.0» снимает все требования. Использование в два шага: нажмите «Все 0.0» — если проверка проходит, причиной был список импортов; затем возвращайте по одной строке, чтобы найти пакет и версию, которых не хватает карте. Вердикт явно указывает, что проверяется только этап LOAD/импортов: этап связывания (наличие реально вызываемых API) проверяет настоящая установка на INSTALL[for install]. Запуск и результат показываются в окне (как при установке): строки шагов, вердикт и блок деталей по каждому AID — при отказе он называет раздел CAP, который покрывает неудачный блок (диапазон байтов load-файла и компоненты, и завершает ли этот блок Import), SW команды с его значением, список импортов ровно в том виде, в каком он проверялся (версии CAP, правки пробы, добавленные синтетические записи, строки без совпадений) и что означает состояние пробы; при успехе — список импортов, который прошёл проверку. diff --git a/frontend/help.html b/frontend/help.html index 707a599..bbe0b90 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -321,7 +321,7 @@ Explore Card (all GP data)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 (P2.b2=0, deprecated by GP) the queries are retried in the expanded E3 form, and the Fetch expanded registry details (load-file / SD associations) 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) — it costs extra secured packets and some cards reject the expanded query, so it is off by default and remembered per browser. Results appear in an explorer view (the success line stays minimal; the counts are in the section headings) with per-item Delete buttons and the detected RAM command format (hover for what compact vs expanded means). Install Package (.cap file)Sends a .cap file to the card via the server: INSTALL[for load] → LOAD ×N → INSTALL[for install (+make selectable)]. The load file is split into LOAD APDUs of the LOAD block size (1–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 .cap simply takes several SMS. As soon as the file is selected it is validated and its memory requirements are estimated in the form: the NVRAM requirement (the code image + persistent data, the GlobalPlatform Card Spec C6+C8 style total) and the RAM estimate. The same box reports the libraries the CAP is linked against (its Import component: library name, AID and the minimum version ≥ x.y, 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) — grouped under the Package, Requires, Memory and Components headings. The Platform line names the minimum Java Card level the card must provide (derived from the javacard.framework import; the header int flag additionally requires int support, Java Card 2.2+) and the ETSI release the UICC API imports come from (uicc.toolkit version → REL-N, per the workspace sim-tools 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 Import component is complete (the step line carries the card's remote SW and names the component the block completed, e.g. completes Import) — a smaller LOAD block only helps when the block exceeds the card's buffer. A corrupt or wrong-format file fails the analysis, and Execute 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 — card memory management, rounding and the registry entry are not included, and the static field image is counted in both parts); the suggested C6 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). - CAP compatibility testAnswers one question without installing anything: can this card satisfy the CAP's import list? The server runs the format check, INSTALL[for load] and the LOAD blocks only up to the block that completes the Import component — the point where the JCRE verifies the import list — then stops: no last-block flag and no INSTALL[for install], so nothing is committed and nothing has to be deleted afterwards. The Import versions to test list is pre-filled from the CAP analysis: AID=version rewrites an import's requested version, +AID=version appends a synthetic import (a card-capability query: the LOAD passes only if the card exports that package at ≥ that version), and Set all 0.0 drops every requirement. Two-step use: press Set all 0.0 — 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 LOAD/import gate only: the link gate (whether the applet's actual API calls exist) is what a real install checks at INSTALL[for install]. + CAP compatibility testAnswers one question without installing anything: can this card satisfy the CAP's import list? The server runs the format check, INSTALL[for load] and the LOAD blocks only up to the block that completes the Import component — the point where the JCRE verifies the import list — then stops: no last-block flag and no INSTALL[for install], so nothing is committed and nothing has to be deleted afterwards. The Import versions to test list is pre-filled from the CAP analysis: AID=version rewrites an import's requested version, +AID=version appends a synthetic import (a card-capability query: the LOAD passes only if the card exports that package at ≥ that version), and Set all 0.0 drops every requirement. Two-step use: press Set all 0.0 — 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 LOAD/import gate only: 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 — on a rejection it names the CAP section the failing block covers (load-file byte range and components, and whether that block completes Import), 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. diff --git a/frontend/index.html b/frontend/index.html index d3efe88..49354ab 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1749,7 +1749,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.6.51'; +const SIMPLE_VERSION = '3.6.52'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== Tab switching ===== @@ -8368,7 +8368,7 @@ function ramOpProgress(p) { (p.total ? Math.min(100, Math.round(p.step * 100 / p.total)) : 10) + '%'; } -function ramOpModalFinish(summary, ok, stepsText) { +function ramOpModalFinish(summary, ok, content, asHtml) { _ramOpActive = false; window.removeEventListener('beforeunload', ramOpBeforeUnload); document.getElementById('ram-op-spin').classList.add('hidden'); @@ -8376,7 +8376,13 @@ function ramOpModalFinish(summary, ok, stepsText) { document.getElementById('ram-op-status').textContent = summary || ''; document.getElementById('ram-op-status').className = 'text-sm font-semibold ' + (ok ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'); - if (stepsText !== undefined) document.getElementById('ram-op-steps').textContent = stepsText || ''; + if (content !== undefined) { + // asHtml is only used by views that build escaped HTML (the + // compatibility-test detail); everything else stays plain text. + const el = document.getElementById('ram-op-steps'); + if (asHtml) el.innerHTML = content || ''; + else el.textContent = content || ''; + } document.getElementById('ram-op-warn').classList.add('hidden'); document.getElementById('ram-op-close').disabled = false; } @@ -9674,7 +9680,7 @@ function ramProbeAllZero() { // the load file; the link gate is what a real install checks later. The // headline distinguishes where the run actually failed: a request error, the // format probe (the card not answering), the INSTALL [for load] request, or -// the import list itself. +// the import list itself - and names the remote status word. function ramCompatVerdict(data) { if (!data) return ''; const stepOk = s => !s.por_error && (s.por_status === 'por_ok' || s.por_status === 'no_por'); @@ -9688,17 +9694,21 @@ function ramCompatVerdict(data) { } const scope = t('tested through LOAD') + ' ' + (data.boundary_block || '?') + '/' + (data.total_blocks || '?'); - if (data.imports_ok) return t('Import list accepted') + ' \u2014 ' + scope; const failed = data.steps.find(s => !stepOk(s)) || {}; + const sw = String(failed.por_sw || ''); + const swName = sw ? lookupSw(sw.substr(0, 2), sw.substr(2, 2), 'gp') : ''; + const swText = sw ? t('remote SW') + ' ' + sw + (swName ? ' (' + swName + ')' : '') + : (data.error || t('no data')); + if (data.imports_ok) return t('Import list accepted') + ' \u2014 ' + scope; if (String(failed.name || '') === 'INSTALL [for load]') { - return t('Load request rejected') + ' \u2014 ' + (data.error || t('no data')); + return t('Load request rejected') + ' \u2014 ' + swText; } - return t('Import list rejected') + ' \u2014 ' + scope + - (data.error ? ' \u00b7 ' + data.error : ''); + return t('Import list rejected') + ' \u2014 ' + scope + ' \u00b7 ' + swText; } -// The applied probe changes of a compatibility test (pure): version rewrites, -// appended synthetic imports and override AIDs that matched nothing. +// The applied probe changes of a compatibility test (pure): version rewrites +// and appended synthetic imports. The unmatched override AIDs are a list of +// their own in the modal detail (ramCompatDetailHtml). function ramCompatProbeNote(data) { const pa = (data && data.probe_imports) || []; const added = pa.filter(p => p.from === null || p.from === undefined).length; @@ -9710,11 +9720,140 @@ function ramCompatProbeNote(data) { (targets.length === 1 ? targets[0] : t('mixed'))); } if (added) parts.push('+' + added + ' ' + t('appended')); - const unmatched = (data && data.probe_unmatched) || []; - if (unmatched.length) parts.push(t('unmatched') + ': ' + unmatched.join(', ')); return parts.length ? t('probe') + ': ' + parts.join(' \u00b7 ') : ''; } +// Import-list rows of a compatibility test (pure): one row per AID the run +// touches - the CAP's imports (with the version the card was asked for, and +// the CAP's own version when the probe lowered it), the appended synthetic +// imports, and the probe override AIDs that matched nothing. +// state: 'cap' | 'lowered' | 'appended' | 'unmatched'. +function ramCompatImportRows(data, mem) { + const applied = (data && data.probe_imports) || []; + const unmatched = (data && data.probe_unmatched) || []; + const byAid = {}; + applied.forEach(p => { const a = String(p.aid || '').toUpperCase(); if (a) byAid[a] = p; }); + const rows = []; + const seen = {}; + const push = r => { if (!r.aid || seen[r.aid]) return; seen[r.aid] = 1; rows.push(r); }; + ((mem && mem.imports) || []).forEach(im => { + const aid = String(im.aid || '').toUpperCase(); + const capVer = (Number(im.major) || 0) + '.' + (Number(im.minor) || 0); + const ap = byAid[aid]; + push({ aid: aid, capVersion: capVer, testedVersion: ap ? ap.to : capVer, + state: ap ? 'lowered' : 'cap' }); + }); + applied.forEach(p => { + const aid = String(p.aid || '').toUpperCase(); + const appended = (p.from === null || p.from === undefined); + push({ aid: aid, capVersion: appended ? null : p.from, testedVersion: p.to, + state: appended ? 'appended' : 'lowered' }); + }); + unmatched.forEach(u => { + const aid = String((u && u.aid) || u || '').toUpperCase(); + push({ aid: aid, capVersion: null, testedVersion: (u && u.version) || '', + state: 'unmatched' }); + }); + return rows; +} + +// One row per import: AID (resolved library name) - tested version, with the +// probe state as a muted note. +function ramCompatRowsHtml(rows) { + if (!rows || !rows.length) return ''; + return '
' + rows.map(r => { + let ver = r.testedVersion ? esc(r.testedVersion) : ''; + if (r.state === 'lowered' && r.capVersion) { + ver = esc(r.capVersion) + ' \u2192 ' + esc(r.testedVersion || '?'); + } + let note = ''; + if (r.state === 'lowered') note = t('probe'); + else if (r.state === 'appended') note = t('appended') + ' \u2014 ' + t('not in the CAP'); + else if (r.state === 'unmatched') note = t('not in the CAP\'s import list'); + return '
' + jcAidHtml(r.aid) + (ver ? ' \u2014 ' + ver : '') + + (note ? ' (' + esc(note) + ')' : '') + + '
'; + }).join('') + '
'; +} + +// Failure analysis of a rejected LOAD (pure HTML): which CAP section the +// failing block covers and what it completes, what the card checks at that +// point (JC VM spec 4.5.2), the remote status word, the import list as tested +// and what the run's probe state implies. Used by the compatibility test +// modal (the install side reuses it in the next step). +function ramCompatFailureHtml(data, mem) { + if (!data || data.success || data.imports_ok) return ''; + const steps = data.steps || []; + const step = steps[(Number(data.failed_step) || 1) - 1] || null; + const name = step ? String(step.name || '') : ''; + if (!/^(LOAD |INSTALL \[for load\])/.test(name)) return ''; + const bs = Number(data.load_block_size) || 0; + const sc = ramStepComponents(name, mem && mem.components, bs); + const loadSize = (mem && mem.code) ? (Number(mem.code.load_file) || 0) : 0; + let html = '
'; + // where it failed: the block, its load-file byte range and CAP components + let where = t('Failed in:') + ' ' + name; + const m = /^LOAD \((\d+)\/(\d+)\)$/.exec(name); + if (m && bs) { + const start = (Number(m[1]) - 1) * bs; + const end = loadSize ? Math.min(loadSize, start + bs) - 1 : start + bs - 1; + where += ' \u2014 ' + t('load-file bytes') + ' ' + start + '\u2013' + end + + (loadSize ? ' / ' + loadSize : ''); + } + if (sc) { + where += ' \u00b7 ' + t('covers') + ' ' + sc.names.join(', '); + if (sc.completed.length) where += ' \u00b7 ' + t('completes') + ' ' + sc.completed[sc.completed.length - 1]; + } + html += '
' + esc(where) + '
'; + // what the card checks at that point (JC VM spec 4.5.2) + const completesImport = !!(sc && sc.completed.indexOf('Import') >= 0); + let importDone = ''; + steps.forEach(s => { + const c = ramStepComponents(String(s.name || ''), mem && mem.components, bs); + if (c && c.completed.indexOf('Import') >= 0) importDone = String(s.name || ''); + }); + if (completesImport) { + html += '
' + esc(t('The card verifies the load file\'s import list when the Import component is complete (JC VM spec 4.5.2).')) + '
'; + } else if (importDone) { + html += '
' + esc(t('The import list already passed \u2014 the Import component completed at') + ' ' + importDone + '.') + '
'; + } + // the status word + const sw = step && step.por_sw ? String(step.por_sw) : ''; + if (sw) { + const swName = lookupSw(sw.substr(0, 2), sw.substr(2, 2), 'gp'); + html += '
' + esc(t('remote SW') + ' ' + sw + (swName ? ' \u2014 ' + swName : '')) + '
'; + } + // the import list as tested + what the probe state implies + const rows = ramCompatImportRows(data, mem); + if (rows.length) { + html += '
' + esc(t('Import list under test') + ' (' + rows.length + '):') + '
'; + html += ramCompatRowsHtml(rows); + const capRows = rows.filter(r => r.state === 'cap' || r.state === 'lowered'); + const atCap = capRows.filter(r => r.state === 'cap').length; + if (capRows.length && !atCap) { + html += '
' + esc(t('Every import was tested at 0.0 \u2014 a package the card does not have (or another load-file check, e.g. memory) is the cause.')) + '
'; + } else if (capRows.length) { + html += '
' + esc(t('The card checks the whole list at that point \u2014 lower the versions (Set all 0.0) to test whether the list is the cause, then restore one line at a time to find the culprit.')) + '
'; + } + } + html += '
'; + return html; +} + +// Modal detail of a compatibility test (pure HTML): the failure analysis when +// the card refused, or the import list that passed. +function ramCompatDetailHtml(data, mem) { + if (!data) return ''; + if (data.imports_ok) { + const rows = ramCompatImportRows(data, mem); + if (!rows.length) return ''; + return '
' + + '
' + esc(t('Import list under test') + ' (' + rows.length + '):') + '
' + + ramCompatRowsHtml(rows) + '
'; + } + return ramCompatFailureHtml(data, mem); +} + // CAP compatibility test: run the load only as far as the Import component's // end and report whether the card accepted the import list. Nothing is // installed or committed (the install operation keeps the CAP's own versions). @@ -9725,36 +9864,63 @@ async function ramCompatTest(sp) { if (file.size > 48 * 1024) { alert(t('CAP file exceeds 48 kB limit')); return; } const parsed = ramProbeParse(document.getElementById('ram-probe-versions').value); if (parsed.error) { alert(parsed.error); return; } + const bsRaw = (document.getElementById('ram-compat-block').value || '').trim(); + let blockSize = null; + if (bsRaw !== '') { + const bs = parseInt(bsRaw, 10); + if (!(bs >= 1 && bs <= 240)) { alert(t('LOAD block size must be 1..240')); return; } + blockSize = bs; + } + const mem = (_capAnalysis.ram && _capAnalysis.ram.memory) || null; + const capPkg = (mem && mem.package_aid) || ''; ramClearResults(); - ramShowProgress(t('Sending to server...')); + // The run holds the card lock; the modal carries the live progress (the + // server reports ram_progress for cap-compat) and the failure analysis. + ramOpModalBegin(t('CAP compatibility test') + (capPkg ? ' \u2014 ' + capPkg : '')); + ramOpModalStatus(t('Reading CAP file...')); const body = { cap_hex: await ramReadFileHex(file), probe_imports: parsed.map, probe_additions: parsed.additions, spi1: sp.spi1, spi2: '01', kic: sp.kic, kid: sp.kid, tar: sp.tar, cntr: sp.cntr, kicKey: sp.kicKey, kidKey: sp.kidKey, }; - const bsRaw = (document.getElementById('ram-compat-block').value || '').trim(); - if (bsRaw !== '') { - const bs = parseInt(bsRaw, 10); - if (!(bs >= 1 && bs <= 240)) { alert(t('LOAD block size must be 1..240')); return; } - body.load_block_size = bs; - } + if (blockSize !== null) body.load_block_size = blockSize; + ramOpModalStatus(t('Sending to server...')); - const data = await pysimFetch('/api/cap-compat', body); - ramHideProgress(); + let data; + try { + data = await pysimFetch('/api/cap-compat', body); + } catch (e) { + const msg = t('Error') + ': ' + e.message; + const el = document.getElementById('ram-result'); + el.textContent = msg; + el.classList.remove('hidden', 'text-green-600'); + el.classList.add('text-red-600'); + ramOpModalFinish(msg, false, ''); + return; + } if (data.final_cntr) ramSaveCntr(data.final_cntr); - const comps = _capAnalysis.ram.memory && _capAnalysis.ram.memory.components; + // Inline at the bottom of the form: the verdict, the short probe note and + // the step lines; the per-AID detail lives in the modal. + const comps = mem && mem.components; let txt = ''; (data.steps || []).forEach((s, idx) => { txt += ramStepLine(s, idx, comps, data.load_block_size) + '\n'; }); const stepsEl = document.getElementById('ram-steps'); stepsEl.classList.remove('hidden'); stepsEl.textContent = txt; - const resultEl = document.getElementById('ram-result'); + const verdict = ramCompatVerdict(data); const probeNote = ramCompatProbeNote(data); - resultEl.textContent = ramCompatVerdict(data) + (probeNote ? ' \u2014 ' + probeNote : ''); + const resultEl = document.getElementById('ram-result'); + resultEl.textContent = verdict + (probeNote ? ' \u2014 ' + probeNote : ''); resultEl.classList.remove('hidden', 'text-green-600', 'text-red-600'); resultEl.classList.add(data.imports_ok ? 'text-green-600' : 'text-red-600'); + // Modal: the step lines (escaped) plus the probe summary and the per-AID + // detail (the failure analysis when the card refused). + let modal = esc(txt); + if (probeNote) modal += '
' + esc(probeNote) + '
'; + modal += ramCompatDetailHtml(data, mem); + ramOpModalFinish(verdict, !!data.imports_ok, modal, true); } async function ramInstallCap(sp) { @@ -17729,6 +17895,17 @@ const LANG_RU = { 'unmatched': 'без совпадений', 'Load request rejected': 'Запрос загрузки отклонён', 'The card did not answer the format probe': 'Карта не ответила на пробу формата', + 'Sending to server...': 'Отправка на сервер...', + 'Failed in:': 'Ошибка в:', + 'load-file bytes': 'байты load-файла', + 'covers': 'охватывает', + 'Import list under test': 'Список импортов под проверкой', + 'not in the CAP': 'нет в CAP', + "not in the CAP's import list": 'нет в списке импортов CAP', + "The card verifies the load file's import list when the Import component is complete (JC VM spec 4.5.2).": 'Карта проверяет список импортов load-файла, когда компонент Import завершён (JC VM spec 4.5.2).', + 'The import list already passed \u2014 the Import component completed at': 'Список импортов уже пройден \u2014 компонент Import завершён на', + 'Every import was tested at 0.0 \u2014 a package the card does not have (or another load-file check, e.g. memory) is the cause.': 'Все импорты проверены с версией 0.0 \u2014 причина в пакете, которого нет на карте (или в другой проверке load-файла, например памяти).', + 'The card checks the whole list at that point \u2014 lower the versions (Set all 0.0) to test whether the list is the cause, then restore one line at a time to find the culprit.': 'Карта проверяет весь список в этой точке \u2014 понизьте версии («Все 0.0»), чтобы проверить, в списке ли причина, затем возвращайте по одной строке, чтобы найти виновника.', 'completes': 'завершает', 'inside': 'внутри', 'the failed step completes': 'неудавшийся шаг завершает', diff --git a/frontend/sw.js b/frontend/sw.js index b433dfd..34c9e3d 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v324'; +const CACHE = 'simple-v325'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/ram.test.js b/frontend/tests/ram.test.js index 5e9f1be..9d582cd 100644 --- a/frontend/tests/ram.test.js +++ b/frontend/tests/ram.test.js @@ -22,7 +22,7 @@ function extractFunc(src, name) { } // Extract chain builder functions and dependencies -const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamBuildRowHex', 'ramFmtLifecycle', 'ramFmtVersion', 'ramMenuState', 'ramEntryKind', 'ramMenuHtml', 'ramRawTlvsHtml', 'ramNvLine', 'ramFmtPrivileges', 'ramRenderExploreHtml', 'ramMergeExpanded', 'ramStepLine', 'ramStepComponents', 'ramInstallFailHint', 'ramProbeParse', 'ramCompatVerdict', 'ramCompatProbeNote', 'ramExpandedDetailsInit', 'ramExpandedDetailsChanged', 'ramGetStatusApdu', 'ramDeleteApdu', +const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamBuildRowHex', 'ramFmtLifecycle', 'ramFmtVersion', 'ramMenuState', 'ramEntryKind', 'ramMenuHtml', 'ramRawTlvsHtml', 'ramNvLine', 'ramFmtPrivileges', 'ramRenderExploreHtml', 'ramMergeExpanded', 'ramStepLine', 'ramStepComponents', 'ramInstallFailHint', 'ramProbeParse', 'ramCompatVerdict', 'ramCompatProbeNote', 'ramCompatImportRows', 'ramCompatRowsHtml', 'ramCompatFailureHtml', 'ramCompatDetailHtml', 'lookupSw', 'ramExpandedDetailsInit', 'ramExpandedDetailsChanged', 'ramGetStatusApdu', 'ramDeleteApdu', 'stkParamsBuild', 'ramRemoteSwOk', 'spPorAccepted', 'ramIncrementCntr', 'ramDeleteFromExplorer', 'ramListingSpi2', 'ramRemoveFromExplorer', 'ramHasInstance', 'ramExpandedQueryApdu', '_parseRawElfEntry', '_parseRawAppEntry', 'ramParseElfStatus', 'ramParseAppStatus', 'ramParseModuleAids', 'parseTLV', '_parseE3Entry', '_parseMenuEntries', 'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute', 'decodePrivileges', 'ramActionBtn', 'ramCapToolkitMode', 'ramOpProgressText', @@ -33,6 +33,8 @@ for (const f of FNS) { } const m = html.match(/const _chains = \{\};/); if (m) code += m[0].replace(/^const /, 'var ') + '\n'; +const swm = html.match(/const SW_MAP = \{[\s\S]*?\n\};/); +if (swm) code += swm[0].replace(/^const /, 'var ') + '\n'; const pn = html.match(/const PRIVILEGE_NAMES = \[[\s\S]*?\n\];/); if (pn) code += pn[0].replace(/^const /, 'var ') + '\n'; const an = html.match(/const JC_AID_NAMES = \{[\s\S]*?\n\};/); @@ -960,9 +962,9 @@ test('ramProbeParse reads AID=version lines', () => { test('ramCompatVerdict distinguishes where the test failed', () => { globalThis.t = s => s; - const step = (name, ok) => ok + const step = (name, ok, sw) => ok ? { name: name, por_status: 'por_ok', por_sw: '9000' } - : { name: name, por_status: 'por_ok', por_sw: '6438', por_error: 'remote SW 6438' }; + : { name: name, por_status: 'por_ok', por_sw: sw || '6438', por_error: 'remote SW ' + (sw || '6438') }; assert.strictEqual(ramCompatVerdict({ imports_ok: true, boundary_block: 3, total_blocks: 240, steps: [step('FORMAT CHECK (compact)', true), step('INSTALL [for load]', true), step('LOAD (3/240)', true)] }), 'Import list accepted \u2014 tested through LOAD 3/240'); @@ -970,6 +972,11 @@ test('ramCompatVerdict distinguishes where the test failed', () => { error: 'remote SW 6438', steps: [step('FORMAT CHECK (compact)', true), step('INSTALL [for load]', true), step('LOAD (3/240)', false)] }), 'Import list rejected \u2014 tested through LOAD 3/240 \u00b7 remote SW 6438'); + // the status word name is shown when the SW map knows it (GP 6985) + assert.strictEqual(ramCompatVerdict({ imports_ok: false, boundary_block: 1, total_blocks: 12, + error: 'LOAD (1/12): remote SW 6985', + steps: [step('FORMAT CHECK (compact)', true), step('INSTALL [for load]', true), step('LOAD (1/12)', false, '6985')] }), + 'Import list rejected \u2014 tested through LOAD 1/12 \u00b7 remote SW 6985 (Conditions of use not satisfied)'); // the card never answered the format probe assert.strictEqual(ramCompatVerdict({ imports_ok: false, boundary_block: 1, total_blocks: 240, steps: [step('FORMAT CHECK (compact)', false), step('FORMAT CHECK (expanded)', false)] }), @@ -977,8 +984,8 @@ test('ramCompatVerdict distinguishes where the test failed', () => { // the INSTALL [for load] request itself was refused assert.strictEqual(ramCompatVerdict({ imports_ok: false, boundary_block: 1, total_blocks: 240, error: 'remote SW 6985', - steps: [step('FORMAT CHECK (compact)', true), step('INSTALL [for load]', false)] }), - 'Load request rejected \u2014 remote SW 6985'); + steps: [step('FORMAT CHECK (compact)', true), step('INSTALL [for load]', false, '6985')] }), + 'Load request rejected \u2014 remote SW 6985 (Conditions of use not satisfied)'); // a request error without steps assert.strictEqual(ramCompatVerdict({ error: 'import probe: invalid version' }), 'Error: import probe: invalid version'); @@ -994,11 +1001,147 @@ test('ramCompatProbeNote summarizes the applied probe', () => { assert.strictEqual(ramCompatProbeNote({ probe_imports: [ { aid: 'A1', from: '1.3', to: '0.0' }, { aid: 'A2', from: '1.4', to: '1.0' }] }), 'probe: 2 imports \u2192 mixed'); - assert.strictEqual(ramCompatProbeNote({ probe_imports: [], probe_unmatched: ['DEADBEEF'] }), - 'probe: unmatched: DEADBEEF'); + // the unmatched AIDs are a list of their own in the modal detail, not a + // comma string in the summary line + assert.strictEqual(ramCompatProbeNote({ probe_imports: [], probe_unmatched: ['DEADBEEF'] }), ''); assert.strictEqual(ramCompatProbeNote({}), ''); }); +test('ramCompatImportRows merges the CAP imports, the probe and the unmatched lines', () => { + const mem = { + imports: [ + { aid: 'A0000000620101', major: 1, minor: 3 }, + { aid: 'A0000000620102', major: 1, minor: 3 }, + { aid: 'A0000000090005FFFFFFFF8911000000', major: 1, minor: 2 }, + ], + }; + const data = { + probe_imports: [ + { aid: 'A0000000620102', from: '1.3', to: '0.0' }, + { aid: 'A0000000620201', from: null, to: '1.0' }, + ], + probe_unmatched: [{ aid: 'A0000000090005FFFFFFFF8912000000', version: '0.0' }], + }; + assert.deepStrictEqual(ramCompatImportRows(data, mem), [ + { aid: 'A0000000620101', capVersion: '1.3', testedVersion: '1.3', state: 'cap' }, + { aid: 'A0000000620102', capVersion: '1.3', testedVersion: '0.0', state: 'lowered' }, + { aid: 'A0000000090005FFFFFFFF8911000000', capVersion: '1.2', testedVersion: '1.2', state: 'cap' }, + { aid: 'A0000000620201', capVersion: null, testedVersion: '1.0', state: 'appended' }, + { aid: 'A0000000090005FFFFFFFF8912000000', capVersion: null, testedVersion: '0.0', state: 'unmatched' }, + ]); + // without the CAP analysis at hand the probe entries still produce rows + assert.deepStrictEqual(ramCompatImportRows(data, null).map(r => r.aid), + ['A0000000620102', 'A0000000620201', 'A0000000090005FFFFFFFF8912000000']); +}); + +test('ramCompatRowsHtml lists AIDs with names, versions and probe state', () => { + globalThis.t = s => s; + const out = ramCompatRowsHtml([ + { aid: 'A0000000620101', capVersion: '1.3', testedVersion: '1.3', state: 'cap' }, + { aid: 'A0000000620102', capVersion: '1.3', testedVersion: '0.0', state: 'lowered' }, + { aid: 'A0000000620201', capVersion: null, testedVersion: '1.0', state: 'appended' }, + { aid: 'A0000000090005FFFFFFFF8912000000', capVersion: null, testedVersion: '0.0', state: 'unmatched' }, + ]); + delete globalThis.t; + assert.ok(out.includes('A0000000620101 (javacard.framework) \u2014 1.3'), out); + assert.ok(out.includes('1.3 \u2192 0.0 (probe)'), out); + assert.ok(out.includes('(appended \u2014 not in the CAP)'), out); + assert.ok(out.includes("(not in the CAP's import list)"), out); + assert.ok(out.includes('(uicc.toolkit)'), out); + assert.strictEqual(ramCompatRowsHtml([]), ''); +}); + +test('ramCompatFailureHtml names the failing CAP section and the import list', () => { + globalThis.t = s => s; + const mem = { + imports: [ + { aid: 'A0000000620101', major: 1, minor: 3 }, + { aid: 'A0000000620102', major: 1, minor: 3 }, + { aid: 'A0000000871005FFFFFFFF8913200000', major: 1, minor: 9 }, + { aid: 'A0000000090005FFFFFFFF8911000000', major: 1, minor: 2 }, + ], + components: [ + { name: 'Header', size: 100 }, + { name: 'Directory', size: 100 }, + { name: 'Import', size: 40 }, + { name: 'Method', size: 200 }, + ], + code: { load_file: 440 }, + }; + const data = { + success: false, imports_ok: false, failed_step: 3, boundary_block: 1, total_blocks: 2, + load_block_size: 240, error: 'LOAD (1/2): remote SW 6985', + steps: [ + { name: 'FORMAT CHECK (compact)', por_status: 'por_ok', por_sw: '9000' }, + { name: 'INSTALL [for load]', por_status: 'por_ok', por_sw: '9000' }, + { name: 'LOAD (1/2)', por_status: 'por_ok', por_sw: '6985', por_error: 'remote SW 6985' }, + ], + probe_imports: [ + { aid: 'A0000000620102', from: '1.3', to: '0.0' }, + { aid: 'A0000000090005FFFFFFFF8911000000', from: '1.2', to: '0.0' }, + { aid: 'A0000000620201', from: null, to: '1.0' }, + ], + probe_unmatched: [{ aid: 'A0000000090005FFFFFFFF8912000000', version: '0.0' }], + }; + const out = ramCompatFailureHtml(data, mem); + assert.ok(out.includes('Failed in: LOAD (1/2) \u2014 load-file bytes 0\u2013239 / 440 \u00b7 covers Header, Directory, Import \u00b7 completes Import'), out); + assert.ok(out.includes("The card verifies the load file's import list when the Import component is complete (JC VM spec 4.5.2)."), out); + assert.ok(out.includes('remote SW 6985 \u2014 Conditions of use not satisfied'), out); + assert.ok(out.includes('Import list under test (6):'), out); + assert.ok(out.includes('(javacard.framework)'), out); + // two imports are still at their CAP versions -> the lower-them hint + assert.ok(out.includes('lower the versions (Set all 0.0)'), out); + assert.ok(!out.includes('Every import was tested at 0.0'), out); + // a run with every import lowered: the list cannot be the cause + const allZero = Object.assign({}, data, { + probe_imports: mem.imports.map(im => ({ aid: im.aid, from: im.major + '.' + im.minor, to: '0.0' })), + probe_unmatched: [], + }); + const out2 = ramCompatFailureHtml(allZero, mem); + assert.ok(out2.includes('Every import was tested at 0.0'), out2); + // a failure later in the file: the import list already passed + const later = Object.assign({}, data, { + failed_step: 4, + steps: [ + { name: 'FORMAT CHECK (compact)', por_status: 'por_ok', por_sw: '9000' }, + { name: 'INSTALL [for load]', por_status: 'por_ok', por_sw: '9000' }, + { name: 'LOAD (1/2)', por_status: 'por_ok', por_sw: '9000' }, + { name: 'LOAD (2/2)', por_status: 'por_ok', por_sw: '6581', por_error: 'remote SW 6581' }, + ], + }); + const out3 = ramCompatFailureHtml(later, mem); + assert.ok(out3.includes('The import list already passed \u2014 the Import component completed at LOAD (1/2).'), out3); + assert.ok(out3.includes('remote SW 6581'), out3); + assert.ok(!out3.includes('4.5.2'), out3); + delete globalThis.t; + // not a LOAD failure at all: no block analysis + assert.strictEqual(ramCompatFailureHtml(Object.assign({}, data, { + failed_step: 1, steps: [{ name: 'FORMAT CHECK (compact)', por_status: 'por_error' }] }), mem), ''); + assert.strictEqual(ramCompatFailureHtml({ success: true, imports_ok: true }, mem), ''); +}); + +test('ramCompatDetailHtml shows the tested list on success and the analysis on failure', () => { + globalThis.t = s => s; + const mem = { imports: [{ aid: 'A0000000620101', major: 1, minor: 3 }] }; + const ok = ramCompatDetailHtml({ imports_ok: true, probe_imports: [] }, mem); + assert.ok(ok.includes('Import list under test (1):'), ok); + assert.ok(ok.includes('(javacard.framework)'), ok); + assert.ok(!ok.includes('Failed in:'), ok); + const bad = ramCompatDetailHtml({ imports_ok: false, failed_step: 1, load_block_size: 240, + steps: [{ name: 'LOAD (1/1)', por_status: 'por_ok', por_sw: '6985', por_error: 'remote SW 6985' }] }, mem); + assert.ok(bad.includes('Failed in: LOAD (1/1)'), bad); + delete globalThis.t; + assert.strictEqual(ramCompatDetailHtml(null, mem), ''); +}); + +test('the compatibility test runs in the modal like the install', () => { + const src = extractFunc(html, 'ramCompatTest'); + assert.ok(src.includes('ramOpModalBegin'), 'must open the operation modal'); + assert.ok(src.includes('ramOpModalFinish'), 'must finish it with the verdict'); + assert.ok(src.includes('ramCompatDetailHtml'), 'the per-AID detail must reach the modal'); + assert.ok(!src.includes('ramShowProgress'), 'the inline progress bar is replaced by the modal'); +}); + test('ramExecute dispatches the compatibility test op', () => { const src = extractFunc(html, 'ramExecute'); assert.ok(/op === 'compat'/.test(src), 'the compat op must be dispatched'); diff --git a/pyproject.toml b/pyproject.toml index d6050fe..dbda99c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.6.51" +version = "3.6.52" 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. diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index acf0551..877bc93 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -31,7 +31,7 @@ from osmocom.tlv import BER_TLV_IE from osmocom.utils import rpad -VERSION = '3.6.51' +VERSION = '3.6.52' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE @@ -895,6 +895,23 @@ def _cap_import_aids(load_file_hex): return [] +def _probe_unmatched(probe, known): + """Import-probe override AIDs that matched no Import entry: the CAP does + not import them, so the card is never asked about them. Returns + [{'aid': hex, 'version': 'x.y'}] (the version the probe requested), sorted + by AID; the 'all' override is skipped.""" + out = [] + for key, version in (probe or {}).items(): + k = str(key).replace(' ', '') + if k.lower() == 'all': + continue + if k.upper() in known: + continue + out.append({'aid': k.upper(), 'version': str(version)}) + out.sort(key=lambda e: e['aid']) + return out + + def _cap_compat_blocks(load_file_hex, block_size): """(boundary, total) LOAD block numbers for the compatibility test. @@ -6990,13 +7007,10 @@ class PysimHandler(BaseHTTPRequestHandler): known = original_aids | { str(a.get('aid', '')).replace(' ', '').upper() for a in additions} - probe_unmatched = sorted( - k for k in probe - if str(k).lower() != 'all' - and str(k).replace(' ', '').upper() not in known) + probe_unmatched = _probe_unmatched(probe, known) sys.stderr.write('CAP-COMPAT: import probe applied: %s%s\n' % ( json.dumps(probe_applied), - (' (unmatched: %s)' % ', '.join(probe_unmatched)) + (' (unmatched: %s)' % ', '.join(e['aid'] for e in probe_unmatched)) if probe_unmatched else '')) block_size_req = body.get('load_block_size') diff --git a/tests/test_cap_memory.py b/tests/test_cap_memory.py index 966939a..a501732 100644 --- a/tests/test_cap_memory.py +++ b/tests/test_cap_memory.py @@ -10,7 +10,8 @@ import zipfile from pysim_simple_server import capmem from pysim_simple_server.server import ( _ber_len, _cap_apdu_sequence, _cap_compat_blocks, _cap_import_aids, - _cap_import_end, _cap_info_body, _cap_parse, _probe_import_versions) + _cap_import_end, _cap_info_body, _cap_parse, _probe_import_versions, + _probe_unmatched) # ─── synthetic CAP builder ─────────────────────────────────────────────── @@ -276,6 +277,22 @@ class ImportProbeTests(unittest.TestCase): self.assertEqual(_cap_import_aids(self._load_file()), [JAVACARD_FRAMEWORK.hex().upper(), self.OTHER_AID.hex().upper()]) + def test_probe_unmatched_reports_the_requested_version(self): + known = set(_cap_import_aids(self._load_file())) + # an override for an AID the CAP does not import: kept, with the + # version the probe asked for (the modal lists it per AID) + self.assertEqual( + _probe_unmatched({JAVACARD_FRAMEWORK.hex().upper(): '0.0', + 'DEADBEEF': '1.2', 'all': '0.0'}, known), + [{'aid': 'DEADBEEF', 'version': '1.2'}]) + # matched AIDs and the 'all' override are not unmatched + self.assertEqual(_probe_unmatched({'all': '0.0'}, known), []) + self.assertEqual(_probe_unmatched({JAVACARD_FRAMEWORK.hex().lower(): '0.0'}, known), []) + # a lowercase / spaced unmatched AID is normalised for display + self.assertEqual(_probe_unmatched({'dead beef': '1.2'}, known), + [{'aid': 'DEADBEEF', 'version': '1.2'}]) + self.assertEqual(_probe_unmatched(None, known), []) + def test_probe_rejects_bad_additions(self): load = self._load_file() with self.assertRaises(ValueError): diff --git a/tests/test_fetch_routes.py b/tests/test_fetch_routes.py index 2fc0dfb..04aa50f 100644 --- a/tests/test_fetch_routes.py +++ b/tests/test_fetch_routes.py @@ -222,6 +222,31 @@ class CapCompatHttpTests(unittest.TestCase): self.assertEqual(status, 400, resp) self.assertIn('import probe', resp['error']) + def test_cap_compat_reports_unmatched_probe_lines_with_versions(self): + # v3.6.52: probe overrides that match no Import entry come back as a + # per-AID list ({aid, version}) for the modal's detail block. + from pysim_simple_server import server as srv + self.server.scc = object() + patches = [ + mock.patch.object(srv, '_ram_detect_format', lambda *a, **k: 'compact'), + mock.patch.object(srv, '_ram_send_gp_apdu', lambda *a, **k: True), + ] + for p in patches: + p.start() + try: + status, resp = self._post('/api/cap-compat', { + 'cap_hex': _mini_cap_hex(), + 'probe_imports': {'A0000000620101': '0.0', 'dead beef': '1.2'}, + }) + finally: + for p in reversed(patches): + p.stop() + self.assertEqual(status, 200, resp) + self.assertTrue(resp['success'], resp) + self.assertEqual(resp['probe_unmatched'], [{'aid': 'DEADBEEF', 'version': '1.2'}]) + self.assertEqual(resp['probe_imports'], + [{'aid': 'A0000000620101', 'from': '1.0', 'to': '0.0'}]) + class RamInstallNvFootprintHttpTests(unittest.TestCase): """The RAM install response carries the measured NV footprint (v3.6.51):