feat: ELF versions via P1=20 retry and per-AID app queries (v3.7.0)

No live card accepts a tag list for the ELF queries (tags/noea/lean all
answer 6A88 on P1=10 - the log of 2026-09-30 11:11), so the version-carrying
CE tag was unreachable there; the ELF listing itself (notags + chained GET
RESPONSE) works and carries AID + life cycle + modules only.

Two fallbacks:
- when the ELF rows have no version and the details pass never got a
  tag-list form, the ladder is walked once more with P1=20 (a card's P1=10
  implementation can be stricter) and merged by AID;
- any version still missing is fetched per applet AID: the AID search
  qualifier (4F <len> <AID>, GP 11.4.2.3 Table 11-35) plus the apps tag
  list selects a single applet entry, whose CE is its package's version
  (the applet <-> package mapping is the AID prefix; at most 6 queries).

Also:
- the app and ISD rows show their parsed CE version (the expanded apps
  listing carries it);
- an unfulfilled 6310 (the next-occurrence page refused, e.g. 6A86) is
  reported as '(first page only)' instead of looking complete;
- the page loop only continues when the chained GET RESPONSE was accepted -
  a refused retry no longer costs an extra P2=03 packet.

sw cache simple-v332; tests: 557 Python / 716 Node.
This commit is contained in:
2026-09-30 11:17:12 +03:00
parent 162930eb96
commit 032f70f509
5 changed files with 113 additions and 14 deletions
+30
View File
@@ -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 <len> <AID>) 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');