From 80c26f14f255fe6a511c9546d7f2c2b82c04ef18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Wed, 30 Sep 2026 08:13:12 +0300 Subject: [PATCH] fix: CAP compatibility test review fixes (v3.6.49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of v3.6.48 found six issues; the two that matter in use: - ramProbeParse('') errored, so the basic 'is this CAP compatible with this card at all?' run (no overrides) was refused by an alert. An empty list is valid now: the test runs with the CAP's own versions. - ramCompatVerdict said 'Import list rejected' for every failure. It now distinguishes: a request error (no steps), a failed format probe (the card not answering), an INSTALL [for load] refusal, and only then the import list - each with its own headline. Plus: - the result line shows the applied probe (ramCompatProbeNote: 'probe: 7 imports -> 0.0 · +1 appended', unmatched AIDs included); - #ram-compat-block validates 1..240 like the install's block size; - the server reports probe_unmatched (override AIDs matching no entry, previously silent when another override matched) and logs them; - tests: the endpoint's probe-400 path (a mini CAP + a dummy scc), _cap_import_aids, the verdict cases and the probe note. sw cache simple-v322; tests: 540 Python / 687 Node. --- frontend/index.html | 58 ++++++++++++++++++++++++++++++----- frontend/sw.js | 2 +- frontend/tests/ram.test.js | 51 ++++++++++++++++++++++++------ pyproject.toml | 2 +- pysim_simple_server/server.py | 43 ++++++++++++++++++++++++-- tests/test_cap_memory.py | 8 +++-- tests/test_fetch_routes.py | 31 +++++++++++++++++++ 7 files changed, 170 insertions(+), 25 deletions(-) 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()