diff --git a/docs/api.md b/docs/api.md
index a773a8e..ce7ccba 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -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.
diff --git a/frontend/help-ru.html b/frontend/help-ru.html
index d01b595..a8e22eb 100644
--- a/frontend/help-ru.html
+++ b/frontend/help-ru.html
@@ -319,7 +319,7 @@
Операция Описание
Таблица показывает имя, тип, число APDU и время создания каждого скрипта, а также кнопки Редактировать и Удалить ; в редакторе есть поле имени и текстовая область APDU. В ходе сессии сервер отдаёт по одному C-APDU на каждый POST карты и отслеживает выполнение: карта сообщает статус в следующем POST (X-Admin-Script-Status), оборвавшаяся сессия досылает только невыполненные APDU (X-Admin-Resume продолжает, новый диалог начинает заново), а завершённый скрипт закрывается ответом 204 No Content. Конструктор RAM/GP вкладки Remote APDU может отправить цепочку команд прямо в прогон кнопкой «В очередь SCP81» .
diff --git a/frontend/help.html b/frontend/help.html
index efbbd4c..6a7c02e 100644
--- a/frontend/help.html
+++ b/frontend/help.html
@@ -318,7 +318,7 @@
@@ -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 = '
' + esc(t('CAP requirements (estimate)')) + '
';
+ html += '
' + 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)) + '
';
+ html += '
' + esc(t('Details')) + ' ';
+ html += '' + 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)) + '
';
+ html += '' + 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)) + '
';
+ html += '' + 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)) + '
';
+ html += '' + esc(t('Estimate only — memory values are not used for installation')) + '
';
+ html += '';
+ (mem.warnings || []).forEach(w => {
+ html += '
\u26a0 ' + esc(w) + '
';
+ });
+ 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 = '
' + esc(t('Analyzing CAP file...')) + ' ';
+ } else if (st.status === 'error') {
+ box.innerHTML = '
' + esc(t('Analysis failed:')) + ' ' + esc(st.error || '') + ' ' +
+ '
' + esc(t('Retry')) + ' ';
+ } 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:': 'Установка не удалась на шаге:',
diff --git a/frontend/sw.js b/frontend/sw.js
index fcb0342..af574ba 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'simple-v260';
+const CACHE = 'simple-v261';
const URLS = [
'index.html',
'help.html',
diff --git a/frontend/tests/capmem.test.js b/frontend/tests/capmem.test.js
new file mode 100644
index 0000000..eabee96
--- /dev/null
+++ b/frontend/tests/capmem.test.js
@@ -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/);
+});
diff --git a/pyproject.toml b/pyproject.toml
index a778350..e7c4e36 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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.
diff --git a/pysim_simple_server/capmem.py b/pysim_simple_server/capmem.py
new file mode 100755
index 0000000..fe6f608
--- /dev/null
+++ b/pysim_simple_server/capmem.py
@@ -0,0 +1,1831 @@
+#!/usr/bin/env python3
+"""capmem - JavaCard CAP file memory analyzer (bundled).
+
+Estimates NVRAM (persistent) and RAM (volatile) requirements by:
+1. Parsing all CAP components (Header, Import, ConstantPool, Class, Descriptor,
+ StaticField, Method, Applet)
+2. Scanning method bytecode for allocation instructions
+3. Computing object sizes using Class component declared sizes
+
+Library API:
+ report, memory = analyze_bytes(cap_bytes) # CAP archive (ZIP) as bytes
+ info = memory_json(report, memory) # compact JSON shape for the API
+ print(format_report(report, memory)) # human-readable report
+
+The model's assumptions (2-byte references, 6-byte object header, NVM cell
+rounding, runtime allocations excluded) are part of the report notes: the
+numbers are an estimate, not a limit.
+"""
+
+import struct, os, sys, zipfile, io
+
+# ═══════════════════════════════════════════════════════════════════
+# JC 2.1 Opcode Table
+# Key: opcode byte, Value: (name, operand_sizes_list)
+# ═══════════════════════════════════════════════════════════════════
+OPCODES = {
+ 0x00: ('nop', []),
+ 0x01: ('aconst_null', []),
+ 0x02: ('sconst_m1', []),
+ 0x03: ('sconst_0', []),
+ 0x04: ('sconst_1', []),
+ 0x05: ('sconst_2', []),
+ 0x06: ('sconst_3', []),
+ 0x07: ('sconst_4', []),
+ 0x08: ('sconst_5', []),
+ 0x09: ('iconst_m1', []),
+ 0x0a: ('iconst_0', []),
+ 0x0b: ('iconst_1', []),
+ 0x0c: ('iconst_2', []),
+ 0x0d: ('iconst_3', []),
+ 0x0e: ('iconst_4', []),
+ 0x0f: ('iconst_5', []),
+ 0x10: ('bspush', [1]),
+ 0x11: ('sspush', [2]),
+ 0x12: ('bipush', [1]),
+ 0x13: ('sipush', [2]),
+ 0x14: ('iipush', [4]),
+ 0x15: ('aload', [1]),
+ 0x16: ('sload', [1]),
+ 0x17: ('iload', [1]),
+ 0x18: ('aload_0', []),
+ 0x19: ('aload_1', []),
+ 0x1a: ('aload_2', []),
+ 0x1b: ('aload_3', []),
+ 0x1c: ('sload_0', []),
+ 0x1d: ('sload_1', []),
+ 0x1e: ('sload_2', []),
+ 0x1f: ('sload_3', []),
+ 0x20: ('iload_0', []),
+ 0x21: ('iload_1', []),
+ 0x22: ('iload_2', []),
+ 0x23: ('iload_3', []),
+ 0x24: ('aaload', []),
+ 0x25: ('baload', []),
+ 0x26: ('saload', []),
+ 0x27: ('iaload', []),
+ 0x28: ('astore', [1]),
+ 0x29: ('sstore', [1]),
+ 0x2a: ('istore', [1]),
+ 0x2b: ('astore_0', []),
+ 0x2c: ('astore_1', []),
+ 0x2d: ('astore_2', []),
+ 0x2e: ('astore_3', []),
+ 0x2f: ('sstore_0', []),
+ 0x30: ('sstore_1', []),
+ 0x31: ('sstore_2', []),
+ 0x32: ('sstore_3', []),
+ 0x33: ('istore_0', []),
+ 0x34: ('istore_1', []),
+ 0x35: ('istore_2', []),
+ 0x36: ('istore_3', []),
+ 0x37: ('aastore', []),
+ 0x38: ('bastore', []),
+ 0x39: ('sastore', []),
+ 0x3a: ('iastore', []),
+ 0x3b: ('pop', []),
+ 0x3c: ('pop2', []),
+ 0x3d: ('dup', []),
+ 0x3e: ('dup2', []),
+ 0x3f: ('dup_x', [1]),
+ 0x40: ('swap_x', [1]),
+ 0x41: ('sadd', []),
+ 0x42: ('iadd', []),
+ 0x43: ('ssub', []),
+ 0x44: ('isub', []),
+ 0x45: ('smul', []),
+ 0x46: ('imul', []),
+ 0x47: ('sdiv', []),
+ 0x48: ('idiv', []),
+ 0x49: ('srem', []),
+ 0x4a: ('irem', []),
+ 0x4b: ('sneg', []),
+ 0x4c: ('ineg', []),
+ 0x4d: ('sshl', []),
+ 0x4e: ('ishl', []),
+ 0x4f: ('sshr', []),
+ 0x50: ('ishr', []),
+ 0x51: ('sushr', []),
+ 0x52: ('iushr', []),
+ 0x53: ('sand', []),
+ 0x54: ('iand', []),
+ 0x55: ('sor', []),
+ 0x56: ('ior', []),
+ 0x57: ('sxor', []),
+ 0x58: ('ixor', []),
+ 0x59: ('sinc', [1, 1]),
+ 0x5a: ('iinc', [1, 1]),
+ 0x5b: ('s2b', []),
+ 0x5c: ('s2i', []),
+ 0x5d: ('i2b', []),
+ 0x5e: ('i2s', []),
+ 0x5f: ('icmp', []),
+ 0x60: ('ifeq', [1]),
+ 0x61: ('ifne', [1]),
+ 0x62: ('iflt', [1]),
+ 0x63: ('ifge', [1]),
+ 0x64: ('ifgt', [1]),
+ 0x65: ('ifle', [1]),
+ 0x66: ('ifnull', [1]),
+ 0x67: ('ifnonnull', [1]),
+ 0x68: ('if_acmpeq', [1]),
+ 0x69: ('if_acmpne', [1]),
+ 0x6a: ('if_scmpeq', [1]),
+ 0x6b: ('if_scmpne', [1]),
+ 0x6c: ('if_scmplt', [1]),
+ 0x6d: ('if_scmpge', [1]),
+ 0x6e: ('if_scmpgt', [1]),
+ 0x6f: ('if_scmple', [1]),
+ 0x70: ('goto', [1]),
+ 0x71: ('jsr', [2]),
+ 0x72: ('ret', [1]),
+ 0x73: ('stableswitch', [2, 2, 2]), # + variable pairs
+ 0x74: ('itableswitch', [2, 2, 2]), # + variable pairs
+ 0x75: ('slookupswitch', [2, 2]), # + variable match/offset pairs
+ 0x76: ('ilookupswitch', [2, 2]), # + variable match/offset pairs
+ 0x77: ('areturn', []),
+ 0x78: ('sreturn', []),
+ 0x79: ('ireturn', []),
+ 0x7a: ('return', []),
+ 0x7b: ('getstatic_a', [2]),
+ 0x7c: ('getstatic_b', [2]),
+ 0x7d: ('getstatic_s', [2]),
+ 0x7e: ('getstatic_i', [2]),
+ 0x7f: ('putstatic_a', [2]),
+ 0x80: ('putstatic_b', [2]),
+ 0x81: ('putstatic_s', [2]),
+ 0x82: ('putstatic_i', [2]),
+ 0x83: ('getfield_a', [1]),
+ 0x84: ('getfield_b', [1]),
+ 0x85: ('getfield_s', [1]),
+ 0x86: ('getfield_i', [1]),
+ 0x87: ('putfield_a', [1]),
+ 0x88: ('putfield_b', [1]),
+ 0x89: ('putfield_s', [1]),
+ 0x8a: ('putfield_i', [1]),
+ 0x8b: ('invokevirtual', [2]),
+ 0x8c: ('invokespecial', [2]),
+ 0x8d: ('invokestatic', [2]),
+ 0x8e: ('invokeinterface', [1, 2, 1]),
+ 0x8f: ('new', [2]),
+ 0x90: ('newarray', [1]),
+ 0x91: ('anewarray', [2]),
+ 0x92: ('arraylength', []),
+ 0x93: ('athrow', []),
+ 0x94: ('checkcast', [1, 2]),
+ 0x95: ('instanceof', [1, 2]),
+ 0x96: ('sinc_w', [1, 2]),
+ 0x97: ('iinc_w', [1, 2]),
+ 0x98: ('ifeq_w', [2]),
+ 0x99: ('ifne_w', [2]),
+ 0x9a: ('iflt_w', [2]),
+ 0x9b: ('ifge_w', [2]),
+ 0x9c: ('ifgt_w', [2]),
+ 0x9d: ('ifle_w', [2]),
+ 0x9e: ('ifnull_w', [2]),
+ 0x9f: ('ifnonnull_w', [2]),
+ 0xa0: ('if_acmpeq_w', [2]),
+ 0xa1: ('if_acmpne_w', [2]),
+ 0xa2: ('if_scmpeq_w', [2]),
+ 0xa3: ('if_scmpne_w', [2]),
+ 0xa4: ('if_scmplt_w', [2]),
+ 0xa5: ('if_scmpge_w', [2]),
+ 0xa6: ('if_scmpgt_w', [2]),
+ 0xa7: ('if_scmple_w', [2]),
+ 0xa8: ('goto_w', [2]),
+ 0xa9: ('getfield_a_w', [2]),
+ 0xaa: ('getfield_b_w', [2]),
+ 0xab: ('getfield_s_w', [2]),
+ 0xac: ('getfield_i_w', [2]),
+ 0xad: ('getfield_a_this', [1]),
+ 0xae: ('getfield_b_this', [1]),
+ 0xaf: ('getfield_s_this', [1]),
+ 0xb0: ('getfield_i_this', [1]),
+ 0xb1: ('putfield_a_w', [2]),
+ 0xb2: ('putfield_b_w', [2]),
+ 0xb3: ('putfield_s_w', [2]),
+ 0xb4: ('putfield_i_w', [2]),
+ 0xb5: ('putfield_a_this', [1]),
+ 0xb6: ('putfield_b_this', [1]),
+ 0xb7: ('putfield_s_this', [1]),
+ 0xb8: ('putfield_i_this', [1]),
+ 0xfe: ('impdep1', []),
+ 0xff: ('impdep2', []),
+}
+
+# array element sizes (for newarray atype) — JCDK 2.1.x converter encoding
+ATYP_SIZES = {0x0a: 1, 0x0b: 1, 0x0c: 2, 0x0d: 4, # JCDK: boolean, byte, short, int
+ 0x04: 1, 0x08: 1, 0x09: 2, 0x0a: 1} # JVM-style fallback (boolean,byte,short,int)
+ATYP_NAMES = {0x0a: 'boolean[]', 0x0b: 'byte[]', 0x0c: 'short[]', 0x0d: 'int[]',
+ 0x04: 'boolean[]', 0x08: 'byte[]', 0x09: 'short[]'}
+
+# ═══════════════════════════════════════════════════════════════════
+# CAP Component Parsers
+# ═══════════════════════════════════════════════════════════════════
+
+class Stream:
+ def __init__(self, data):
+ self.data = data
+ self.pos = 0
+
+ def read_byte(self):
+ v = self.data[self.pos]
+ self.pos += 1
+ return v
+
+ def read_u1(self):
+ return self.read_byte()
+
+ def read_u2(self):
+ b = self.data[self.pos:self.pos+2]
+ self.pos += 2
+ return struct.unpack('>H', b)[0]
+
+ def read_i1(self):
+ v = self.data[self.pos]
+ self.pos += 1
+ return v if v < 128 else v - 256
+
+ def read_i2(self):
+ b = self.data[self.pos:self.pos+2]
+ self.pos += 2
+ return struct.unpack('>h', b)[0]
+
+ def read_i4(self):
+ b = self.data[self.pos:self.pos+4]
+ self.pos += 4
+ return struct.unpack('>i', b)[0]
+
+ def read_bytes(self, n):
+ b = self.data[self.pos:self.pos+n]
+ self.pos += n
+ return b
+
+ def read_int(self):
+ return self.read_i4()
+
+ def available(self):
+ return len(self.data) - self.pos
+
+
+class CapComponent:
+ """Base class for a parsed CAP component."""
+ def __init__(self, tag, data):
+ self.tag = tag
+ self.raw_size = len(data)
+ self.stream = Stream(data)
+ self.stream.read_byte() # tag
+ self.component_size = self.stream.read_u2()
+
+
+# ──────────────────────── Header ────────────────────────
+class Header(CapComponent):
+ TAG = 0x01
+ def __init__(self, data):
+ super().__init__(self.TAG, data)
+ self.magic = self.stream.read_int()
+ self.minor_version = self.stream.read_u1() # cap format version (minor)
+ self.major_version = self.stream.read_u1() # cap format version (major)
+ self.flags = self.stream.read_u1()
+ self.package_minor = self.stream.read_u1()
+ self.package_major = self.stream.read_u1()
+ aid_len = self.stream.read_u1()
+ self.aid = self.stream.read_bytes(aid_len)
+
+ def package_version_str(self):
+ return f"{self.package_major}.{self.package_minor}"
+
+
+# ──────────────────────── Import ────────────────────────
+class PackageInfo:
+ def __init__(self):
+ self.minor = 0
+ self.major = 0
+ self.aid = b''
+ self.aid_hex = ''
+
+class Import(CapComponent):
+ TAG = 0x04
+ def __init__(self, data):
+ super().__init__(self.TAG, data)
+ count = self.stream.read_u1()
+ self.packages = []
+ for _ in range(count):
+ p = PackageInfo()
+ p.minor = self.stream.read_u1()
+ p.major = self.stream.read_u1()
+ aid_len = self.stream.read_u1()
+ p.aid = self.stream.read_bytes(aid_len)
+ p.aid_hex = p.aid.hex().upper()
+ self.packages.append(p)
+
+
+# ──────────────────────── ConstantPool ────────────────────────
+class ConstantPoolEntry:
+ TAGS = {
+ 1: 'ClassRef', 2: 'InstanceFieldRef',
+ 3: 'VirtualMethodRef', 4: 'SuperMethodRef',
+ 5: 'StaticFieldRef', 6: 'StaticMethodRef',
+ }
+ def __init__(self, tag):
+ self.tag = tag
+ self.tag_name = self.TAGS.get(tag, f'Unknown(0x{tag:02x})')
+
+
+class CPClassRef(ConstantPoolEntry):
+ def __init__(self, tag, stream):
+ super().__init__(tag)
+ self.class_ref = read_class_ref(stream)
+ self.padding = stream.read_u1()
+
+
+class CPStaticFieldRef(ConstantPoolEntry):
+ def __init__(self, tag, stream):
+ super().__init__(tag)
+ self.static_field_ref = read_static_field_ref(stream)
+
+
+class CPStaticMethodRef(ConstantPoolEntry):
+ def __init__(self, tag, stream):
+ super().__init__(tag)
+ self.static_method_ref = read_static_method_ref(stream)
+
+
+class CPMethodOrFieldRef(ConstantPoolEntry):
+ """InstanceFieldRef, VirtualMethodRef, SuperMethodRef"""
+ def __init__(self, tag, stream):
+ super().__init__(tag)
+ self.class_ref = read_class_ref(stream)
+ self.token = stream.read_u1()
+
+
+class ClassRef:
+ def __init__(self):
+ self.is_internal = True
+ self.internal_ref = 0
+ self.package_token = 0
+ self.class_token = 0
+
+ def __repr__(self):
+ if self.is_internal:
+ return f'ClassRef(internal={self.internal_ref})'
+ return f'ClassRef(external pkg={self.package_token} class={self.class_token})'
+
+
+class StaticFieldRef:
+ def __init__(self):
+ self.is_internal = True
+ self.offset = 0
+ self.padding = 0
+ self.package_token = 0
+ self.class_token = 0
+ self.token = 0
+
+ def __repr__(self):
+ if self.is_internal:
+ return f'StaticFieldRef(internal offset=0x{self.offset:x})'
+ return f'StaticFieldRef(external pkg={self.package_token} class={self.class_token} token={self.token})'
+
+
+class StaticMethodRef:
+ def __init__(self):
+ self.is_internal = True
+ self.offset = 0
+ self.padding = 0
+ self.package_token = 0
+ self.class_token = 0
+ self.token = 0
+
+ def __repr__(self):
+ if self.is_internal:
+ return f'StaticMethodRef(internal offset=0x{self.offset:x})'
+ return f'StaticMethodRef(external pkg={self.package_token} class={self.class_token} token={self.token})'
+
+
+def read_class_ref(stream):
+ buf = stream.read_u2()
+ ref = ClassRef()
+ if (buf & 0x8000) == 0:
+ ref.is_internal = True
+ ref.internal_ref = buf
+ else:
+ ref.is_internal = False
+ ref.class_token = buf & 0xFF
+ ref.package_token = (buf >> 8) & 0xFF
+ return ref
+
+
+def read_static_field_ref(stream):
+ buf = stream.read_u1()
+ ref = StaticFieldRef()
+ if (buf & 0x80) == 0:
+ ref.is_internal = True
+ ref.padding = buf
+ ref.offset = stream.read_u2()
+ else:
+ ref.is_internal = False
+ ref.package_token = buf
+ ref.class_token = stream.read_u1()
+ ref.token = stream.read_u1()
+ return ref
+
+
+def read_static_method_ref(stream):
+ buf = stream.read_u1()
+ ref = StaticMethodRef()
+ if buf == 0:
+ ref.is_internal = True
+ ref.padding = buf
+ ref.offset = stream.read_u2()
+ else:
+ ref.is_internal = False
+ ref.package_token = buf
+ ref.class_token = stream.read_u1()
+ ref.token = stream.read_u1()
+ return ref
+
+
+class ConstantPool(CapComponent):
+ TAG = 0x05
+ def __init__(self, data):
+ super().__init__(self.TAG, data)
+ count = self.stream.read_u2()
+ self.entries = []
+ for _ in range(count):
+ tag = self.stream.read_u1()
+ if tag == 1: # ClassRef
+ self.entries.append(CPClassRef(tag, self.stream))
+ elif tag in (2, 3, 4): # InstanceFieldRef, VirtualMethodRef, SuperMethodRef
+ self.entries.append(CPMethodOrFieldRef(tag, self.stream))
+ elif tag == 5: # StaticFieldRef
+ self.entries.append(CPStaticFieldRef(tag, self.stream))
+ elif tag == 6: # StaticMethodRef
+ self.entries.append(CPStaticMethodRef(tag, self.stream))
+ else:
+ raise ValueError(f"Unknown constant pool tag: {tag}")
+
+
+# ──────────────────────── Applet ────────────────────────
+class AppletInfo:
+ def __init__(self):
+ self.aid = b''
+ self.aid_hex = ''
+ self.install_method_offset = 0
+
+
+class Applet(CapComponent):
+ TAG = 0x03
+ def __init__(self, data):
+ super().__init__(self.TAG, data)
+ count = self.stream.read_u1()
+ self.applets = []
+ for _ in range(count):
+ aid_len = self.stream.read_u1()
+ aid = self.stream.read_bytes(aid_len)
+ info = AppletInfo()
+ info.aid = aid
+ info.aid_hex = aid.hex().upper()
+ info.install_method_offset = self.stream.read_u2()
+ self.applets.append(info)
+
+
+# ──────────────────────── Class ────────────────────────
+class ClassInfo:
+ def __init__(self):
+ self.offset = 0
+ self.flags = 0
+ self.interface_count = 0
+ self.super_class_ref = None
+ self.declared_instance_size = 0
+ self.first_ref_token = 0
+ self.ref_count = 0
+ self.public_method_table_base = 0
+ self.public_method_table_count = 0
+ self.package_method_table_base = 0
+ self.package_method_table_count = 0
+ self.interfaces = []
+ self.has_remote = False
+
+
+class InterfaceInfo:
+ def __init__(self):
+ self.offset = 0
+ self.flags = 0
+ self.remote_interfaces = []
+
+
+class ClassComponent(CapComponent):
+ TAG = 0x06
+ def __init__(self, data):
+ super().__init__(self.TAG, data)
+ self.classes = []
+ self.interfaces = []
+ self.records = [] # stream order: both classes and interfaces
+ while self.stream.available() > 0:
+ buf = self.stream.read_u1()
+ if (buf & 0x80) != 0:
+ # interface: bitfield then interfaceCount × ClassRef (no superClassRef)
+ ii = InterfaceInfo()
+ ii.offset = self.stream.pos - 1
+ ii.flags = buf
+ ii.interface_count = buf & 0x0F
+ for _ in range(ii.interface_count):
+ ii.remote_interfaces.append(read_class_ref(self.stream))
+ self.interfaces.append(ii)
+ self.records.append(ii)
+ else:
+ # class
+ ci = ClassInfo()
+ ci.offset = self.stream.pos - 1
+ ci.flags = buf
+ ci.interface_count = buf & 0x0F
+ ci.has_remote = (buf & 0x20) != 0
+ ci.super_class_ref = read_class_ref(self.stream)
+ ci.declared_instance_size = self.stream.read_u1()
+ ci.first_ref_token = self.stream.read_u1()
+ ci.ref_count = self.stream.read_u1()
+ ci.public_method_table_base = self.stream.read_u1()
+ ci.public_method_table_count = self.stream.read_u1()
+ ci.package_method_table_base = self.stream.read_u1()
+ ci.package_method_table_count = self.stream.read_u1()
+ # public virtual method table
+ pvmt = []
+ for _ in range(ci.public_method_table_count):
+ pvmt.append(self.stream.read_u2())
+ ci.public_virtual_method_table = pvmt
+ # package virtual method table
+ pmvt = []
+ for _ in range(ci.package_method_table_count):
+ pmvt.append(self.stream.read_u2())
+ ci.package_virtual_method_table = pmvt
+ # interfaces (each = ClassRef u2 + count u1 + count × u1)
+ for _ in range(ci.interface_count):
+ iref = read_class_ref(self.stream)
+ ci.interfaces.append(iref)
+ cnt = self.stream.read_u1()
+ for _ in range(cnt):
+ self.stream.read_u1()
+ # remote interfaces info (only if ACC_REMOTE)
+ if ci.has_remote:
+ remote_methods_count = self.stream.read_u1()
+ for _ in range(remote_methods_count):
+ # RemoteMethodInfo: nameOffset(u1) sigOffset(u1)
+ self.stream.read_u1()
+ self.stream.read_u1()
+ self.stream.read_u1() # hashModifierLength
+ hash_len = self.stream.read_u1()
+ self.stream.read_bytes(hash_len)
+ # className info
+ name_len = self.stream.read_u1()
+ self.stream.read_bytes(name_len)
+ self.classes.append(ci)
+ self.records.append(ci)
+
+
+# ──────────────────────── Descriptor ────────────────────────
+class FieldDescriptor:
+ def __init__(self):
+ self.token = 0
+ self.access_flags = 0
+ self.is_static = False
+ self.static_field_ref = None # StaticFieldRef
+ self.instance_class_ref = None # ClassRef
+ self.instance_token = 0
+ self.type_offset = 0
+
+
+class MethodDescriptor:
+ def __init__(self):
+ self.token = 0
+ self.access_flags = 0
+ self.method_offset = 0 # offset in Method.cap bytecode area
+ self.type_offset = 0 # offset in Descriptor type array
+ self.bytecode_count = 0
+ self.exception_handler_count = 0
+ self.exception_handler_index = 0
+
+
+class ClassDescriptor:
+ def __init__(self):
+ self.token = 0
+ self.access_flags = 0
+ self.this_class_ref = None
+ self.interface_count = 0
+ self.field_count = 0
+ self.method_count = 0
+ self.fields = []
+ self.methods = []
+
+
+class TypeDescriptorInfo:
+ def __init__(self):
+ self.constant_pool_count = 0
+ self.constant_pool_types = []
+ self.type_descriptors = []
+
+
+class Descriptor(CapComponent):
+ TAG = 0x0b
+ def __init__(self, data):
+ super().__init__(self.TAG, data)
+ self.class_count = self.stream.read_u1()
+ self.class_descriptors = []
+ for _ in range(self.class_count):
+ cd = ClassDescriptor()
+ cd.token = self.stream.read_u1()
+ cd.access_flags = self.stream.read_u1()
+ cd.this_class_ref = read_class_ref(self.stream)
+ cd.interface_count = self.stream.read_u1()
+ cd.field_count = self.stream.read_u2()
+ cd.method_count = self.stream.read_u2()
+ # interfaces
+ for _ in range(cd.interface_count):
+ read_class_ref(self.stream) # consume
+ # fields
+ for _ in range(cd.field_count):
+ fd = FieldDescriptor()
+ fd.token = self.stream.read_u1()
+ fd.access_flags = self.stream.read_u1()
+ fd.is_static = (fd.access_flags & 0x08) != 0
+ if fd.is_static:
+ fd.static_field_ref = read_static_field_ref(self.stream)
+ else:
+ fd.instance_class_ref = read_class_ref(self.stream)
+ fd.instance_token = self.stream.read_u1()
+ fd.type_offset = self.stream.read_u2()
+ cd.fields.append(fd)
+ # methods
+ for _ in range(cd.method_count):
+ md = MethodDescriptor()
+ md.token = self.stream.read_u1()
+ md.access_flags = self.stream.read_u1()
+ md.method_offset = self.stream.read_u2()
+ md.type_offset = self.stream.read_u2()
+ md.bytecode_count = self.stream.read_u2()
+ md.exception_handler_count = self.stream.read_u2()
+ md.exception_handler_index = self.stream.read_u2()
+ cd.methods.append(md)
+ self.class_descriptors.append(cd)
+ # Type descriptor info (read until end of component)
+ self.type_info = TypeDescriptorInfo()
+ self.type_info.constant_pool_count = self.stream.read_u2()
+ for _ in range(self.type_info.constant_pool_count):
+ self.type_info.constant_pool_types.append(self.stream.read_u2())
+ # Read TypeDescriptor entries until end of component
+ self.type_info.type_descriptors = []
+ while self.stream.available() > 0:
+ nibble_count = self.stream.read_u1()
+ byte_count = (nibble_count + 1) // 2
+ self.stream.read_bytes(byte_count) # consume type bytes
+
+
+# ──────────────────────── StaticField ────────────────────────
+class ArrayInitRecord:
+ def __init__(self):
+ self.element_type = 0
+ self.count = 0
+ self.values = []
+
+
+class StaticFieldComponent(CapComponent):
+ TAG = 0x08
+ def __init__(self, data):
+ super().__init__(self.TAG, data)
+ self.image_size = self.stream.read_u2()
+ self.reference_count = self.stream.read_u2()
+ self.array_init_count = self.stream.read_u2()
+ self.array_init_records = []
+ total_init_bytes = 0
+ for _ in range(self.array_init_count):
+ rec = ArrayInitRecord()
+ rec.element_type = self.stream.read_u1()
+ rec.count = self.stream.read_u2()
+ rec.values = list(self.stream.read_bytes(rec.count))
+ total_init_bytes += rec.count
+ self.array_init_records.append(rec)
+ self.total_init_bytes = total_init_bytes
+ self.default_value_count = self.stream.read_u2()
+ self.non_default_value_count = self.stream.read_u2()
+
+
+# ──────────────────────── RefLocation ────────────────────────
+class RefLocation(CapComponent):
+ TAG = 0x09
+ def __init__(self, data):
+ super().__init__(self.TAG, data)
+ # Not needed for allocation analysis — consume safely.
+ self.raw_payload = self.stream.data[self.stream.pos:]
+
+
+# ──────────────────────── ExceptionHandler ────────────────────────
+class ExceptionHandler:
+ def __init__(self):
+ self.start = 0
+ self.end = 0
+ self.handler = 0
+ self.catch_type = 0
+
+
+# ──────────────────────── Method ────────────────────────
+class MethodHeader:
+ def __init__(self):
+ self.flags = 0
+ self.max_stack = 0
+ self.nargs = 0
+ self.max_locals = 0
+ self.is_extended = False
+
+
+class Method(CapComponent):
+ TAG = 0x07
+ def __init__(self, data):
+ super().__init__(self.TAG, data)
+ handler_count = self.stream.read_u1()
+ self.handler_count = handler_count
+ self.exception_handlers = []
+ for _ in range(handler_count):
+ eh = ExceptionHandler()
+ eh.start = self.stream.read_u2()
+ eh.end = self.stream.read_u2()
+ eh.handler = self.stream.read_u2()
+ eh.catch_type = self.stream.read_u1()
+ self.exception_handlers.append(eh)
+ # Method offsets are relative to the START OF CONTENT (index 0 = tag+size
+ # boundary, i.e. the handler count byte at raw offset 3).
+ self.bytecode_data = self.stream.data[3:]
+
+
+# ═══════════════════════════════════════════════════════════════════
+# CAP Reader
+# ═══════════════════════════════════════════════════════════════════
+class CAP:
+ def __init__(self, data):
+ """`data` is the .cap archive (a ZIP) as bytes."""
+ self.data = data
+ self.components = {}
+ self._read_cap()
+
+ def _read_cap(self):
+ # ZIP archive of nested *.cap components
+ with zipfile.ZipFile(io.BytesIO(self.data), 'r') as z:
+ for name in z.namelist():
+ if name.endswith('.cap'):
+ self._add_component(z.read(name))
+
+ def _add_component(self, data):
+ tag = data[0]
+ if tag == 0x01:
+ self.components['header'] = Header(data)
+ elif tag == 0x03:
+ self.components['applet'] = Applet(data)
+ elif tag == 0x04:
+ self.components['import'] = Import(data)
+ elif tag == 0x05:
+ self.components['constant_pool'] = ConstantPool(data)
+ elif tag == 0x06:
+ self.components['class'] = ClassComponent(data)
+ elif tag == 0x07:
+ self.components['method'] = Method(data)
+ elif tag == 0x08:
+ self.components['static_field'] = StaticFieldComponent(data)
+ elif tag == 0x09:
+ self.components['ref_location'] = RefLocation(data)
+ elif tag == 0x0b:
+ self.components['descriptor'] = Descriptor(data)
+
+ def has(self, name):
+ return name in self.components
+
+
+# ═══════════════════════════════════════════════════════════════════
+# Bytecode Instruction Disassembler (linear scan, no control flow)
+# ═══════════════════════════════════════════════════════════════════
+
+class Instruction:
+ def __init__(self, offset, opcode, name, operands, raw_bytes):
+ self.offset = offset
+ self.opcode = opcode
+ self.name = name
+ self.operands = operands # list of operand values (ints)
+ self.raw = raw_bytes
+ self.size = len(raw_bytes)
+
+ def __repr__(self):
+ ops = ', '.join(f'0x{o:x}' if isinstance(o, int) else str(o) for o in self.operands)
+ return f'[{self.offset:04x}] {self.name} {ops}'
+
+
+def disassemble_region(bytecode_data, start_offset=0):
+ """Linear disassembly of a bytecode region. Returns list of Instructions."""
+ instructions = []
+ pos = 0
+ while pos < len(bytecode_data):
+ opcode = bytecode_data[pos]
+ entry = OPCODES.get(opcode)
+ if entry is None:
+ # Unknown opcode — stop disassembly
+ break
+ name, operand_sizes = entry
+ operands = []
+ raw = [opcode]
+ pos += 1
+ for size in operand_sizes:
+ val_bytes = bytecode_data[pos:pos+size]
+ if len(val_bytes) < size:
+ break
+ if size == 1:
+ val = val_bytes[0]
+ elif size == 2:
+ val = struct.unpack('>H', val_bytes)[0]
+ elif size == 4:
+ val = struct.unpack('>i', val_bytes)[0]
+ else:
+ val = 0
+ operands.append(val)
+ raw.extend(val_bytes)
+ pos += size
+
+ # Handle variable-length switch instructions
+ if opcode in (0x73, 0x74): # stableswitch, itableswitch
+ low = operands[1] if len(operands) > 1 else 0
+ high = operands[2] if len(operands) > 2 else 0
+ n_entries = high - low + 1
+ if n_entries > 0 and n_entries < 10000:
+ for _ in range(n_entries):
+ val_bytes = bytecode_data[pos:pos+2]
+ if len(val_bytes) < 2:
+ break
+ raw.extend(val_bytes)
+ pos += 2
+ elif opcode in (0x75, 0x76): # slookupswitch, ilookupswitch
+ npair = operands[1] if len(operands) > 1 else 0
+ if 0 < npair < 10000:
+ for _ in range(npair):
+ # match (u2) + offset (u2)
+ val_bytes = bytecode_data[pos:pos+4]
+ if len(val_bytes) < 4:
+ break
+ raw.extend(val_bytes)
+ pos += 4
+
+ instructions.append(Instruction(start_offset + pos - len(raw), opcode, name, operands, raw))
+
+ return instructions
+
+
+# ═══════════════════════════════════════════════════════════════════
+# Constant Pool Resolver
+# ═══════════════════════════════════════════════════════════════════
+
+class ConstantPoolResolver:
+ """Resolves constant pool references to human-readable strings."""
+ def __init__(self, cap):
+ self.cap = cap
+ # Imported packages are addressed by their token (0x80 + index).
+ # Store them keyed by full 0x00-0xff token space for CP references.
+ self.import_packages = {}
+ if cap.has('import'):
+ for i, p in enumerate(cap.components['import'].packages):
+ self.import_packages[0x80 + i] = p
+ self.cp_entries = []
+ if cap.has('constant_pool'):
+ self.cp_entries = cap.components['constant_pool'].entries
+ # Map internal class token (this_class_ref space) → declared_instance_size.
+ # Class.cap stream records and Descriptor.cap class descriptors are emitted
+ # by the converter in the same order, so they pair positionally.
+ self.token_to_inst_size = {}
+ if cap.has('descriptor') and cap.has('class'):
+ cd = cap.components['descriptor'].class_descriptors
+ cc = cap.components['class']
+ records = cc.records if hasattr(cc, 'records') and cc.records else cc.classes + cc.interfaces
+ for i, d in enumerate(cd):
+ r = d.this_class_ref
+ if not r.is_internal:
+ continue
+ size = 0
+ if i < len(records):
+ size = records[i].declared_instance_size if hasattr(records[i], 'declared_instance_size') else 0
+ self.token_to_inst_size[r.internal_ref] = size
+
+ def resolve_class_ref(self, ref):
+ if ref.is_internal:
+ return f'class[{ref.internal_ref}]'
+ pkg = self.import_packages.get(ref.package_token)
+ pkg_name = pkg.aid_hex if pkg else f'pkg[{ref.package_token}]'
+ return f'{pkg_name}:class[{ref.class_token}]'
+
+ def resolve_method_ref_token(self, cp_index):
+ """Resolve a constant pool index to a method ref, return (class_desc, token)."""
+ if cp_index < len(self.cp_entries):
+ entry = self.cp_entries[cp_index]
+ if hasattr(entry, 'class_ref') and hasattr(entry, 'token'):
+ return (entry.class_ref, entry.token)
+ return None
+
+ def resolve_new_instance_size(self, cp_index):
+ """Given a 'new' opcode's constant-pool index, return the declared instance
+ size (bytes) of the class being instantiated, or 0 if unresolvable."""
+ info = self.resolve_new_class(cp_index)
+ return info['size'] if info else 0
+
+ def resolve_new_class(self, cp_index):
+ """Resolve a 'new' opcode's CP index to (size, external, token) info."""
+ if cp_index >= len(self.cp_entries):
+ return None
+ entry = self.cp_entries[cp_index]
+ if not isinstance(entry, CPClassRef):
+ return None
+ ref = entry.class_ref
+ if not ref.is_internal:
+ return {'size': 0, 'external': True, 'token': ref.class_token,
+ 'pkg': ref.package_token}
+ return {'size': self.token_to_inst_size.get(ref.internal_ref, 0),
+ 'external': False, 'token': ref.internal_ref}
+
+ def is_make_transient_bytearray(self, cp_index):
+ """Check if a constant pool method ref is JCSystem.makeTransient*.
+ Returns a string key identifying which makeTransient variant, or None."""
+ if cp_index >= len(self.cp_entries):
+ return None
+ entry = self.cp_entries[cp_index]
+ if not isinstance(entry, CPStaticMethodRef):
+ return None
+ ref = entry.static_method_ref
+ if ref.is_internal:
+ return None
+ # javacard.framework (api21): JCSystem class_token = 8
+ # makeTransientBooleanArray = 12, makeTransientByteArray = 13
+ # makeTransientObjectArray = 14, makeTransientShortArray = 15
+ if ref.class_token != 8:
+ return None
+ pkg = self.import_packages.get(ref.package_token)
+ if pkg is None:
+ return None
+ aid_bytes = pkg.aid
+ if len(aid_bytes) >= 7 and aid_bytes[:7] == bytes([0xA0, 0x00, 0x00, 0x00, 0x62, 0x01, 0x01]):
+ return {12: 'makeTransientBooleanArray',
+ 13: 'makeTransientByteArray',
+ 14: 'makeTransientObjectArray',
+ 15: 'makeTransientShortArray'}.get(ref.token)
+ return None
+
+
+# ═══════════════════════════════════════════════════════════════════
+# Method Structure Builder
+# ═══════════════════════════════════════════════════════════════════
+
+class MethodStructure:
+ def __init__(self):
+ self.class_index = 0
+ self.method_token = 0
+ self.access_flags = 0
+ self.max_stack = 0
+ self.nargs = 0
+ self.max_locals = 0
+ self.bytecode_count = 0
+ self.is_static = False
+ self.is_abstract = False
+ self.type_offset = 0
+ self.header_size = 0
+ self.bytecode_data = b''
+ self.start_offset = 0 # offset in Method.cap bytecode area
+ self.name_guess = '?' # heuristic name
+
+
+def build_methods(cap):
+ """Build ordered list of MethodStructure from CAP components."""
+ if not cap.has('descriptor') or not cap.has('method'):
+ return []
+
+ desc = cap.components['descriptor']
+ method_comp = cap.components['method']
+ bytecode_data = method_comp.bytecode_data
+
+ methods = []
+
+ for cls in desc.class_descriptors:
+ for md in cls.methods:
+ ms = MethodStructure()
+ ms.class_index = cls.token
+ ms.method_token = md.token
+ ms.access_flags = md.access_flags
+ ms.is_static = (md.access_flags & 0x08) != 0
+ ms.is_abstract = (md.access_flags & 0x04) != 0
+ ms.bytecode_count = md.bytecode_count
+ ms.type_offset = md.type_offset
+ ms.start_offset = md.method_offset
+
+ # Parse method header from bytecode area
+ if md.bytecode_count > 0 and md.method_offset < len(bytecode_data):
+ hdr_pos = md.method_offset
+ bitfield = bytecode_data[hdr_pos]
+ flags_nibble = (bitfield & 0xF0) >> 4
+ if flags_nibble == 8: # ACC_EXTENDED
+ ms.is_extended = True
+ ms.max_stack = bytecode_data[hdr_pos + 1]
+ ms.nargs = bytecode_data[hdr_pos + 2]
+ ms.max_locals = bytecode_data[hdr_pos + 3]
+ ms.header_size = 4
+ elif flags_nibble == 0: # ACC (normal)
+ ms.max_stack = bitfield & 0x0F
+ hdr_byte2 = bytecode_data[hdr_pos + 1]
+ ms.nargs = (hdr_byte2 & 0xF0) >> 4
+ ms.max_locals = hdr_byte2 & 0x0F
+ ms.header_size = 2
+ else:
+ # abstract (4) or unknown — no bytecode
+ ms.header_size = 0
+ ms.bytecode_count = 0
+
+ # Extract bytecode
+ bc_start = hdr_pos + ms.header_size
+ ms.bytecode_data = bytecode_data[bc_start:bc_start + md.bytecode_count]
+
+ methods.append(ms)
+
+ return methods
+
+
+# ═══════════════════════════════════════════════════════════════════
+# Method Name Heuristics
+# ═══════════════════════════════════════════════════════════════════
+
+def guess_method_names(cap, methods):
+ """Heuristically assign method names based on static flags, signatures, bytecode patterns."""
+ if not cap.has('applet') or not cap.has('descriptor'):
+ return
+
+ # Get applet install method offsets
+ applet_offsets = set()
+ for ai in cap.components['applet'].applets:
+ applet_offsets.add(ai.install_method_offset)
+
+ # Get class descriptors
+ desc = cap.components['descriptor']
+ class_by_token = {}
+ for cls in desc.class_descriptors:
+ class_by_token[cls.token] = cls
+
+ # For each class, find the
, , install methods
+ for ms in methods:
+ cls = class_by_token.get(ms.class_index)
+ if cls is None:
+ continue
+
+ # : static method whose bytecode writes static fields (putstatic)
+ if ms.is_static and ms.bytecode_count > 0:
+ bc = ms.bytecode_data
+ has_putstatic = any(op in (0x7f, 0x80, 0x81, 0x82) for op in bc) # putstatic_*
+ if has_putstatic:
+ ms.name_guess = ''
+ elif ms.bytecode_count < 50 and ms.max_locals == 0:
+ ms.name_guess = '(trivial)'
+ else:
+ ms.name_guess = ''
+
+ # Constructor : non-static, has invokestatic or invokespecial to super
+ if not ms.is_static and ms.bytecode_count > 0:
+ bc = ms.bytecode_data
+ if b'\x8c' in bc or b'\x8b' in bc: # invokespecial/invokevirtual
+ ms.name_guess = ''
+
+ # Mark install() methods — methods whose offset matches the applet install offsets
+ for ms in methods:
+ if ms.start_offset in applet_offsets and ms.bytecode_count > 0:
+ ms.name_guess = 'install'
+
+
+# ═══════════════════════════════════════════════════════════════════
+# Allocation Scanner
+# ═══════════════════════════════════════════════════════════════════
+
+class Allocation:
+ def __init__(self, kind, size, context, detail):
+ self.kind = kind # 'new', 'newarray', 'anewarray', 'make_transient_bytearray', 'make_transient_objectarray', 'make_transient_shortarray'
+ self.size = size # byte count or element count * elem_size (0 if unknown)
+ self.context = context # method name or '', 'install', etc.
+ self.detail = detail # human-readable description
+ self.is_persistent = True # False if transient
+ self.is_transient = False
+ self.element_count = 0
+ self.element_size = 0
+
+ def __repr__(self):
+ return f'{self.kind}({self.detail}) size={self.size}B [{self.context}]'
+
+
+def scan_method_bytecode(ms, cap, resolver, warnings=None):
+ """Scan a method's bytecode for allocation instructions. Returns list of Allocation."""
+ allocations = []
+ bc = ms.bytecode_data
+ if not bc:
+ return allocations
+
+ # Simple constant stack for tracking push values
+ stack = []
+ pos = 0
+
+ while pos < len(bc):
+ op = bc[pos]
+ entry = OPCODES.get(op)
+ if entry is None:
+ if warnings is not None:
+ warnings.append('method class[%d].token[%d]: unknown opcode 0x%02X, scan stopped'
+ % (ms.class_index, ms.method_token, op))
+ break
+ name, operand_sizes = entry
+ operands = []
+ pos += 1
+ for sz in operand_sizes:
+ val_bytes = bc[pos:pos+sz]
+ if len(val_bytes) < sz:
+ break
+ if sz == 1:
+ operands.append(val_bytes[0])
+ elif sz == 2:
+ operands.append(struct.unpack('>H', val_bytes)[0])
+ elif sz == 4:
+ operands.append(struct.unpack('>i', val_bytes)[0])
+ pos += sz
+
+ # Handle switch
+ if op in (0x73, 0x74):
+ low = operands[1] if len(operands) > 1 else 0
+ high = operands[2] if len(operands) > 2 else 0
+ n = high - low + 1
+ if 0 < n < 10000:
+ pos += n * 2
+ elif op in (0x75, 0x76):
+ npair = operands[1] if len(operands) > 1 else 0
+ if 0 < npair < 10000:
+ pos += npair * 4
+
+ # Track stack for constant values
+ # Push constants
+ if op == 0x10: # bspush
+ stack.append(operands[0])
+ elif op == 0x12: # bipush
+ stack.append(operands[0])
+ elif op == 0x11: # sspush
+ stack.append(operands[0])
+ elif op == 0x13: # sipush
+ stack.append(operands[0])
+ elif op == 0x14: # iipush
+ stack.append(operands[0])
+ elif op == 0x02: # sconst_m1
+ stack.append(-1)
+ elif op in range(0x03, 0x09): # sconst_0..sconst_5
+ stack.append(op - 0x03)
+ elif op == 0x09: # iconst_m1
+ stack.append(-1)
+ elif op in range(0x0a, 0x10): # iconst_0..iconst_5
+ stack.append(op - 0x0a)
+ elif op in (0x59, 0x96): # sinc, sinc_w — local increment, don't track precisely
+ stack.append('?')
+ elif op == 0x5c: # s2i
+ pass # type conversion, stack unchanged
+ elif op == 0x5b: # s2b
+ pass
+ elif op == 0x5d: # i2b
+ pass
+ elif op == 0x5e: # i2s
+ pass
+ elif op == 0x42: # iadd
+ if len(stack) >= 2:
+ a, b = stack[-2], stack[-1]
+ if isinstance(a, int) and isinstance(b, int):
+ stack[-2] = a + b
+ else:
+ stack[-2] = '?'
+ stack.pop()
+ elif op == 0x44: # isub
+ if len(stack) >= 2:
+ a, b = stack[-2], stack[-1]
+ if isinstance(a, int) and isinstance(b, int):
+ stack[-2] = a - b
+ else:
+ stack[-2] = '?'
+ stack.pop()
+ elif op == 0x3b: # pop
+ if stack: stack.pop()
+ elif op == 0x3c: # pop2
+ if len(stack) >= 2:
+ stack.pop(); stack.pop()
+ elif op == 0x3d: # dup
+ if stack: stack.append(stack[-1])
+ elif op in (0x3e, 0x3f): # dup2, dup_x
+ if len(stack) >= 2:
+ stack.append(stack[-2])
+ elif op == 0x40: # swap_x
+ if len(stack) >= 2:
+ stack[-1], stack[-2] = stack[-2], stack[-1]
+ elif op in (0x77, 0x78, 0x79): # areturn, sreturn, ireturn
+ if stack: stack.pop()
+ elif op == 0x7a: # return
+ pass
+ elif op == 0x93: # athrow
+ if stack: stack.pop()
+ elif op in range(0x7b, 0x7f): # getstatic_* — push field value
+ stack.append('?')
+ elif op in range(0x7f, 0x83): # putstatic_* — pop value
+ if stack: stack.pop()
+ elif op in range(0x83, 0x87): # getfield_*
+ if stack: stack.pop() # pop objectref
+ stack.append('?') # push field value
+ elif op in range(0x87, 0x8b): # putfield_*
+ if len(stack) >= 2: # pops value + objectref
+ stack.pop()
+ stack.pop()
+ elif stack:
+ stack.pop()
+ elif op in range(0x18, 0x1c): # aload_0..aload_3 — push local ref
+ stack.append('?')
+ elif op in range(0x1c, 0x20): # sload_0..3
+ stack.append('?')
+ elif op in range(0x20, 0x24): # iload_0..3
+ stack.append('?')
+ elif op in (0x15, 0x16, 0x17): # aload/sload/iload — push local
+ stack.append('?')
+ elif op in range(0x28, 0x2b): # astore/sstore/istore
+ if stack: stack.pop()
+ elif op in range(0x2b, 0x37): # astore_0..3, sstore_0..3, istore_0..3
+ if stack: stack.pop()
+ elif op in (0x24, 0x25, 0x26, 0x27): # aaload, baload, saload, iaload
+ if len(stack) >= 2:
+ stack.pop() # index
+ stack.pop() # arrayref
+ stack.append('?') # pushed value
+ elif op in (0x37, 0x38, 0x39, 0x3a): # aastore, bastore, sastore, iastore
+ if len(stack) >= 3:
+ stack.pop(); stack.pop(); stack.pop()
+ elif len(stack) >= 2:
+ stack.pop(); stack.pop()
+ elif op == 0x92: # arraylength
+ if stack: stack.pop() # pop arrayref
+ stack.append('?') # push length
+ elif op == 0x94: # checkcast
+ if stack: stack.pop()
+ stack.append('?')
+ elif op in (0x8b, 0x8c, 0x8e): # invokevirtual/invokespecial/invokeinterface
+ # approximate: args consumed, one implicit return pushed
+ if stack: stack.pop()
+ stack.append('?')
+ elif op in (0x41, 0x43, 0x45, 0x47, 0x49): # sadd, ssub, smul, sdiv, srem
+ if len(stack) >= 2:
+ a, b = stack[-2], stack[-1]
+ if isinstance(a, int) and isinstance(b, int):
+ if op == 0x41: stack[-2] = a + b
+ elif op == 0x43: stack[-2] = a - b
+ elif op == 0x45: stack[-2] = a * b
+ elif op == 0x47:
+ stack[-2] = a // b if b != 0 else '?'
+ elif op == 0x49:
+ stack[-2] = a % b if b != 0 else '?'
+ else:
+ stack[-2] = '?'
+ stack.pop()
+
+ # ─── Allocation Detection ───
+ if op == 0x8f: # new
+ class_token = operands[0]
+ # Compute object size from Class component instance_size if resolvable
+ info = resolver.resolve_new_class(class_token)
+ obj_size = info['size'] if info else 0
+ if info and info.get('external'):
+ size_desc = '(imported class)'
+ elif obj_size:
+ size_desc = f'={obj_size}B'
+ else:
+ size_desc = '(unknown size)'
+ alloc = Allocation('new', obj_size, ms.name_guess,
+ f'new class[{class_token}] {size_desc}')
+ alloc.detail = f'new class token {class_token}'
+ if obj_size:
+ alloc.element_count = 1
+ alloc.element_size = obj_size
+ allocations.append(alloc)
+ stack.append('?') # push object ref
+
+ elif op == 0x90: # newarray
+ atype = operands[0]
+ elem_count = stack[-1] if stack else '?'
+ elem_size = ATYP_SIZES.get(atype, 1)
+ type_name = ATYP_NAMES.get(atype, f'unknown[{atype}]')
+ if stack: stack.pop() # pop count
+ stack.append('?') # push array ref
+ if isinstance(elem_count, int):
+ total_bytes = elem_count * elem_size
+ alloc = Allocation('newarray', total_bytes, ms.name_guess,
+ f'new {type_name}[{elem_count}] = {total_bytes}B')
+ alloc.element_count = elem_count
+ alloc.element_size = elem_size
+ else:
+ alloc = Allocation('newarray', 0, ms.name_guess,
+ f'new {type_name}[?] (size unknown)')
+ allocations.append(alloc)
+
+ elif op == 0x91: # anewarray
+ class_token = operands[0]
+ elem_count = stack[-1] if stack else '?'
+ if stack: stack.pop() # pop element count
+ stack.append('?') # push array ref
+ if isinstance(elem_count, int):
+ alloc = Allocation('anewarray', elem_count * 2, ms.name_guess,
+ f'new Object[{elem_count}] class[{class_token}] = {elem_count * 2}B refs')
+ alloc.element_count = elem_count
+ alloc.element_size = 2 # refs
+ else:
+ alloc = Allocation('anewarray', 0, ms.name_guess,
+ f'new Object[?] class[{class_token}] (size unknown)')
+ allocations.append(alloc)
+
+ elif op == 0x8d: # invokestatic
+ cp_index = operands[0]
+ result = resolver.is_make_transient_bytearray(cp_index)
+ if result:
+ # Identified as makeTransient* call
+ # Size is the short argument on stack (popped before call)
+ # Stack: [size, clearOnDeselect] → the method takes (short size, byte mode)
+ elem_count = stack[-2] if len(stack) >= 2 else '?'
+ mode = stack[-1] if len(stack) >= 1 else '?'
+ mode_str = {0: 'CLEAR_ON_DESELECT', 1: 'CLEAR_ON_RESET'}.get(mode, f'mode={mode}')
+
+ if result in ('makeTransientByteArray', 'makeTransientBooleanArray'):
+ if isinstance(elem_count, int):
+ alloc = Allocation(f'make_transient_{result.lower()}', elem_count, ms.name_guess,
+ f'JCSystem.{result}({elem_count}, {mode_str}) = {elem_count}B')
+ alloc.is_transient = True
+ alloc.is_persistent = False
+ alloc.element_count = elem_count
+ alloc.element_size = 1
+ else:
+ alloc = Allocation(f'make_transient_{result.lower()}', 0, ms.name_guess,
+ f'JCSystem.{result}(?, {mode_str})')
+ alloc.is_transient = True
+ alloc.is_persistent = False
+ allocations.append(alloc)
+ elif result == 'makeTransientShortArray':
+ if isinstance(elem_count, int):
+ alloc = Allocation('make_transient_shortarray', elem_count * 2, ms.name_guess,
+ f'JCSystem.{result}({elem_count}, {mode_str}) = {elem_count * 2}B')
+ alloc.is_transient = True
+ alloc.is_persistent = False
+ alloc.element_count = elem_count
+ alloc.element_size = 2
+ else:
+ alloc = Allocation('make_transient_shortarray', 0, ms.name_guess,
+ f'JCSystem.{result}(?, {mode_str})')
+ alloc.is_transient = True
+ alloc.is_persistent = False
+ allocations.append(alloc)
+ elif result == 'makeTransientObjectArray':
+ if isinstance(elem_count, int):
+ alloc = Allocation('make_transient_objectarray', elem_count * 2, ms.name_guess,
+ f'JCSystem.{result}({elem_count}, {mode_str}) = {elem_count * 2}B refs')
+ alloc.is_transient = True
+ alloc.is_persistent = False
+ alloc.element_count = elem_count
+ alloc.element_size = 2 # reference size
+ else:
+ alloc = Allocation('make_transient_objectarray', 0, ms.name_guess,
+ f'JCSystem.{result}(?, {mode_str})')
+ alloc.is_transient = True
+ alloc.is_persistent = False
+ allocations.append(alloc)
+ # Don't pop stack for invokestatic — args are consumed
+ # For makeTransient: pops the args
+ if result:
+ n_args = 2 # makeTransient*(short, byte)
+ for _ in range(min(n_args, len(stack))):
+ stack.pop()
+ stack.append('?') # return value (object ref)
+
+ return allocations
+
+
+# ═══════════════════════════════════════════════════════════════════
+# Main Analysis
+# ═══════════════════════════════════════════════════════════════════
+
+def analyze_bytes(cap_bytes, verbose=False):
+ """Analyze a CAP archive (bytes) and return (report, memory)."""
+ cap = CAP(cap_bytes)
+ report = {}
+
+ # Header
+ if cap.has('header'):
+ h = cap.components['header']
+ report['cap_version'] = f'{h.major_version}.{h.minor_version}'
+ report['package_version'] = h.package_version_str()
+ report['package_aid'] = h.aid.hex().upper()
+
+ # Import packages
+ if cap.has('import'):
+ pkgs = cap.components['import'].packages
+ report['import_count'] = len(pkgs)
+ report['packages'] = []
+ for p in pkgs:
+ report['packages'].append({
+ 'version': f'{p.major}.{p.minor}',
+ 'aid': p.aid_hex,
+ })
+
+ # Applets
+ if cap.has('applet'):
+ applets = cap.components['applet'].applets
+ report['applet_count'] = len(applets)
+ report['applets'] = []
+ for a in applets:
+ report['applets'].append({
+ 'aid': a.aid_hex,
+ 'install_method_offset': a.install_method_offset,
+ })
+
+ # Classes
+ if cap.has('class'):
+ classes = cap.components['class'].classes
+ report['class_count'] = len(classes)
+ report['classes'] = []
+ total_instance_size = 0
+ total_ref_count = 0
+ for ci in classes:
+ report['classes'].append({
+ 'flags': f'0x{ci.flags:02x}',
+ 'instance_size': ci.declared_instance_size,
+ 'ref_count': ci.ref_count,
+ 'public_methods': ci.public_method_table_count,
+ 'package_methods': ci.package_method_table_count,
+ })
+ total_instance_size += ci.declared_instance_size
+ total_ref_count += ci.ref_count
+ report['total_instance_size'] = total_instance_size
+ report['total_ref_count'] = total_ref_count
+
+ # Descriptor inventory
+ if cap.has('descriptor'):
+ desc = cap.components['descriptor']
+ total_fields = 0
+ static_fields = 0
+ instance_fields = 0
+ total_methods = 0
+ for cls in desc.class_descriptors:
+ total_fields += cls.field_count
+ total_methods += cls.method_count
+ for f in cls.fields:
+ if f.is_static:
+ static_fields += 1
+ else:
+ instance_fields += 1
+ report['descriptor_classes'] = len(desc.class_descriptors)
+ report['total_fields'] = total_fields
+ report['static_fields'] = static_fields
+ report['instance_fields'] = instance_fields
+ report['total_methods_desc'] = total_methods
+
+ # StaticField
+ if cap.has('static_field'):
+ sf = cap.components['static_field']
+ report['static_image_size'] = sf.image_size
+ report['static_ref_count'] = sf.reference_count
+ report['static_array_init_count'] = sf.array_init_count
+ report['static_array_init_bytes'] = sf.total_init_bytes
+ report['static_default_value_count'] = sf.default_value_count
+ report['static_non_default_value_count'] = sf.non_default_value_count
+ # Show array init details
+ report['array_inits'] = []
+ for rec in sf.array_init_records:
+ report['array_inits'].append({
+ 'element_type': rec.element_type,
+ 'byte_count': rec.count,
+ 'values_preview': rec.values[:20] if len(rec.values) > 20 else rec.values,
+ })
+
+ # Build methods
+ methods = build_methods(cap)
+ guess_method_names(cap, methods)
+ report['method_count'] = len(methods)
+ report['method_component_size'] = (
+ len(cap.components['method'].bytecode_data) if cap.has('method') else 0)
+
+ # Peak JCVM operand-stack frame estimate: worst (max_stack*2 + (nargs+max_locals)*2)
+ worst_stack = max((m.max_stack for m in methods), default=0)
+ worst_var = max((m.nargs + m.max_locals for m in methods), default=0)
+ report['ram_frame_bytes'] = worst_stack * 2 + worst_var * 2
+ report['max_operand_stack'] = worst_stack
+
+ # Resolve constant pool for method names
+ resolver = ConstantPoolResolver(cap)
+
+ # Scan all methods for allocations
+ warnings = []
+ all_allocations = []
+ method_details = []
+ for ms in methods:
+ allocs = scan_method_bytecode(ms, cap, resolver, warnings)
+ if allocs:
+ all_allocations.extend(allocs)
+ method_details.append({
+ 'class': ms.class_index,
+ 'token': ms.method_token,
+ 'name': ms.name_guess,
+ 'static': ms.is_static,
+ 'bytecode_count': ms.bytecode_count,
+ 'max_stack': ms.max_stack,
+ 'nargs': ms.nargs,
+ 'max_locals': ms.max_locals,
+ 'allocations': allocs,
+ })
+
+ report['all_allocations'] = all_allocations
+ report['methods_with_allocations'] = method_details
+ report['warnings'] = warnings
+
+ return report, compute_memory(report)
+
+
+def compute_memory(report):
+ """Compute NVRAM and RAM estimates from analysis report."""
+ result = {}
+
+ INSTALL_CTX = ('', 'install', '')
+
+ # ── NVRAM (persistent) ──
+ niram = report.get('static_image_size', 0) # static field cells (incl. static refs)
+ niram += report.get('static_array_init_bytes', 0) # data of static arrays created at install
+
+ # 3. Applet instance cells (instance_size per applet instance created at install)
+ # NOTE: `new` allocations resolved from instance sizes are already counted in
+ # persistent_alloc_bytes below, so do NOT add total_instance_size again here.
+ total_instance = report.get('total_instance_size', 0)
+
+ # Persistent allocations, split into install-time (persist from power-on) vs runtime
+ persistent_alloc_bytes = 0
+ persistent_alloc_details = []
+ runtime_persistent_bytes = 0
+ runtime_persistent_details = []
+ for alloc in report.get('all_allocations', []):
+ if not alloc.is_persistent:
+ continue
+ if alloc.context in INSTALL_CTX:
+ persistent_alloc_bytes += alloc.size
+ persistent_alloc_details.append(alloc)
+ else:
+ runtime_persistent_bytes += alloc.size
+ runtime_persistent_details.append(alloc)
+
+ niram += persistent_alloc_bytes
+
+ # ── RAM (volatile) ──
+ transient_alloc_bytes = 0
+ transient_alloc_details = []
+ runtime_transient_bytes = 0
+ runtime_transient_details = []
+ for alloc in report.get('all_allocations', []):
+ if not alloc.is_transient:
+ continue
+ if alloc.context in INSTALL_CTX:
+ transient_alloc_bytes += alloc.size
+ transient_alloc_details.append(alloc)
+ else:
+ runtime_transient_bytes += alloc.size
+ runtime_transient_details.append(alloc)
+
+ # Object header overhead estimate (per object/array: ~6 bytes typical:
+ # class/array header + length field + NVM cell rounding)
+ OBJECT_HEADER_OVERHEAD = 6
+
+ # Objects created during install: static arrays + install allocations + applet instance(s)
+ inst_object_count = report.get('static_array_init_count', 0)
+ for alloc in persistent_alloc_details:
+ inst_object_count += 1
+ inst_object_count += report.get('applet_count', 0)
+ niram_overhead = inst_object_count * OBJECT_HEADER_OVERHEAD
+ niram_with_overhead = niram + niram_overhead
+
+ # Reference storage for instance reference fields (2 bytes each on-card)
+ ref_storage = report.get('total_ref_count', 0) * 2
+
+ result['nvram_static_image'] = report.get('static_image_size', 0)
+ result['nvram_array_init'] = report.get('static_array_init_bytes', 0)
+ result['nvram_persistent_objects'] = persistent_alloc_bytes
+ result['nvram_persistent_details'] = persistent_alloc_details
+ result['runtime_persistent_bytes'] = runtime_persistent_bytes
+ result['runtime_persistent_details'] = runtime_persistent_details
+ result['nvram_object_header_overhead'] = niram_overhead
+ result['nvram_object_count'] = inst_object_count
+ result['nvram_total'] = niram_with_overhead
+ result['nvram_with_ref_storage'] = niram_with_overhead + ref_storage
+
+ result['ram_transient_arrays'] = transient_alloc_bytes
+ result['ram_transient_details'] = transient_alloc_details
+ result['runtime_transient_bytes'] = runtime_transient_bytes
+ result['runtime_transient_details'] = runtime_transient_details
+ result['ram_frame_bytes'] = report.get('ram_frame_bytes', 0) # peak method frame (operand stack + locals/args)
+
+ result['object_header_overhead_model'] = f'{OBJECT_HEADER_OVERHEAD}B per object (header + length + NVM rounding)'
+ result['reference_count'] = report.get('total_ref_count', 0)
+ result['reference_storage'] = ref_storage
+
+ return result
+
+
+def memory_json(report, memory):
+ """Compact, stable JSON shape for the API/PWA (all byte counts)."""
+ ram_transient = memory.get('ram_transient_arrays', 0)
+ ram_runtime = memory.get('runtime_transient_bytes', 0)
+ ram_frame = memory.get('ram_frame_bytes', 0)
+ return {
+ 'cap_version': report.get('cap_version'),
+ 'package_version': report.get('package_version'),
+ 'package_aid': report.get('package_aid'),
+ 'applet_count': report.get('applet_count', 0),
+ 'applets': [a.get('aid') for a in report.get('applets', [])],
+ 'class_count': report.get('class_count', 0),
+ 'method_count': report.get('method_count', 0),
+ 'code': {
+ 'method_component': report.get('method_component_size', 0),
+ },
+ 'nvram': {
+ 'static_image': memory.get('nvram_static_image', 0),
+ 'array_init': memory.get('nvram_array_init', 0),
+ 'install_objects': memory.get('nvram_persistent_objects', 0),
+ 'header_overhead': memory.get('nvram_object_header_overhead', 0),
+ 'ref_storage': memory.get('reference_storage', 0),
+ 'total': memory.get('nvram_with_ref_storage', 0),
+ 'runtime': memory.get('runtime_persistent_bytes', 0),
+ },
+ 'ram': {
+ 'transient_arrays': ram_transient,
+ 'runtime_transient': ram_runtime,
+ 'peak_frame': ram_frame,
+ 'total': ram_transient + ram_runtime + ram_frame,
+ },
+ 'suggested': {
+ 'c6': report.get('method_component_size', 0),
+ 'c7': ram_transient + 256,
+ 'c8': memory.get('nvram_with_ref_storage', 0),
+ },
+ 'warnings': list(report.get('warnings', [])),
+ }
+
+
+# ═══════════════════════════════════════════════════════════════════
+# Report Formatter
+# ═══════════════════════════════════════════════════════════════════
+
+def format_report(report, memory):
+ lines = []
+ lines.append('=' * 72)
+ lines.append('CAP MEMORY ANALYSIS: %s' % (report.get('package_aid') or '?'))
+ lines.append('=' * 72)
+
+ if 'cap_version' in report:
+ lines.append(f'CAP format: {report["cap_version"]}')
+ if 'package_version' in report:
+ lines.append(f'Package version: {report["package_version"]}')
+ if 'package_aid' in report:
+ lines.append(f'Package AID: {report["package_aid"]}')
+
+ lines.append('')
+
+ # Applets
+ if report.get('applets'):
+ lines.append(f'Applets: {report["applet_count"]}')
+ for a in report['applets']:
+ lines.append(f' AID: {a["aid"]}')
+ lines.append('')
+
+ # Classes
+ lines.append(f'Classes: {report.get("class_count", 0)}')
+ for i, ci in enumerate(report.get('classes', [])):
+ lines.append(f' [{i}] flags={ci["flags"]} instance_size={ci["instance_size"]}B refs={ci["ref_count"]} public_methods={ci["public_methods"]}')
+ lines.append(f' Total instance size: {report.get("total_instance_size", 0)}B')
+ lines.append(f' Total ref count: {report.get("total_ref_count", 0)}')
+ lines.append('')
+
+ # Descriptors
+ lines.append(f'Descriptors: {report.get("descriptor_classes", 0)} classes, {report.get("total_fields", 0)} fields ({report.get("static_fields", 0)} static, {report.get("instance_fields", 0)} instance), {report.get("total_methods_desc", 0)} methods')
+ lines.append('')
+
+ # StaticField
+ sf = report
+ lines.append(f'Static Field Component:')
+ lines.append(f' Image size (persistent): {sf.get("static_image_size", 0)}B')
+ lines.append(f' Reference count: {sf.get("static_ref_count", 0)}')
+ lines.append(f' Array init records: {sf.get("static_array_init_count", 0)} ({sf.get("static_array_init_bytes", 0)}B total)')
+ lines.append(f' Default values: {sf.get("static_default_value_count", 0)}, Non-default: {sf.get("static_non_default_value_count", 0)}')
+ if report.get('array_inits'):
+ lines.append(f' Array init details:')
+ for i, ai in enumerate(report['array_inits'][:10]):
+ vals_preview = ai['values_preview'][:10]
+ lines.append(f' [{i}] type={ai["element_type"]} size={ai["byte_count"]}B values={vals_preview}')
+ if len(report['array_inits']) > 10:
+ lines.append(f' ... and {len(report["array_inits"]) - 10} more')
+ lines.append('')
+
+ # Methods
+ lines.append(f'Methods: {report.get("method_count", 0)} total')
+ if report.get('methods_with_allocations'):
+ lines.append(f' Methods with allocations: {len(report["methods_with_allocations"])}')
+ for md in report['methods_with_allocations']:
+ lines.append(f' class[{md["class"]}].token[{md["token"]}] {md["name"]} '
+ f'static={md["static"]} bc={md["bytecode_count"]}B '
+ f'stack={md["max_stack"]} nargs={md["nargs"]} locals={md["max_locals"]}')
+ for alloc in md['allocations']:
+ lines.append(f' {alloc}')
+ lines.append('')
+
+ # ── SUMMARY ──
+ lines.append('=' * 72)
+ lines.append('MEMORY SUMMARY')
+ lines.append('=' * 72)
+ lines.append('')
+ lines.append(f'NVRAM (Persistent):')
+ lines.append(f' Static field image: {memory["nvram_static_image"]:>6}B')
+ lines.append(f' Static array init data: {memory["nvram_array_init"]:>6}B')
+ lines.append(f' Install-time allocs: {memory["nvram_persistent_objects"]:>6}B (new objects + newarray/anewarray)')
+ lines.append(f' Object header overhead: {memory["nvram_object_header_overhead"]:>6}B ({memory["nvram_object_count"]} objects × 6B)')
+ lines.append(f' ─────────────────────────────')
+ lines.append(f' Subtotal NVRAM: {memory["nvram_total"]:>6}B')
+ lines.append(f' + Instance ref storage: {memory["reference_storage"]:>6}B ({memory["reference_count"]} refs × 2B)')
+ lines.append(f' Total NVRAM estimate: {memory["nvram_with_ref_storage"]:>6}B')
+ lines.append(f' {memory["object_header_overhead_model"]}')
+ if memory.get('runtime_persistent_bytes'):
+ lines.append(f' NOTE: runtime (non-install) persistent allocs: {memory["runtime_persistent_bytes"]}B')
+ for alloc in memory['runtime_persistent_details']:
+ lines.append(f' {alloc}')
+ unknown_rp = [a for a in memory.get('runtime_persistent_details', []) if not a.size]
+ if unknown_rp:
+ lines.append(f' NOTE: {len(unknown_rp)} runtime persistent alloc(s) with analysis-unknown size:')
+ for a in unknown_rp:
+ lines.append(f' {a}')
+ lines.append('')
+
+ lines.append(f'RAM (Volatile):')
+ lines.append(f' Transient arrays (install): {memory["ram_transient_arrays"]:>4}B')
+ if memory['ram_transient_details']:
+ for alloc in memory['ram_transient_details']:
+ lines.append(f' {alloc}')
+ if memory.get('runtime_transient_bytes'):
+ lines.append(f' Runtime transient allocs: {memory["runtime_transient_bytes"]:>6}B (peak per session)')
+ unknown = [a for a in memory.get('runtime_transient_details', []) if not a.size]
+ if unknown:
+ lines.append(f' NOTE: {len(unknown)} runtime transient alloc(s) have analysis-unknown size:')
+ for a in unknown:
+ lines.append(f' {a}')
+ ram_total = (memory["ram_transient_arrays"]
+ + memory.get("runtime_transient_bytes", 0)
+ + memory["ram_frame_bytes"])
+ lines.append(f' + Peak method frame: {memory["ram_frame_bytes"]:>6}B (max(max_stack×2, (nargs+max_locals)×2))')
+ lines.append(f' Total RAM estimate: ~{ram_total:>6}B (applet-controlled; APDU buffer/JCRE stack are shared card RAM)')
+ lines.append('')
+
+ lines.append(f'Code size (Method.cap): ~{report.get("method_component_size", 0)}B (component = bytecode + headers)')
+ lines.append('')
+
+ # C7/C8 byte values
+ ram_total = memory['ram_transient_arrays'] + 256
+ nvram_total = memory['nvram_with_ref_storage']
+ lines.append(f'Suggested values for .oszn / .conf:')
+ lines.append(f' [LIMITS] code = {report.get("method_component_size", 0)} (Method.cap component = bytecode + headers)')
+ lines.append(f' [LIMITS] data = 0x{nvram_total:04x} ({nvram_total})')
+ lines.append(f' [LIMITS] ram = 0x{ram_total:04x} ({ram_total})')
+ lines.append(f' C7 02 {ram_total:04X} (RAM)')
+ lines.append(f' C8 02 {nvram_total:04X} (NVRAM)')
+
+ lines.append('')
+ lines.append('NOTES:')
+ lines.append(' - Object sizes are based on declared instance sizes from Class component.')
+ lines.append(' - NVM cell rounding varies by card (typically 4-16B per cell allocation).')
+ lines.append(' - Transient arrays assume 2B per reference in object arrays.')
+ lines.append(' - Runtime allocations (in process/processToolkit) not included in install-time NVRAM.')
+ lines.append(' - Code size estimate is approximate; actual C6 code size = Method.cap component size.')
+ lines.append(' - For exact code size, use: java -cp capdump.jar com.sun.javacard.capdump.CapDump .cap')
+
+ return '\n'.join(lines)
+
+
+# ═══════════════════════════════════════════════════════════════════
+# CLI
+# ═══════════════════════════════════════════════════════════════════
+
+def main():
+ """Dev CLI: python -m pysim_simple_server.capmem """
+ if len(sys.argv) < 2:
+ print(f'Usage: {sys.argv[0]} ')
+ sys.exit(1)
+
+ cap_path = sys.argv[1]
+ if not os.path.exists(cap_path):
+ print(f'Error: {cap_path} not found')
+ sys.exit(1)
+
+ with open(cap_path, 'rb') as f:
+ data = f.read()
+
+ report, memory = analyze_bytes(data)
+ print(format_report(report, memory))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py
index f8fff67..59d6056 100644
--- a/pysim_simple_server/server.py
+++ b/pysim_simple_server/server.py
@@ -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)
diff --git a/tests/test_cap_memory.py b/tests/test_cap_memory.py
new file mode 100644
index 0000000..23b4b30
--- /dev/null
+++ b/tests/test_cap_memory.py
@@ -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()