Files
simple/tests/test_cap_memory.py
T
catarrh 0255a956e2 feat: show the CAP's required libraries, package identity and components (v3.5.16)
The Import component (JC VM spec 6.6) is now exposed by /api/cap-info and
rendered in the CAP analysis box:

- imports: the libraries the CAP is linked against with the export-file
  versions and the number of distinct constant-pool references (6.7),
  displayed as "name >= version" (a card resolves an import only with the
  same major and a minor >= the recorded one, 4.5.2).  Standard names come
  from the AID table; each gets a family label (Oracle JavaCard / ETSI SIM
  2G / ETSI UICC / 3GPP USIM-ISIM / GlobalPlatform) and, for
  javacard.framework, a Java Card SDK release hint derived from the local
  Oracle SDK kit corpus (jc211..jc305u4 exports; unknown versions stay
  unhinted).  Vendor/applet AIDs stay bare.
- Header package flags (Table 6-4: int / exports / applet package) and the
  optional JC 2.2 package_name (absent in all CAP 2.1 files).
- components: every archive entry in load-file order with its size and
  share of the load file (including the Directory/Export entries capmem
  does not parse); the sizes sum to load_file_bytes.
- The PWA box leads with "Requires: ..." when imports exist, keeps the
  compiled-against line (Java Card hint + CAP format), the package/applet
  identity, the import details (family, refs, AID) and the component
  breakdown in Details; a memory-only response still renders.

Tests: Python +2 (imports/flags/name/components; header flags + package
name) with the synthetic CAP builder extended; frontend +2 renderer cases
(unknown AIDs, no-import responses) plus jcAidNorm/jcAidFamily tests; the
jcAidNorm extraction added to the ram/scp81 harnesses.
Help EN/RU, docs/api.md, AGENTS.

591 frontend / 473 Python green; version 3.5.16; sw simple-v269.
2026-09-27 02:23:03 +03:00

291 lines
13 KiB
Python

"""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, flags=0, name=None):
# magic, cap minor/major, flags, package minor/major, aid (LV)
payload = _u4(0xDECAFFED) + bytes([1, 2, flags, 1, 2]) + _u1(len(aid)) + aid
if name is not None: # JC 2.2+ package_name_info
raw = name.encode('utf-8')
payload += _u1(len(raw)) + raw
return _component(0x01, payload)
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, header_flags=0, header_name=None):
components = {'Header': _header(header_aid, header_flags, header_name),
'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=[(0, 1, 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)
# without the load file the tool's bytecode-only C6 is kept and no
# C6+C8-style total is reported
self.assertEqual(info['code']['load_file'], 0)
self.assertIsNone(info['nvram']['requirement'])
# the imported library (with the distinct CP reference count), the
# header flags and the component list (ZIP order, load file order)
self.assertEqual(info['imports'],
[{'aid': 'A0000000620101', 'minor': 0, 'major': 1, 'refs': 1}])
self.assertEqual(info['flags'], {'raw': 0, 'int': False, 'export': False, 'applet': False})
self.assertIsNone(info['package_name'])
names = [c['name'] for c in info['components']]
self.assertIn('Header', names)
self.assertIn('Import', names)
self.assertIn('Method', names)
def test_header_flags_and_package_name(self):
cap = build_cap(header_flags=0x05, header_name='com/example/applet')
report, _ = capmem.analyze_bytes(cap)
info = capmem.memory_json(report, capmem.compute_memory(report))
self.assertEqual(info['flags'], {'raw': 5, 'int': True, 'export': False, 'applet': True})
self.assertEqual(info['package_name'], 'com/example/applet')
# a CAP 2.1 header (no name bytes) reports no name
report, _ = capmem.analyze_bytes(build_cap())
self.assertIsNone(report['package_name'])
def test_memory_json_nvram_requirement_uses_the_load_file(self):
report, memory = capmem.analyze_bytes(rich_cap())
info = capmem.memory_json(report, memory, load_file_bytes=1000)
self.assertEqual(info['code']['load_file'], 1000)
self.assertEqual(info['suggested']['c6'], 1000)
self.assertEqual(info['nvram']['requirement'],
1000 + memory['nvram_with_ref_storage'])
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)
# the endpoint feeds the load file size in: C6 = package image and
# nvram.requirement = code image + persistent data (GP C6+C8 style)
self.assertEqual(resp['memory']['code']['load_file'], resp['load_file_bytes'])
self.assertEqual(resp['memory']['suggested']['c6'], resp['load_file_bytes'])
self.assertEqual(resp['memory']['nvram']['requirement'],
resp['load_file_bytes'] + resp['memory']['nvram']['total'])
# the component sizes are the load file parts
self.assertEqual(sum(c['size'] for c in resp['memory']['components']),
resp['load_file_bytes'])
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()