From b7798a7fd9b383e9826684cb35590a2a50e3928d 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 09:57:41 +0300 Subject: [PATCH] feat: expanded registry handles 61xx/6310, lean form, grouped report (v3.6.55) The complete live trace (2026-09-30) showed the card was not refusing nearly as much as the report suggested: - the ISD expanded query answered 6131 and the ELF notags query 617F/613F - response data pending, and no GET RESPONSE was ever sent; - the apps expanded query answered 6310 (more occurrences) and only the first page was merged; - the ELF tag-list forms (tags, noea) answered 6A88 because they still carried the application-only C5 privileges / C4 ELF AID. Fixes: - 61xx -> ramGetResponseApdu(sw) = 00C00000 (up to 4 rounds); - 6310 -> the same query as a next-occurrence page (P2.b1=1, P2=03, up to 8 pages), so the apps listing is complete; - a 'lean' ladder form with only the objects valid for the kind: ELFs get 4F 9F70 CE 84 (AID, life cycle, version, modules), the ISD/apps get pySim's 4F 9F70 C5 CC; the ladder is tags -> noea -> lean -> notags; - the report groups by listing label with the best outcome winning (entries > accepted-but-empty > refusal): the P1=10/P1=20 ELF double query no longer prints 'ELFs, ELFs' and a pass with data is never called a refusal; the ELF version hint is form-aware. sw cache simple-v328; tests: 550 Python / 709 Node. --- frontend/index.html | 204 ++++++++++++++++++++++++---------- frontend/sw.js | 2 +- frontend/tests/ram.test.js | 83 +++++++++++--- pyproject.toml | 2 +- pysim_simple_server/server.py | 2 +- 5 files changed, 220 insertions(+), 73 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 1e6efa5..c3eea12 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1749,7 +1749,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.54'; +const SIMPLE_VERSION = '3.6.55'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== Tab switching ===== @@ -9154,36 +9154,66 @@ function ramMergeExpanded(collector, parsed) { }); } -// Summary of the expanded-registry pass (pure). `report` holds one entry per -// listing: {label, compact, form, sw} - `form` is the query form the card -// accepted ('tags' | 'noea' | 'notags') or null when it refused every form, -// `compact` says whether that listing's compact query worked, `sw` the last -// remote status word. Shown in the explorer so a fallback to the compact -// listing (and the missing package versions) is never silent. -function ramExpandedReport(report) { - const rows = (report || []).filter(r => r && r.label); - if (!rows.length) return ''; - const formName = f => f === 'tags' ? t('tag list + EA') - : (f === 'noea' ? t('tag list') : t('no tag list')); - const parts = []; - RAM_EXPANDED_FORMS.forEach(f => { - const labels = rows.filter(r => r.form === f).map(r => r.label); - if (labels.length) parts.push(formName(f) + ' (' + labels.join(', ') + ')'); - }); - rows.forEach(r => { - if (r.form) return; - const why = r.sw ? ' (' + r.sw + ')' : ''; - if (!r.compact) { - parts.push(r.label + ': ' + (r.sw || t('no data'))); - } else if (r.p1 === '80') { - // cards commonly report nothing for the ISD in the expanded form - // (GPPro treats its 6A88/6A86 as 'no data to report') - parts.push(r.label + ': ' + t('no expanded data') + why + ' \u2014 ' + t('compact listing used')); +// Group the per-listing expanded outcomes (pure): one group per label, best +// outcome wins (a pass that returned entries > an accepted-but-empty pass > +// a refusal), so a listing queried twice (P1=10 and then the P1=20 fallback) +// never appears twice in the report and a form that produced data is never +// reported as a refusal. +function ramExpandedGroups(report) { + const groups = []; + (report || []).forEach(r => { + if (!r || !r.label) return; + let g = groups.find(x => x.label === r.label); + if (!g) { + g = { p1: r.p1 || '', label: r.label, form: null, entries: 0, score: 0, + sw: '', compact: false, refused: false }; + groups.push(g); + } + g.compact = g.compact || !!r.compact; + if (r.form) { + const score = ((r.entries || 0) > 0 ? 100 : 0) + + (RAM_EXPANDED_FORMS.length - RAM_EXPANDED_FORMS.indexOf(r.form)); + if (score > g.score) { + g.form = r.form; + g.entries = r.entries || 0; + g.score = score; + } } else { - parts.push(r.label + ': ' + t('refused') + why + ' \u2014 ' + t('compact listing used')); + g.refused = true; + if (r.sw) g.sw = r.sw; } }); - const anyForm = rows.some(r => r.form); + return groups; +} + +// Summary of the expanded-registry pass (pure): which query form the card +// accepted per listing and where the run fell back to the compact listing. +// Shown in the explorer so a fallback (and the missing package versions) is +// never silent. +function ramExpandedReport(report) { + const groups = ramExpandedGroups(report); + if (!groups.length) return ''; + const formName = f => f === 'tags' ? t('tag list + EA') + : (f === 'noea' ? t('tag list') : (f === 'lean' ? t('lean tag list') : t('no tag list'))); + const parts = []; + RAM_EXPANDED_FORMS.forEach(f => { + const labels = groups.filter(g => g.form === f).map(g => g.label); + if (labels.length) parts.push(formName(f) + ' (' + labels.join(', ') + ')'); + }); + groups.forEach(g => { + if (g.form) return; + const why = g.sw ? ' (' + g.sw + ')' : ''; + if (!g.compact) { + parts.push(g.label + ': ' + (g.sw || t('no data'))); + } else if (g.p1 === '80') { + // cards commonly report nothing for the ISD in the expanded form + // (GPPro treats its 6A88/6A86 as 'no data to report') + parts.push(g.label + ': ' + t('no expanded data') + why + ' \u2014 ' + t('compact listing used')); + } else { + parts.push(g.label + ': ' + t('refused') + why + ' \u2014 ' + t('compact listing used')); + } + }); + const anyForm = groups.some(g => g.form); const head = anyForm ? t('Expanded registry:') : t('Expanded registry refused by the card') + ' \u2014 ' + @@ -9191,8 +9221,25 @@ function ramExpandedReport(report) { return head + ' ' + parts.join(' \u00b7 '); } +// The form the ELF listing answered with ('' when it never produced entries): +// the version hint is worded from it. +function ramExpandedElfForm(report) { + const g = ramExpandedGroups(report).find(x => x.label === t('ELFs')); + return g && g.form && (g.entries || 0) > 0 ? g.form : ''; +} + +// Why the ELF rows show no version (pure). The version exists only in the +// tag-list forms of the expanded query: without a tag list the card builds the +// default E3 (AID + life cycle + modules, GP 11.4.3.1). +function ramElfVersionHint(elfForm) { + if (elfForm === 'notags') { + return t('Package versions are only in the tag-list forms of the expanded registry query \u2014 this card accepted only the no-tag-list form.'); + } + return t('Package versions come from the expanded registry query \u2014 this card did not return them.'); +} + // Render the full explorer result as structured HTML -function ramRenderExploreHtml(mem, isd, apps, elfs, fmt, expanded) { +function ramRenderExploreHtml(mem, isd, apps, elfs, fmt, expanded, elfForm) { let html = ''; if (mem && (mem.appCount != null || mem.freeNV != null || mem.freeV != null)) { html += '
'; @@ -9255,7 +9302,7 @@ function ramRenderExploreHtml(mem, isd, apps, elfs, fmt, expanded) { html += '
'; html += '
' + esc(t('Executable Load Files (ELFs) / Packages')) + ' (' + elfs.length + ')
'; if (!elfs.some(o => o.version)) { - html += '
' + esc(t('Package versions come from the expanded registry query \u2014 this card did not return them.')) + '
'; + html += '
' + esc(ramElfVersionHint(elfForm)) + '
'; } elfs.forEach(o => { html += '
'; @@ -9305,7 +9352,7 @@ function ramRenderExplorer() { explorerEl.innerHTML = ''; return; } - const html = ramRenderExploreHtml(_ramExplorerData.mem, _ramExplorerData.isd, _ramExplorerData.apps, _ramExplorerData.elfs, _ramExplorerData.fmt, _ramExplorerData.expanded); + const html = ramRenderExploreHtml(_ramExplorerData.mem, _ramExplorerData.isd, _ramExplorerData.apps, _ramExplorerData.elfs, _ramExplorerData.fmt, _ramExplorerData.expanded, _ramExplorerData.elfForm); explorerEl.innerHTML = html || esc(t('(no data)')); explorerEl.classList.remove('hidden'); pysimApplyAvailability(); @@ -9329,7 +9376,11 @@ async function ramExplore(sp) { // the remembered form first, then the others. The ISD gets a single // attempt - cards commonly answer 6A88/6A86 there ("no data to report", // GPPro), which must not poison the detection for the apps/ELF queries. - // Returns {form, parsed, sw}; form is null when every attempt was refused. + // A '61xx' answer means the card holds the response data (fetched with + // GET RESPONSE); a '6310' means more occurrences (re-issued as a + // next-occurrence page, P2.b1=1) - both are chained here, so an answer is + // only called empty or refused when it really is. Returns + // {form, parsed, sw, entries}; form is null when no form produced data. async function expandedPass(p1, label, parser, spi2) { let forms = expandedForm ? [expandedForm].concat(RAM_EXPANDED_FORMS.filter(f => f !== expandedForm)) @@ -9337,30 +9388,48 @@ async function ramExplore(sp) { if (p1 === '80') forms = [expandedForm || 'tags']; let lastSw = ''; for (const form of forms) { - ramShowProgress(label + ' P1=' + p1 + ' P2=02 (' + form + ')...'); - const res = await ramSendOta(ramExpandedQueryApdu(p1, form), Object.assign({}, sp, { cntr, spi2 })); - 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)) { - // the packet itself was not accepted: another form will not help - lastSw = res.por ? res.por.response_status : (res.error || t('no data')); - break; + let data = ''; + let sw = ''; + let accepted = true; + const sendOne = async (apdu, what) => { + ramShowProgress(label + ' P1=' + p1 + ' ' + what + '...'); + const res = await ramSendOta(apdu, Object.assign({}, sp, { cntr, spi2 })); + 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)) { + accepted = false; + sw = res.por ? res.por.response_status : (res.error || t('no data')); + return ''; + } + sw = ((res.por.decoded && res.por.decoded.last_status_word) || '').toUpperCase(); + return res.por.decoded ? (res.por.decoded.last_response_data || '') : ''; + }; + let page = 0; + while (accepted && page++ < 8) { + data += await sendOne(ramExpandedQueryApdu(p1, form, page > 1), + page > 1 ? 'P2=03 (' + form + ')' : 'P2=02 (' + form + ')'); + if (!accepted) break; + let rounds = 0; + while (/^61[0-9A-F]{2}$/.test(sw) && rounds++ < 4) { + data += await sendOne(ramGetResponseApdu(sw), 'GET RESPONSE'); + if (!accepted) break; + } + if (!accepted || sw !== '6310') break; } - const data = res.por.decoded ? (res.por.decoded.last_response_data || '') : ''; - const sw = ((res.por.decoded && res.por.decoded.last_status_word) || '').toUpperCase(); const parsed = data ? parser(data) : []; if (parsed.length) { expandedForm = form; - return { form: form, parsed: parsed, sw: sw }; + return { form: form, parsed: parsed, sw: sw, entries: parsed.length }; } lastSw = sw; + if (!accepted) break; if (ramRemoteSwOk(sw)) { // accepted but empty: nothing to gain from another form - return { form: form, parsed: [], sw: sw }; + return { form: form, parsed: [], sw: sw, entries: 0 }; } } - return { form: null, parsed: [], sw: lastSw }; + return { form: null, parsed: [], sw: lastSw, entries: 0 }; } async function paginate(p1, collector, parser, label, quiet) { @@ -9433,7 +9502,7 @@ async function ramExplore(sp) { if (collector.length === before || wantDetails) { const out = await expandedPass(p1, label, parser, spi2); expandedReport.push({ p1: p1, label: label, compact: collector.length > before, - form: out.form, sw: out.sw }); + form: out.form, sw: out.sw, entries: out.entries }); if (collector.length === before) { // Nothing from the compact pass: the expanded result is the listing collector.push(...out.parsed); @@ -9502,7 +9571,8 @@ async function ramExplore(sp) { } _ramExplorerData = { mem: mem, isd: isd, apps: apps, elfs: elfs, fmt: sp.ram_format || '', - expanded: ramExpandedReport(expandedReport) }; + expanded: ramExpandedReport(expandedReport), + elfForm: ramExpandedElfForm(expandedReport) }; ramRenderExplorer(); } @@ -10897,10 +10967,13 @@ function ramGetStatusApdu(p1, p2) { // for the details pass (on by default: it carries the package versions, the // SD/load-file associations and the applet menu entries). // -// Cards differ in what they accept, so the query is tried in three forms: +// Cards differ in what they accept, so the query is tried in four forms: // 'tags' - the reference trace tag list (`4F9F70C5C4CCCEEA`), the richest // 'noea' - the same list without the TS 102 226 'EA' SCP Registry Data -// (a card that errors on the requested-but-absent EA object) +// 'lean' - only the objects valid for the kind: the load file version and +// modules for ELFs (the app-only C5/C4 make strict cards answer +// 6A88), pySim's field list (AID, life cycle, privileges, SD AID) +// for the ISD/apps // 'notags' - no tag list (GPPro's GET STATUS data field `4F00` only): the // card builds the default E3 (AID + life cycle + privileges or // modules; cards commonly include the load file version anyway) @@ -10908,15 +10981,32 @@ function ramGetStatusApdu(p1, p2) { // list present the card returns only the listed tags, so without it the // expanded fallback would list packages without their modules (no module rows // / Install buttons on cards that reject the compact form). -const RAM_EXPANDED_FORMS = ['tags', 'noea', 'notags']; +const RAM_EXPANDED_FORMS = ['tags', 'noea', 'lean', 'notags']; -function ramExpandedQueryApdu(p1, form) { - const f = form || 'tags'; - if (f === 'notags') return '80F2' + p1 + '02' + '02' + '4F00' + '00'; +// The tag list of one form for a listing ('' = the no-tag-list form). +function ramExpandedTags(p1, form) { const elf = p1 === '10' || p1 === '20'; - const tags = (f === 'noea' ? '4F9F70C5C4CCCE' : '4F9F70C5C4CCCEEA') + (elf ? '84' : ''); + if (form === 'notags') return ''; + if (form === 'lean') return elf ? '4F9F70CE84' : '4F9F70C5CC'; + const base = form === 'noea' ? '4F9F70C5C4CCCE' : '4F9F70C5C4CCCEEA'; + 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) { + const tags = ramExpandedTags(p1, form || 'tags'); + const p2 = next ? '03' : '02'; + if (!tags) return '80F2' + p1 + p2 + '02' + '4F00' + '00'; const hex = n => n.toString(16).toUpperCase().padStart(2, '0'); - return '80F2' + p1 + '02' + hex(tags.length / 2 + 4) + '4F005C' + hex(tags.length / 2) + tags + '00'; + return '80F2' + p1 + p2 + hex(tags.length / 2 + 4) + '4F005C' + hex(tags.length / 2) + tags + '00'; +} + +// GET RESPONSE for a '61xx' remote status word: the card holds xx bytes of +// response data (the compact path chains C0000000 for the same situation). +function ramGetResponseApdu(sw) { + const s = String(sw || '').toUpperCase(); + const len = /^61[0-9A-F]{2}$/.test(s) ? s.substr(2, 2) : '00'; + return '00C00000' + len; } // The ISD (80) and memory responses fit the ENVELOPE; the Apps (40) and ELF @@ -18632,12 +18722,14 @@ const LANG_RU = { 'tag list + EA': 'список тегов + EA', 'tag list': 'список тегов', 'no tag list': 'без списка тегов', + 'lean tag list': 'сокращённый список тегов', 'refused': 'отказ', 'compact listing used': 'использован компактный список', 'no expanded data': 'нет расширенных данных', 'Expanded registry refused by the card': 'Карта отклонила расширенный реестр', 'compact listing only (package versions unavailable)': 'только компактный список (версии пакетов недоступны)', 'Package versions come from the expanded registry query \u2014 this card did not return them.': 'Версии пакетов приходят из расширенного запроса реестра \u2014 эта карта их не вернула.', + 'Package versions are only in the tag-list forms of the expanded registry query \u2014 this card accepted only the no-tag-list form.': 'Версии пакетов есть только в формах расширенного запроса со списком тегов \u2014 эта карта приняла только форму без списка тегов.', 'STK menu entry:': 'Пункт меню STK:', 'position': 'позиция', 'Other registry data:': 'Прочие данные реестра:', diff --git a/frontend/sw.js b/frontend/sw.js index 107a16d..f26f4f5 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v327'; +const CACHE = 'simple-v328'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/ram.test.js b/frontend/tests/ram.test.js index bebd54f..801fd72 100644 --- a/frontend/tests/ram.test.js +++ b/frontend/tests/ram.test.js @@ -25,7 +25,7 @@ function extractFunc(src, name) { const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamBuildRowHex', 'ramFmtLifecycle', 'ramFmtVersion', 'ramMenuState', 'ramEntryKind', 'ramMenuHtml', 'ramRawTlvsHtml', 'ramNvLine', 'ramFmtPrivileges', 'ramRenderExploreHtml', 'ramMergeExpanded', 'ramStepLine', 'ramStepComponents', 'ramInstallFailHint', 'ramProbeParse', 'ramCompatVerdict', 'ramCompatProbeNote', 'ramCompatImportRows', 'ramCompatRowsHtml', 'ramCompatFailureHtml', 'ramCompatDetailHtml', 'lookupSw', 'ramExpandedDetailsInit', 'ramExpandedDetailsChanged', 'ramGetStatusApdu', 'ramDeleteApdu', 'stkParamsBuild', 'ramRemoteSwOk', 'spPorAccepted', 'ramIncrementCntr', 'ramDeleteFromExplorer', 'ramListingSpi2', 'ramRemoveFromExplorer', 'ramHasInstance', 'ramExpandedQueryApdu', '_parseRawElfEntry', '_parseRawAppEntry', 'ramParseElfStatus', 'ramParseAppStatus', 'ramParseModuleAids', 'parseTLV', '_parseE3Entry', '_parseMenuEntries', - 'ramExpandedReport', + 'ramExpandedReport', 'ramExpandedTags', 'ramExpandedGroups', 'ramExpandedElfForm', 'ramElfVersionHint', 'ramGetResponseApdu', 'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute', 'decodePrivileges', 'ramActionBtn', 'ramCapToolkitMode', 'ramOpProgressText', 'jcAidNorm', 'jcAidName', 'jcAidSuffix', 'jcAidHtml']; let code = ''; @@ -549,7 +549,7 @@ test('ramCardIdxAfterRemove keeps the remembered index aligned', () => { assert.strictEqual(ramCardIdxAfterRemove(null, 1), null); }); -test('ramExpandedQueryApdu builds the three expanded query forms', () => { +test('ramExpandedQueryApdu builds the expanded query forms', () => { // the reference trace form (richest: the tag list incl. the TS 102 226 EA) for (const p1 of ['80', '40']) { assert.strictEqual(ramExpandedQueryApdu(p1), @@ -564,30 +564,50 @@ test('ramExpandedQueryApdu builds the three expanded query forms', () => { // without EA: a card that errors on the requested-but-absent EA object assert.strictEqual(ramExpandedQueryApdu('40', 'noea'), '80F240020B4F005C074F9F70C5C4CCCE00'); assert.strictEqual(ramExpandedQueryApdu('10', 'noea'), '80F210020C4F005C084F9F70C5C4CCCE8400'); + // lean: only the objects valid for the kind - the load file version and + // modules for ELFs (C5 privileges / C4 ELF AID are application objects), + // pySim's field list for the ISD/apps + assert.strictEqual(ramExpandedQueryApdu('10', 'lean'), '80F21002094F005C054F9F70CE8400'); + assert.strictEqual(ramExpandedQueryApdu('20', 'lean'), '80F22002094F005C054F9F70CE8400'); + assert.strictEqual(ramExpandedQueryApdu('40', 'lean'), '80F24002094F005C054F9F70C5CC00'); + assert.strictEqual(ramExpandedQueryApdu('80', 'lean'), '80F28002094F005C054F9F70C5CC00'); // no tag list at all (GPPro's GET STATUS data field: 4F00 only) assert.strictEqual(ramExpandedQueryApdu('80', 'notags'), '80F28002024F0000'); assert.strictEqual(ramExpandedQueryApdu('10', 'notags'), '80F21002024F0000'); - assert.deepStrictEqual(RAM_EXPANDED_FORMS, ['tags', 'noea', 'notags']); + // the next-occurrence page (P2.b1=1) keeps the form and its tag list + assert.strictEqual(ramExpandedQueryApdu('40', 'tags', true), '80F240030C4F005C084F9F70C5C4CCCEEA00'); + assert.strictEqual(ramExpandedQueryApdu('10', 'lean', true), '80F21003094F005C054F9F70CE8400'); + assert.strictEqual(ramExpandedQueryApdu('10', 'notags', true), '80F21003024F0000'); + assert.deepStrictEqual(RAM_EXPANDED_FORMS, ['tags', 'noea', 'lean', 'notags']); +}); + +test('ramGetResponseApdu fetches a 61xx response (the announced length)', () => { + assert.strictEqual(ramGetResponseApdu('6131'), '00C0000031'); + assert.strictEqual(ramGetResponseApdu('617F'), '00C000007F'); + assert.strictEqual(ramGetResponseApdu('613f'), '00C000003F'); + // not a 61xx: ask for all available data + assert.strictEqual(ramGetResponseApdu('9000'), '00C0000000'); + assert.strictEqual(ramGetResponseApdu(''), '00C0000000'); }); test('ramExpandedReport summarizes the query forms and the fallbacks', () => { globalThis.t = s => s; // every listing answered the reference form assert.strictEqual(ramExpandedReport([ - { p1: '80', label: 'ISD', compact: true, form: 'tags', sw: '9000' }, - { p1: '40', label: 'Apps', compact: true, form: 'tags', sw: '9000' }, - { p1: '10', label: 'ELFs', compact: true, form: 'tags', sw: '9000' }, + { p1: '80', label: 'ISD', compact: true, form: 'tags', sw: '9000', entries: 1 }, + { p1: '40', label: 'Apps', compact: true, form: 'tags', sw: '9000', entries: 2 }, + { p1: '10', label: 'ELFs', compact: true, form: 'tags', sw: '9000', entries: 4 }, ]), 'Expanded registry: tag list + EA (ISD, Apps, ELFs)'); // the ISD commonly has no expanded data (6A88/6A86 is normal there) assert.strictEqual(ramExpandedReport([ { p1: '80', label: 'ISD', compact: true, form: null, sw: '6A88' }, - { p1: '40', label: 'Apps', compact: true, form: 'tags', sw: '9000' }, - { p1: '10', label: 'ELFs', compact: true, form: 'noea', sw: '9000' }, + { p1: '40', label: 'Apps', compact: true, form: 'tags', sw: '9000', entries: 2 }, + { p1: '10', label: 'ELFs', compact: true, form: 'noea', sw: '9000', entries: 4 }, ]), 'Expanded registry: tag list + EA (Apps) \u00b7 tag list (ELFs) \u00b7 ISD: no expanded data (6A88) \u2014 compact listing used'); // a listing the card refused entirely: the compact listing carried it assert.strictEqual(ramExpandedReport([ { p1: '40', label: 'Apps', compact: true, form: null, sw: '6A88' }, - { p1: '10', label: 'ELFs', compact: true, form: 'notags', sw: '9000' }, + { p1: '10', label: 'ELFs', compact: true, form: 'notags', sw: '9000', entries: 4 }, ]), 'Expanded registry: no tag list (ELFs) \u00b7 Apps: refused (6A88) \u2014 compact listing used'); // nothing answered: the headline says the versions are unavailable assert.strictEqual(ramExpandedReport([ @@ -598,17 +618,49 @@ test('ramExpandedReport summarizes the query forms and the fallbacks', () => { assert.strictEqual(ramExpandedReport([ { p1: '10', label: 'ELFs', compact: false, form: null, sw: '6A88' }, ]), 'Expanded registry refused by the card \u2014 compact listing only (package versions unavailable) ELFs: 6A88'); + // the ELF listing queried twice (P1=10 accepted-but-empty, then the P1=20 + // fallback with data): one mention, from the pass that produced entries + assert.strictEqual(ramExpandedReport([ + { p1: '10', label: 'ELFs', compact: false, form: 'notags', sw: '9000', entries: 0 }, + { p1: '20', label: 'ELFs', compact: false, form: 'notags', sw: '9000', entries: 5 }, + ]), 'Expanded registry: no tag list (ELFs)'); + // a refusal followed by a pass with data is not reported as a refusal + assert.strictEqual(ramExpandedReport([ + { p1: '10', label: 'ELFs', compact: false, form: null, sw: '6A88' }, + { p1: '20', label: 'ELFs', compact: false, form: 'lean', sw: '9000', entries: 5 }, + ]), 'Expanded registry: lean tag list (ELFs)'); + // the richer form wins when both produced entries + assert.strictEqual(ramExpandedReport([ + { 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)'); assert.strictEqual(ramExpandedReport([]), ''); delete globalThis.t; }); -test('the explorer hints when the card returned no package versions', () => { +test('ramExpandedElfForm names the ELF listing form for the version hint', () => { globalThis.t = s => s; - const noVer = ramRenderExploreHtml(null, [], [], [{ aid: 'E1', lifecycle: '01', privileges: '00' }]); - const withVer = ramRenderExploreHtml(null, [], [], [{ aid: 'E1', lifecycle: '01', privileges: '00', version: '0100' }]); + assert.strictEqual(ramExpandedElfForm([ + { p1: '10', label: 'ELFs', compact: false, form: 'notags', sw: '9000', entries: 5 }, + ]), 'notags'); + // an accepted-but-empty pass is no form for the hint + assert.strictEqual(ramExpandedElfForm([ + { p1: '10', label: 'ELFs', compact: false, form: 'notags', sw: '9000', entries: 0 }, + ]), ''); + assert.strictEqual(ramExpandedElfForm([]), ''); delete globalThis.t; - assert.ok(noVer.includes('Package versions come from the expanded registry query'), noVer); - assert.ok(!withVer.includes('Package versions come from the expanded registry query'), withVer); +}); + +test('the explorer hints why the package versions are missing', () => { + globalThis.t = s => s; + const elfs = [{ aid: 'E1', lifecycle: '01', privileges: '00' }]; + const noForm = ramRenderExploreHtml(null, [], [], elfs); + const noTags = ramRenderExploreHtml(null, [], [], elfs, '', '', 'notags'); + const withVer = ramRenderExploreHtml(null, [], [], [{ aid: 'E1', lifecycle: '01', privileges: '00', version: '0100' }], '', '', 'notags'); + delete globalThis.t; + assert.ok(noForm.includes('Package versions come from the expanded registry query'), noForm); + assert.ok(noTags.includes('only in the tag-list forms of the expanded registry query'), noTags); + assert.ok(!withVer.includes('Package versions'), withVer); }); test('the Explore detects the expanded query form and reports it', () => { @@ -619,6 +671,9 @@ test('the Explore detects the expanded query form and reports it', () => { // the ISD gets a single attempt: its 6A88 must not poison the detection assert.ok(src.includes("if (p1 === '80') forms = [expandedForm || 'tags'];"), 'the ISD must not walk the ladder'); + // a 61xx answer holds the data (GET RESPONSE) and a 6310 pages + assert.ok(src.includes('ramGetResponseApdu(sw)'), '61xx must be answered with GET RESPONSE'); + assert.ok(src.includes('ramExpandedQueryApdu(p1, form, page > 1)'), '6310 must page'); }); test('ramParseAppStatus parses the expanded E3 listing (vendor traces)', () => { diff --git a/pyproject.toml b/pyproject.toml index 2e4b8da..1c4ce56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.6.54" +version = "3.6.55" 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 d38cc49..43ed253 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.54' +VERSION = '3.6.55' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE