fix: CAP compatibility test review fixes (v3.6.49)
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.
This commit is contained in:
+50
-8
@@ -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': 'неудавшийся шаг завершает',
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'simple-v321';
|
||||
const CACHE = 'simple-v322';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
+41
-10
@@ -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');
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user