diff --git a/frontend/index.html b/frontend/index.html
index f2093a9..ec9ee54 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1745,7 +1745,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.6.48';
+const SIMPLE_VERSION = '3.6.49';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching =====
@@ -9515,9 +9515,8 @@ function ramProbeParse(text) {
map[m[2].toLowerCase() === 'all' ? 'all' : m[2].toUpperCase()] = ver;
}
}
- if (!Object.keys(map).length && !additions.length) {
- return { error: t('No import versions given') };
- }
+ // an empty list is valid: the compatibility test then runs with the CAP's
+ // own versions (the basic "is this CAP compatible with this card?" question)
return { map: map, additions: additions };
}
@@ -9541,16 +9540,50 @@ function ramProbeAllZero() {
// Verdict line for the CAP compatibility test (pure). The test covers the
// LOAD/import gate only - the card checks the import list while it verifies
-// the load file; the link gate is what a real install checks later.
+// the load file; the link gate is what a real install checks later. The
+// headline distinguishes where the run actually failed: a request error, the
+// format probe (the card not answering), the INSTALL [for load] request, or
+// the import list itself.
function ramCompatVerdict(data) {
if (!data) return '';
+ const stepOk = s => !s.por_error && (s.por_status === 'por_ok' || s.por_status === 'no_por');
+ if (!data.steps || !data.steps.length) {
+ return t('Error') + ': ' + (data.error || t('no data'));
+ }
+ const formats = data.steps.filter(s => /^FORMAT CHECK/.test(String(s.name || '')));
+ if (formats.length && formats.every(s => !stepOk(s))) {
+ return t('The card did not answer the format probe') +
+ (data.error ? ' \u00b7 ' + data.error : '');
+ }
const scope = t('tested through LOAD') + ' ' + (data.boundary_block || '?') +
'/' + (data.total_blocks || '?');
if (data.imports_ok) return t('Import list accepted') + ' \u2014 ' + scope;
+ const failed = data.steps.find(s => !stepOk(s)) || {};
+ if (String(failed.name || '') === 'INSTALL [for load]') {
+ return t('Load request rejected') + ' \u2014 ' + (data.error || t('no data'));
+ }
return t('Import list rejected') + ' \u2014 ' + scope +
(data.error ? ' \u00b7 ' + data.error : '');
}
+// The applied probe changes of a compatibility test (pure): version rewrites,
+// appended synthetic imports and override AIDs that matched nothing.
+function ramCompatProbeNote(data) {
+ const pa = (data && data.probe_imports) || [];
+ const added = pa.filter(p => p.from === null || p.from === undefined).length;
+ const changed = pa.filter(p => p.from !== null && p.from !== undefined);
+ const parts = [];
+ if (changed.length) {
+ const targets = Array.from(new Set(changed.map(p => p.to)));
+ parts.push(changed.length + ' ' + t('imports') + ' \u2192 ' +
+ (targets.length === 1 ? targets[0] : t('mixed')));
+ }
+ if (added) parts.push('+' + added + ' ' + t('appended'));
+ const unmatched = (data && data.probe_unmatched) || [];
+ if (unmatched.length) parts.push(t('unmatched') + ': ' + unmatched.join(', '));
+ return parts.length ? t('probe') + ': ' + parts.join(' \u00b7 ') : '';
+}
+
// CAP compatibility test: run the load only as far as the Import component's
// end and report whether the card accepted the import list. Nothing is
// installed or committed (the install operation keeps the CAP's own versions).
@@ -9570,8 +9603,12 @@ async function ramCompatTest(sp) {
spi1: sp.spi1, spi2: '01', kic: sp.kic, kid: sp.kid,
tar: sp.tar, cntr: sp.cntr, kicKey: sp.kicKey, kidKey: sp.kidKey,
};
- const bs = parseInt(document.getElementById('ram-compat-block').value || '', 10);
- if (bs >= 1 && bs <= 240) body.load_block_size = bs;
+ const bsRaw = (document.getElementById('ram-compat-block').value || '').trim();
+ if (bsRaw !== '') {
+ const bs = parseInt(bsRaw, 10);
+ if (!(bs >= 1 && bs <= 240)) { alert(t('LOAD block size must be 1..240')); return; }
+ body.load_block_size = bs;
+ }
const data = await pysimFetch('/api/cap-compat', body);
ramHideProgress();
@@ -9583,7 +9620,8 @@ async function ramCompatTest(sp) {
stepsEl.classList.remove('hidden');
stepsEl.textContent = txt;
const resultEl = document.getElementById('ram-result');
- resultEl.textContent = ramCompatVerdict(data);
+ const probeNote = ramCompatProbeNote(data);
+ resultEl.textContent = ramCompatVerdict(data) + (probeNote ? ' \u2014 ' + probeNote : '');
resultEl.classList.remove('hidden', 'text-green-600', 'text-red-600');
resultEl.classList.add(data.imports_ok ? 'text-green-600' : 'text-red-600');
}
@@ -17537,6 +17575,10 @@ const LANG_RU = {
'probe': 'проба',
'imports': 'импортов',
'mixed': 'разные',
+ 'appended': 'добавлено',
+ 'unmatched': 'без совпадений',
+ 'Load request rejected': 'Запрос загрузки отклонён',
+ 'The card did not answer the format probe': 'Карта не ответила на пробу формата',
'completes': 'завершает',
'inside': 'внутри',
'the failed step completes': 'неудавшийся шаг завершает',
diff --git a/frontend/sw.js b/frontend/sw.js
index 5e03a1d..7783d17 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'simple-v321';
+const CACHE = 'simple-v322';
const URLS = [
'index.html',
'help.html',
diff --git a/frontend/tests/ram.test.js b/frontend/tests/ram.test.js
index 9d02cd1..702fb48 100644
--- a/frontend/tests/ram.test.js
+++ b/frontend/tests/ram.test.js
@@ -22,7 +22,7 @@ function extractFunc(src, name) {
}
// Extract chain builder functions and dependencies
-const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamBuildRowHex', 'ramFmtLifecycle', 'ramFmtPrivileges', 'ramRenderExploreHtml', 'ramStepLine', 'ramStepComponents', 'ramInstallFailHint', 'ramProbeParse', 'ramCompatVerdict', 'ramExpandedDetailsInit', 'ramExpandedDetailsChanged', 'ramGetStatusApdu', 'ramDeleteApdu',
+const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamBuildRowHex', 'ramFmtLifecycle', 'ramFmtPrivileges', 'ramRenderExploreHtml', 'ramStepLine', 'ramStepComponents', 'ramInstallFailHint', 'ramProbeParse', 'ramCompatVerdict', 'ramCompatProbeNote', 'ramExpandedDetailsInit', 'ramExpandedDetailsChanged', 'ramGetStatusApdu', 'ramDeleteApdu',
'stkParamsBuild', 'ramRemoteSwOk', 'spPorAccepted', 'ramIncrementCntr', 'ramDeleteFromExplorer', 'ramListingSpi2', 'ramRemoveFromExplorer', 'ramHasInstance', 'ramExpandedQueryApdu',
'_parseRawElfEntry', '_parseRawAppEntry', 'ramParseElfStatus', 'ramParseAppStatus', 'parseTLV', '_parseE3Entry',
'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute', 'decodePrivileges', 'ramActionBtn', 'ramCapToolkitMode', 'ramOpProgressText',
@@ -787,8 +787,9 @@ test('ramProbeParse reads AID=version lines', () => {
// malformed lines and an empty field are errors
assert.ok(ramProbeParse('A0000000620101').error);
assert.ok(ramProbeParse('A0000000620101=x.y').error);
- assert.ok(ramProbeParse('').error);
- assert.ok(ramProbeParse(' \n# only comments\n').error);
+ // an empty list is valid: the test then runs with the CAP's own versions
+ assert.deepStrictEqual(ramProbeParse(''), { map: {}, additions: [] });
+ assert.deepStrictEqual(ramProbeParse(' \n# only comments\n'), { map: {}, additions: [] });
// '+AID=version' appends a synthetic import (a card-capability query)
r = ramProbeParse('+A0000000090005FFFFFFFF8912000000=1.11');
assert.deepStrictEqual(r.map, {});
@@ -800,17 +801,47 @@ test('ramProbeParse reads AID=version lines', () => {
assert.ok(ramProbeParse('+all=1.0').error);
});
-test('ramCompatVerdict reports the import-gate verdict', () => {
+test('ramCompatVerdict distinguishes where the test failed', () => {
globalThis.t = s => s;
- const ok = ramCompatVerdict({ imports_ok: true, boundary_block: 3, total_blocks: 240 });
- assert.strictEqual(ok, 'Import list accepted \u2014 tested through LOAD 3/240');
- const bad = ramCompatVerdict({ imports_ok: false, boundary_block: 3, total_blocks: 240,
- error: 'remote SW 6438' });
- assert.ok(bad.startsWith('Import list rejected \u2014 tested through LOAD 3/240'), bad);
- assert.ok(bad.includes('remote SW 6438'), bad);
+ const step = (name, ok) => ok
+ ? { name: name, por_status: 'por_ok', por_sw: '9000' }
+ : { name: name, por_status: 'por_ok', por_sw: '6438', por_error: 'remote SW 6438' };
+ assert.strictEqual(ramCompatVerdict({ imports_ok: true, boundary_block: 3, total_blocks: 240,
+ steps: [step('FORMAT CHECK (compact)', true), step('INSTALL [for load]', true), step('LOAD (3/240)', true)] }),
+ 'Import list accepted \u2014 tested through LOAD 3/240');
+ assert.strictEqual(ramCompatVerdict({ imports_ok: false, boundary_block: 3, total_blocks: 240,
+ error: 'remote SW 6438',
+ steps: [step('FORMAT CHECK (compact)', true), step('INSTALL [for load]', true), step('LOAD (3/240)', false)] }),
+ 'Import list rejected \u2014 tested through LOAD 3/240 \u00b7 remote SW 6438');
+ // the card never answered the format probe
+ assert.strictEqual(ramCompatVerdict({ imports_ok: false, boundary_block: 1, total_blocks: 240,
+ steps: [step('FORMAT CHECK (compact)', false), step('FORMAT CHECK (expanded)', false)] }),
+ 'The card did not answer the format probe');
+ // the INSTALL [for load] request itself was refused
+ assert.strictEqual(ramCompatVerdict({ imports_ok: false, boundary_block: 1, total_blocks: 240,
+ error: 'remote SW 6985',
+ steps: [step('FORMAT CHECK (compact)', true), step('INSTALL [for load]', false)] }),
+ 'Load request rejected \u2014 remote SW 6985');
+ // a request error without steps
+ assert.strictEqual(ramCompatVerdict({ error: 'import probe: invalid version' }),
+ 'Error: import probe: invalid version');
assert.strictEqual(ramCompatVerdict(null), '');
});
+test('ramCompatProbeNote summarizes the applied probe', () => {
+ globalThis.t = s => s;
+ assert.strictEqual(ramCompatProbeNote({ probe_imports: [
+ { aid: 'A1', from: '1.3', to: '0.0' }, { aid: 'A2', from: '1.4', to: '0.0' },
+ { aid: 'A3', from: null, to: '1.0' }] }),
+ 'probe: 2 imports \u2192 0.0 \u00b7 +1 appended');
+ assert.strictEqual(ramCompatProbeNote({ probe_imports: [
+ { aid: 'A1', from: '1.3', to: '0.0' }, { aid: 'A2', from: '1.4', to: '1.0' }] }),
+ 'probe: 2 imports \u2192 mixed');
+ assert.strictEqual(ramCompatProbeNote({ probe_imports: [], probe_unmatched: ['DEADBEEF'] }),
+ 'probe: unmatched: DEADBEEF');
+ assert.strictEqual(ramCompatProbeNote({}), '');
+});
+
test('ramExecute dispatches the compatibility test op', () => {
const src = extractFunc(html, 'ramExecute');
assert.ok(/op === 'compat'/.test(src), 'the compat op must be dispatched');
diff --git a/pyproject.toml b/pyproject.toml
index fe3e809..f8c9bfc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
-version = "3.6.48"
+version = "3.6.49"
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/server.py b/pysim_simple_server/server.py
index bec3a01..3801880 100644
--- a/pysim_simple_server/server.py
+++ b/pysim_simple_server/server.py
@@ -31,7 +31,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
-VERSION = '3.6.48'
+VERSION = '3.6.49'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -870,6 +870,31 @@ def _cap_import_end(load_file_hex):
raise ValueError('no Import component in the load file')
+def _cap_import_aids(load_file_hex):
+ """The AIDs of the load file's Import component (upper-case hex)."""
+ data = bytes.fromhex(load_file_hex or '')
+ off = 0
+ while off + 3 <= len(data):
+ tag = data[off]
+ size = int.from_bytes(data[off + 1:off + 3], 'big')
+ if tag == 0x04:
+ start = off + 3
+ end = start + size
+ out = []
+ p = start + 1
+ for _ in range(data[start]):
+ if p + 3 > end:
+ break
+ aid_len = data[p + 2]
+ if p + 3 + aid_len > end:
+ break
+ out.append(bytes(data[p + 3:p + 3 + aid_len]).hex().upper())
+ p += 3 + aid_len
+ return out
+ off += 3 + size
+ return []
+
+
def _cap_compat_blocks(load_file_hex, block_size):
"""(boundary, total) LOAD block numbers for the compatibility test.
@@ -6888,9 +6913,11 @@ class PysimHandler(BaseHTTPRequestHandler):
loadfile_aid, module_aid, loadfile_data = _cap_parse(cap_hex)
probe_applied = None
+ probe_unmatched = []
probe = body.get('probe_imports') or {}
additions = body.get('probe_additions') or []
if probe or additions:
+ original_aids = set(_cap_import_aids(loadfile_data))
try:
loadfile_data, probe_applied = _probe_import_versions(
loadfile_data, probe, additions)
@@ -6899,8 +6926,17 @@ class PysimHandler(BaseHTTPRequestHandler):
self._send_json(resp, 400)
self._log_resp(resp)
return
- sys.stderr.write('CAP-COMPAT: import probe applied: %s\n'
- % json.dumps(probe_applied))
+ known = original_aids | {
+ str(a.get('aid', '')).replace(' ', '').upper()
+ for a in additions}
+ probe_unmatched = sorted(
+ k for k in probe
+ if str(k).lower() != 'all'
+ and str(k).replace(' ', '').upper() not in known)
+ sys.stderr.write('CAP-COMPAT: import probe applied: %s%s\n' % (
+ json.dumps(probe_applied),
+ (' (unmatched: %s)' % ', '.join(probe_unmatched))
+ if probe_unmatched else ''))
block_size_req = body.get('load_block_size')
if block_size_req in (None, ''):
@@ -6961,6 +6997,7 @@ class PysimHandler(BaseHTTPRequestHandler):
'boundary_block': boundary, 'total_blocks': total_blocks,
'load_file_aid': loadfile_aid, 'module_aid': module_aid,
'ram_format': ram_format, 'probe_imports': probe_applied,
+ 'probe_unmatched': probe_unmatched,
'final_cntr': state['cntr'], 'load_block_size': block_size,
'load_block_size_requested': block_size_req}
if not ok:
diff --git a/tests/test_cap_memory.py b/tests/test_cap_memory.py
index e32e681..966939a 100644
--- a/tests/test_cap_memory.py
+++ b/tests/test_cap_memory.py
@@ -9,8 +9,8 @@ import zipfile
from pysim_simple_server import capmem
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)
+ _ber_len, _cap_apdu_sequence, _cap_compat_blocks, _cap_import_aids,
+ _cap_import_end, _cap_info_body, _cap_parse, _probe_import_versions)
# ─── synthetic CAP builder ───────────────────────────────────────────────
@@ -272,6 +272,10 @@ class ImportProbeTests(unittest.TestCase):
self.assertEqual(data[3 + 3 * 2:3 + 3 * 2 + 2], import_size.to_bytes(2, 'big'))
self.assertTrue(self._walk_ok(patched))
+ def test_cap_import_aids(self):
+ self.assertEqual(_cap_import_aids(self._load_file()),
+ [JAVACARD_FRAMEWORK.hex().upper(), self.OTHER_AID.hex().upper()])
+
def test_probe_rejects_bad_additions(self):
load = self._load_file()
with self.assertRaises(ValueError):
diff --git a/tests/test_fetch_routes.py b/tests/test_fetch_routes.py
index f6b6eaf..21e3d04 100644
--- a/tests/test_fetch_routes.py
+++ b/tests/test_fetch_routes.py
@@ -157,6 +157,28 @@ class CardFreeGetTests(unittest.TestCase):
self.assertNotIn(path, _CARD_FREE_GET)
+def _mini_cap_hex():
+ """A minimal parseable CAP archive (Header + Applet + one Import)."""
+ import io as _io
+ import struct as _struct
+ import zipfile as _zipfile
+
+ def comp(tag, payload):
+ return bytes([tag]) + _struct.pack('>H', len(payload)) + payload
+
+ aid = bytes.fromhex('0102030405')
+ header = comp(0x01, _struct.pack('>I', 0xDECAFFED) + bytes([1, 2, 0, 1, 2]) +
+ bytes([len(aid)]) + aid)
+ applet = comp(0x03, bytes([1, len(aid)]) + aid + b'\x00\x01')
+ imports = comp(0x04, bytes([1]) + bytes([0, 1, 7]) + bytes.fromhex('A0000000620101'))
+ buf = _io.BytesIO()
+ with _zipfile.ZipFile(buf, 'w') as z:
+ z.writestr('Header.cap', header)
+ z.writestr('Applet.cap', applet)
+ z.writestr('Import.cap', imports)
+ return buf.getvalue().hex().upper()
+
+
class CapCompatHttpTests(unittest.TestCase):
"""POST /api/cap-compat exists (the CAP compatibility test, v3.6.48)."""
@@ -190,6 +212,15 @@ class CapCompatHttpTests(unittest.TestCase):
self.assertEqual(status, 503, resp)
self.assertIn('error', resp)
+ def test_cap_compat_rejects_a_bad_probe(self):
+ # a parseable CAP + a bad override -> 400 from the probe validation,
+ # before any card I/O (a dummy scc object is enough)
+ self.server.scc = object()
+ status, resp = self._post('/api/cap-compat',
+ {'cap_hex': _mini_cap_hex(), 'probe_imports': {'all': 'x'}})
+ self.assertEqual(status, 400, resp)
+ self.assertIn('import probe', resp['error'])
+
if __name__ == '__main__':
unittest.main()