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:
+43
@@ -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.
|
||||
|
||||
@@ -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] → LOAD ×N → INSTALL[for install (+make selectable)]. Load-файл делится на LOAD APDU размера <strong>размер блока LOAD</strong> (1–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] → LOAD ×N → INSTALL[for install (+make selectable)]. Load-файл делится на LOAD APDU размера <strong>размер блока LOAD</strong> (1–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] → LOAD ×N → INSTALL [for install]. Файл используется только для генерации APDU — он не сохраняется, как и его имя.</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] → LOAD ×N → INSTALL [for install]. Файл используется только для генерации APDU — он не сохраняется, как и его имя. При выборе файл проверяется и показывается оценка требований к памяти (код / NVRAM / RAM); <strong>Сгенерировать</strong> недоступна до успешного анализа.</li>
|
||||
<li><strong>Delete AID</strong> — список AID (по одному в строке) и P2 (<em>object only</em> / <em>object and related objects</em>) → 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>«В очередь SCP81»</strong>.</p>
|
||||
|
||||
+2
-2
@@ -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] → LOAD ×N → INSTALL[for install (+make selectable)]. The load file is split into LOAD APDUs of the <strong>LOAD block size</strong> (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 <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] → LOAD ×N → INSTALL[for install (+make selectable)]. The load file is split into LOAD APDUs of the <strong>LOAD block size</strong> (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 <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] → LOAD ×N → INSTALL [for install]. The file is only used to generate the APDUs — 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] → LOAD ×N → INSTALL [for install]. The file is only used to generate the APDUs — 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>) → 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
@@ -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
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'simple-v260';
|
||||
const CACHE = 'simple-v261';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Executable
+1831
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user