feat: offline file list + SELECT picker for the SIM/USIM RFM builders (v3.4.0)

The SELECT rows (compact RFM chains and the Expanded Script C-APDU picker,
which shares the row editor) now have a file picker instead of typing FIDs,
paths or chains by hand.

Data:
- `pysim_simple_server/uicc_files.py` builds the standard file list cardless
  from pySim's profiles and application classes (CardProfileUICC + CardProfileSIM
  for the MF tree, every concrete CardApplication subclass for the ADFs) and
  writes `frontend/uicc_files.json` (490 entries: canonical FID path, symbolic
  name, fid, kind, root, ADF AID).  `tests/test_uicc_files.py` regenerates it
  and fails when pySim adds or renames files.  The asset is precached by the
  service worker, so the builders keep working offline.

Picker:
- Sources merged per canonical path: the loaded file-manager tree (card names
  and probed presence; probed-absent files hidden), custom files, and the
  shipped standard list (specs-default until "Probe all files" has run).
- Default list shows what the current method can express; "all files" reveals
  the rest, and picking one auto-switches the method - preferring path (one
  SELECT) over chain - with a note line explaining the switch.  A "starts in"
  selector sets the implicit current DF (UICC shared-FS RFM app starts in MF,
  an ADF RFM app in its ADF, TS 102 226 7.2/7.3 - the TAR decides; default per
  builder, overridable per row).
- Fill rules: by FID only for direct children of the current DF; path = FID
  sequence from MF without the MF identifier (ISO 7816-4) or the relative tail
  (USIM P1=09); chain stays relative and follows the TS 102 221 11.1.1.2 FID
  search order (children, parent, siblings), so no hop to the common ancestor.
  ADF roots are not pickable (selection is by AID; TS 102 226 7.1 forbids
  P1=04 for RFM) - the By AID method stays manual.

Tests/docs: frontend/tests/uicc_files.test.js (asset shape, merge/priority/
exclusion, fill matrix, relative chains, session-context tracking, option
grouping); help 2.1/2.2 EN+RU, READMEs, AGENTS.  sw cache -> simple-v242.
This commit is contained in:
2026-09-23 08:20:10 +03:00
parent 7aee1818b8
commit b8734189cf
12 changed files with 4762 additions and 5 deletions
+225
View File
@@ -0,0 +1,225 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
const asset = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'uicc_files.json'), 'utf8'));
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
// The RFM SELECT file picker: merge of the card tree / custom files / the
// shipped standard list, and the per-method fill logic (relative FID chains
// per TS 102 221 11.1.1.2, path from MF without the MF identifier per
// ISO 7816-4, path from the current DF per TS 102 221 11.1.1.2 P1=09).
const FNS = ['chainKind', 'rfmRowKey', 'rfmParentPath', 'rfmEntryByPath', 'rfmRoots',
'rfmFileEntries', 'rfmStartDf', 'rfmSetStartDf', 'rfmNoticeClear', 'rfmApplySelect',
'rfmCurrentDf', 'rfmFidsFrom', 'rfmRelativeChain', 'rfmSelectFill', 'rfmFileOptionsHtml',
'escHtml', 'esc', 'pysimFsNodePath', 'pysimCustomFid'];
let code = '';
for (const f of FNS) code += extractFunc(html, f) + '\n';
for (const c of ['const _chains = \\{\\};', 'let _uiccStdFiles = null;', 'let _uiccStdLoad = null;',
'let _rfmShowAll = false;', 'const _rfmFilter = \\{\\};', 'const _rfmNotice = \\{\\};',
'const _rfmStartDf = \\{\\};']) {
const m = html.match(new RegExp(c));
if (!m) throw new Error('declaration not found: ' + c);
code += m[0].replace(/^(const|let) /, 'var ') + '\n';
}
code += 'var pysimCustomFiles = [];\n';
code += 'var pysimFsTreeRoot = null;\n';
code += 'function chainRender() {}\n';
eval(code);
// The shipped asset stands in for the fetched standard list.
_uiccStdFiles = asset.files;
function entry(p) {
const e = _uiccStdFiles.find(f => f.path === p);
assert.ok(e, 'asset entry missing: ' + p);
return e;
}
function row(cmd, fields) { return { cmd: cmd, fields: fields || {} }; }
function setChain(chainId, rows) {
_chains[chainId] = { rows: rows || [] };
return chainId;
}
test('the shipped asset is well formed and covers the standard trees', () => {
assert.ok(asset.files.length > 300, 'entries: ' + asset.files.length);
const paths = asset.files.map(f => f.path);
assert.strictEqual(paths.length, new Set(paths).size, 'duplicate paths');
assert.strictEqual(entry('MF/2FE2').name, 'EF.ICCID');
assert.strictEqual(entry('MF/7F10').name, 'DF.TELECOM');
assert.strictEqual(entry('MF/7F10/6F40').name, 'EF.MSISDN');
assert.strictEqual(entry('MF/7F20/6F46').name, 'EF.SPN');
assert.strictEqual(entry('ADF.USIM/6F07').name, 'EF.IMSI');
assert.strictEqual(entry('ADF.USIM').kind, 'adf');
assert.strictEqual(entry('ADF.USIM').aid, 'A0000000871002');
assert.strictEqual(entry('ADF.ISIM').aid, 'A0000000871004');
});
test('rfmFidsFrom walks a path down to the FIDs', () => {
const list = rfmFileEntries();
assert.strictEqual(rfmFidsFrom(list, 'MF', 'MF/7F10/6F40'), '7F106F40');
assert.strictEqual(rfmFidsFrom(list, 'MF/7F10', 'MF/7F10/6F40'), '6F40');
assert.strictEqual(rfmFidsFrom(list, 'MF/7F10', 'MF/7F20'), '');
assert.strictEqual(rfmFidsFrom(list, 'MF', 'ADF.USIM/6F07'), '');
});
test('rfmRelativeChain uses the FID search order (children/parent/siblings)', () => {
const list = rfmFileEntries();
assert.strictEqual(rfmRelativeChain(list, 'MF', 'MF/7F10/6F40'), '7F10,6F40');
// Sibling branch: from DF.TELECOM to EF.SPN under DF.GSM.
assert.strictEqual(rfmRelativeChain(list, 'MF/7F10', 'MF/7F20/6F46'), '7F20,6F46');
// One level up then sideways.
assert.strictEqual(rfmRelativeChain(list, 'MF/7F10/5F3A', 'MF/7F20'), '7F10,7F20');
assert.strictEqual(rfmRelativeChain(list, 'ADF.USIM', 'ADF.USIM/6F07'), '6F07');
assert.strictEqual(rfmRelativeChain(list, 'ADF.USIM/5F3A', 'ADF.USIM/6F07'), '6F07');
// Different roots: a FID select cannot cross into another tree.
assert.strictEqual(rfmRelativeChain(list, 'MF', 'ADF.USIM/6F07'), '');
assert.strictEqual(rfmRelativeChain(list, 'ADF.USIM', 'MF/2FE2'), '');
});
test('rfmSelectFill keeps a method that can express the pick', () => {
setChain('chain-sim', [row('select', { method: 'fid' })]);
let fill = rfmSelectFill('chain-sim', 0, entry('MF/7F10'), rfmFileEntries());
assert.deepStrictEqual(fill, { method: 'fid', fields: { fid: '7F10' }, switched: false });
setChain('chain-sim', [row('select', { method: 'chain' })]);
fill = rfmSelectFill('chain-sim', 0, entry('MF/7F10/6F40'), rfmFileEntries());
assert.deepStrictEqual(fill, { method: 'chain', fields: { chain: '7F10,6F40' }, switched: false });
});
test('rfmSelectFill prefers path when the method has to switch', () => {
// SIM RFM: path is P1=08 (from MF) only.
setChain('chain-sim', [row('select', { method: 'fid' })]);
let fill = rfmSelectFill('chain-sim', 0, entry('MF/7F10/6F40'), rfmFileEntries());
assert.deepStrictEqual(fill, { method: 'path', fields: { path: '7F106F40', base: 'mf' }, switched: true });
// ADF entry cannot be reached from MF by FID/path/chain.
assert.strictEqual(rfmSelectFill('chain-sim', 0, entry('ADF.USIM/6F07'), rfmFileEntries()), null);
// USIM RFM starts in ADF.USIM: its files are direct children, deep ones use
// the relative path from the current DF.
setChain('chain-usim', [row('select', { method: 'fid' })]);
fill = rfmSelectFill('chain-usim', 0, entry('ADF.USIM/6F07'), rfmFileEntries());
assert.deepStrictEqual(fill, { method: 'fid', fields: { fid: '6F07' }, switched: false });
setChain('chain-usim', [row('select', { method: 'fid' })]);
fill = rfmSelectFill('chain-usim', 0, entry('ADF.USIM/5F3A/4F22'), rfmFileEntries());
assert.deepStrictEqual(fill, { method: 'path', fields: { path: '5F3A4F22', base: 'df' }, switched: true });
// Cross-root: an MF file is reachable from the ADF session by path from MF.
setChain('chain-usim', [row('select', { method: 'fid' })]);
fill = rfmSelectFill('chain-usim', 0, entry('MF/2FE2'), rfmFileEntries());
assert.deepStrictEqual(fill, { method: 'path', fields: { path: '2FE2', base: 'mf' }, switched: true });
});
test('rfmSelectFill falls back to chain when path cannot express it', () => {
// Session in ADF.USIM, currently inside DF.PHONEBOOK: a target in the
// DF.GSM-ACCESS branch is not under the current DF and not in the MF tree,
// so only the relative chain works.
const list = rfmFileEntries();
setChain('chain-usim', [row('select', { method: 'path', path: '5F3A', base: 'df' })]);
const fill = rfmSelectFill('chain-usim', 1, entry('ADF.USIM/5F3B/4F20'), list);
assert.strictEqual(fill.method, 'chain');
assert.strictEqual(fill.fields.chain, '5F3B,4F20');
assert.strictEqual(fill.switched, true);
// ADF roots are selected by AID, not by the picker.
assert.strictEqual(rfmSelectFill('chain-usim', 1, entry('ADF.USIM'), list), null);
});
test('rfmCurrentDf tracks the session context across rows', () => {
const list = rfmFileEntries();
setChain('chain-sim', [
row('select', { method: 'fid', fid: '7F10' }),
row('select', { method: 'fid', fid: '6F40' }),
row('select', { method: 'path', path: '7F106F40' }),
]);
assert.strictEqual(rfmCurrentDf('chain-sim', 0, list), 'MF');
assert.strictEqual(rfmCurrentDf('chain-sim', 1, list), 'MF/7F10');
// Selecting an EF keeps the current DF.
assert.strictEqual(rfmCurrentDf('chain-sim', 2, list), 'MF/7F10');
// A path select sets the DF to the file's parent.
assert.strictEqual(rfmCurrentDf('chain-sim', 3, list), 'MF/7F10');
setChain('chain-usim', [
row('select', { method: 'path', path: '5F3A', base: 'df' }),
row('select', { method: 'chain', chain: '4F22' }),
]);
assert.strictEqual(rfmCurrentDf('chain-usim', 0, list), 'ADF.USIM');
assert.strictEqual(rfmCurrentDf('chain-usim', 1, list), 'ADF.USIM/5F3A');
assert.strictEqual(rfmCurrentDf('chain-usim', 2, list), 'ADF.USIM/5F3A');
// The picker can start the session elsewhere (TAR decides the implicit DF).
rfmSetStartDf('chain-sim', 'ADF.USIM');
assert.strictEqual(rfmCurrentDf('chain-sim', 0, list), 'ADF.USIM');
rfmSetStartDf('chain-sim', 'MF');
});
test('rfmFileEntries merges card tree, custom files and the standard list', () => {
const mf = { name: 'MF', fid: '3F00', isDir: true, parent: null, children: [] };
const gsm = { name: 'DF.GSM', fid: '7F20', isDir: true, parent: mf, exists: true, children: [] };
const adn = { name: 'EF.ADN', fid: '6F3A', isDir: false, parent: gsm, exists: false, children: null };
const iccid = { name: 'EF.ICCID', fid: '2FE2', isDir: false, parent: mf, exists: true, children: null };
const customNode = { name: 'EF.MYFILE', fid: '6F99', isDir: false, parent: mf, custom: true, customPath: 'MF/6F99', exists: null, children: null };
gsm.children.push(adn);
mf.children.push(gsm, iccid, customNode);
pysimFsTreeRoot = mf;
pysimCustomFiles = [{ path: 'MF/6F99', name: 'EF.MYFILE', kind: 'ef' }];
const list = rfmFileEntries();
const byPath = {};
list.forEach(e => { byPath[e.path] = e; });
// The card tree wins over the standard entry with the same path and hides
// probed-absent files.
assert.strictEqual(byPath['MF/7F20'].source, 'card');
assert.strictEqual(byPath['MF/2FE2'].exists, true);
assert.ok(!byPath['MF/7F20/6F3A'], 'probed-absent file must be dropped');
assert.strictEqual(byPath['MF/6F99'].source, 'custom');
// The standard list still covers branches the card tree has not loaded.
assert.ok(byPath['MF/7F10/6F40'], 'standard entry missing');
assert.strictEqual(byPath['MF/7F10/6F40'].source, 'standard');
// ADF roots are group headers, not entries.
assert.ok(!list.some(e => e.kind === 'adf'));
pysimFsTreeRoot = null;
pysimCustomFiles = [];
});
test('rfmFileOptionsHtml groups by root and marks method switches', () => {
const list = rfmFileEntries();
setChain('chain-sim', [row('select', { method: 'fid' })]);
// Default: only what by-FID from MF can express (its direct children).
const opts = rfmFileOptionsHtml('chain-sim', 0, list);
assert.ok(opts.includes('<optgroup label="MF">'));
assert.ok(opts.includes('MF/7F10'), 'DF.TELECOM is a direct child');
assert.ok(!opts.includes('MF/7F10/6F40'), 'deep files need a switch');
assert.ok(!opts.includes('<optgroup label="ADF.USIM">'), 'ADF files are not reachable from MF');
// "all files" reveals the switchable entries, marked with the method they
// would switch to; files in another root stay hidden (the session start
// selector covers those).
_rfmShowAll = true;
const all = rfmFileOptionsHtml('chain-sim', 0, list);
assert.ok(all.includes('MF/7F10/6F40'));
assert.ok(all.includes('→ path'), 'switching entries are marked');
assert.ok(!all.includes('<optgroup label="ADF.USIM">'));
// Starting the session in the ADF makes its files pickable instead.
_rfmShowAll = false;
rfmSetStartDf('chain-sim', 'ADF.USIM');
const adf = rfmFileOptionsHtml('chain-sim', 0, list);
assert.ok(adf.includes('<optgroup label="ADF.USIM">'));
assert.ok(adf.includes('ADF.USIM/6F07'), 'EF.IMSI is a direct child of ADF.USIM');
rfmSetStartDf('chain-sim', 'MF');
_rfmFilter['chain-sim#0'] = 'imsi';
const filtered = rfmFileOptionsHtml('chain-sim', 0, list);
assert.ok(!filtered.includes('EF.ADN'));
delete _rfmFilter['chain-sim#0'];
});