v1.9.26: profiler (card file-structure & content verification)

New 'Profiler' sub-tab in card-reader view:
- Named rulesets (profiles) persisted in localStorage; each has an ordered
  list of filesystem rules (type 'file', extensible to OTA/TAR checks later)
- List page: New profile / Profile from card / Import profile + per-profile
  Edit / Check / Export / Delete
- Editor page: inline rule fields (path, file type, size, record length,
  record count, contents with exact/mask/'?'-wildcard modes)
- Results page: sequential rule checks with live progress and a pass/fail
  report (existence + FCI attributes + content match)
- 'Profile from card' scans the equipped card; creates a rule only for files
  that exist (FCI present). A scan-options dialog lets the user skip contents
  of frequently-overwritten dynamic files (LOCI/PSLOCI/EPSLOCI/5GS3GPPLOCI/
  Keys/KeysPS/SMS/Kc/KcGPRS/LOCIGPRS/CBMID/SMSS), checked by default
- ADF-rooted paths use the AID; paths resolve via a new server _select_path

Server:
- _select_path() resolves MF/ADF-AID-rooted paths (pySim can't select ADF by AID)
- /api/select and /api/read accept 'path'; /api/select returns file_size,
  record_len, num_of_rec

Also fix: silent SELECT (P2=0x0C) no longer emits a trailing Le byte
(chain builder) - matches ETSI TS 102 221 silent-select behavior.

Tests: +profiler.test.js; frontend 117 pass, Python 61 pass.
SW cache v37.
This commit is contained in:
2026-09-09 15:51:48 +03:00
parent 1db6c12d52
commit 201b0df278
5 changed files with 729 additions and 13 deletions
+622 -7
View File
@@ -18,7 +18,7 @@
<div class="max-w-7xl mx-auto px-6 py-2">
<div class="flex items-center justify-between mb-3">
<h1 class="text-2xl font-bold text-heading">OTAMan <span id="slogan" class="text-sm font-normal text-gray-500 dark:text-slate-400 ml-2" data-l10n="SIM OTA with a Human Face">SIM OTA with a Human Face</span> <span class="text-xs text-gray-400 dark:text-slate-500 ml-1">v1.9.25</span></h1>
<h1 class="text-2xl font-bold text-heading">OTAMan <span id="slogan" class="text-sm font-normal text-gray-500 dark:text-slate-400 ml-2" data-l10n="SIM OTA with a Human Face">SIM OTA with a Human Face</span> <span class="text-xs text-gray-400 dark:text-slate-500 ml-1">v1.9.26</span></h1>
<div class="flex items-center gap-4">
<button id="install-btn" class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700" style="display:none">INSTALL PWA [for offline use]</button>
<a href="https://github.com/anttro/otaman" target="_blank" class="text-xs text-gray-400 hover:text-gray-600 dark:text-slate-500 dark:hover:text-slate-300">github</a>
@@ -648,6 +648,7 @@
<div class="flex gap-1 mb-2">
<button class="pysim-subtab px-3 py-1 text-sm rounded-full bg-blue-600 text-white" data-pysim-sub="files" data-l10n="File manager">File manager</button>
<button class="pysim-subtab px-3 py-1 text-sm rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-pysim-sub="custom" data-l10n="Custom files">Custom files</button>
<button class="pysim-subtab px-3 py-1 text-sm rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-pysim-sub="profiler" data-l10n="Profiler">Profiler</button>
<button class="pysim-subtab px-3 py-1 text-sm rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-pysim-sub="cmd" data-l10n="pySim command line">pySim command line</button>
<button class="pysim-subtab px-3 py-1 text-sm rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-pysim-sub="apdu" data-l10n="Raw APDU">Raw APDU</button>
<button class="pysim-subtab px-3 py-1 text-sm rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-pysim-sub="proactive">Proactive UICC</button>
@@ -696,6 +697,36 @@
<textarea id="pysim-cf-io" class="hidden mt-2 w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1.5 bg-gray-100 dark:bg-slate-800" rows="6"></textarea>
</div>
<div id="pysim-sub-profiler" class="hidden">
<div id="profiler-list">
<div class="flex flex-wrap gap-2 mb-3">
<button onclick="profilerNew()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="New profile">New profile</button>
<button onclick="profilerFromCard()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Profile from card">Profile from card</button>
<button onclick="document.getElementById('profiler-import-file').click()" class="px-2.5 py-1 text-sm rounded bg-gray-600 text-white hover:bg-gray-700" data-l10n="Import profile">Import profile</button>
<input type="file" id="profiler-import-file" accept=".json,application/json" class="hidden" onchange="profilerImportFile(this)">
</div>
<div id="profiler-list-body"></div>
</div>
<div id="profiler-editor" class="hidden">
<div class="flex flex-wrap gap-2 items-center mb-3">
<button onclick="profilerBackToList()" class="px-2.5 py-1 text-sm rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Back to list">Back to list</button>
<input id="profiler-name" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="Profile name">
<button onclick="profilerAddRule()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Add rule">Add rule</button>
<button onclick="profilerSaveProfile()" class="px-2.5 py-1 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700" data-l10n="Save">Save</button>
</div>
<div id="profiler-rules"></div>
</div>
<div id="profiler-results" class="hidden">
<div class="flex flex-wrap gap-2 items-center mb-3">
<button onclick="profilerBackToList()" class="px-2.5 py-1 text-sm rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Back to list">Back to list</button>
<span id="profiler-results-title" class="text-sm font-semibold"></span>
</div>
<div id="profiler-progress" class="mb-2 text-xs font-mono text-gray-600 dark:text-slate-400"></div>
<div id="profiler-summary" class="mb-2 text-sm"></div>
<div id="profiler-report"></div>
</div>
</div>
<div id="pysim-sub-cmd" class="hidden">
<div class="flex gap-2 items-center mb-2">
<div class="relative flex-1">
@@ -762,6 +793,21 @@
</div>
</div>
<div id="profiler-scan-modal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center hidden">
<div class="bg-white dark:bg-slate-800 rounded-lg p-6 max-w-sm w-full mx-4 max-h-[85vh] overflow-auto">
<h3 class="text-lg font-semibold mb-4 text-gray-800 dark:text-slate-200" data-l10n="Profile from card">Profile from card</h3>
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Profile name">Profile name</label>
<input id="profiler-scan-name" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2 dark:bg-slate-800 mb-3" placeholder="Profile name">
<div class="mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Ignore contents of:">Ignore contents of:</div>
<div id="profiler-scan-ignore" class="space-y-1 mb-3 max-h-56 overflow-auto"></div>
<div id="profiler-scan-error" class="text-xs text-red-500 hidden mb-2"></div>
<div class="flex gap-2 justify-end">
<button onclick="profilerScanCancel()" class="px-3 py-1.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-100 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Cancel">Cancel</button>
<button id="profiler-scan-btn" onclick="profilerScanStart()" class="px-4 py-1.5 text-xs rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Scan">Scan</button>
</div>
</div>
</div>
<div id="stk-menu-panel" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center hidden">
<div class="bg-white dark:bg-slate-800 rounded-lg p-4 max-w-xs w-full max-h-[80vh] flex flex-col">
<div class="flex justify-between items-center mb-3">
@@ -1602,7 +1648,7 @@ function chainSimBuildRowHex(chainId, idx, row) {
var fid = (f.fid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (fid.length !== 4) return '';
var apdu = buildApdu(cla, 0xA4, 0x00, p2, 0x02, fid);
if (!isSim) apdu += '00';
if (!isSim && !silent) apdu += '00';
return apdu;
}
if (method === 'path') {
@@ -1610,14 +1656,14 @@ function chainSimBuildRowHex(chainId, idx, row) {
if (path.length < 2 || path.length % 2 !== 0) return '';
var p1 = (!isSim && f.base === 'df') ? 0x09 : 0x08;
var apdu2 = buildApdu(cla, 0xA4, p1, p2, path.length / 2, path);
if (!isSim) apdu2 += '00';
if (!isSim && !silent) apdu2 += '00';
return apdu2;
}
if (method === 'dfname') {
var aid = (f.dfname || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (aid.length < 2) return '';
var apdu3 = buildApdu(cla, 0xA4, 0x04, p2, aid.length / 2, aid);
if (!isSim) apdu3 += '00';
if (!isSim && !silent) apdu3 += '00';
return apdu3;
}
if (method === 'chain') {
@@ -1634,7 +1680,7 @@ function chainSimBuildRowHex(chainId, idx, row) {
var fid2 = tok.replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (fid2.length !== 4) continue;
var s = buildSelect(cla, 0x00, p2, fid2);
if (!isSim) s += '00';
if (!isSim && !silent) s += '00';
out += s;
}
return out;
@@ -5178,12 +5224,13 @@ function pysimSwitchSubtab(name) {
btn.classList.toggle('text-gray-700', !isActive);
btn.classList.toggle('dark:text-slate-300', !isActive);
});
['cmd', 'apdu', 'files', 'custom', 'proactive'].forEach(s => {
['cmd', 'apdu', 'files', 'custom', 'proactive', 'profiler'].forEach(s => {
document.getElementById('pysim-sub-' + s).classList.toggle('hidden', s !== name);
});
if (name === 'custom') pysimCustomRender();
if (name === 'proactive') { stkCheckMenu(); pysimEventsRender(); pysimProactiveLogRender(); pysimPliRender(); pysimPollStatusInit(); }
pysimHelpAnchor = {cmd:'pysim-cmdline', apdu:'raw-apdu', files:'file-manager', custom:'custom-files', proactive:'proactive-uicc'}[name] || 'card-reader';
if (name === 'profiler') profilerSetView('list');
pysimHelpAnchor = {cmd:'pysim-cmdline', apdu:'raw-apdu', files:'file-manager', custom:'custom-files', proactive:'proactive-uicc', profiler:'card-reader'}[name] || 'card-reader';
setHelpAnchor(pysimHelpAnchor);
}
@@ -6601,6 +6648,541 @@ function pysimCustomImportField() {
// Init custom files
pysimCustomLoad();
// ===== Profiler =====
let profiles = [];
let profilerView = 'list';
let profilerEditId = null;
let profilerDraft = null;
let profilerResults = null;
// Files whose contents are ignored (contents check omitted) when scanning.
// Keyed by FID; display name is the pySim name.
const PROFILER_IGNORE_FILES = [
{ fid: '6F7E', name: 'EF.LOCI' },
{ fid: '6F73', name: 'EF.PSLOCI' },
{ fid: '6FE3', name: 'EF.EPSLOCI' },
{ fid: '4F01', name: 'EF.5GS3GPPLOCI' },
{ fid: '6F08', name: 'EF.Keys' },
{ fid: '6F09', name: 'EF.KeysPS' },
{ fid: '6F3C', name: 'EF.SMS' },
{ fid: '6F20', name: 'EF.Kc' },
{ fid: '4F52', name: 'EF.KcGPRS' },
{ fid: '6F53', name: 'EF.LOCIGPRS' },
{ fid: '6F48', name: 'EF.CBMID' },
{ fid: '6F43', name: 'EF.SMSS' },
];
function profilerLoad() {
try {
const d = localStorage.getItem('otaman_profiles');
profiles = d ? JSON.parse(d) : [];
} catch (e) { profiles = []; }
}
function profilerSave() {
localStorage.setItem('otaman_profiles', JSON.stringify(profiles));
}
function profilerNewId() {
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
}
function profilerNormHex(s) {
return (s || '').toUpperCase().replace(/[^0-9A-F?]/g, '');
}
// Match an expected hex (may contain '?' nibble wildcards) against actual hex.
function profilerMatch(mode, expected, actual) {
const e = profilerNormHex(expected);
const a = profilerNormHex(actual);
if (!e) return false;
if (mode === 'mask') {
if (e.indexOf('?') < 0) {
return a.indexOf(e) === 0;
}
if (e.length !== a.length) return false;
for (let i = 0; i < e.length; i++) {
if (e[i] !== '?' && e[i] !== a[i]) return false;
}
return true;
}
return e === a;
}
function profilerValidateProfile(obj) {
if (!obj || typeof obj !== 'object') return 'Not an object';
if (!obj.name) return 'Missing name';
if (!Array.isArray(obj.rules)) return 'Missing rules array';
for (const r of obj.rules) {
if (!r || typeof r !== 'object') return 'Invalid rule';
if (r.type !== 'file') return 'Unsupported rule type: ' + r.type;
if (!r.path) return 'Rule missing path';
}
return null;
}
function profilerRenderList() {
const el = document.getElementById('profiler-list-body');
if (!profiles.length) {
el.innerHTML = '<span class="text-gray-400">' + t('No profiles defined.') + '</span>';
return;
}
let html = '';
for (let i = 0; i < profiles.length; i++) {
const p = profiles[i];
html += '<div class="flex items-center gap-2 py-1.5 border-b border-gray-200 dark:border-slate-700">';
html += '<b>' + esc(p.name) + '</b>';
html += '<span class="text-xs text-gray-400 dark:text-slate-500">' + esc(new Date(p.created).toLocaleString()) + '</span>';
html += '<span class="text-xs text-gray-400 dark:text-slate-500">(' + p.rules.length + ' ' + esc(t('rules')) + ')</span>';
html += '<span class="ml-auto flex gap-1">';
html += '<button onclick="profilerEdit(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-blue-600 text-white hover:bg-blue-700">' + esc(t('Edit')) + '</button>';
html += '<button onclick="profilerCheck(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700">' + esc(t('Check')) + '</button>';
html += '<button onclick="profilerExport(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-gray-600 text-white hover:bg-gray-700">' + esc(t('Export')) + '</button>';
html += '<button onclick="profilerDelete(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-red-600 text-white hover:bg-red-700">' + esc(t('Delete')) + '</button>';
html += '</span></div>';
}
el.innerHTML = html;
}
function profilerSetView(view) {
profilerView = view;
document.getElementById('profiler-list').classList.toggle('hidden', view !== 'list');
document.getElementById('profiler-editor').classList.toggle('hidden', view !== 'editor');
document.getElementById('profiler-results').classList.toggle('hidden', view !== 'results');
if (view === 'list') profilerRenderList();
}
function profilerNew() {
const name = prompt(t('Profile name') + ':', '');
if (!name) return;
profiles.push({ id: profilerNewId(), name: name, created: new Date().toISOString(), rules: [] });
profilerSave();
profilerEdit(profiles.length - 1);
}
function profilerFromCard() {
document.getElementById('profiler-scan-name').value = '';
const ignore = document.getElementById('profiler-scan-ignore');
let html = '';
for (const f of PROFILER_IGNORE_FILES) {
html += '<label class="flex items-center gap-2 text-sm">' +
'<input type="checkbox" data-ignore-fid="' + f.fid + '" checked>' +
'<span>' + esc(t('Ignore contents of')) + ' ' + esc(f.name) + '</span></label>';
}
ignore.innerHTML = html;
document.getElementById('profiler-scan-error').classList.add('hidden');
document.getElementById('profiler-scan-modal').classList.remove('hidden');
}
function profilerScanCancel() {
document.getElementById('profiler-scan-modal').classList.add('hidden');
}
async function profilerScanStart() {
const name = document.getElementById('profiler-scan-name').value.trim();
const errEl = document.getElementById('profiler-scan-error');
if (!name) {
errEl.textContent = t('Please enter a profile name');
errEl.classList.remove('hidden');
return;
}
const ignoreFids = new Set();
document.querySelectorAll('#profiler-scan-ignore input[data-ignore-fid]').forEach(cb => {
if (cb.checked) ignoreFids.add(cb.getAttribute('data-ignore-fid').toUpperCase());
});
const btn = document.getElementById('profiler-scan-btn');
btn.disabled = true;
btn.textContent = t('Scanning...');
try {
const rules = await profilerScanCard(ignoreFids);
const profile = { id: profilerNewId(), name: name, created: new Date().toISOString(), rules: rules };
profiles.push(profile);
profilerSave();
profilerScanCancel();
profilerEdit(profiles.length - 1);
} catch (e) {
errEl.textContent = e.message;
errEl.classList.remove('hidden');
} finally {
btn.disabled = false;
btn.textContent = t('Scan');
}
}
// Recursively walk the card filesystem and build rules for files that exist.
async function profilerScanCard(ignoreFids) {
const rules = [];
const seen = new Set();
// dir: { name, fid, parentSel (to select this dir), pathPrefix (rule-path segs for its children) }
async function walkDir(dir) {
let body = { name: dir.name, fid: dir.fid };
if (dir.parentSel) body.parent_sel = dir.parentSel;
let data;
try {
data = await pysimFetch('/api/tree', body);
} catch (e) { return; }
if (!data || data.exists === false) return;
for (const c of (data.children || [])) {
// parent selector for c, mirroring getParentSel()
const parentSel = (dir.name === 'MF') ? 'MF'
: (dir.name && dir.name.startsWith('ADF.')) ? dir.name
: (dir.fid ? dir.fid : dir.name);
if (c.isDir) {
// ADF roots use the AID, not the generic ADF fid
const childPrefix = c.aid ? [c.aid.toUpperCase()]
: dir.pathPrefix.concat(c.fid ? c.fid.toUpperCase() : c.name);
await walkDir({ name: c.name, fid: c.fid, parentSel: parentSel, pathPrefix: childPrefix });
} else {
const childPath = dir.pathPrefix.concat(c.fid ? c.fid.toUpperCase() : c.name).join('/');
if (seen.has(childPath)) continue;
seen.add(childPath);
const rule = await profilerBuildFileRule(childPath, c, ignoreFids);
if (rule) rules.push(rule);
}
}
}
// MF-rooted walk
await walkDir({ name: 'MF', fid: null, parentSel: null, pathPrefix: ['MF'] });
// Sweep custom files not already covered (map leading MF fid 3F00 -> MF)
for (const cf of pysimCustomFiles) {
const parts = cf.path.split('/').filter(Boolean);
if (!parts.length) continue;
let segs;
if (parts[0].toUpperCase() === '3F00') segs = ['MF'].concat(parts.slice(1));
else segs = parts;
const path = segs.join('/');
if (seen.has(path)) continue;
seen.add(path);
const rule = await profilerBuildFileRule(path, { fid: cf.fid, name: cf.name }, ignoreFids);
if (rule) rules.push(rule);
}
return rules;
}
async function profilerBuildFileRule(path, c, ignoreFids) {
let sel;
try {
sel = await pysimFetch('/api/select', { path: path });
} catch (e) { return null; }
if (!sel || sel.exists !== true) return null;
const rule = {
type: 'file',
path: path,
fileType: sel.file_type || null,
fileSize: (sel.file_size === null || sel.file_size === undefined) ? null : sel.file_size,
recordLen: (sel.record_len === null || sel.record_len === undefined) ? null : sel.record_len,
numRecords: (sel.num_of_rec === null || sel.num_of_rec === undefined) ? null : sel.num_of_rec,
content: null,
};
const fid = (c.fid || sel.fid || '').toUpperCase();
if (ignoreFids.has(fid)) return rule;
try {
const rd = await pysimFetch('/api/read', { path: path, mode: 'raw' });
if (rd && rd.success) {
if (rd.records) {
rule.content = {
mode: 'exact', kind: 'record',
records: rd.records.map(r => ({ num: r.num, data: r.data })),
};
} else if (rd.data) {
rule.content = { mode: 'exact', kind: 'transparent', expected: rd.data };
}
}
} catch (e) {}
return rule;
}
function profilerImportFile(input) {
const file = input.files && input.files[0];
input.value = '';
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const obj = JSON.parse(reader.result);
const err = profilerValidateProfile(obj);
if (err) throw new Error(err);
obj.id = profilerNewId();
obj.created = obj.created || new Date().toISOString();
profiles.push(obj);
profilerSave();
profilerRenderList();
} catch (e) {
alert('Import error: ' + e.message);
}
};
reader.readAsText(file);
}
function profilerExport(i) {
const p = profiles[i];
downloadJson((p.name || 'profile').replace(/[^a-zA-Z0-9_-]+/g, '_') + '.json', p);
}
function profilerDelete(i) {
const p = profiles[i];
if (!confirm(t('Delete profile') + ' "' + p.name + '"?') ) return;
profiles.splice(i, 1);
profilerSave();
profilerRenderList();
}
function profilerEdit(i) {
const p = profiles[i];
profilerEditId = p.id;
profilerDraft = JSON.parse(JSON.stringify(p));
profilerSetView('editor');
document.getElementById('profiler-name').value = profilerDraft.name;
profilerRenderEditor();
}
function profilerBackToList() {
profilerDraft = null;
profilerEditId = null;
profilerSetView('list');
}
function profilerAddRule() {
if (!profilerDraft) return;
profilerDraft.rules.push({ type: 'file', path: '', fileType: null, fileSize: null, recordLen: null, numRecords: null, content: null });
profilerRenderEditor();
}
function profilerDeleteRule(i) {
if (!profilerDraft) return;
profilerDraft.rules.splice(i, 1);
profilerRenderEditor();
}
function profilerRenderEditor() {
if (!profilerDraft) return;
const el = document.getElementById('profiler-rules');
if (!profilerDraft.rules.length) {
el.innerHTML = '<span class="text-gray-400">' + t('No rules defined.') + '</span>';
return;
}
let html = '';
for (let i = 0; i < profilerDraft.rules.length; i++) {
const r = profilerDraft.rules[i];
html += profilerRenderRule(i, r);
}
el.innerHTML = html;
}
function profilerRenderRule(i, r) {
const fts = { transparent: 'transparent', linear_fixed: 'linear_fixed', cyclic: 'cyclic', ber_tlv: 'ber_tlv', df: 'df' };
let ftOpts = '<option value=""' + (!r.fileType ? ' selected' : '') + '></option>';
for (const k in fts) {
ftOpts += '<option value="' + k + '"' + (r.fileType === k ? ' selected' : '') + '>' + fts[k] + '</option>';
}
const cm = r.content ? r.content.mode : 'exact';
let html = '<div class="mb-2 p-3 border border-gray-200 dark:border-slate-600 rounded">';
html += '<div class="flex items-center gap-2 mb-2">';
html += '<span class="text-xs font-semibold text-gray-500 dark:text-slate-400">#' + (i + 1) + '</span>';
html += '<button onclick="profilerDeleteRule(' + i + ')" class="ml-auto px-2 py-0.5 text-xs rounded bg-red-600 text-white hover:bg-red-700">' + esc(t('Delete')) + '</button>';
html += '</div>';
html += '<div class="grid grid-cols-1 gap-2 mb-2">';
html += '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('Path')) + '</label>' +
'<input value="' + escHtml(r.path || '') + '" oninput="profilerUpdateRule(' + i + ',\'path\',this.value)" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="MF/7F10/6F3A"></div>';
html += '</div>';
html += '<div class="grid grid-cols-2 md:grid-cols-4 gap-2 mb-2">';
html += '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('File type')) + '</label>' +
'<select onchange="profilerUpdateRule(' + i + ',\'fileType\',this.value||null)" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' + ftOpts + '</select></div>';
html += '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('Size')) + '</label>' +
'<input type="number" value="' + (r.fileSize === null ? '' : r.fileSize) + '" oninput="profilerUpdateRule(' + i + ',\'fileSize\',this.value===\'\'?null:parseInt(this.value))" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="—"></div>';
html += '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('Record length')) + '</label>' +
'<input type="number" value="' + (r.recordLen === null ? '' : r.recordLen) + '" oninput="profilerUpdateRule(' + i + ',\'recordLen\',this.value===\'\'?null:parseInt(this.value))" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="—"></div>';
html += '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('Record count')) + '</label>' +
'<input type="number" value="' + (r.numRecords === null ? '' : r.numRecords) + '" oninput="profilerUpdateRule(' + i + ',\'numRecords\',this.value===\'\'?null:parseInt(this.value))" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="—"></div>';
html += '</div>';
html += profilerRenderContentEditor(i, r);
html += '</div>';
return html;
}
function profilerRenderContentEditor(i, r) {
const c = r.content;
const mode = c ? c.mode : 'exact';
let html = '<div class="mb-1 text-xs text-gray-600 dark:text-slate-400">' + esc(t('Contents')) + '</div>';
html += '<div class="flex items-center gap-3 mb-1">';
html += '<label class="text-xs flex items-center gap-1"><input type="radio" name="profiler-cm-' + i + '" value="none"' + (!c ? ' checked' : '') + ' onchange="profilerSetContent(' + i + ',null)"> ' + esc(t('None')) + '</label>';
html += '<label class="text-xs flex items-center gap-1"><input type="radio" name="profiler-cm-' + i + '" value="exact"' + (c && mode === 'exact' ? ' checked' : '') + ' onchange="profilerSetContent(' + i + ',\'exact\')"> ' + esc(t('Exact')) + '</label>';
html += '<label class="text-xs flex items-center gap-1"><input type="radio" name="profiler-cm-' + i + '" value="mask"' + (c && mode === 'mask' ? ' checked' : '') + ' onchange="profilerSetContent(' + i + ',\'mask\')"> ' + esc(t('Mask')) + '</label>';
html += '</div>';
if (c) {
if (c.kind === 'record') {
html += '<div class="space-y-1">';
for (let ri = 0; ri < c.records.length; ri++) {
const rec = c.records[ri];
html += '<div class="flex items-center gap-2"><span class="text-xs text-gray-500 w-8">' + rec.num + '</span>' +
'<input value="' + escHtml(rec.data) + '" oninput="profilerUpdateRecord(' + i + ',' + ri + ',this.value)" class="flex-1 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800"></div>';
}
html += '</div>';
} else {
html += '<textarea oninput="profilerUpdateRuleContent(' + i + ',this.value)" rows="2" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800">' + escHtml(c.expected || '') + '</textarea>';
}
}
return html;
}
function profilerUpdateRule(i, field, value) {
if (!profilerDraft) return;
profilerDraft.rules[i][field] = value;
}
function profilerSetContent(i, mode) {
if (!profilerDraft) return;
const r = profilerDraft.rules[i];
if (!mode) {
r.content = null;
} else if (r.content) {
r.content.mode = mode;
} else {
r.content = { mode: mode, kind: 'transparent', expected: '' };
}
profilerRenderEditor();
}
function profilerUpdateRuleContent(i, value) {
if (!profilerDraft) return;
const c = profilerDraft.rules[i].content;
if (c) c.expected = value;
}
function profilerUpdateRecord(i, ri, value) {
if (!profilerDraft) return;
const c = profilerDraft.rules[i].content;
if (c && c.records) c.records[ri].data = value;
}
function profilerSaveProfile() {
if (!profilerDraft) return;
profilerDraft.name = document.getElementById('profiler-name').value.trim();
const idx = profiles.findIndex(p => p.id === profilerEditId);
if (idx >= 0) profiles[idx] = profilerDraft;
profilerSave();
profilerSetView('list');
}
async function profilerCheck(i) {
const p = profiles[i];
profilerSetView('results');
document.getElementById('profiler-results-title').textContent = p.name;
const prog = document.getElementById('profiler-progress');
const summary = document.getElementById('profiler-summary');
const report = document.getElementById('profiler-report');
report.innerHTML = '';
summary.innerHTML = '';
const results = [];
let passed = 0, failed = 0, errors = 0;
for (let ri = 0; ri < p.rules.length; ri++) {
const r = p.rules[ri];
prog.textContent = t('Checking') + ' ' + (ri + 1) + '/' + p.rules.length + ' — ' + r.path;
const res = await profilerRunRule(r);
results.push(res);
if (res.status === 'pass') passed++;
else if (res.status === 'fail') failed++;
else errors++;
}
prog.textContent = '';
summary.innerHTML = '<span class="text-emerald-600 font-semibold">' + passed + ' ' + t('passed') + '</span>' +
' · <span class="text-red-600 font-semibold">' + failed + ' ' + t('failed') + '</span>' +
' · <span class="text-yellow-600 font-semibold">' + errors + ' ' + t('errors') + '</span>';
report.innerHTML = profilerRenderReport(results);
}
async function profilerRunRule(r) {
const res = { path: r.path, status: 'pass', checks: [] };
let sel;
try {
sel = await pysimFetch('/api/select', { path: r.path });
} catch (e) {
res.status = 'error';
res.error = e.message;
return res;
}
if (!sel || sel.exists !== true) {
res.status = 'fail';
res.checks.push({ label: 'exists', expected: true, actual: false, ok: false });
return res;
}
res.checks.push({ label: 'exists', expected: true, actual: true, ok: true });
if (r.fileType) {
const ok = sel.file_type === r.fileType;
if (!ok) res.status = 'fail';
res.checks.push({ label: 'fileType', expected: r.fileType, actual: sel.file_type, ok: ok });
}
if (r.fileSize !== null && r.fileSize !== undefined) {
const ok = sel.file_size === r.fileSize;
if (!ok) res.status = 'fail';
res.checks.push({ label: 'fileSize', expected: r.fileSize, actual: sel.file_size, ok: ok });
}
if (r.recordLen !== null && r.recordLen !== undefined) {
const ok = sel.record_len === r.recordLen;
if (!ok) res.status = 'fail';
res.checks.push({ label: 'recordLen', expected: r.recordLen, actual: sel.record_len, ok: ok });
}
if (r.numRecords !== null && r.numRecords !== undefined) {
const ok = sel.num_of_rec === r.numRecords;
if (!ok) res.status = 'fail';
res.checks.push({ label: 'numRecords', expected: r.numRecords, actual: sel.num_of_rec, ok: ok });
}
if (r.content) {
let rd;
try {
rd = await pysimFetch('/api/read', { path: r.path, mode: 'raw' });
} catch (e) {
res.status = 'error';
res.error = e.message;
return res;
}
if (!rd || !rd.success) {
res.status = 'fail';
res.checks.push({ label: 'content', expected: 'readable', actual: 'read failed (' + (rd && rd.sw ? rd.sw : '?') + ')', ok: false });
} else if (r.content.kind === 'record') {
const exp = r.content.records || [];
const act = rd.records || [];
if (exp.length !== act.length) {
res.status = 'fail';
res.checks.push({ label: 'content.records', expected: exp.length + ' records', actual: act.length + ' records', ok: false });
} else {
for (let j = 0; j < exp.length; j++) {
const ok = exp[j].num === act[j].num && profilerMatch(r.content.mode, exp[j].data, act[j].data);
if (!ok) res.status = 'fail';
res.checks.push({ label: 'content.rec' + exp[j].num, expected: exp[j].data, actual: act[j].data, ok: ok });
}
}
} else {
const ok = profilerMatch(r.content.mode, r.content.expected, rd.data);
if (!ok) res.status = 'fail';
res.checks.push({ label: 'content', expected: r.content.expected, actual: rd.data, ok: ok });
}
}
return res;
}
function profilerRenderReport(results) {
let html = '';
for (const r of results) {
const color = r.status === 'pass' ? 'text-emerald-600' : (r.status === 'fail' ? 'text-red-600' : 'text-yellow-600');
const icon = r.status === 'pass' ? '✓' : (r.status === 'fail' ? '✗' : '⚠');
html += '<div class="mb-2 p-2 border border-gray-200 dark:border-slate-600 rounded">';
html += '<div class="flex items-center gap-2"><span class="' + color + ' font-bold">' + icon + '</span><span class="font-mono text-sm">' + esc(r.path) + '</span></div>';
if (r.error) html += '<div class="text-xs text-yellow-600 mt-1">' + esc(r.error) + '</div>';
for (const c of r.checks) {
if (c.ok) continue;
html += '<div class="text-xs text-red-600 mt-1">' + esc(c.label) + ': ' + esc(t('expected')) + ' <b>' + esc(String(c.expected)) + '</b>, ' + esc(t('actual')) + ' <b>' + esc(String(c.actual)) + '</b></div>';
}
html += '</div>';
}
return html;
}
profilerLoad();
// Init sub-tab pills
document.querySelectorAll('.pysim-subtab').forEach(btn => {
btn.addEventListener('click', () => pysimSwitchSubtab(btn.dataset.pysimSub));
@@ -6797,6 +7379,39 @@ const LANG_RU = {
'Saving...': 'Сохранение...',
'Saved': 'Сохранено',
'Save': 'Сохранить',
'Profiler': 'Профилёр',
'New profile': 'Новый профиль',
'Profile from card': 'Профиль с карты',
'Import profile': 'Импорт профиля',
'Back to list': 'Назад к списку',
'Add rule': 'Добавить правило',
'Scan': 'Сканировать',
'Ignore contents of:': 'Игнорировать содержимое:',
'Ignore contents of': 'Игнорировать содержимое',
'No profiles defined.': 'Профили не заданы.',
'rules': 'правил',
'Check': 'Проверить',
'Export': 'Экспорт',
'Profile name': 'Имя профиля',
'Please enter a profile name': 'Введите имя профиля',
'Scanning...': 'Сканирование...',
'Delete profile': 'Удалить профиль',
'No rules defined.': 'Правила не заданы.',
'Path': 'Путь',
'File type': 'Тип файла',
'Size': 'Размер',
'Record length': 'Длина записи',
'Record count': 'Кол-во записей',
'Contents': 'Содержимое',
'None': 'Нет',
'Exact': 'Точное',
'Mask': 'Маска',
'Checking': 'Проверка',
'passed': 'пройдено',
'failed': 'не пройдено',
'errors': 'ошибок',
'expected': 'ожидалось',
'actual': 'фактически',
};
let currentLang = 'en';
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'otaman-v36';
const CACHE = 'otaman-v37';
const URLS = [
'index.html',
'help.html',
+63
View File
@@ -0,0 +1,63 @@
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 = ['profilerNormHex', 'profilerMatch', 'profilerValidateProfile'];
let code = '';
for (const f of FNS) code += extractFunc(html, f) + '\n';
eval(code);
test('profilerNormHex uppercases and strips non-hex', () => {
assert.strictEqual(profilerNormHex('aabbcc'), 'AABBCC');
assert.strictEqual(profilerNormHex(' aa bb cc '), 'AABBCC');
assert.strictEqual(profilerNormHex('08?91?'), '08?91?');
assert.strictEqual(profilerNormHex(''), '');
});
test('exact match requires full equality', () => {
assert.ok(profilerMatch('exact', 'AABBCC', 'aabbcc'));
assert.ok(!profilerMatch('exact', 'AABB', 'AABBCC'));
assert.ok(!profilerMatch('exact', 'AABBCC', 'AABB'));
assert.ok(!profilerMatch('exact', '', 'AABB'));
});
test('mask with ? wildcard matches per nibble', () => {
assert.ok(profilerMatch('mask', '08?91?', '081910'));
assert.ok(profilerMatch('mask', '08?91?', '08A91B'));
assert.ok(!profilerMatch('mask', '08?91?', '091910'));
assert.ok(!profilerMatch('mask', '08?91?', '0891')); // length mismatch
});
test('mask without ? is a prefix match', () => {
assert.ok(profilerMatch('mask', '0891', '08911012345678'));
assert.ok(profilerMatch('mask', '0891', '0891'));
assert.ok(!profilerMatch('mask', '0891', '081910'));
assert.ok(!profilerMatch('mask', '', '0891'));
});
test('profile validation', () => {
assert.strictEqual(profilerValidateProfile(null), 'Not an object');
assert.strictEqual(profilerValidateProfile({ name: 'x' }), 'Missing rules array');
assert.strictEqual(profilerValidateProfile({ name: 'x', rules: [{ type: 'ota' }] }), 'Unsupported rule type: ota');
assert.strictEqual(profilerValidateProfile({ name: 'x', rules: [{ type: 'file' }] }), 'Rule missing path');
assert.strictEqual(profilerValidateProfile({ name: 'x', rules: [{ type: 'file', path: 'MF/7F10/6F3A' }] }), null);
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-otaman-server"
version = "1.9.25"
version = "1.9.26"
description = "HTTP REST server wrapping pysim for the OTAMan PWA"
requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
+42 -4
View File
@@ -18,7 +18,7 @@ from osmocom.construct import GsmOrUcs2Adapter
from osmocom.tlv import BER_TLV_IE
VERSION = '1.9.25'
VERSION = '1.9.26'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -158,6 +158,34 @@ def _select_with_parent(lchan, name, parent_sel, app):
return fcp
def _select_path(lchan, path, app):
"""Select a file described by a full path.
Path is '/' separated; the first element is either 'MF' (or the MF fid
'3F00') or an ADF AID (hex). Remaining elements are FIDs or file names.
pySim's lchan.select() cannot select an ADF by its raw AID (selectables are
keyed by name/fid only), so ADF roots are resolved through rs.mf.applications.
"""
parts = [p for p in (path or '').split('/') if p]
if not parts:
raise RuntimeError('Empty path')
rs = app.rs
first = parts[0]
if first.upper() in ('MF', '3F00'):
lchan.select('MF', app)
else:
aid = first.lower()
adf = rs.mf.applications.get(aid)
if not adf:
adf = next((v for k, v in rs.mf.applications.items() if k.lower() == aid), None)
if not adf:
raise RuntimeError('ADF not found: %s' % first)
lchan.select_file(adf, app)
for seg in parts[1:]:
lchan.select(seg, app)
return lchan.selected_file
def _parse_tree_output(output):
lines = (output or '').split('\n')
children = []
@@ -1591,6 +1619,7 @@ class PysimHandler(BaseHTTPRequestHandler):
return
body = self._read_body()
self._log_req(body)
path = body.get('path')
fid = body.get('fid')
name = fid if fid else body.get('name', '')
parent_sel = body.get('parent_sel')
@@ -1601,12 +1630,18 @@ class PysimHandler(BaseHTTPRequestHandler):
return
lchan = rs.lchan[0]
try:
_select_with_parent(lchan, name, parent_sel, app)
if path:
_select_path(lchan, path, app)
else:
_select_with_parent(lchan, name, parent_sel, app)
cur = lchan.selected_file
data = {
'name': cur.name if cur else None,
'fid': cur.fid.upper() if cur and cur.fid else None,
'file_type': _get_file_type(lchan, cur),
'file_size': lchan.selected_file_size() if lchan else None,
'record_len': lchan.selected_file_record_len() if lchan else None,
'num_of_rec': lchan.selected_file_num_of_rec() if lchan else None,
'exists': True,
}
self._send_json(data)
@@ -1623,9 +1658,9 @@ class PysimHandler(BaseHTTPRequestHandler):
return
body = self._read_body()
self._log_req(body)
path = body.get('path')
fid = body.get('fid')
name = fid if fid else body.get('name', '')
fid = body.get('fid')
parent_sel = body.get('parent_sel')
mode = body.get('mode', 'raw')
rs = app.rs
@@ -1636,7 +1671,10 @@ class PysimHandler(BaseHTTPRequestHandler):
lchan = rs.lchan[0]
try:
sel = fid if fid else name
_select_with_parent(lchan, sel, parent_sel, app)
if path:
_select_path(lchan, path, app)
else:
_select_with_parent(lchan, sel, parent_sel, app)
ft = _get_file_type(lchan, lchan.selected_file)
is_record = ft in ('linear_fixed', 'cyclic')
if mode == 'decoded':