feat: CAP compatibility test as its own operation + append-only imports (v3.6.48)

The import probe no longer lives in 'Install Package (.cap file)': a
diagnostic that can leave a LOADED-only package does not belong in the
operation meant to produce a working applet.  /api/ram-install no longer
takes probe_imports; the install form's probe fieldset is gone.

New RAM operation CAP compatibility test (POST /api/cap-compat): the
server runs the format check, INSTALL [for load] and the LOAD blocks only
up to the block that completes the Import component (_cap_import_end +
_cap_compat_blocks, computed from the load file and the C4 TLV framing),
then stops - no last-block flag, no INSTALL [for install], so nothing is
committed and nothing needs deleting.  The verdict (imports_ok,
boundary_block/total_blocks) states it covers the LOAD/import gate only;
the link gate stays the real install's business.  Verified on the real
RemMobileID CAP: the boundary is LOAD 1/64, 2/128, 3/240 for 240/120/64 B
blocks - exactly the blocks where the live cards rejected the load.

_probe_import_versions(load_file, overrides, additions) now supports
append-only synthetic imports: +AID=version appends a new entry, the
Import component's size field and the Directory's Import size are patched,
and existing entries keep their positions (constant-pool import indices
stay valid); an AID that is already imported becomes a version override.
This is the card-capability query ('does this card export package X at
>= Y?') without building a probe applet.

PWA: new op section (CAP file + analysis + the version list + block size),
ramCompatTest with inline results and ramCompatVerdict, ramProbeParse
understands +AID=version; help EN/RU documents the operation, the two-step
bisect and the gate scope.

sw cache simple-v321; tests: 538 Python / 686 Node.
This commit is contained in:
2026-09-30 07:57:11 +03:00
parent ef949e6a17
commit 0dc79a3ba4
9 changed files with 490 additions and 121 deletions
+77 -1
View File
@@ -8,7 +8,9 @@ import unittest
import zipfile
from pysim_simple_server import capmem
from pysim_simple_server.server import _cap_info_body, _cap_parse, _probe_import_versions
from pysim_simple_server.server import (
_ber_len, _cap_apdu_sequence, _cap_compat_blocks, _cap_import_end,
_cap_info_body, _cap_parse, _probe_import_versions)
# ─── synthetic CAP builder ───────────────────────────────────────────────
@@ -221,6 +223,80 @@ class ImportProbeTests(unittest.TestCase):
self.assertEqual(applied2, [])
self.assertEqual(_same, load)
def _walk_ok(self, load_hex):
"""The component sizes must cover the file exactly (a mis-patched
size field or Directory entry would overshoot/undershoot)."""
data = bytes.fromhex(load_hex)
off = 0
while off + 3 <= len(data):
size = int.from_bytes(data[off + 1:off + 3], 'big')
off += 3 + size
return off == len(data)
def test_probe_appends_synthetic_imports(self):
load = self._load_file()
patched, applied = _probe_import_versions(
load, {}, [{'aid': 'A0000000620102', 'version': '1.3'}])
self.assertGreater(len(patched), len(load)) # the file grew
entries = _import_entries(patched)
self.assertEqual(entries[-1], ('A0000000620102', 1, 3))
self.assertIsNone(applied[-1]['from'])
self.assertEqual(applied[-1]['to'], '1.3')
self.assertTrue(self._walk_ok(patched))
# an AID that is already imported becomes a version override
patched2, applied2 = _probe_import_versions(
load, {}, [{'aid': JAVACARD_FRAMEWORK.hex().upper(), 'version': '0.0'}])
self.assertEqual(len(patched2), len(load))
self.assertEqual([(maj, mn) for _a, maj, mn in _import_entries(patched2)][0], (0, 0))
self.assertEqual(applied2, [{'aid': JAVACARD_FRAMEWORK.hex().upper(),
'from': '1.3', 'to': '0.0'}])
def test_probe_append_patches_the_directory_size(self):
load = self._load_file()
directory = _component(0x02, b'\x00' * 28) # sizes by tag, all zero
with_dir = (directory + bytes.fromhex(load)).hex().upper()
patched, _applied = _probe_import_versions(
with_dir, {}, [{'aid': 'A0000000620102', 'version': '1.0'}])
data = bytes.fromhex(patched)
off = 0
import_size = None
while off + 3 <= len(data):
tag = data[off]
size = int.from_bytes(data[off + 1:off + 3], 'big')
if tag == 0x04:
import_size = size
break
off += 3 + size
self.assertIsNotNone(import_size)
# the Directory's array entry for tag 4 (index 3) follows the size
self.assertEqual(data[3 + 3 * 2:3 + 3 * 2 + 2], import_size.to_bytes(2, 'big'))
self.assertTrue(self._walk_ok(patched))
def test_probe_rejects_bad_additions(self):
load = self._load_file()
with self.assertRaises(ValueError):
_probe_import_versions(load, {}, [{'aid': 'AB', 'version': '1.0'}])
with self.assertRaises(ValueError):
_probe_import_versions(load, {}, [{'aid': 'A0000000620102', 'version': 'x'}])
def test_compat_boundary_is_the_block_completing_the_import(self):
load = self._load_file()
load_aid = 'A0000000620100'
module_aid = 'A000000062010001'
import_end = _cap_import_end(load)
for bs in (10, 64, 240):
boundary, total = _cap_compat_blocks(load, bs)
# cross-check against the LOAD sequence: locate the Import's last
# byte in the concatenated C4 TLV and see which block holds it
seq = _cap_apdu_sequence(load_aid, module_aid, load, block_size=bs)
blocks = seq[1:-1] # the LOAD APDUs
self.assertEqual(len(blocks), total)
tlv = ''.join(b[10:] for b in blocks) # 5-byte APDU header
header_chars = len(_ber_len(len(load) // 2))
pos = header_chars + (import_end - 1) * 2
self.assertEqual(pos // (bs * 2) + 1, boundary)
self.assertLessEqual(boundary, total)
def test_probe_rejects_unknown_aids_and_bad_versions(self):
load = self._load_file()
with self.assertRaises(ValueError):
+34
View File
@@ -157,5 +157,39 @@ class CardFreeGetTests(unittest.TestCase):
self.assertNotIn(path, _CARD_FREE_GET)
class CapCompatHttpTests(unittest.TestCase):
"""POST /api/cap-compat exists (the CAP compatibility test, v3.6.48)."""
def setUp(self):
self.server = _build_http_server('127.0.0.1', 0, PysimHandler)
self.server.log_requests = False
self.server.app = None
self.server.sl = None
self.server.scc = None
threading.Thread(target=self.server.serve_forever, daemon=True).start()
self.port = self.server.server_address[1]
def tearDown(self):
self.server.shutdown()
self.server.server_close()
def _post(self, path, body):
req = urllib.request.Request(
'http://127.0.0.1:%d%s' % (self.port, path),
data=json.dumps(body).encode(),
headers={'Content-Type': 'application/json'}, method='POST')
try:
with urllib.request.urlopen(req, timeout=5) as res:
return res.status, json.loads(res.read() or b'{}')
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read() or b'{}')
def test_cap_compat_route_exists(self):
# without a card session it answers 503 (route present), not 404
status, resp = self._post('/api/cap-compat', {'cap_hex': '00'})
self.assertEqual(status, 503, resp)
self.assertIn('error', resp)
if __name__ == '__main__':
unittest.main()