@@ -1025,7 +1034,7 @@ function cApduSwitchSubtab(name) {
btn.classList.toggle('text-gray-700', !active);
btn.classList.toggle('dark:text-slate-300', !active);
});
- ['sim', 'usim', 'ber', 'ram'].forEach(s => {
+ ['sim', 'usim', 'ber', 'ram', 'parse'].forEach(s => {
const el = document.getElementById('c-apdu-sub-' + s);
if (el) el.classList.toggle('hidden', s !== name);
});
@@ -1033,7 +1042,7 @@ function cApduSwitchSubtab(name) {
updateSimUsimFields(name);
updateSimSelectMethod(name);
}
- setHelpAnchor({sim:'sim-rfm', usim:'usim-rfm', ber:'ber-tlv', ram:'ram-gp'}[name] || 'c-apdu');
+ setHelpAnchor({sim:'sim-rfm', usim:'usim-rfm', ber:'ber-tlv', ram:'ram-gp', parse:'c-apdu'}[name] || 'c-apdu');
}
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
@@ -2457,6 +2466,485 @@ function genBerTlv() {
document.getElementById('ber-pack-btn').disabled = false;
}
+// ===== C-APDU Parser =====
+const PARSE_INS = {
+ 'A4': {name:'SELECT', data:'lc'},
+ 'B0': {name:'READ BINARY', data:'le'},
+ 'B2': {name:'READ RECORD(S)', data:'le'},
+ 'DC': {name:'UPDATE RECORD', data:'lc'},
+ 'D6': {name:'UPDATE BINARY', data:'lc'},
+ '0E': {name:'ERASE BINARY', data:'none'},
+ '44': {name:'ACTIVATE FILE', data:'none'},
+ '04': {name:'DEACTIVATE FILE', data:'none'},
+ '20': {name:'VERIFY', data:'lc'},
+ '24': {name:'CHANGE', data:'lc'},
+ 'E6': {name:'INSTALL', data:'lc'},
+ 'E8': {name:'LOAD', data:'lc'},
+ 'E4': {name:'DELETE', data:'lc'},
+ 'F2': {name:'GET STATUS', data:'lc'},
+ 'CA': {name:'GET DATA', data:'none'},
+ 'E2': {name:'STORE DATA', data:'lc'},
+ 'F0': {name:'SET STATUS', data:'lc'},
+ '82': {name:'EXTERNAL AUTHENTICATE', data:'lc'},
+ '88': {name:'INTERNAL AUTHENTICATE', data:'lc'},
+};
+
+function parseBerLen(hex, i) {
+ const b = parseInt(hex.substr(i, 2), 16);
+ if (b < 128) return {len: b, consumed: 2};
+ const n = parseInt(hex.substr(i + 2, b * 2), 16);
+ return {len: n, consumed: 2 + b * 2};
+}
+
+function parseTlvList(hex) {
+ const out = [];
+ let i = 0;
+ while (i + 4 <= hex.length) {
+ const tag = hex.substr(i, 2);
+ let tagBytes = 2;
+ if ((parseInt(tag, 16) & 0x1F) === 0x1F) {
+ tagBytes = 4;
+ if (i + 4 > hex.length) break;
+ }
+ const r = parseBerLen(hex, i + tagBytes);
+ const len = r.len;
+ const consumed = tagBytes + r.consumed;
+ if (i + consumed + len * 2 > hex.length) break;
+ const value = hex.substr(i + consumed, len * 2);
+ const raw = hex.substr(i, consumed + len * 2);
+ out.push({tag, length: len, value, raw});
+ i += consumed + len * 2;
+ }
+ return out;
+}
+
+function baseCompTag(tag) {
+ const b = parseInt(tag, 16);
+ return (b >= 0x80 && b <= 0x9F) ? (b & 0x7F).toString(16).padStart(2, '0').toUpperCase() : tag;
+}
+
+function decodePrivileges(hex) {
+ if (hex.length < 2) return '';
+ const b1 = parseInt(hex.substr(0, 2), 16);
+ const parts = [];
+ if (b1 & 0x80) parts.push('Security Domain');
+ if (b1 & 0x40) parts.push('DAP Verification');
+ if (b1 & 0x20) parts.push('Delegated Management');
+ if (b1 & 0x10) parts.push('Card Lock');
+ if (b1 & 0x08) parts.push('Card Reset');
+ if (b1 & 0x04) parts.push('Global Registry');
+ if (b1 & 0x02) parts.push('CVM Management');
+ if (b1 & 0x01) parts.push('Mandated DAP Verification');
+ if (hex.length >= 4) {
+ const b2 = parseInt(hex.substr(2, 2), 16);
+ if (b2 & 0x80) parts.push('Global Lock');
+ if (b2 & 0x04) parts.push('Final Application');
+ if (b2 & 0x02) parts.push('Receipt Generation');
+ }
+ if (hex.length >= 6) {
+ const b3 = parseInt(hex.substr(4, 2), 16);
+ if (b3 & 0x80) parts.push('Contactless Activation');
+ }
+ return parts.join(', ') || 'None';
+}
+
+function gsm7Decode(octets) {
+ const bits = [];
+ for (const b of octets) {
+ for (let j = 0; j < 8; j++) bits.push((b >> j) & 1);
+ }
+ let out = '';
+ let escape = false;
+ for (let i = 0; i + 7 <= bits.length; i += 7) {
+ let septet = 0;
+ for (let j = 0; j < 7; j++) septet |= bits[i + j] << j;
+ if (escape) {
+ const ext = Object.entries(GSM7_EXT_MAP).find(([,v]) => v === septet);
+ out += ext ? ext[0] : '?';
+ escape = false;
+ } else if (septet === 0x1B) {
+ escape = true;
+ } else {
+ out += septet < GSM7_ALPHABET.length ? GSM7_ALPHABET[septet] : '?';
+ }
+ }
+ return out;
+}
+
+function decodeTextData(hex) {
+ if (!hex || hex.length < 2) return '?';
+ if (hex.length >= 4 && hex.length % 4 === 0) {
+ let allPrintable = true;
+ let ucs2 = '';
+ for (let i = 0; i < hex.length; i += 4) {
+ const cp = parseInt(hex.substr(i, 4), 16);
+ if (cp < 0x20 || cp > 0x10FFFF) { allPrintable = false; break; }
+ ucs2 += String.fromCodePoint(cp);
+ }
+ if (allPrintable && ucs2.length > 0) return ucs2;
+ }
+ try {
+ const bytes = new Uint8Array(hex.length / 2);
+ for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
+ const gsm = gsm7Decode(bytes);
+ if (gsm.length > 0 && !gsm.includes('\uFFFD')) return gsm;
+ } catch (e) {}
+ return '?';
+}
+
+function describeToolkit(hex) {
+ const children = [];
+ if (!hex || hex.length < 2) return children;
+ let isUicc = true;
+ if (hex.length >= 2) {
+ const first = parseInt(hex.substr(0, 2), 16);
+ if (first <= 0x0F) isUicc = false;
+ }
+ let i = 0;
+ if (isUicc) {
+ children.push({label:'Priority', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()}); i += 2;
+ children.push({label:'Timers', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()}); i += 2;
+ children.push({label:'Text length', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()}); i += 2;
+ const menus = parseInt(hex.substr(i,2), 16); i += 2;
+ children.push({label:'Menu entries', hex:hex.substr(i-2,2), desc:menus.toString()});
+ for (let m = 0; m < menus && i + 4 <= hex.length; m++) {
+ children.push({label:'Menu pair '+(m+1), hex:hex.substr(i,4), desc:'Pos ' + parseInt(hex.substr(i,2),16) + ', ID ' + hex.substr(i+2,2)});
+ i += 4;
+ }
+ children.push({label:'Channels', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()}); i += 2;
+ if (i + 2 <= hex.length) {
+ const mslLen = parseInt(hex.substr(i,2), 16); i += 2;
+ if (mslLen > 0 && i + mslLen * 2 <= hex.length) {
+ children.push({label:'MSL', hex:hex.substr(i-2, 2+mslLen*2), desc:'Length ' + mslLen + ' value ' + hex.substr(i, mslLen*2)});
+ i += mslLen * 2;
+ } else {
+ children.push({label:'MSL', hex:hex.substr(i-2,2), desc:'No MSL'});
+ }
+ }
+ if (i + 2 <= hex.length) {
+ const tarLen = parseInt(hex.substr(i,2), 16); i += 2;
+ if (tarLen > 0 && i + tarLen * 2 <= hex.length) {
+ children.push({label:'TAR', hex:hex.substr(i-2, 2+tarLen*2), desc:hex.substr(i, tarLen*2)});
+ i += tarLen * 2;
+ } else {
+ children.push({label:'TAR', hex:hex.substr(i-2,2), desc:'No TAR'});
+ }
+ }
+ if (i + 2 <= hex.length) {
+ children.push({label:'Max services', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()});
+ }
+ } else {
+ const adLen = parseInt(hex.substr(i,2), 16); i += 2;
+ if (adLen > 0 && i + adLen * 2 <= hex.length) {
+ children.push({label:'Access Domain', hex:hex.substr(i-2, 2+adLen*2), desc:hex.substr(i, adLen*2)});
+ i += adLen * 2;
+ } else {
+ children.push({label:'Access Domain', hex:hex.substr(i-2,2), desc:'Empty'});
+ }
+ children.push({label:'Priority', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()}); i += 2;
+ children.push({label:'Timers', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()}); i += 2;
+ children.push({label:'Text length', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()}); i += 2;
+ const menus = parseInt(hex.substr(i,2), 16); i += 2;
+ children.push({label:'Menu entries', hex:hex.substr(i-2,2), desc:menus.toString()});
+ for (let m = 0; m < menus && i + 4 <= hex.length; m++) {
+ children.push({label:'Menu pair '+(m+1), hex:hex.substr(i,4), desc:'Pos ' + parseInt(hex.substr(i,2),16) + ', ID ' + hex.substr(i+2,2)});
+ i += 4;
+ }
+ children.push({label:'Channels', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()}); i += 2;
+ if (i + 2 <= hex.length) {
+ const mslLen = parseInt(hex.substr(i,2), 16); i += 2;
+ if (mslLen > 0 && i + mslLen * 2 <= hex.length) {
+ children.push({label:'MSL', hex:hex.substr(i-2, 2+mslLen*2), desc:'Length ' + mslLen + ' value ' + hex.substr(i, mslLen*2)});
+ i += mslLen * 2;
+ } else {
+ children.push({label:'MSL', hex:hex.substr(i-2,2), desc:'No MSL'});
+ }
+ }
+ if (i + 2 <= hex.length) {
+ const tarLen = parseInt(hex.substr(i,2), 16); i += 2;
+ if (tarLen > 0 && i + tarLen * 2 <= hex.length) {
+ children.push({label:'TAR', hex:hex.substr(i-2, 2+tarLen*2), desc:hex.substr(i, tarLen*2)});
+ i += tarLen * 2;
+ } else {
+ children.push({label:'TAR', hex:hex.substr(i-2,2), desc:'No TAR'});
+ }
+ }
+ }
+ return children;
+}
+
+function describeProactiveCommand(tlvs) {
+ const children = [];
+ for (const tlv of tlvs) {
+ const base = baseCompTag(tlv.tag);
+ let desc = '';
+ if (base === '01') {
+ const cmdNum = parseInt(tlv.value.substr(0, 2), 16);
+ const qual = tlv.value.substr(4, 2);
+ let typeName = 'Command #' + cmdNum;
+ for (const [k, arr] of Object.entries(BER_QUAL)) {
+ const found = arr.find(([v]) => v === qual);
+ if (found) { typeName += ', ' + k.toUpperCase() + ' qual ' + found[1]; break; }
+ }
+ desc = typeName;
+ } else if (base === '02') {
+ const src = tlv.value.substr(0, 2);
+ const dst = tlv.value.substr(2, 2);
+ const srcName = (BER_DEVICES.find(([v]) => v === src) || [])[1] || src;
+ const dstName = (BER_DEVICES.find(([v]) => v === dst) || [])[1] || dst;
+ desc = 'from ' + srcName + ' to ' + dstName;
+ } else if (base === '0D' || base === '8D') {
+ desc = decodeTextData(tlv.value);
+ } else if (base === '04' || base === '84') {
+ if (tlv.value.length >= 4) {
+ const unit = ['minutes','seconds','tenths'][parseInt(tlv.value.substr(0,2), 16)] || '?';
+ const val = parseInt(tlv.value.substr(2,2), 16);
+ desc = val + ' ' + unit;
+ }
+ } else if (base === '05' || base === '85') {
+ desc = 'Alpha ID: ' + tlv.value;
+ } else if (base === '0E' || base === '8E') {
+ const tone = tlv.value.substr(0, 2);
+ const toneName = (BER_TONES.find(([v]) => v === tone) || [])[1] || tone;
+ desc = 'Tone: ' + toneName;
+ } else if (base === '12' || base === '92') {
+ desc = 'File list: ' + tlv.value;
+ } else {
+ desc = tlv.value || '(empty)';
+ }
+ children.push({label:'TLV ' + tlv.tag + ' (' + baseCompTag(tlv.tag) + ')', hex:tlv.raw, desc});
+ }
+ return children;
+}
+
+function parseBerScript(hex) {
+ const rows = [];
+ let i = 0;
+ if (hex.startsWith('AA')) {
+ const r = parseBerLen(hex, 2);
+ i = 2 + r.consumed;
+ } else if (hex.startsWith('AE80')) {
+ i = 4;
+ } else {
+ return rows;
+ }
+ while (i < hex.length) {
+ if (hex.startsWith('0000', i)) break;
+ if (i + 4 > hex.length) break;
+ const tag = hex.substr(i, 2);
+ const r = parseBerLen(hex, i + 2);
+ const len = r.len;
+ const consumed = 2 + r.consumed;
+ if (i + consumed + len * 2 > hex.length) break;
+ const value = hex.substr(i + consumed, len * 2);
+ const raw = hex.substr(i, consumed + len * 2);
+ let label = 'Row ' + (rows.length + 1);
+ let children = [];
+ if (tag === '22') {
+ label = 'C-APDU';
+ children = [{label:'APDU', hex:value, desc:value}];
+ } else if (tag === '81') {
+ label = 'Immediate Action';
+ children = parseActionRow(value, '81');
+ } else if (tag === '82') {
+ label = 'Error Action';
+ children = parseActionRow(value, '82');
+ } else if (tag === '83') {
+ label = 'Script Chaining';
+ children = [{label:'Flags', hex:value.substr(0,2), desc:parseChainingFlags(value.substr(0,2))}];
+ if (value.length > 2) children.push({label:'Script ID', hex:value.substr(2), desc:value.substr(2)});
+ } else {
+ label = 'Row ' + (rows.length + 1) + ' (tag ' + tag + ')';
+ }
+ rows.push({label, hex:raw, desc:'', children});
+ i += consumed + len * 2;
+ }
+ if (i < hex.length && !hex.startsWith('0000', i)) {
+ rows.push({label:'Trailing (unrecognized)', hex:hex.substr(i), desc:hex.substr(i)});
+ }
+ return rows;
+}
+
+function parseChainingFlags(hex) {
+ if (hex === '01') return 'First';
+ if (hex === '11') return 'First, keep across reset';
+ if (hex === '02') return 'Intermediary (more to follow)';
+ if (hex === '03') return 'Intermediary (last)';
+ return hex;
+}
+
+function parseActionRow(hex, tag) {
+ if (!hex || hex === '00') return [{label:'No action', hex:hex||'', desc:tag === '82' ? 'No action (82 00)' : ''}];
+ if (hex.length === 2) return [{label:'Action indicator', hex, desc:'Value ' + hex}];
+ const tlvs = parseTlvList(hex);
+ if (tlvs.length > 0) {
+ const children = describeProactiveCommand(tlvs);
+ return [{label:'Proactive command', hex, desc:'', children}];
+ }
+ return [{label:'Raw', hex, desc:hex}];
+}
+
+function parseOneApdu(hex) {
+ if (hex.length < 10) return null;
+ const cla = hex.substr(0, 2);
+ const ins = hex.substr(2, 2);
+ const p1 = hex.substr(4, 2);
+ const p2 = hex.substr(6, 2);
+ const p3 = hex.substr(8, 2);
+ const insInfo = PARSE_INS[ins];
+ const children = [
+ {label:'CLA', hex:cla, desc:cla === '80' ? 'GP/RAM' : cla === 'A0' ? 'SIM' : 'UICC'},
+ {label:'INS', hex:ins, desc:insInfo ? insInfo.name : 'Unknown'},
+ {label:'P1', hex:p1, desc:''},
+ {label:'P2', hex:p2, desc:''},
+ ];
+ if (insInfo && insInfo.data === 'le') {
+ children.push({label:'Le', hex:p3, desc:'Expected length: ' + parseInt(p3, 16)});
+ } else if (insInfo && insInfo.data === 'none') {
+ children.push({label:'P3', hex:p3, desc:'No data'});
+ } else {
+ const lc = parseInt(p3, 16);
+ children.push({label:'Lc', hex:p3, desc:'Data length: ' + lc});
+ if (lc > 0 && hex.length >= 10 + lc * 2) {
+ const data = hex.substr(10, lc * 2);
+ children.push({label:'Data', hex:data, desc:data});
+ if (hex.length > 10 + lc * 2) {
+ const le = hex.substr(10 + lc * 2, 2);
+ children.push({label:'Le', hex:le, desc:'Expected response length: ' + parseInt(le, 16)});
+ }
+ }
+ }
+ const totalLen = 10 + (children.find(c => c.label === 'Data') ? parseInt(children.find(c => c.label === 'Lc').hex, 16) * 2 : 0) + (children.find(c => c.label === 'Le (tail)') ? 2 : 0);
+ return {label:'APDU', hex:hex.substr(0, totalLen), desc:'', children};
+}
+
+function parseCompactApdus(hex) {
+ const apdus = [];
+ let i = 0;
+ let prevCla = '';
+ while (i + 4 <= hex.length) {
+ const first = hex.substr(i, 2);
+ const second = hex.substr(i + 2, 2);
+ let fullCla = '';
+ let fullIns = '';
+ let consumed = 0;
+ if ((first === '80' || first === 'A0' || first === '00') && PARSE_INS[second]) {
+ fullCla = first;
+ fullIns = second;
+ consumed = 2;
+ } else if (PARSE_INS[first] && prevCla && i > 0) {
+ fullCla = prevCla;
+ fullIns = first;
+ consumed = 0;
+ }
+ if (fullCla) {
+ const p3 = parseInt(hex.substr(i + 6 + consumed, 2), 16);
+ const dataType = PARSE_INS[fullIns].data;
+ let apduLen = 8;
+ if (dataType === 'lc') {
+ apduLen += p3 * 2;
+ }
+ if (i + consumed + apduLen <= hex.length) {
+ const apduHex = fullCla + hex.substr(i + consumed, apduLen);
+ const parsed = parseOneApdu(apduHex);
+ if (parsed) {
+ prevCla = fullCla;
+ parsed.hex = hex.substr(i, consumed + apduLen);
+ parsed.label = consumed === 0 ? 'APDU (implied CLA)' : 'APDU';
+ apdus.push(parsed);
+ i += consumed + apduLen;
+ continue;
+ }
+ }
+ }
+ apdus.push({label:'Unknown', hex:hex.substr(i, 2), desc:'Unrecognized'});
+ i += 2;
+ }
+ if (i < hex.length) {
+ apdus.push({label:'Trailing (unrecognized)', hex:hex.substr(i), desc:hex.substr(i)});
+ }
+ return apdus;
+}
+
+function parseHexTree(hex) {
+ const s = hex.replace(/[^0-9a-fA-F]/g, '').toUpperCase();
+ if (s.length % 2 !== 0) return {label:'Error', hex:'', desc:'Odd hex length'};
+ if (s.startsWith('AA') && s.length >= 4) {
+ const rows = parseBerScript(s);
+ return {label:'Expanded Script (AA)', hex:s, desc:'', children:rows};
+ }
+ if (s.startsWith('AE80')) {
+ const rows = parseBerScript(s);
+ return {label:'Expanded Script (AE80)', hex:s, desc:'', children:rows};
+ }
+ const apdus = parseCompactApdus(s);
+ return {label:'Compact C-APDU chain', hex:s, desc:'', children:apdus};
+}
+
+function renderTree(node, container) {
+ const div = document.createElement('div');
+ div.className = 'ml-3 border-l-2 border-gray-300 dark:border-slate-600 pl-2 mb-1';
+ const header = document.createElement('div');
+ header.className = 'flex gap-2 items-start py-0.5 cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-700 rounded';
+ if (node.children && node.children.length > 0) {
+ const toggle = document.createElement('span');
+ toggle.textContent = '▾';
+ toggle.className = 'text-xs text-gray-400 w-4 flex-shrink-0';
+ header.appendChild(toggle);
+ header.onclick = () => {
+ const body = div.querySelector('.tree-body');
+ if (body) {
+ body.classList.toggle('hidden');
+ toggle.textContent = body.classList.contains('hidden') ? '▸' : '▾';
+ }
+ };
+ } else {
+ const dot = document.createElement('span');
+ dot.textContent = '·';
+ dot.className = 'text-xs text-gray-300 w-4 flex-shrink-0';
+ header.appendChild(dot);
+ }
+ const label = document.createElement('span');
+ label.className = 'text-xs font-medium text-gray-700 dark:text-slate-300';
+ label.textContent = node.label;
+ header.appendChild(label);
+ if (node.hex) {
+ const hex = document.createElement('span');
+ hex.className = 'text-xs font-mono text-gray-500 dark:text-slate-400 ml-2';
+ hex.textContent = node.hex;
+ header.appendChild(hex);
+ }
+ if (node.desc) {
+ const desc = document.createElement('span');
+ desc.className = 'text-xs text-gray-600 dark:text-slate-400 ml-2';
+ desc.textContent = node.desc;
+ header.appendChild(desc);
+ }
+ div.appendChild(header);
+ if (node.children && node.children.length > 0) {
+ const body = document.createElement('div');
+ body.className = 'tree-body';
+ div.appendChild(body);
+ for (const child of node.children) {
+ renderTree(child, body);
+ }
+ }
+ container.appendChild(div);
+}
+
+function parseHex() {
+ const input = document.getElementById('parse-input');
+ const output = document.getElementById('parse-output');
+ const hex = input.value.replace(/[^0-9a-fA-F]/g, '').toUpperCase();
+ if (!hex || hex.length % 2 !== 0) {
+ output.innerHTML = '
Invalid hex input
';
+ return;
+ }
+ const tree = parseHexTree(hex);
+ output.innerHTML = '';
+ renderTree(tree, output);
+}
+
// ===== Response Parser =====
const SW_MAP = {
generic: {
@@ -4933,6 +5421,7 @@ const LANG_RU = {
'Error: ciphering requires KIc key': 'Ошибка: ciphering requires KIc key',
'Error: MAC requires KID key': 'Ошибка: MAC requires KID key',
'Response parser': 'Парсер ответов',
+ 'C-APDU Parser': 'Разбор C-APDU',
'Cards': 'Карты',
'Card reader': 'Картридер',
'Card presets are stored locally in your browser. JSON export/import buttons are below the card list.': 'Данные карт хранятся локально в браузере. Кнопки экспорта/импорта JSON — под списком карт.',
diff --git a/frontend/tests/apdu_parse.test.js b/frontend/tests/apdu_parse.test.js
new file mode 100644
index 0000000..6760908
--- /dev/null
+++ b/frontend/tests/apdu_parse.test.js
@@ -0,0 +1,176 @@
+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);
+}
+
+function findNode(tree, label) {
+ if (tree.label === label) return tree;
+ if (tree.children) {
+ for (const c of tree.children) {
+ const r = findNode(c, label);
+ if (r) return r;
+ }
+ }
+ return null;
+}
+
+function findNodes(tree, label) {
+ const out = [];
+ if (tree.label === label) out.push(tree);
+ if (tree.children) {
+ for (const c of tree.children) out.push(...findNodes(c, label));
+ }
+ return out;
+}
+
+const consts = ['BER_QUAL', 'BER_DEVICES', 'BER_TONES', 'GSM7_ALPHABET', 'GSM7_EXT_MAP'];
+let prefix = '';
+for (const c of consts) {
+ const m = html.match(new RegExp('const\\s+' + c + '\\s*=\\s*(?:\\[|\\{)[\\s\\S]*?(?:\\n[\\]\\}];|[\\]\\}];\\n)'));
+ if (!m) throw new Error(c + ' not found');
+ prefix += m[0].replace(/^const /, 'var ') + '\n';
+}
+const start = html.indexOf('// ===== C-APDU Parser =====');
+const end = html.indexOf('// ===== Response Parser =====');
+eval(prefix + html.slice(start, end).replace(/^const PARSE_INS = /m, 'var PARSE_INS = '));
+
+test('Compact RAM INSTALL [for install] UICC', () => {
+ const tree = parseHexTree('80E60C00214F08A000000151000000C70100EA128010000000020101020200011603B0000100');
+ assert.strictEqual(tree.label, 'Compact C-APDU chain');
+ assert.strictEqual(tree.children.length, 1);
+ const apdu = tree.children[0];
+ assert.strictEqual(apdu.label, 'APDU');
+ assert.ok(findNode(apdu, 'INS').desc.includes('INSTALL'));
+});
+
+test('Compact RAM INSTALL [for install] SIM (CA) access domain 5A', () => {
+ const tree = parseHexTree('80E60C00204F08A000000151000000C70100CA11015A000000020101020200011603B00001');
+ const apdu = tree.children[0];
+ assert.ok(findNode(apdu, 'INS').desc.includes('INSTALL'));
+});
+
+test('Expanded AA DISPLAY TEXT UCS2 "HI" + 5s', () => {
+ const tree = parseHexTree('AA1681130103012101020281820D040048004904020105');
+ assert.strictEqual(tree.label, 'Expanded Script (AA)');
+ assert.strictEqual(tree.children.length, 1);
+ assert.strictEqual(tree.children[0].label, 'Immediate Action');
+});
+
+test('Expanded AA DISPLAY TEXT GSM7 "HI"', () => {
+ const tree = parseHexTree('AA0F810D0103012101020281820D02C824');
+ assert.strictEqual(tree.children.length, 1);
+ assert.strictEqual(tree.children[0].label, 'Immediate Action');
+});
+
+test('Expanded AA REFRESH + file list', () => {
+ const tree = parseHexTree('AA10810E01030101000202818212036F3B2F');
+ assert.strictEqual(tree.children.length, 1);
+ assert.strictEqual(tree.children[0].label, 'Immediate Action');
+});
+
+test('Expanded AE80 PLAY TONE + Error Action no action', () => {
+ const tree = parseHexTree('AE80810C0103200101020281820E010182000000');
+ assert.strictEqual(tree.label, 'Expanded Script (AE80)');
+ assert.strictEqual(tree.children.length, 2);
+ assert.strictEqual(tree.children[0].label, 'Immediate Action');
+ assert.strictEqual(tree.children[1].label, 'Error Action');
+});
+
+test('Immediate Action CR-set tags (real-world encoding)', () => {
+ const tree = parseHexTree('AA0E8101818109810301010482028182');
+ assert.strictEqual(tree.children.length, 2);
+ assert.strictEqual(tree.children[0].label, 'Immediate Action');
+ assert.strictEqual(tree.children[1].label, 'Immediate Action');
+});
+
+test('CR-set DISPLAY TEXT UCS2 "HI" + 5s', () => {
+ const tree = parseHexTree('AA1581138103012101820281828D040048004984020105');
+ assert.strictEqual(tree.children.length, 1);
+});
+
+test('CR-set PLAY TONE (tag 8E)', () => {
+ const tree = parseHexTree('AA0E810C8103012001820281828E0101');
+ assert.strictEqual(tree.children.length, 1);
+});
+
+test('CR-set REFRESH file list (tag 92)', () => {
+ const tree = parseHexTree('AA0F810D81030101048202818292023F00');
+ assert.strictEqual(tree.children.length, 1);
+});
+
+test('Expanded AA Error Action = proactive DISPLAY TEXT', () => {
+ const tree = parseHexTree('AA0F820D0103012101020281820D022852');
+ assert.strictEqual(tree.children.length, 1);
+ assert.strictEqual(tree.children[0].label, 'Error Action');
+});
+
+test('Expanded AA 22 C-APDU row (GET STATUS)', () => {
+ const tree = parseHexTree('AA09220780F22000024F00');
+ assert.strictEqual(tree.children.length, 1);
+ assert.strictEqual(tree.children[0].label, 'C-APDU');
+});
+
+test('Chained SIM SELECT + UPDATE RECORD', () => {
+ const tree = parseHexTree('A0A40000026F3BDC0102032B2F2D');
+ assert.strictEqual(tree.label, 'Compact C-APDU chain');
+ assert.strictEqual(tree.children.length, 2);
+ assert.strictEqual(tree.children[0].label, 'APDU');
+});
+
+test('INTERNAL AUTHENTICATE', () => {
+ const tree = parseHexTree('8088000008800F3495BA2355CD');
+ assert.strictEqual(tree.children.length, 1);
+ const apdu = tree.children[0];
+ assert.strictEqual(apdu.label, 'APDU');
+ assert.ok(findNode(apdu, 'INS').desc.includes('INTERNAL AUTHENTICATE'));
+ assert.ok(findNode(apdu, 'Data'));
+});
+
+test('baseCompTag clears CR bit for 0x80-0x9F', () => {
+ assert.strictEqual(baseCompTag('81'), '01');
+ assert.strictEqual(baseCompTag('82'), '02');
+ assert.strictEqual(baseCompTag('8D'), '0D');
+ assert.strictEqual(baseCompTag('8E'), '0E');
+ assert.strictEqual(baseCompTag('92'), '12');
+ assert.strictEqual(baseCompTag('84'), '04');
+ assert.strictEqual(baseCompTag('85'), '05');
+ assert.strictEqual(baseCompTag('01'), '01');
+ assert.strictEqual(baseCompTag('22'), '22');
+});
+
+test('parseTlvList handles BER-TLVs', () => {
+ const tlvs = parseTlvList('810301210182028182');
+ assert.strictEqual(tlvs.length, 2);
+ assert.strictEqual(tlvs[0].tag, '81');
+ assert.strictEqual(tlvs[0].length, 3);
+ assert.strictEqual(tlvs[0].value, '012101');
+ assert.strictEqual(tlvs[1].tag, '82');
+});
+
+test('gsm7Decode unpacks "HI" from C824', () => {
+ const bytes = new Uint8Array([0xC8, 0x24]);
+ assert.strictEqual(gsm7Decode(bytes), 'HI');
+});
+
+test('decodePrivileges', () => {
+ assert.ok(decodePrivileges('00').includes('None'));
+ assert.ok(decodePrivileges('80').includes('Security Domain'));
+});
\ No newline at end of file