' + esc(t('Privileges:')) + ' ' + ramFmtPrivileges(o.privileges) + ' (' + o.privileges + ')
';
@@ -9315,6 +9318,7 @@ function ramRenderExploreHtml(mem, isd, apps, elfs, fmt, expanded, elfForm) {
html += '
';
html += '
' + esc(t('Application / Instance AID:')) + ' ' + (o.aid ? jcAidHtml(o.aid) : '?') + '
';
html += '
' + esc(t('Lifecycle:')) + ' ' + ramFmtLifecycle(o.lifecycle || '', ramEntryKind(o)) + '
';
+ if (o.version) html += '
' + esc(t('Version:')) + ' ' + esc(ramFmtVersion(o.version)) + '
';
if (o.privileges) html += '
' + esc(t('Privileges:')) + ' ' + ramFmtPrivileges(o.privileges) + ' (' + o.privileges + ')
';
if (o.implicitSel) html += '
' + esc(t('Implicit sel:')) + ' ' + o.implicitSel + '
';
if (o.elfAid) html += '
' + esc(t('Load File AID / Package AID:')) + ' ' + jcAidHtml(o.elfAid) + '
';
@@ -9463,6 +9467,7 @@ async function ramExplore(sp) {
return res.por.decoded ? (res.por.decoded.last_response_data || '') : '';
};
let page = 0;
+ let more = false;
while (accepted && page++ < 8) {
const apdu = ramExpandedQueryApdu(p1, form, page > 1);
const what = page > 1 ? 'P2=03 (' + form + ')' : 'P2=02 (' + form + ')';
@@ -9481,13 +9486,18 @@ async function ramExplore(sp) {
// more occurrences, and a '61xx' page may mask that signal
// (the GET RESPONSE's own SW replaces it) - try one more page
if (p1 === '80') break;
- if (sw !== '6310' && !pending) break;
+ // continue only when the answer really says 'more occurrences'
+ // or the chained retry was accepted (a '61xx' page masks the
+ // 6310 signal with the GET RESPONSE's own SW); a refused retry
+ // must not cost another packet (v3.7.0)
+ if (sw !== '6310' && !(pending && ramRemoteSwOk(sw))) break;
+ more = true;
}
const parsed = data ? parser(data) : [];
if (parsed.length) {
expandedForm = form;
return { form: form, parsed: parsed, sw: sw, entries: parsed.length,
- transport: transportError };
+ transport: transportError, more: more };
}
lastSw = sw;
lastTransport = transportError;
@@ -9495,10 +9505,11 @@ async function ramExplore(sp) {
if (ramRemoteSwOk(sw)) {
// accepted but empty: nothing to gain from another form
return { form: form, parsed: [], sw: sw, entries: 0,
- transport: transportError };
+ transport: transportError, more: more };
}
}
- return { form: null, parsed: [], sw: lastSw, entries: 0, transport: lastTransport };
+ return { form: null, parsed: [], sw: lastSw, entries: 0,
+ transport: lastTransport, more: false };
}
async function paginate(p1, collector, parser, label, quiet) {
@@ -9574,7 +9585,7 @@ async function ramExplore(sp) {
const out = await expandedPass(p1, label, parser, spi2);
expandedReport.push({ p1: p1, label: label, compact: collector.length > before,
form: out.form, sw: out.sw, entries: out.entries,
- transport: out.transport });
+ transport: out.transport, more: out.more });
if (collector.length === before) {
// Nothing from the compact pass: the expanded result is the listing
collector.push(...out.parsed);
@@ -9627,10 +9638,61 @@ async function ramExplore(sp) {
await paginate('20', elfs, ramParseElfStatus, t('ELFs'), true);
if (!elfs.length) errors.push(...elfFailure);
}
+ // The ELF tag-list forms may be refused for P1=10 while P1=20 accepts
+ // them (a card's P1=10 implementation can be stricter): when the ELF rows
+ // have no version and the details pass never got a tag-list form, walk the
+ // ladder once more with P1=20 and merge by AID (v3.7.0).
+ async function elfTagListRetry() {
+ if (cntrLow || !elfs.length || elfs.some(o => o.version)) return;
+ const g = ramExpandedGroups(expandedReport).find(x => x.label === t('ELFs'));
+ if (g && ['tags', 'noea', 'lean'].indexOf(g.form) >= 0) return;
+ const saved = expandedForm;
+ expandedForm = null; // start the ladder at the richest form
+ try {
+ const out = await expandedPass('20', t('ELFs'), ramParseElfStatus, ramListingSpi2('20'));
+ expandedReport.push({ p1: '20', label: t('ELFs'), compact: true,
+ form: out.form, sw: out.sw, entries: out.entries,
+ transport: out.transport, more: out.more });
+ if (out.parsed.length) ramMergeExpanded(elfs, out.parsed);
+ } finally {
+ if (expandedForm === null) expandedForm = saved;
+ }
+ }
+ await elfTagListRetry();
+
// A package whose own listing carries no version may still be known from
// its applet instance (the app entry carries the load file version and the
// package AID); the rows are annotated when this inference is used.
ramDeriveElfVersions(elfs, apps);
+
+ // Still missing versions: query the apps listing once per applet AID (the
+ // AID search qualifier selects one entity). The apps tag list works even
+ // where the ELF one is refused, and an applet's CE is its package's
+ // version - the applet <-> package mapping is the AID prefix (v3.7.0).
+ async function elfPerAidVersions() {
+ const missing = elfs.filter(e => !e.version && e.aid);
+ for (const e of missing.slice(0, 6)) {
+ if (cntrLow) break;
+ const app = apps.find(a => a.aid && !a.version &&
+ String(a.aid).toUpperCase().indexOf(String(e.aid).toUpperCase()) === 0);
+ if (!app) continue;
+ ramShowProgress(t('Version') + ' ' + e.aid + '...');
+ const res = await sendOta(ramExpandedQueryApdu('40', 'tags', false, app.aid),
+ Object.assign({}, sp, { cntr, spi2: ramListingSpi2('40') }));
+ if (res.ram_format) sp.ram_format = res.ram_format;
+ if (res.final_cntr) cntr = res.final_cntr;
+ else if (res.success && spPorAccepted(res.por)) cntr = ramIncrementCntr(cntr);
+ if (!res.success || !res.por || !spPorAccepted(res.por)) continue;
+ const data = res.por.decoded ? (res.por.decoded.last_response_data || '') : '';
+ const parsed = data ? ramParseAppStatus(data) : [];
+ const hit = parsed.find(x => x.version);
+ if (hit) {
+ e.version = hit.version;
+ e.versionFrom = 'app';
+ }
+ }
+ }
+ await elfPerAidVersions();
// Only write the counter when a packet was actually accepted: a run whose
// start value was stale must never lower the preset counter.
if (cntr !== sp.cntr) ramSaveCntr(cntr);
@@ -11173,13 +11235,19 @@ function ramExpandedTags(p1, form) {
return base + (elf ? '84' : '');
}
-// `next` = the next-occurrence page (P2.b1=1, GP 11.4.3.2) after a '6310'.
-function ramExpandedQueryApdu(p1, form, next) {
+// `next` = the next-occurrence page (P2.b1=1, GP 11.4.3.2) after a '6310';
+// `aidHex` = an AID search qualifier (`4F
`, GP 11.4.2.3
+// Table 11-35) that selects a single registry entity - used to fetch one
+// applet's entry when the listing cannot be paged (v3.7.0).
+function ramExpandedQueryApdu(p1, form, next, aidHex) {
const tags = ramExpandedTags(p1, form || 'tags');
const p2 = next ? '03' : '02';
- if (!tags) return '80F2' + p1 + p2 + '02' + '4F00' + '00';
+ const aid = (aidHex || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
const hex = n => n.toString(16).toUpperCase().padStart(2, '0');
- return '80F2' + p1 + p2 + hex(tags.length / 2 + 4) + '4F005C' + hex(tags.length / 2) + tags + '00';
+ const filter = aid ? '4F' + hex(aid.length / 2) + aid : '4F00';
+ const lc = filter.length / 2 + (tags ? tags.length / 2 + 2 : 0);
+ return '80F2' + p1 + p2 + hex(lc) + filter +
+ (tags ? '5C' + hex(tags.length / 2) + tags : '') + '00';
}
// A '61xx' remote answer means the card holds the response data. The live
@@ -18919,6 +18987,7 @@ const LANG_RU = {
'card counter': 'счётчик карты',
'the counter must be above it': 'счётчик должен быть больше',
'Go to preset': 'К пресету',
+ 'first page only': 'только первая страница',
'the operation was stopped': 'операция остановлена',
'STK menu entry:': 'Пункт меню STK:',
'position': 'позиция',
diff --git a/frontend/sw.js b/frontend/sw.js
index 2628401..5f5baf3 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'simple-v331';
+const CACHE = 'simple-v332';
const URLS = [
'index.html',
'help.html',
diff --git a/frontend/tests/ram.test.js b/frontend/tests/ram.test.js
index ad88ba6..c81053e 100644
--- a/frontend/tests/ram.test.js
+++ b/frontend/tests/ram.test.js
@@ -580,6 +580,12 @@ test('ramExpandedQueryApdu builds the expanded query forms', () => {
assert.strictEqual(ramExpandedQueryApdu('10', 'lean', true), '80F21003094F005C054F9F70CE8400');
assert.strictEqual(ramExpandedQueryApdu('10', 'notags', true), '80F21003024F0000');
assert.deepStrictEqual(RAM_EXPANDED_FORMS, ['tags', 'noea', 'lean', 'notags']);
+ // an AID search qualifier (4F ) selects one entity: used to
+ // fetch a single applet's entry when the listing cannot be paged
+ assert.strictEqual(ramExpandedQueryApdu('40', 'tags', false, 'A1130001180001FFFFFFFF89A1003908'),
+ '80F240021C4F10A1130001180001FFFFFFFF89A10039085C084F9F70C5C4CCCEEA00');
+ assert.strictEqual(ramExpandedQueryApdu('40', 'notags', false, 'A1130001180001'),
+ '80F24002094F07A113000118000100');
});
test('ramChainGetResponse chains the GET RESPONSE into the query string', () => {
@@ -667,6 +673,10 @@ test('ramExpandedReport summarizes the query forms and the fallbacks', () => {
{ p1: '10', label: 'ELFs', compact: true, form: 'lean', sw: '9000', entries: 3 },
{ p1: '20', label: 'ELFs', compact: true, form: 'notags', sw: '9000', entries: 5 },
]), 'Expanded registry: lean tag list (ELFs)');
+ // an unfulfilled 6310 (the next-occurrence page was refused) is noted
+ assert.strictEqual(ramExpandedReport([
+ { p1: '40', label: 'Apps', compact: true, form: 'tags', sw: '6A86', entries: 2, more: true },
+ ]), 'Expanded registry: tag list + EA (Apps (first page only))');
// a request/transport failure is not a remote status word
assert.strictEqual(ramExpandedReport([
{ p1: '40', label: 'Apps', compact: true, form: 'tags', sw: '9000', entries: 9 },
@@ -734,6 +744,26 @@ test('ramCntrLowHtml states the facts and offers the preset jump', () => {
assert.strictEqual(ramCntrLowHtml(null, 0), '');
});
+test('the explorer shows versions on app and ISD rows too', () => {
+ globalThis.t = s => s;
+ const out = ramRenderExploreHtml(null,
+ [{ aid: 'A000000151000000', lifecycle: '0F', privileges: '82FC80', version: '0001' }],
+ [{ aid: 'A1130001180001FFFFFFFF89A1003908', lifecycle: '07', privileges: '000000', version: '0903' }],
+ []);
+ delete globalThis.t;
+ assert.ok(out.includes('Version: 0.1'), out);
+ assert.ok(out.includes('Version: 9.3'), out);
+});
+
+test('the Explore retries the ELF tag lists with P1=20 and per applet AID', () => {
+ const src = extractFunc(html, 'ramExplore');
+ assert.ok(src.includes('elfTagListRetry'), 'the P1=20 tag-list retry must exist');
+ assert.ok(src.includes("expandedPass('20'"), 'the retry must query P1=20');
+ assert.ok(src.includes('elfPerAidVersions'), 'the per-AID version queries must exist');
+ assert.ok(src.includes("ramExpandedQueryApdu('40', 'tags', false, app.aid)"),
+ 'the per-AID query must use the AID filter');
+});
+
test('every SCP80/RAM flow checks the low counter and stops', () => {
// the Explore: the guarded sender, the loop guards and the final branch
const explore = extractFunc(html, 'ramExplore');
diff --git a/pyproject.toml b/pyproject.toml
index c721212..fa80550 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
-version = "3.6.58"
+version = "3.7.0"
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 d1a344a..128d5f5 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.58'
+VERSION = '3.7.0'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE