profiler: per-rule FCP/FCI verification modes

Each rule now carries an fciMode ('type' | 'type_size' | 'exact') plus an
fciHex (raw SELECT response) so rules can verify different depths of file
control information:

- Filetype only (FCP): exists + file type
- Filetype + size (FCP): adds file size (or record length/count) - previous
  behavior
- Exact FCI: adds byte-for-byte comparison of the raw SELECT response (the
  FCP '62' template), catching FID/AID, life-cycle, security-attribute and
  proprietary-parameter changes

/profile from card/ gains a matching FCP/FCI selector (default Filetype +
size); rules store fciMode and always capture fciHex so they can be
upgraded to Exact FCI in the editor without rescanning. The rule editor
adds the selector, hides size/record fields in 'type' mode and shows an
editable FCI hex textarea in 'exact' mode. Contents checks stay independent.

Server /api/select now returns fci_hex (raw FCP template hex, uppercased).
profilerValidateProfile accepts the new mode; profilerNormHexStrict added
for byte-exact comparison (no '?' wildcards). Legacy rules without fciMode
default to type_size.

10 new tests (build-rule fields, run-rule modes incl. byte compare +
missing-FCI, validation). Docs + RU i18n synced. SW cache v52 -> v53,
version 1.9.28.
This commit is contained in:
2026-09-10 22:17:57 +03:00
parent 69b4fbfb42
commit 486f57eb30
7 changed files with 161 additions and 31 deletions
+71 -22
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.27</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.28</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>
@@ -914,6 +914,12 @@
<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>
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="FCP/FCI check">FCP/FCI check</label>
<select id="profiler-scan-fci-mode" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 mb-3">
<option value="type" data-l10n="Filetype only (FCP)">Filetype only (FCP)</option>
<option value="type_size" selected data-l10n="Filetype + size (FCP)">Filetype + size (FCP)</option>
<option value="exact" data-l10n="Exact FCI">Exact FCI</option>
</select>
<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>
@@ -6978,6 +6984,11 @@ function profilerNormHex(s) {
return (s || '').toUpperCase().replace(/[^0-9A-F?]/g, '');
}
// Strict hex normalization for byte-exact comparisons (no '?' wildcards).
function profilerNormHexStrict(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);
@@ -7037,6 +7048,7 @@ function profilerValidateProfile(obj) {
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';
if (r.fciMode && !['type', 'type_size', 'exact'].includes(r.fciMode)) return 'Invalid FCP/FCI mode: ' + r.fciMode;
}
return null;
}
@@ -7114,11 +7126,12 @@ async function profilerScanStart() {
ignoreNames.add((cb.getAttribute('data-ignore-name') || '').toUpperCase());
}
});
const fciMode = document.getElementById('profiler-scan-fci-mode').value;
const btn = document.getElementById('profiler-scan-btn');
btn.disabled = true;
btn.textContent = t('Scanning...');
try {
const rules = await profilerScanCard(ignoreFids, ignoreNames);
const rules = await profilerScanCard(ignoreFids, ignoreNames, fciMode);
const profile = { id: profilerNewId(), name: name, created: new Date().toISOString(), rules: rules };
profiles.push(profile);
profilerSave();
@@ -7134,7 +7147,7 @@ async function profilerScanStart() {
}
// Recursively walk the card filesystem and build rules for files that exist.
async function profilerScanCard(ignoreFids, ignoreNames) {
async function profilerScanCard(ignoreFids, ignoreNames, fciMode) {
const rules = [];
const seen = new Set();
@@ -7161,7 +7174,7 @@ async function profilerScanCard(ignoreFids, ignoreNames) {
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, ignoreNames);
const rule = await profilerBuildFileRule(childPath, c, ignoreFids, ignoreNames, fciMode);
if (rule) rules.push(rule);
}
}
@@ -7180,13 +7193,13 @@ async function profilerScanCard(ignoreFids, ignoreNames) {
const path = segs.join('/');
if (seen.has(path)) continue;
seen.add(path);
const rule = await profilerBuildFileRule(path, { fid: cf.fid, name: cf.name }, ignoreFids, ignoreNames);
const rule = await profilerBuildFileRule(path, { fid: cf.fid, name: cf.name }, ignoreFids, ignoreNames, fciMode);
if (rule) rules.push(rule);
}
return rules;
}
async function profilerBuildFileRule(path, c, ignoreFids, ignoreNames) {
async function profilerBuildFileRule(path, c, ignoreFids, ignoreNames, fciMode) {
let sel;
try {
sel = await pysimFetch('/api/select', { path: path });
@@ -7201,6 +7214,8 @@ async function profilerBuildFileRule(path, c, ignoreFids, ignoreNames) {
fileSize: isRecord ? null : ((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,
fciMode: fciMode || 'type_size',
fciHex: sel.fci_hex || null,
content: null,
};
const fid = (c.fid || sel.fid || '').toUpperCase();
@@ -7278,7 +7293,7 @@ function profilerBackToList() {
function profilerAddRule() {
if (!profilerDraft) return;
profilerDraft.rules.push({ type: 'file', path: '', name: null, fileType: null, fileSize: null, recordLen: null, numRecords: null, content: null });
profilerDraft.rules.push({ type: 'file', path: '', name: null, fileType: null, fileSize: null, recordLen: null, numRecords: null, fciMode: 'type_size', fciHex: null, content: null });
profilerRenderEditor();
}
@@ -7310,6 +7325,7 @@ function profilerRenderRule(i, r) {
ftOpts += '<option value="' + k + '"' + (r.fileType === k ? ' selected' : '') + '>' + fts[k] + '</option>';
}
const cm = r.content ? r.content.mode : 'exact';
const fciMode = r.fciMode || 'type_size';
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>';
@@ -7322,20 +7338,32 @@ function profilerRenderRule(i, r) {
'<input value="' + escHtml(r.path || '') + '" oninput="profilerUpdateRulePath(' + i + ',this.value,this)" 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>';
const vis = profilerFileFields(r.fileType);
const showSize = vis.size && fciMode !== 'type';
const showRecords = vis.records && fciMode !== 'type';
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('FCP/FCI check')) + '</label>' +
'<select onchange="profilerUpdateRuleFciMode(' + i + ',this.value)" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="type"' + (fciMode === 'type' ? ' selected' : '') + '>' + esc(t('Filetype only (FCP)')) + '</option>' +
'<option value="type_size"' + (fciMode === 'type_size' ? ' selected' : '') + '>' + esc(t('Filetype + size (FCP)')) + '</option>' +
'<option value="exact"' + (fciMode === 'exact' ? ' selected' : '') + '>' + esc(t('Exact FCI')) + '</option>' +
'</select></div>';
html += '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('File type')) + '</label>' +
'<select onchange="profilerUpdateRuleFileType(' + i + ',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>';
if (vis.size) {
if (showSize) {
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>';
}
if (vis.records) {
if (showRecords) {
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>';
if (fciMode === 'exact') {
html += '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('FCI hex (raw SELECT response)')) + '</label>' +
'<textarea oninput="profilerUpdateRule(' + i + ',\'fciHex\',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" placeholder="62...">' + escHtml(r.fciHex || '') + '</textarea></div>';
}
if (r.fileType !== 'df') {
html += profilerRenderContentEditor(i, r);
}
@@ -7407,6 +7435,12 @@ function profilerUpdateRuleFileType(i, value) {
profilerRenderEditor();
}
function profilerUpdateRuleFciMode(i, value) {
if (!profilerDraft) return;
profilerDraft.rules[i].fciMode = value;
profilerRenderEditor();
}
function profilerSetContent(i, mode) {
if (!profilerDraft) return;
const r = profilerDraft.rules[i];
@@ -7513,20 +7547,30 @@ async function profilerRunRule(r) {
res.checks.push({ label: 'fileType', expected: r.fileType, actual: sel.file_type, ok: ok });
}
const isRecord = profilerContentKindForFileType(r.fileType) === 'record';
if (!isRecord && r.fileSize !== null && r.fileSize !== undefined) {
const ok = sel.file_size === r.fileSize;
if (!ok) { res.status = 'fail'; sizeMismatch = true; }
res.checks.push({ label: 'fileSize', expected: r.fileSize, actual: sel.file_size, ok: ok });
const fciMode = r.fciMode || 'type_size';
if (fciMode !== 'type') {
if (!isRecord && r.fileSize !== null && r.fileSize !== undefined) {
const ok = sel.file_size === r.fileSize;
if (!ok) { res.status = 'fail'; sizeMismatch = true; }
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'; sizeMismatch = true; }
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'; sizeMismatch = true; }
res.checks.push({ label: 'numRecords', expected: r.numRecords, actual: sel.num_of_rec, ok: ok });
}
}
if (r.recordLen !== null && r.recordLen !== undefined) {
const ok = sel.record_len === r.recordLen;
if (!ok) { res.status = 'fail'; sizeMismatch = true; }
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'; sizeMismatch = true; }
res.checks.push({ label: 'numRecords', expected: r.numRecords, actual: sel.num_of_rec, ok: ok });
if (fciMode === 'exact' && r.fciHex) {
const expected = profilerNormHexStrict(r.fciHex);
const actual = profilerNormHexStrict(sel.fci_hex);
const ok = !!expected && expected === actual;
if (!ok) res.status = 'fail';
res.checks.push({ label: 'fci', expected: r.fciHex, actual: sel.fci_hex || '', ok: ok });
}
if (r.content) {
let rd;
@@ -7805,6 +7849,11 @@ const LANG_RU = {
'Scan': 'Сканировать',
'Ignore contents of:': 'Игнорировать содержимое:',
'Ignore contents of': 'Игнорировать содержимое',
'FCP/FCI check': 'Проверка FCP/FCI',
'Filetype only (FCP)': 'Только тип файла (FCP)',
'Filetype + size (FCP)': 'Тип файла + размер (FCP)',
'Exact FCI': 'Точный FCI',
'FCI hex (raw SELECT response)': 'FCI hex (сырой ответ SELECT)',
'No profiles defined.': 'Профили не заданы.',
'rules': 'правил',
'Check': 'Проверить',