feat: CAP memory estimation with a confirm step before install (v3.5.8)

Selecting a .cap in the RAM installer or the SCP81 "Install from .cap"
template now runs a read-only analysis (POST /api/cap-info) before any
APDU is built: the archive is validated structurally (a corrupt or
wrong-format file fails here) and the bundled capmem analyzer estimates
the code size and the persistent (NVRAM) / volatile (RAM) requirements,
with the tool's suggested C6/C7/C8 quotas shown as information.  The
form's action button (Execute / Generate) stays disabled until the
analysis succeeds - pressing it is the user's confirmation to continue.

- pysim_simple_server/capmem.py: bundled analyzer (component parsers +
  JCVM opcode table + method-bytecode allocation scan), adapted to take
  the CAP archive as bytes and return report/memory dicts; output
  verified byte-identical to the workspace tool on 21 real CAPs.
- _cap_info_body + POST /api/cap-info (read-only; the install endpoints
  stay unchanged and self-sufficient).
- PWA: shared capAnalyzeFile/capMemHtml/capGateOk helpers, estimate box
  under both CAP inputs (reusing the idle #ram-cap-info div, new
  #scripts-cap-info), data-cap-gate gating in pysimApplyAvailability,
  stale-response guard, Retry, EN/RU strings.
- Tests: tests/test_cap_memory.py (synthetic CAPs: new/newarray/
  makeTransientByteArray/static fields/unknown-opcode warnings/corrupt
  input), frontend capmem.test.js (renderer, gate, analyze flow).
- help EN/RU, docs/api.md, AGENTS; version 3.5.8; sw cache simple-v261.

567 frontend / 429 Python green.
This commit is contained in:
2026-09-26 08:50:46 +03:00
parent b88f04fc69
commit d9e6c6c9dd
10 changed files with 2461 additions and 13 deletions
+43
View File
@@ -38,6 +38,7 @@ a 3.x PWA).
| `/api/help` | POST | pySim help for a given command |
| `/api/send-ota` | POST | SCP80 OTA secured packet delivery |
| `/api/ram-install` | POST | Install a Java Card `.cap` file via SCP80 (INSTALL[for load] → LOAD ×N → INSTALL[for install]) |
| `/api/cap-info` | POST | Validate a `.cap` archive and estimate its code/NVRAM/RAM requirements (read-only) |
| `/api/sp-verify` | POST | Verify secured packet against pySim reference |
| `/api/menu` | GET | Current STK menu (title + items + active) |
| `/api/menu-select` | POST | ENVELOPE(Menu Selection) with item_id |
@@ -319,6 +320,48 @@ same `por` structure if decoding succeeds.
The SPI2 `por_in_submit` bit (0x20) selects submit-mode PoR.
### `POST /api/cap-info`
Validate a Java Card `.cap` archive and estimate its memory requirements.
Read-only: it runs the same structural parse as the install paths (so a
corrupt or wrong-format file fails here first) plus the bundled CAP analyzer
(`pysim_simple_server/capmem.py` — component parsers and a method-bytecode
allocation scan). It never touches the card, the SCP81 listener or the
scripts; the install endpoints stay self-sufficient and the estimate is not
used for installation.
```json
{"cap_hex": "504B0304..."}
```
**Response (ok):**
```json
{
"ok": true,
"load_file_aid": "AA1902BC226001", "module_aid": "AA1902BC226001",
"load_file_bytes": 1234,
"memory": {
"package_aid": "AA1902BC226001",
"applet_count": 1, "applets": ["AA1902BC226001"],
"class_count": 3, "method_count": 12,
"code": {"method_component": 850},
"nvram": {"static_image": 12, "array_init": 4, "install_objects": 100,
"header_overhead": 24, "ref_storage": 8, "total": 148, "runtime": 0},
"ram": {"transient_arrays": 16, "runtime_transient": 0, "peak_frame": 8, "total": 24},
"suggested": {"c6": 850, "c7": 272, "c8": 148},
"warnings": []
}
}
```
**Errors** (HTTP 200 with `ok: false`, like `/api/scp81/gen-install`):
`{"ok": false, "error": "cap parse failed: File is not a zip file"}` for a
corrupt/wrong archive, or `cap analysis failed: …` when a component passes
the structural parse but not the analyzer. The numbers are an estimate:
the model assumes 2-byte references, a 6-byte object header and NVM cell
rounding, and does not include applet-created runtime objects/arrays.
### `POST /api/ram-install`
Install a Java Card `.cap` file on the card via GlobalPlatform commands (INSTALL[for load] → LOAD ×N → INSTALL[for install (+ make selectable)]) wrapped in SCP80 secured packets. Each step is sent via ENVELOPE and its PoR is checked; the sequence aborts on the first PoR error. The `.cap` archive (a ZIP of nested components) is parsed server-side in `_cap_parse`; no external tooling is required.
+2 -2
View File
@@ -319,7 +319,7 @@
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Операция</th><th class="text-left py-1 px-2">Описание</th></tr></thead>
<tbody>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Обзор карты (все данные GP)</td><td class="py-1 px-2">Запрос GET STATUS для ISD, приложений, ELF и модулей ELF, а также GET DATA FF21 для информации о памяти. Результаты отображаются в обзоре с кнопками <strong>Удалить</strong> для каждого элемента.</td></tr>
<tr><td class="py-1 px-2">Установка пакета (.cap файл)</td><td class="py-1 px-2">Отправка <code class="font-mono text-sm">.cap</code> файла на карту через сервер: INSTALL[for load] &rarr; LOAD &times;N &rarr; INSTALL[for install (+make selectable)]. Load-файл делится на LOAD APDU размера <strong>размер блока LOAD</strong> (1&ndash;240 байт полезной нагрузки, по умолчанию 240); каждый защищённый пакет доставляется одним SMS или, если он больше, конкатенированной SMS-PP загрузкой до 5 сегментов, так что большой <code>.cap</code> просто занимает несколько SMS.</td></tr>
<tr><td class="py-1 px-2">Установка пакета (.cap файл)</td><td class="py-1 px-2">Отправка <code class="font-mono text-sm">.cap</code> файла на карту через сервер: INSTALL[for load] &rarr; LOAD &times;N &rarr; INSTALL[for install (+make selectable)]. Load-файл делится на LOAD APDU размера <strong>размер блока LOAD</strong> (1&ndash;240 байт полезной нагрузки, по умолчанию 240); каждый защищённый пакет доставляется одним SMS или, если он больше, конкатенированной SMS-PP загрузкой до 5 сегментов, так что большой <code>.cap</code> просто занимает несколько SMS. Сразу после выбора файл проверяется и в форме показывается <strong>оценка требований к памяти</strong> (код / NVRAM / RAM); повреждённый файл не проходит анализ, и <strong>Execute</strong> остаётся недоступной до успешного анализа.</td></tr>
</tbody>
</table>
@@ -376,7 +376,7 @@
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Empty</strong> — начать с пустого списка.</li>
<li><strong>Explore ISD</strong> — эталонная административная последовательность (<code class="font-mono text-sm">GET DATA FF21</code>, листинги GET STATUS ISD/ELF/приложений, <code class="font-mono text-sm">GET DATA 0085</code>); длинные листинги автоматически продолжаются страницами <code class="font-mono text-sm">SW 6310/CAFE</code>.</li>
<li><strong>Install from .cap</strong> — выберите <code class="font-mono text-sm">.cap</code>, при необходимости SD AID, параметры install/STK и <em>Make selectable</em>; кнопка <strong>Сгенерировать</strong> строит INSTALL [for load] &rarr; LOAD &times;N &rarr; INSTALL [for install]. Файл используется только для генерации APDU &mdash; он не сохраняется, как и его имя.</li>
<li><strong>Install from .cap</strong> — выберите <code class="font-mono text-sm">.cap</code>, при необходимости SD AID, параметры install/STK и <em>Make selectable</em>; кнопка <strong>Сгенерировать</strong> строит INSTALL [for load] &rarr; LOAD &times;N &rarr; INSTALL [for install]. Файл используется только для генерации APDU &mdash; он не сохраняется, как и его имя. При выборе файл проверяется и показывается оценка требований к памяти (код / NVRAM / RAM); <strong>Сгенерировать</strong> недоступна до успешного анализа.</li>
<li><strong>Delete AID</strong> — список AID (по одному в строке) и P2 (<em>object only</em> / <em>object and related objects</em>) &rarr; APDU DELETE.</li>
</ul>
<p class="text-sm mb-3">Таблица показывает имя, тип, число APDU и время создания каждого скрипта, а также кнопки <strong>Редактировать</strong> и <strong>Удалить</strong>; в редакторе есть поле имени и текстовая область APDU. В ходе сессии сервер отдаёт по одному C-APDU на каждый POST карты и отслеживает выполнение: карта сообщает статус в следующем POST (<code class="font-mono text-sm">X-Admin-Script-Status</code>), оборвавшаяся сессия досылает только невыполненные APDU (<code class="font-mono text-sm">X-Admin-Resume</code> продолжает, новый диалог начинает заново), а завершённый скрипт закрывается ответом <code class="font-mono text-sm">204 No Content</code>. Конструктор RAM/GP вкладки Remote APDU может отправить цепочку команд прямо в прогон кнопкой <strong>&laquo;В очередь SCP81&raquo;</strong>.</p>
+2 -2
View File
@@ -318,7 +318,7 @@
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Operation</th><th class="text-left py-1 px-2">Description</th></tr></thead>
<tbody>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Explore Card (all GP data)</td><td class="py-1 px-2">Queries GET STATUS for ISD, Applications, ELFs, and ELF Modules, plus GET DATA FF21 for memory info. Results appear in an explorer view with per-item <strong>Delete</strong> buttons.</td></tr>
<tr><td class="py-1 px-2">Install Package (.cap file)</td><td class="py-1 px-2">Sends a <code class="font-mono text-sm">.cap</code> file to the card via the server: INSTALL[for load] &rarr; LOAD &times;N &rarr; INSTALL[for install (+make selectable)]. The load file is split into LOAD APDUs of the <strong>LOAD block size</strong> (1&ndash;240 bytes of payload, 240 by default); each secured packet is delivered as a single SMS or, when larger, as a concatenated SMS-PP download of up to 5 segments, so a large <code class="font-mono text-sm">.cap</code> simply takes several SMS.</td></tr>
<tr><td class="py-1 px-2">Install Package (.cap file)</td><td class="py-1 px-2">Sends a <code class="font-mono text-sm">.cap</code> file to the card via the server: INSTALL[for load] &rarr; LOAD &times;N &rarr; INSTALL[for install (+make selectable)]. The load file is split into LOAD APDUs of the <strong>LOAD block size</strong> (1&ndash;240 bytes of payload, 240 by default); each secured packet is delivered as a single SMS or, when larger, as a concatenated SMS-PP download of up to 5 segments, so a large <code class="font-mono text-sm">.cap</code> simply takes several SMS. As soon as the file is selected it is validated and its <strong>memory requirements are estimated</strong> (code / NVRAM / RAM) in the form; a corrupt or wrong-format file fails the analysis, and <strong>Execute</strong> stays disabled until a successful analysis.</td></tr>
</tbody>
</table>
@@ -375,7 +375,7 @@
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Empty</strong> — start from scratch.</li>
<li><strong>Explore ISD</strong> — the reference administration sequence (<code class="font-mono text-sm">GET DATA FF21</code>, GET STATUS ISD/ELF/application listings, <code class="font-mono text-sm">GET DATA 0085</code>); long listings auto-continue through the <code class="font-mono text-sm">SW 6310/CAFE</code> pages.</li>
<li><strong>Install from .cap</strong> — pick a <code class="font-mono text-sm">.cap</code> plus optional SD AID, install/STK parameters and <em>make selectable</em>; <strong>Generate</strong> builds INSTALL [for load] &rarr; LOAD &times;N &rarr; INSTALL [for install]. The file is only used to generate the APDUs &mdash; it is not stored, not even its name.</li>
<li><strong>Install from .cap</strong> — pick a <code class="font-mono text-sm">.cap</code> plus optional SD AID, install/STK parameters and <em>make selectable</em>; <strong>Generate</strong> builds INSTALL [for load] &rarr; LOAD &times;N &rarr; INSTALL [for install]. The file is only used to generate the APDUs &mdash; it is not stored, not even its name. On selection it is validated and its memory requirements are estimated (code / NVRAM / RAM); <strong>Generate</strong> stays disabled until the analysis succeeds.</li>
<li><strong>Delete AID</strong> — AID list (one per line) and P2 (<em>object only</em> / <em>object and related objects</em>) &rarr; DELETE APDUs.</li>
</ul>
<p class="text-sm mb-3">The table lists each script with its kind, APDU count and creation time, plus <strong>Edit</strong> and <strong>Delete</strong>; the editor has a name field and the APDU textarea. Over a session the server serves one C-APDU per card POST and tracks execution: the card reports status in its next POST (<code class="font-mono text-sm">X-Admin-Script-Status</code>), a session that dies resends only the unexecuted APDUs (<code class="font-mono text-sm">X-Admin-Resume</code> continues, a fresh dialog restarts), and a completed script is closed with <code class="font-mono text-sm">204 No Content</code>. The Remote APDU tab's RAM/GP builder can feed a command chain straight into the run with <strong>Queue in SCP81</strong>.</p>
+162 -6
View File
@@ -695,12 +695,12 @@
<option value="install-cap" data-l10n="Install Package (.cap file)">Install Package (.cap file)</option>
</select>
</div>
<button data-needs="card" onclick="ramExecute()" class="px-5 py-1.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Execute">Execute</button>
<button data-needs="card" data-cap-gate="ram" onclick="ramExecute()" class="px-5 py-1.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Execute">Execute</button>
</div>
<div id="ram-install-params" class="hidden mb-3">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="CAP file (max 48 kB)">CAP file (max 48 kB)</label>
<input type="file" id="ram-cap-file" accept=".cap,.zip" class="w-full text-sm text-gray-600 dark:text-slate-300">
<input type="file" id="ram-cap-file" accept=".cap,.zip" onchange="ramCapFileChanged()" class="w-full text-sm text-gray-600 dark:text-slate-300">
<div id="ram-cap-info" class="text-xs text-gray-500 mt-1 hidden"></div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300 mt-2" data-l10n="SD AID (empty = ISD)">SD AID (empty = ISD)</label>
<input id="ram-sd-aid" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="A000000003000000" maxlength="32">
@@ -1315,7 +1315,7 @@
<div class="flex flex-wrap items-end gap-3">
<div>
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="CAP file">CAP file</label>
<input type="file" id="scripts-cap-file" accept=".cap,.zip" class="text-xs text-gray-600 dark:text-slate-300">
<input type="file" id="scripts-cap-file" accept=".cap,.zip" onchange="scriptsCapFileChanged()" class="text-xs text-gray-600 dark:text-slate-300">
</div>
<div>
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="SD AID (empty = ISD)">SD AID (empty = ISD)</label>
@@ -1330,8 +1330,9 @@
<input id="scripts-cap-stk" placeholder="CA TLV" class="w-40 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
</div>
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-slate-400"><input type="checkbox" id="scripts-cap-makesel" checked> <span data-l10n="Make selectable">Make selectable</span></label>
<button data-needs="server" onclick="scriptsGenerateInstall()" class="px-3 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Generate">Generate</button>
<button data-needs="server" data-cap-gate="scripts" onclick="scriptsGenerateInstall()" class="px-3 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Generate">Generate</button>
</div>
<div id="scripts-cap-info" class="text-xs text-gray-500 mt-1 hidden"></div>
<div class="text-xs text-gray-500 dark:text-slate-400 mt-2" data-l10n="The .cap is only used to generate the APDUs: the script stores the APDU list, not the file.">The .cap is only used to generate the APDUs: the script stores the APDU list, not the file.</div>
</div>
<div id="scripts-delete-params" class="hidden mt-3 border-t border-gray-200 dark:border-slate-700 pt-3">
@@ -1559,7 +1560,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.5.7';
const SIMPLE_VERSION = '3.5.8';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching =====
@@ -6463,6 +6464,12 @@ function pysimApplyAvailability() {
disabled = true;
if (!hint) hint = t('No decoder for this file');
}
const capGate = el.getAttribute('data-cap-gate');
if (capGate && !capGateOk(capGate)) {
// A .cap must be analyzed successfully before its action runs.
disabled = true;
if (!hint) hint = t('Analyze the CAP file first');
}
el.disabled = disabled;
if (hint) el.setAttribute('title', hint);
else el.removeAttribute('title');
@@ -7108,6 +7115,131 @@ function ramReadFileHex(file) {
});
}
// ===== CAP file analysis (memory estimate) =====
// A .cap selected in the RAM installer or in the SCP81 "Install from .cap"
// template is analyzed server-side before any APDU is built: /api/cap-info
// validates the archive (a corrupt/wrong file fails here) and estimates the
// code size, persistent (NVRAM) and volatile (RAM) requirements. A
// successful analysis is required before the form's action button
// (data-cap-gate, checked in pysimApplyAvailability); the estimate itself is
// informational and is never used for installation.
const _capAnalysis = {
ram: { key: null, status: 'idle', memory: null, error: null, token: 0 },
scripts: { key: null, status: 'idle', memory: null, error: null, token: 0 },
};
function capFileKey(file) {
return file ? (file.name + ':' + file.size + ':' + file.lastModified) : '';
}
function capGateOk(kind) {
if (kind === 'ram') {
const op = document.getElementById('ram-op');
if (op && op.value !== 'install-cap') return true; // only the install op is gated
}
const st = _capAnalysis[kind];
return !!(st && st.status === 'ok');
}
function capMemBytes(n) {
const v = Number(n) || 0;
if (v < 1024) return v + ' B';
return (v / 1024).toFixed(1) + ' kB';
}
// Estimate box body; pure apart from esc()/t(). `mem` is the server's
// memory object (pysim_simple_server.capmem.memory_json).
function capMemHtml(mem) {
if (!mem) return '';
const nv = mem.nvram || {}, ram = mem.ram || {}, code = mem.code || {}, sug = mem.suggested || {};
const hex2 = v => (Number(v) || 0).toString(16).toUpperCase().padStart(4, '0');
let html = '<div class="font-medium text-gray-700 dark:text-slate-300">' + esc(t('CAP requirements (estimate)')) + '</div>';
html += '<div>' + esc(t('Code (Method.cap):')) + ' ' + esc(capMemBytes(code.method_component)) +
' · ' + esc(t('Persistent (NVRAM):')) + ' ' + esc(capMemBytes(nv.total)) +
' · ' + esc(t('RAM (volatile):')) + ' ' + esc(capMemBytes(ram.total)) + '</div>';
html += '<details class="mt-1"><summary class="cursor-pointer text-gray-400">' + esc(t('Details')) + '</summary>';
html += '<div>' + esc(t('Static image:')) + ' ' + esc(capMemBytes(nv.static_image)) +
' · ' + esc(t('Static array init:')) + ' ' + esc(capMemBytes(nv.array_init)) +
' · ' + esc(t('Install-time objects:')) + ' ' + esc(capMemBytes(nv.install_objects)) +
' · ' + esc(t('Object headers:')) + ' ' + esc(capMemBytes(nv.header_overhead)) +
' · ' + esc(t('Reference storage:')) + ' ' + esc(capMemBytes(nv.ref_storage)) + '</div>';
html += '<div>' + esc(t('Transient arrays:')) + ' ' + esc(capMemBytes(ram.transient_arrays)) +
' · ' + esc(t('Runtime transient:')) + ' ' + esc(capMemBytes(ram.runtime_transient)) +
' · ' + esc(t('Peak method frame:')) + ' ' + esc(capMemBytes(ram.peak_frame)) + '</div>';
html += '<div>' + esc(t('Suggested install quotas (informational):')) +
' C6=' + esc(String(Number(sug.c6) || 0)) + ' C7=0x' + esc(hex2(sug.c7)) + ' C8=0x' + esc(hex2(sug.c8)) + '</div>';
html += '<div class="text-gray-400">' + esc(t('Estimate only — memory values are not used for installation')) + '</div>';
html += '</details>';
(mem.warnings || []).forEach(w => {
html += '<div class="text-amber-600 dark:text-amber-400">\u26a0 ' + esc(w) + '</div>';
});
return html;
}
function capRenderAnalysis(kind) {
const box = document.getElementById(kind === 'ram' ? 'ram-cap-info' : 'scripts-cap-info');
if (!box) return;
const st = _capAnalysis[kind];
if (!st || st.status === 'idle') {
box.classList.add('hidden');
box.innerHTML = '';
return;
}
box.classList.remove('hidden');
if (st.status === 'analyzing') {
box.innerHTML = '<span class="text-gray-400">' + esc(t('Analyzing CAP file...')) + '</span>';
} else if (st.status === 'error') {
box.innerHTML = '<span class="text-red-500">' + esc(t('Analysis failed:')) + ' ' + esc(st.error || '') + '</span>' +
' <button class="underline text-gray-500 hover:text-gray-700 dark:text-slate-400" onclick="' +
(kind === 'ram' ? 'ramCapFileChanged()' : 'scriptsCapFileChanged()') + '">' + esc(t('Retry')) + '</button>';
} else {
box.innerHTML = capMemHtml(st.memory);
}
}
// Read the selected file, validate + estimate it on the server and update the
// form. The token guards against out-of-order responses on re-selection.
async function capAnalyzeFile(file, kind) {
const st = _capAnalysis[kind];
st.token++;
const token = st.token;
st.key = capFileKey(file);
st.memory = null;
st.error = null;
st.status = file ? 'analyzing' : 'idle';
capRenderAnalysis(kind);
pysimApplyAvailability();
if (!file) return;
try {
const hex = await ramReadFileHex(file);
const resp = await pysimFetch('/api/cap-info', { cap_hex: hex });
if (token !== st.token) return;
if (resp && resp.ok) {
st.status = 'ok';
st.memory = resp.memory || {};
} else {
st.status = 'error';
st.error = (resp && resp.error) || t('Analysis failed');
}
} catch (e) {
if (token !== st.token) return;
st.status = 'error';
st.error = String(e && e.message ? e.message : e);
}
capRenderAnalysis(kind);
pysimApplyAvailability();
}
function ramCapFileChanged() {
const input = document.getElementById('ram-cap-file');
capAnalyzeFile(input && input.files ? input.files[0] : null, 'ram');
}
function scriptsCapFileChanged() {
const input = document.getElementById('scripts-cap-file');
capAnalyzeFile(input && input.files ? input.files[0] : null, 'scripts');
}
// Collect SP params from the SP form (populated by ramApplyCard -> cardsApply)
function getRamSpParams() {
return {
@@ -7729,10 +7861,14 @@ async function ramInstallCap(sp) {
}
async function ramExecute() {
const op = document.getElementById('ram-op').value;
if (op === 'install-cap' && !capGateOk('ram')) {
alert(t('Analyze the CAP file first'));
return;
}
ramClearResults();
const cardIdx = parseInt(document.getElementById('ram-card-sel').value, 10);
if (!isNaN(cardIdx) && cards[cardIdx]) _ramCardIdx = cardIdx;
const op = document.getElementById('ram-op').value;
const sp = getRamSpParams();
if (!sp.kicKey || !sp.kidKey) {
alert(t('Select a card preset with keys first (RAM subtab → Card preset)'));
@@ -10871,6 +11007,7 @@ async function scriptsGenerateInstall() {
const fileInput = document.getElementById('scripts-cap-file');
const file = fileInput.files[0];
if (!file) { scriptsMsg(t('Select a .cap file'), 'text-red-500'); return; }
if (!capGateOk('scripts')) { scriptsMsg(t('Analyze the CAP file first'), 'text-red-500'); return; }
if (file.size > 48 * 1024) { scriptsMsg(t('CAP file exceeds 48 kB limit'), 'text-red-500'); return; }
scriptsMsg(t('Reading CAP file...'), 'text-gray-500');
try {
@@ -14950,6 +15087,25 @@ const LANG_RU = {
'CAP file exceeds 48 kB limit': 'Файл CAP превышает лимит 48 кБ',
'Reading CAP file...': 'Чтение файла CAP...',
'Sending to server for install...': 'Отправка на сервер для установки...',
'Analyzing CAP file...': 'Анализ CAP-файла...',
'Analysis failed:': 'Ошибка анализа:',
'Retry': 'Повторить',
'Analyze the CAP file first': 'Сначала проанализируйте CAP-файл',
'CAP requirements (estimate)': 'Требования CAP (оценка)',
'Code (Method.cap):': 'Код (Method.cap):',
'Persistent (NVRAM):': 'Энергонезависимая память (NVRAM):',
'RAM (volatile):': 'ОЗУ (volatile):',
'Details': 'Подробнее',
'Static image:': 'Образ статических полей:',
'Static array init:': 'Инициализация статических массивов:',
'Install-time objects:': 'Объекты при установке:',
'Object headers:': 'Заголовки объектов:',
'Reference storage:': 'Хранение ссылок:',
'Transient arrays:': 'Transient-массивы:',
'Runtime transient:': 'Transient во время работы:',
'Peak method frame:': 'Пиковый кадр метода:',
'Suggested install quotas (informational):': 'Рекомендуемые квоты установки (справочно):',
'Estimate only — memory values are not used for installation': 'Только оценка — значения памяти не используются при установке',
'Step': 'Шаг',
'Install OK': 'Установка OK',
'Install FAILED at step:': 'Установка не удалась на шаге:',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v260';
const CACHE = 'simple-v261';
const URLS = [
'index.html',
'help.html',
+140
View File
@@ -0,0 +1,140 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractBlock(startMarker, endMarker) {
const start = html.indexOf(startMarker);
const end = html.indexOf(endMarker, start);
if (start < 0 || end < 0) throw new Error('block not found: ' + startMarker);
return html.slice(start, end);
}
function extractFunc(src, name, asyncFn) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
}
// Rewrite the top-level const so it leaks out of sloppy-mode eval.
eval(extractBlock('const _capAnalysis = {', 'function capFileKey').replace(/^const /gm, 'var '));
eval(extractFunc(html, 'capFileKey'));
eval(extractFunc(html, 'capGateOk'));
eval(extractFunc(html, 'capMemBytes'));
eval(extractFunc(html, 'capMemHtml'));
eval(extractFunc(html, 'capAnalyzeFile', true));
globalThis.esc = s => s;
globalThis.t = s => s;
globalThis.capRenderAnalysis = () => {};
globalThis.pysimApplyAvailability = () => {};
function resetState() {
_capAnalysis.ram = { key: null, status: 'idle', memory: null, error: null, token: 0 };
_capAnalysis.scripts = { key: null, status: 'idle', memory: null, error: null, token: 0 };
}
function memFixture() {
return {
code: { method_component: 2048 },
nvram: { static_image: 12, array_init: 4, install_objects: 100, header_overhead: 12, ref_storage: 8, total: 136, runtime: 0 },
ram: { transient_arrays: 16, runtime_transient: 0, peak_frame: 8, total: 24 },
suggested: { c6: 2048, c7: 272, c8: 136 },
warnings: [],
};
}
test('capMemBytes formats bytes and kilobytes', () => {
assert.strictEqual(capMemBytes(0), '0 B');
assert.strictEqual(capMemBytes(1023), '1023 B');
assert.strictEqual(capMemBytes(1024), '1.0 kB');
assert.strictEqual(capMemBytes(1587), '1.5 kB');
assert.strictEqual(capMemBytes(null), '0 B');
});
test('capMemHtml renders the summary, breakdown and warnings', () => {
const mem = memFixture();
mem.warnings = ['method class[0].token[1]: unknown opcode 0xAA, scan stopped'];
const out = capMemHtml(mem);
assert.ok(out.includes('CAP requirements (estimate)'), out);
assert.ok(out.includes('Code (Method.cap): 2.0 kB'), out);
assert.ok(out.includes('Persistent (NVRAM): 136 B'), out);
assert.ok(out.includes('RAM (volatile): 24 B'), out);
assert.ok(out.includes('Static image: 12 B'), out);
assert.ok(out.includes('Reference storage: 8 B'), out);
assert.ok(out.includes('Peak method frame: 8 B'), out);
assert.ok(out.includes('C6=2048 C7=0x0110 C8=0x0088'), out);
assert.ok(out.includes('Estimate only'), out);
assert.ok(out.includes('unknown opcode 0xAA'), out);
assert.strictEqual(capMemHtml(null), '');
});
test('capGateOk gates the RAM install op and the scripts form', () => {
resetState();
globalThis.document = { getElementById: id => (id === 'ram-op' ? { value: 'install-cap' } : null) };
assert.strictEqual(capGateOk('ram'), false);
_capAnalysis.ram.status = 'ok';
assert.strictEqual(capGateOk('ram'), true);
_capAnalysis.scripts.status = 'error';
assert.strictEqual(capGateOk('scripts'), false);
// other RAM ops (explore/delete) are not gated
globalThis.document = { getElementById: id => (id === 'ram-op' ? { value: 'explore' } : null) };
assert.strictEqual(capGateOk('ram'), true);
});
test('capAnalyzeFile stores the estimate and ignores stale responses', async () => {
resetState();
const pending = [];
globalThis.ramReadFileHex = async f => f.name + 'hex';
globalThis.pysimFetch = (url, body) => new Promise(resolve => pending.push(resolve));
const f1 = { name: 'a.cap', size: 1, lastModified: 1 };
const f2 = { name: 'b.cap', size: 2, lastModified: 2 };
const p1 = capAnalyzeFile(f1, 'ram');
const p2 = capAnalyzeFile(f2, 'ram');
assert.strictEqual(_capAnalysis.ram.status, 'analyzing');
// let both file reads settle so the fetches are in flight
for (let i = 0; i < 10 && pending.length < 2; i++) await Promise.resolve();
// resolve the first (stale) response after the second selection
pending[0]({ ok: true, memory: { code: { method_component: 111 } } });
pending[1]({ ok: true, memory: { code: { method_component: 222 } } });
await Promise.all([p1, p2]);
assert.strictEqual(_capAnalysis.ram.status, 'ok');
assert.strictEqual(_capAnalysis.ram.memory.code.method_component, 222);
assert.strictEqual(_capAnalysis.ram.key, capFileKey(f2));
globalThis.pysimFetch = undefined;
});
test('capAnalyzeFile records server and network failures', async () => {
resetState();
globalThis.ramReadFileHex = async () => '00';
globalThis.pysimFetch = async () => ({ ok: false, error: 'cap parse failed: File is not a zip file' });
await capAnalyzeFile({ name: 'bad.cap', size: 1, lastModified: 1 }, 'scripts');
assert.strictEqual(_capAnalysis.scripts.status, 'error');
assert.ok(_capAnalysis.scripts.error.includes('not a zip file'), _capAnalysis.scripts.error);
globalThis.pysimFetch = async () => { throw new Error('boom'); };
await capAnalyzeFile({ name: 'net.cap', size: 1, lastModified: 1 }, 'scripts');
assert.strictEqual(_capAnalysis.scripts.status, 'error');
assert.strictEqual(_capAnalysis.scripts.error, 'boom');
});
test('the CAP inputs and their action buttons are wired', () => {
assert.match(html, /id="ram-cap-file"[^>]*onchange="ramCapFileChanged\(\)"/);
assert.match(html, /id="scripts-cap-file"[^>]*onchange="scriptsCapFileChanged\(\)"/);
assert.match(html, /data-cap-gate="ram"/);
assert.match(html, /data-cap-gate="scripts"/);
assert.match(html, /id="ram-cap-info"/);
assert.match(html, /id="scripts-cap-info"/);
assert.match(html, /\/api\/cap-info/);
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
version = "3.5.7"
version = "3.5.8"
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.
+1831
View File
File diff suppressed because it is too large Load Diff
+35 -1
View File
@@ -19,6 +19,7 @@ from pysim_simple_server import netsim
from pysim_simple_server import netstate
from pysim_simple_server import scp81
from pysim_simple_server import esim
from pysim_simple_server import capmem
from smartcard.CardMonitoring import CardMonitor, CardObserver
from cmd2.exceptions import CommandSetRegistrationError
@@ -29,7 +30,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
VERSION = '3.5.7'
VERSION = '3.5.8'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -2412,6 +2413,30 @@ def _scp81_gen_install(body):
'module_aid': module_aid}
def _cap_info_body(body):
"""Validate a .cap archive and estimate its memory requirements.
Read-only: runs the same structural parse as the install paths (so a
corrupt/wrong file fails here first) plus the capmem analyzer. Never
touches the card, the SCP81 listener or the scripts; the estimate is
informational - the install paths stay self-sufficient."""
body = body or {}
cap_hex = re.sub(r'\s', '', body.get('cap_hex') or '')
if not cap_hex:
return {'ok': False, 'error': 'No cap_hex provided'}
try:
loadfile_aid, module_aid, loadfile_data = _cap_parse(cap_hex)
except Exception as e:
return {'ok': False, 'error': 'cap parse failed: %s' % e}
try:
report, memory = capmem.analyze_bytes(bytes.fromhex(cap_hex))
info = capmem.memory_json(report, memory)
except Exception as e:
return {'ok': False, 'error': 'cap analysis failed: %s' % e}
return {'ok': True, 'load_file_aid': loadfile_aid, 'module_aid': module_aid,
'load_file_bytes': len(loadfile_data) // 2, 'memory': info}
def _scp81_bip_control(body):
global _SCP81_LISTENER, _SCP81_PSKS, _SCP81_PSK_LEGACY
global _SCP81_MODE, _SCP81_TARGET
@@ -5285,6 +5310,15 @@ class PysimHandler(BaseHTTPRequestHandler):
resp = {'ok': False, 'error': str(e)}
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/cap-info':
body = self._read_body()
self._log_req(body)
try:
resp = _cap_info_body(body)
except Exception as e:
resp = {'ok': False, 'error': str(e)}
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/scp81/log-clear':
body = self._read_body()
self._log_req(body)
+244
View File
@@ -0,0 +1,244 @@
"""Tests for the bundled CAP memory analyzer (capmem) and /api/cap-info helper."""
import io
import struct
import unittest
import zipfile
from pysim_simple_server import capmem
from pysim_simple_server.server import _cap_info_body
# ─── synthetic CAP builder ───────────────────────────────────────────────
def _u1(v):
return bytes([v])
def _u2(v):
return struct.pack('>H', v)
def _u4(v):
return struct.pack('>I', v)
def _component(tag, payload):
return bytes([tag]) + _u2(len(payload) + 3) + payload
def _header(aid):
# magic, cap minor/major, flags, package minor/major, aid (LV)
return _component(0x01, _u4(0xDECAFFED) + bytes([1, 2, 0, 1, 2]) + _u1(len(aid)) + aid)
def _applet(aid, install_offset):
return _component(0x03, _u1(1) + _u1(len(aid)) + aid + _u2(install_offset))
def _import(packages):
payload = _u1(len(packages))
for minor, major, aid in packages:
payload += bytes([minor, major, len(aid)]) + aid
return _component(0x04, payload)
def _cp_class_ref_internal(internal_ref):
# tag 1: ClassRef (u2) + padding (u1)
return _u1(1) + _u2(internal_ref) + _u1(0)
def _cp_static_method_external(pkg_token, class_token, token):
# tag 6: StaticMethodRef; first byte != 0 -> external pkg/class/token
return _u1(6) + bytes([pkg_token, class_token, token])
def _constant_pool(entries):
return _component(0x05, _u2(len(entries)) + b''.join(entries))
def _class_record(instance_size, ref_count=0, super_ref=0):
# flags (bit7=0 class, no interfaces, no remote), super ref, instance size,
# first ref token, ref count, public/package method table base+count
p = _u1(0) + _u2(super_ref) + _u1(instance_size) + _u1(0) + _u1(ref_count)
p += _u1(0) + _u1(0) + _u1(0) + _u1(0)
return p
def _class_component(records):
return _component(0x06, b''.join(records))
def _descriptor(class_token, this_class_ref, methods, fields=()):
payload = _u1(1)
payload += _u1(class_token) + _u1(0) + _u2(this_class_ref) + _u1(0)
payload += _u2(len(fields)) + _u2(len(methods))
for token, access_flags, static_ref, type_offset in fields:
payload += _u1(token) + _u1(access_flags)
if access_flags & 0x08:
payload += bytes([static_ref])
else:
payload += _u2(0) + _u1(0)
payload += _u2(type_offset)
for token, access_flags, offset, bytecode_count in methods:
payload += _u1(token) + _u1(access_flags) + _u2(offset) + _u2(0)
payload += _u2(bytecode_count) + _u2(0) + _u2(0)
payload += _u2(0) # constant_pool_count (no type descriptors)
return _component(0x0b, payload)
def _method_component(bytecode):
# handler_count = 0; method offsets are relative to the bytecode area
# start (index 0 = handler count byte), so the first method sits at 1.
return _component(0x07, _u1(0) + bytecode)
def _method_header(max_stack, nargs, max_locals):
return bytes([max_stack & 0x0F, ((nargs & 0x0F) << 4) | (max_locals & 0x0F)])
def _static_field(image_size, ref_count, array_inits=()):
payload = _u2(image_size) + _u2(ref_count) + _u2(len(array_inits))
for element_type, values in array_inits:
payload += _u1(element_type) + _u2(len(values)) + bytes(values)
payload += _u2(1) + _u2(0)
return _component(0x08, payload)
def build_cap(header_aid=b'\x01\x02\x03\x04\x05', applet_aid=b'\x01\x02\x03\x04\x05',
imports=(), cp_entries=(), classes=(), descriptor=None, method_bytecode=None,
static=None):
components = {'Header': _header(header_aid), 'Applet': _applet(applet_aid, 1)}
if imports:
components['Import'] = _import(imports)
if cp_entries:
components['ConstantPool'] = _constant_pool(cp_entries)
if classes:
components['Class'] = _class_component(classes)
if descriptor is not None:
components['Descriptor'] = descriptor
if method_bytecode is not None:
components['Method'] = _method_component(method_bytecode)
if static is not None:
components['StaticField'] = static
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as z:
for name, data in components.items():
z.writestr('pkg/%s.cap' % name, data)
return buf.getvalue()
# A CAP with one class (instance size 6) and one install() method that:
# new class[cp0] -> 6 B persistent
# newarray byte[10] -> 10 B persistent
# JCSystem.makeTransientByteArray(16, CLEAR_ON_RESET) -> 16 B RAM
JAVACARD_FRAMEWORK = bytes.fromhex('A0000000620101')
def rich_cap(with_static=True):
body = (b'\x8f\x00\x00' # new cp0 (class ref -> instance size 6)
b'\x10\x0a' # bspush 10
b'\x90\x0b' # newarray byte[10]
b'\x11\x00\x10' # sspush 16
b'\x10\x01' # bpush 1 (clearOnReset)
b'\x8d\x00\x01' # invokestatic cp1 (makeTransientByteArray)
b'\x7a') # return
bytecode = _method_header(2, 0, 0) + body
return build_cap(
imports=[(1, 2, JAVACARD_FRAMEWORK)],
cp_entries=[_cp_class_ref_internal(1), _cp_static_method_external(0x80, 8, 13)],
classes=[_class_record(instance_size=6, ref_count=2)],
descriptor=_descriptor(0, 1, [(0, 0, 1, len(body))]),
method_bytecode=bytecode,
static=_static_field(8, 2, [(0x0C, [0, 1, 0, 2])]) if with_static else None,
)
class TestCapAnalyzer(unittest.TestCase):
def test_minimal_cap_reports_zero_code_and_applet_overhead(self):
report, memory = capmem.analyze_bytes(build_cap())
self.assertEqual(report['package_aid'], '0102030405')
self.assertEqual(report['applet_count'], 1)
self.assertEqual(report['method_component_size'], 0)
# one applet instance -> one 6-byte object header, nothing else
self.assertEqual(memory['nvram_object_header_overhead'], 6)
self.assertEqual(memory['nvram_with_ref_storage'], 6)
self.assertEqual(memory['ram_transient_arrays'], 0)
def test_rich_cap_splits_nvram_and_ram(self):
report, memory = capmem.analyze_bytes(rich_cap())
# new (6) + newarray (10) are install-time persistent allocations
self.assertEqual(memory['nvram_persistent_objects'], 16)
# static image 8 + static array init 4
self.assertEqual(memory['nvram_static_image'], 8)
self.assertEqual(memory['nvram_array_init'], 4)
# 4 objects: static array + 2 install allocations + applet instance
self.assertEqual(memory['nvram_object_count'], 4)
self.assertEqual(memory['nvram_object_header_overhead'], 24)
# 2 references x 2 bytes
self.assertEqual(memory['reference_storage'], 4)
self.assertEqual(memory['nvram_with_ref_storage'], 8 + 4 + 16 + 24 + 4)
# makeTransientByteArray(16) is the only transient allocation
self.assertEqual(memory['ram_transient_arrays'], 16)
# peak frame: max_stack 2 -> 4 B, no locals/args
self.assertEqual(memory['ram_frame_bytes'], 4)
# code size = Method.cap bytecode area (handler count + header + body)
self.assertEqual(report['method_component_size'], 1 + 2 + len(b'\x8f\x00\x00\x10\x0a\x90\x0b\x11\x00\x10\x10\x01\x8d\x00\x01\x7a'))
def test_memory_json_shape_and_suggested_quotas(self):
report, memory = capmem.analyze_bytes(rich_cap())
info = capmem.memory_json(report, memory)
self.assertEqual(info['package_aid'], '0102030405')
self.assertEqual(info['code']['method_component'], report['method_component_size'])
self.assertEqual(info['nvram']['total'], memory['nvram_with_ref_storage'])
self.assertEqual(info['ram']['total'],
memory['ram_transient_arrays'] + memory['runtime_transient_bytes'] + memory['ram_frame_bytes'])
self.assertEqual(info['suggested']['c6'], report['method_component_size'])
self.assertEqual(info['suggested']['c7'], memory['ram_transient_arrays'] + 256)
self.assertEqual(info['suggested']['c8'], memory['nvram_with_ref_storage'])
self.assertEqual(info['warnings'], [])
self.assertIn('applets', info)
def test_unknown_opcode_stops_the_scan_with_a_warning(self):
bytecode = _method_header(1, 0, 0) + b'\xee' # 0xEE is not a JC 2.1 opcode
cap = build_cap(classes=[_class_record(instance_size=4)],
descriptor=_descriptor(0, 1, [(0, 0, 1, 1)]),
method_bytecode=bytecode)
report, _ = capmem.analyze_bytes(cap)
self.assertEqual(len(report['warnings']), 1)
self.assertIn('unknown opcode 0xEE', report['warnings'][0])
info = capmem.memory_json(*capmem.analyze_bytes(cap))
self.assertEqual(len(info['warnings']), 1)
def test_corrupt_input_raises(self):
with self.assertRaises(Exception):
capmem.analyze_bytes(b'not a zip at all')
class TestCapInfoBody(unittest.TestCase):
def test_ok_response_carries_aids_and_estimate(self):
resp = _cap_info_body({'cap_hex': rich_cap().hex()})
self.assertTrue(resp['ok'], resp)
self.assertEqual(resp['load_file_aid'], '0102030405')
self.assertEqual(resp['module_aid'], '0102030405')
self.assertTrue(resp['load_file_bytes'] > 0)
self.assertEqual(resp['memory']['package_aid'], '0102030405')
self.assertTrue(resp['memory']['nvram']['total'] > 0)
def test_bad_hex_and_corrupt_archives_are_rejected(self):
self.assertFalse(_cap_info_body({})['ok'])
bad = _cap_info_body({'cap_hex': '00'})
self.assertFalse(bad['ok'])
self.assertIn('cap parse failed', bad['error'])
def test_structural_but_unanalyzable_archive_reports_analysis_error(self):
# _cap_parse accepts Header+Applet; a broken ConstantPool component
# (unknown tag) fails in the analyzer instead.
cap = build_cap(cp_entries=[b'\x7f\x00\x00']) # unknown CP tag 0x7f
resp = _cap_info_body({'cap_hex': cap.hex()})
self.assertFalse(resp['ok'])
self.assertIn('cap analysis failed', resp['error'])
if __name__ == '__main__':
unittest.main()