EF content decoders: decoded fields in the file manager, snapshots, checks and comparisons (v2.4.0)
Client-side, offline decoder layer (ported from simtrace-analyser, verified against the pinned specs and pySim test vectors): - file manager Decoded pill: decoded field table instead of the raw pySim JSON dump (per-record fields for record files); the server's read_*_decoded JSON stays behind a 'pySim JSON (server)' disclosure; switching Raw/Decoded re-reads the open file - snapshot view: decoded field block for transparent files and a per-record decoded summary for record files - profile check / snapshot comparison report: content mismatches of files with a decoder render as a per-field decoded diff (only the differing fields, expected | actual); the raw expected/actual pair is kept when the decoded values match - coverage: ICCID, IMSI/IST, UST/SST, LI/PL, SPN, PLMNsel/PLMNwAcT/ OPLMNwAcT/HPLMNwAcT, OPL, SPDI, PNN, LOCI/PSLOCI/EPSLOCI/EPSNSC/LOCIGPRS, Kc/KcGPRS, ACC, PHASE, CBMI/CBMID/CBMIR, ECC, AD, ACL, SMSP, SMS, ARR, DIR, IMPI/DOMAIN/IMPU, ADN/FDN/SDN/MSISDN/LND/BDN, EXT1-5, SUME (TS 51.011 §10.3.34); unknown files fall back to raw - new frontend/tests/ef_decode.test.js (14 tests); help EN/RU + READMEs document the decoded views; version 2.4.0, SW cache simple-v191 - also fix the README/RUS snapshot-comparison text: comparison is always exact (no content masking)
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
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');
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const FNS = [
|
||||
'efBytes', 'efHex', 'efSwapNibbles', 'efAllFf', 'efRstripFf', 'efTlv', 'efUcs2Be',
|
||||
'efGsm7Octets', 'efAnnexA', 'efBcdAddress', 'efBcdAddressLen', 'efPlmn', 'efPlmnAct', 'efPnnText',
|
||||
'efDecIccid', 'efDecImsi', 'efDecLi', 'efDecServiceTable', 'efDecPlmnList', 'efDecPlmnWact',
|
||||
'efDecSpn', 'efDecLoci', 'efDecPsLoci', 'efDecEpsLoci', 'efDecEpsNsc', 'efDecKc', 'efDecAcc',
|
||||
'efDecPhase', 'efDecCbmi', 'efDecCbmir', 'efDecEcc', 'efDecOpl', 'efDecAd', 'efDecAcl',
|
||||
'efDecSmsp', 'efDecSpdi', 'efDecNai', 'efDecDir', 'efDecArr', 'efDecPnn', 'efDecAdn',
|
||||
'efDecExt1', 'efDecSms', 'efDecSume', 'efFindDecoder', 'efFidFromPath', 'efDecodeBytes',
|
||||
'efDecodeFile', 'efDecodeHex', 'efIsRawOnly', 'efFieldLabel', 'efPrimitive', 'efFlatten',
|
||||
'efDiffData', 'efDataSummary', 'efContentDiff', 'gsm7Decode', 'efRenderFieldsHtml',
|
||||
'pysimFsDecodedHtml', 'profilerRenderReport', 'profilerLabelText', 'profilerResultAspects',
|
||||
'profilerAspectSummary', 'profilerMatchedRecordsText', 'profilerRawDataCheck', 'profilerCustomNameForPath',
|
||||
'pysimCustomNormPath',
|
||||
];
|
||||
|
||||
let code = '';
|
||||
for (const f of FNS) code += extractFunc(html, f) + '\n';
|
||||
for (const c of ['GSM7_ALPHABET', 'GSM7_EXT_MAP']) {
|
||||
code += html.match(new RegExp('const ' + c + ' = [\\s\\S]*?\\n\\];'))[0].replace('const ', 'var ') + '\n';
|
||||
}
|
||||
for (const c of ['EF_UST_SERVICES', 'EF_SST_SERVICES', 'EF_IST_SERVICES', 'EF_PHASE_NAMES']) {
|
||||
code += html.match(new RegExp('const ' + c + ' = \\{.*\\};'))[0].replace('const ', 'var ') + '\n';
|
||||
}
|
||||
code += html.match(/const EF_FIELD_LABELS = \{[\s\S]*?\n\};/)[0].replace('const ', 'var ') + '\n';
|
||||
code += html.match(/const EF_DECODERS = \[[\s\S]*?\n\];/)[0].replace('const ', 'var ') + '\n';
|
||||
code += 'var pysimCustomFiles = [];\nvar pysimFsSelected = \'EF.IMSI\';\n';
|
||||
code += 'function esc(s){return String(s);}\nfunction escHtml(s){return String(s);}\nfunction t(k){return k;}\n';
|
||||
eval(code);
|
||||
|
||||
test('ef helpers: bytes, hex, swap, TLV, PLMN', () => {
|
||||
assert.strictEqual(efHex(efBytes('a0ff01')), 'A0FF01');
|
||||
assert.strictEqual(efSwapNibbles('1234'), '2143');
|
||||
assert.strictEqual(efPlmn(efBytes('22F860')).mcc, '228');
|
||||
assert.strictEqual(efPlmn(efBytes('22F860')).mnc, '06');
|
||||
assert.ok(efAllFf(efBytes('FFFF')));
|
||||
assert.ok(!efAllFf(efBytes('FF00')));
|
||||
// 05 03 AA BB CC + 1E 01 DD
|
||||
const tlvs = efTlv(efBytes('0503AABBCC1E01DD'));
|
||||
assert.deepStrictEqual(tlvs.map(t => t.tag), [0x05, 0x1E]);
|
||||
assert.strictEqual(efHex(tlvs[0].value), 'AABBCC');
|
||||
assert.strictEqual(efHex(tlvs[1].value), 'DD');
|
||||
// long-form length
|
||||
assert.strictEqual(efTlv(efBytes('058105' + '00'.repeat(5)))[0].value.length, 5);
|
||||
});
|
||||
|
||||
test('efAnnexA decodes GSM7 octets, UCS2 and packed variants', () => {
|
||||
assert.strictEqual(efAnnexA(efBytes('47534D2D52204348')), 'GSM-R CH'); // one octet per char
|
||||
assert.strictEqual(efAnnexA(efBytes('8000470053004D')), 'GSM'); // UCS2 prefix
|
||||
assert.strictEqual(efAnnexA(efBytes('')), '');
|
||||
assert.strictEqual(efAnnexA(efBytes('FFFFFFFF')), '');
|
||||
});
|
||||
|
||||
test('EF decoders match the pinned spec / pySim test vectors', () => {
|
||||
assert.deepStrictEqual(efDecIccid(efBytes('988812010000400310f0')), { iccid: '8988211000000430010' });
|
||||
assert.deepStrictEqual(efDecImsi(efBytes('082982608200002080')), { imsi: '228062800000208' });
|
||||
assert.deepStrictEqual(efDecLi(efBytes('6465')), { languages: ['de'] });
|
||||
assert.deepStrictEqual(efDecLi(efBytes('FFFF')), { languages: [] });
|
||||
assert.deepStrictEqual(efDecSpn(efBytes('0147534d2d52204348ffffffffffffffff')), { show_in_hplmn: true, hide_in_oplmn: false, name: 'GSM-R CH' });
|
||||
assert.deepStrictEqual(efDecAcc(efBytes('0000')), { access_classes: [] });
|
||||
assert.deepStrictEqual(efDecAcc(efBytes('8000')), { access_classes: [0] });
|
||||
assert.deepStrictEqual(efDecEcc(efBytes('19f101')), { number: '911', esc: '0x01' });
|
||||
assert.deepStrictEqual(efDecKc(efBytes('837d783609a3858f05')), { kc: '837D783609A3858F', ksi: 0 });
|
||||
assert.deepStrictEqual(efDecPlmnList(efBytes('22F860')), { plmns: [{ mcc: '228', mnc: '06' }] });
|
||||
assert.deepStrictEqual(efDecCbmi(efBytes('0010FFFF0020')), { message_ids: [16, 32] });
|
||||
assert.deepStrictEqual(efDecCbmir(efBytes('0000FFFEFFFF')), { ranges: [['0000', 'FFFE']] });
|
||||
assert.deepStrictEqual(efDecPhase(efBytes('03')), { phase: 'phase 2 and higher' });
|
||||
assert.deepStrictEqual(efDecNai(efBytes('8021696d732e6d6e633030302e6d63633733382e336770706e6574776f726b2e6f7267')),
|
||||
{ text: 'ims.mnc000.mcc738.3gppnetwork.org' });
|
||||
assert.deepStrictEqual(efDecImsi(efBytes('FFFF')), { empty: true });
|
||||
assert.deepStrictEqual(efDecIccid(efBytes('FFFFFFFFFFFF')), { empty: true });
|
||||
});
|
||||
|
||||
test('EF.SUME decodes the SET UP MENU title and icon TLVs (TS 51.011 10.3.34)', () => {
|
||||
// 05 05 "Menu!" + 1E 02 01 23 + FF padding
|
||||
const d = efDecSume(efBytes('05054D656E75211E020123FFFFFFFF'));
|
||||
assert.deepStrictEqual(d, { title: 'Menu!', icon: '0123' });
|
||||
assert.deepStrictEqual(efDecSume(efBytes('FFFFFFFF')), { empty: true });
|
||||
// UCS2 title: 05 09 80 00 4D 00 65 00 6E 00 75
|
||||
assert.deepStrictEqual(efDecSume(efBytes('050980004D0065006E0075FFFFFFFF')),
|
||||
{ title: 'Menu' });
|
||||
});
|
||||
|
||||
test('ADN-format records: alpha + BCD number + CCP/EXT1', () => {
|
||||
const d = efDecAdn(efBytes('42204841203120536963FFFFFFFFFFFF06810628560810FFFFFFFFFFFFFF'));
|
||||
assert.strictEqual(d.name, 'B HA 1 Sic');
|
||||
assert.strictEqual(d.number, '6082658001');
|
||||
assert.strictEqual(d.ccp, undefined);
|
||||
assert.strictEqual(d.ext1, undefined);
|
||||
// international number in EF.MSISDN
|
||||
const m = efDecAdn(efBytes('ffffffffffffffffffffffffffffffffffffffff04b12143f5ffffffffffffffffff'));
|
||||
assert.strictEqual(m.name, '');
|
||||
assert.strictEqual(m.number, '12345');
|
||||
// EXT1 per TS 51.011 10.5.10: type 02 (additional data) + count 03 + BCD
|
||||
assert.deepStrictEqual(efDecExt1(efBytes('02032143F5' + 'FF'.repeat(8))), { number: '12345' });
|
||||
});
|
||||
|
||||
test('EF record decoders: SMS, OPL, DIR, ARR, IMPI/SMSP', () => {
|
||||
const opl = efDecOpl(efBytes('62f2100000fffe01'));
|
||||
assert.deepStrictEqual(opl, { mcc: '262', mnc: '01', lac_range: ['0000', 'FFFE'], pnn_record: 1 });
|
||||
const dir = efDecDir(efBytes('61294f10a0000000871002ffffffff890709000050055553696d31730ea00c80011781025f608203454150'));
|
||||
assert.strictEqual(dir.applications.length, 1);
|
||||
assert.strictEqual(dir.applications[0].aid, 'A0000000871002FFFFFFFF8907090000');
|
||||
assert.strictEqual(dir.applications[0].label, 'USim1');
|
||||
const arr = efDecArr(efBytes('800101a40683010a950108'));
|
||||
assert.deepStrictEqual(arr.rules, [{ tag: '0x80', hex: '01' }, { tag: '0xA4', hex: '83010A950108' }]);
|
||||
const impi = efDecNai(efBytes('803137333830303630303030303031303140696d732e6d6e633030302e6d63633733382e336770706e6574776f726b2e6f7267'));
|
||||
assert.strictEqual(impi.text, '738006000000101@ims.mnc000.mcc738.3gppnetwork.org');
|
||||
const smsp = efDecSmsp(efBytes('534d5343ffffffffffffffffffffffffe1ffffffffffffffffffffffff0891945197109099f9ffffff0000a9'));
|
||||
assert.strictEqual(smsp.alpha, 'SMSC');
|
||||
assert.strictEqual(smsp.sca, '+4915790109999');
|
||||
assert.strictEqual(smsp.tp_pid, '0x00');
|
||||
assert.strictEqual(smsp.tp_dcs, '0x00');
|
||||
// EF.SMS status byte + skip SMSC + TPDU
|
||||
const sms = efDecSms(efBytes('030891945197109099f90000'));
|
||||
assert.strictEqual(sms.direction, 'MT');
|
||||
assert.strictEqual(sms.status, 'message to be read');
|
||||
assert.strictEqual(sms.tpdu, '0000');
|
||||
});
|
||||
|
||||
test('service tables: UST service bits and IST fallback for 6F07', () => {
|
||||
const ust = efDecServiceTable(efBytes('00000010'), EF_UST_SERVICES);
|
||||
assert.deepStrictEqual(ust.services, [{ n: 29, name: 'Data download via SMS-CB' }]);
|
||||
const ist = efDecImsi(efBytes('01'));
|
||||
assert.deepStrictEqual(ist.services, [{ n: 1, name: 'P-CSCF address' }]);
|
||||
});
|
||||
|
||||
test('decoder registry resolves by name first, then FID; unknown returns null', () => {
|
||||
assert.strictEqual(efFindDecoder('EF.IMSI', null).fid, '6f07');
|
||||
assert.strictEqual(efFindDecoder(null, '2fe2').name, 'EF.ICCID');
|
||||
assert.strictEqual(efFindDecoder('ADF.USIM/EF.LOCI', null).name, 'EF.LOCI');
|
||||
assert.strictEqual(efFindDecoder('EF.SUME', null).fid, '6f54');
|
||||
assert.strictEqual(efFindDecoder('EF.NOPE', 'ABCD'), null);
|
||||
assert.strictEqual(efFidFromPath('ADF.USIM/6F07'), '6F07');
|
||||
assert.strictEqual(efFidFromPath('MF/7F10/6F3A'), '6F3A');
|
||||
assert.strictEqual(efFidFromPath('ADF.USIM'), null);
|
||||
});
|
||||
|
||||
test('efDecodeFile handles transparent and record content', () => {
|
||||
const t = efDecodeFile('EF.IMSI', null, { kind: 'transparent', data: '082982608200002080' });
|
||||
assert.strictEqual(typeof t.fn, 'function');
|
||||
assert.strictEqual(t.data.imsi, '228062800000208');
|
||||
const r = efDecodeFile('EF.ADN', '6f3a', { kind: 'record', records: [
|
||||
{ num: 1, data: '42204841203120536963FFFFFFFFFFFF06810628560810FFFFFFFFFFFFFF' },
|
||||
{ num: 2, data: 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF' },
|
||||
] });
|
||||
assert.strictEqual(r.records.length, 2);
|
||||
assert.strictEqual(r.records[0].data.name, 'B HA 1 Sic');
|
||||
assert.strictEqual(r.records[0].empty, false);
|
||||
assert.strictEqual(r.records[1].empty, true);
|
||||
assert.strictEqual(efDecodeFile('EF.NOPE', null, { kind: 'transparent', data: '00' }), null);
|
||||
});
|
||||
|
||||
test('efFlatten renders fields, services and PLMNs for display', () => {
|
||||
assert.deepStrictEqual(efFlatten({ imsi: '123' }), [['IMSI', '123']]);
|
||||
assert.deepStrictEqual(efFlatten({ services: [{ n: 5, name: 'Call Control by USIM' }] }),
|
||||
[['Service 5', 'Call Control by USIM']]);
|
||||
assert.deepStrictEqual(efFlatten({ plmns: [{ mcc: '262', mnc: '01', access_tech: 'GSM' }] }),
|
||||
[['PLMN 1', '262-01 (GSM)']]);
|
||||
assert.deepStrictEqual(efFlatten({ show_in_hplmn: true }), [['Show in HPLMN', 'yes']]);
|
||||
assert.deepStrictEqual(efFlatten({ title: 'Menu!', icon: '0123' }), [['Title', 'Menu!'], ['Icon', '0123']]);
|
||||
});
|
||||
|
||||
test('efDiffData reports only differing fields; efContentDiff decodes both sides', () => {
|
||||
assert.deepStrictEqual(efDiffData({ imsi: '1', kc: 'AA' }, { imsi: '2', kc: 'AA' }), [['IMSI', '1', '2']]);
|
||||
assert.deepStrictEqual(efDiffData({ imsi: '1' }, { imsi: '1' }), []);
|
||||
const dd = efContentDiff('EF.IMSI', null, '082982608200002080', '082982608200002081');
|
||||
assert.ok(dd && dd.rows.length === 1);
|
||||
assert.strictEqual(dd.rows[0][0], 'IMSI');
|
||||
assert.strictEqual(dd.rows[0][1], '228062800000208');
|
||||
const same = efContentDiff('EF.IMSI', null, '082982608200002080', '092982608200002080');
|
||||
assert.ok(same && same.sameDecoded);
|
||||
assert.strictEqual(efContentDiff('EF.NOPE', null, '00', '01'), null);
|
||||
});
|
||||
|
||||
test('efDataSummary produces one-line summaries', () => {
|
||||
assert.strictEqual(efDataSummary(efDecImsi(efBytes('082982608200002080'))), 'IMSI 228062800000208');
|
||||
assert.strictEqual(efDataSummary(efDecIccid(efBytes('988812010000400310f0'))), 'ICCID 8988211000000430010');
|
||||
assert.strictEqual(efDataSummary(efDecSume(efBytes('05054D656E7521FFFFFFFF'))), 'Menu!');
|
||||
assert.strictEqual(efDataSummary({ plmns: [{ mcc: '262', mnc: '01' }] }), '262-01');
|
||||
});
|
||||
|
||||
test('file manager decoded view renders fields, not JSON', () => {
|
||||
const out = pysimFsDecodedHtml({ name: 'EF.IMSI', fid: null }, { success: true, data: '082982608200002080' });
|
||||
assert.match(out, /IMSI/);
|
||||
assert.match(out, /228062800000208/);
|
||||
assert.match(out, /pySim JSON \(server\)/);
|
||||
assert.doesNotMatch(out, /\{"imsi"/);
|
||||
// no decoder -> raw fallback with a note
|
||||
const raw = pysimFsDecodedHtml({ name: 'EF.NOPE', fid: 'abcd' }, { success: true, data: '0011' });
|
||||
assert.match(raw, /No decoder for this file/);
|
||||
assert.match(raw, /0011/);
|
||||
});
|
||||
|
||||
test('profilerRenderReport shows decoded field diffs and the raw-only note', () => {
|
||||
const res = {
|
||||
path: 'ADF.USIM/6F07', name: 'EF.IMSI', status: 'fail',
|
||||
checks: [{ label: 'content', expected: '082982608200002080', actual: '082982608200002081', ok: false }],
|
||||
};
|
||||
const out = profilerRenderReport([res], null);
|
||||
assert.match(out, /Decoded comparison/);
|
||||
assert.match(out, /IMSI/);
|
||||
assert.match(out, /228062800000208/);
|
||||
assert.match(out, /228062800000218/);
|
||||
const same = profilerRenderReport([{
|
||||
path: 'ADF.USIM/6F07', name: 'EF.IMSI', status: 'fail',
|
||||
checks: [{ label: 'content', expected: '082982608200002080', actual: '092982608200002080', ok: false }],
|
||||
}], null);
|
||||
assert.match(same, /Decoded values match/);
|
||||
});
|
||||
Reference in New Issue
Block a user