profiler: compare two card snapshots

New Compare snapshots button on the Card snapshots tab opens a dialog
with Master snapshot / Snapshot to check selects and the two mask
options (Match first 4 bytes for EF.IMSI / EF.ICCID, checked by
default). The comparison runs like a profile check where the master
snapshot takes the place of the profile: every file must match exactly
(exact FCI, contents), except the first 4 bytes of masked EF.IMSI/EF.ICCID.

profilerRulesFromSnapshot() synthesizes exact-match rules from the
master; profilerSnapshotSource() of the checked snapshot is the data
source; files present only in the checked snapshot are appended as
failed 'extra file' results via profilerExtraFileResults() and the new
optional extraResults param of profilerRunProfile(). Results reuse the
existing view, summary and Only mismatches filter; Back to list returns
to the snapshots tab. Tests for rule synthesis, masking, comparison and
extra files. SW cache v87 -> v88.
This commit is contained in:
2026-09-12 08:14:03 +03:00
parent 5d096453fb
commit 30ff0f7a74
5 changed files with 193 additions and 4 deletions
+1
View File
@@ -392,6 +392,7 @@
<li><strong>Импорт снимка</strong> &mdash; загружает снимок из JSON-файла.</li> <li><strong>Импорт снимка</strong> &mdash; загружает снимок из JSON-файла.</li>
<li>В каждой строке снимка — <strong>Открыть</strong>, <strong>Экспорт</strong> и <strong>Удалить</strong>. <strong>Открыть</strong> показывает все захваченные данные только для чтения (сырой FCI с декодированным FCI, содержимое); редактируется только имя снимка.</li> <li>В каждой строке снимка — <strong>Открыть</strong>, <strong>Экспорт</strong> и <strong>Удалить</strong>. <strong>Открыть</strong> показывает все захваченные данные только для чтения (сырой FCI с декодированным FCI, содержимое); редактируется только имя снимка.</li>
<li><strong>Проверить снимок карты</strong> в строке профиля выполняет правила профиля на выбранном из списка снимке, без картридера. Отчёт такой же, как при проверке карты; файлы, содержимое которых не было захвачено при сканировании, помечаются как непроверяемые ошибки.</li> <li><strong>Проверить снимок карты</strong> в строке профиля выполняет правила профиля на выбранном из списка снимке, без картридера. Отчёт такой же, как при проверке карты; файлы, содержимое которых не было захвачено при сканировании, помечаются как непроверяемые ошибки.</li>
<li><strong>Сравнить снимки</strong> сравнивает два снимка без картридера так же, как проверка профиля: выберите <em>эталонный</em> снимок и <em>снимок для проверки</em>, при необходимости включите маску первых 4 байт EF.IMSI/EF.ICCID (включена по умолчанию) и получите такой же отчёт. Файлы, которые есть только в проверяемом снимке, помечаются как лишние. «К списку» возвращает на вкладку «Снимки карт».</li>
</ul> </ul>
<h3 id="usage-scenarios" class="text-lg font-medium mb-2">5.7 Сценарии использования</h3> <h3 id="usage-scenarios" class="text-lg font-medium mb-2">5.7 Сценарии использования</h3>
+1
View File
@@ -392,6 +392,7 @@
<li><strong>Import snapshot</strong> &mdash; loads a snapshot from a JSON file.</li> <li><strong>Import snapshot</strong> &mdash; loads a snapshot from a JSON file.</li>
<li>Each snapshot row has <strong>Open</strong>, <strong>Export</strong>, and <strong>Delete</strong>. <strong>Open</strong> shows all captured data read-only (raw FCI with the decoded FCI, contents); only the snapshot name is editable.</li> <li>Each snapshot row has <strong>Open</strong>, <strong>Export</strong>, and <strong>Delete</strong>. <strong>Open</strong> shows all captured data read-only (raw FCI with the decoded FCI, contents); only the snapshot name is editable.</li>
<li><strong>Check card snapshot</strong> on a profile row runs the profile rules against a snapshot you pick from the list, without a card reader. The report is the same as a live check; files whose contents were not captured during the scan are reported as unverifiable errors.</li> <li><strong>Check card snapshot</strong> on a profile row runs the profile rules against a snapshot you pick from the list, without a card reader. The report is the same as a live check; files whose contents were not captured during the scan are reported as unverifiable errors.</li>
<li><strong>Compare snapshots</strong> compares two snapshots offline, exactly like a profile check: pick the <em>master</em> snapshot and the <em>snapshot to check</em>, optionally masking the first 4 bytes of EF.IMSI/EF.ICCID (on by default), and get the same pass/fail report. Files present only in the checked snapshot are reported as extra files. Back to list returns to the Card snapshots tab.</li>
</ul> </ul>
<h3 id="usage-scenarios" class="text-lg font-medium mb-2">5.7 Usage scenarios</h3> <h3 id="usage-scenarios" class="text-lg font-medium mb-2">5.7 Usage scenarios</h3>
+113 -2
View File
@@ -830,6 +830,7 @@
<div class="flex flex-wrap gap-2 mb-3"> <div class="flex flex-wrap gap-2 mb-3">
<button onclick="snapshotNew()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="New snapshot">New snapshot</button> <button onclick="snapshotNew()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="New snapshot">New snapshot</button>
<button onclick="document.getElementById('snapshot-import-file').click()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Import snapshot">Import snapshot</button> <button onclick="document.getElementById('snapshot-import-file').click()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Import snapshot">Import snapshot</button>
<button onclick="snapshotCompareOpen()" class="px-2.5 py-1 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700" data-l10n="Compare snapshots">Compare snapshots</button>
<input type="file" id="snapshot-import-file" accept=".json,application/json" class="hidden" onchange="snapshotImportFile(this)"> <input type="file" id="snapshot-import-file" accept=".json,application/json" class="hidden" onchange="snapshotImportFile(this)">
</div> </div>
<div id="snapshot-list-body"></div> <div id="snapshot-list-body"></div>
@@ -975,6 +976,21 @@
</div> </div>
</div> </div>
<div id="snapshot-compare-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="Compare snapshots">Compare snapshots</h3>
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Master snapshot">Master snapshot</label>
<select id="snapshot-compare-master" 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"></select>
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Snapshot to check">Snapshot to check</label>
<select id="snapshot-compare-check" 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"></select>
<div id="snapshot-compare-mask" class="space-y-1 mb-3"></div>
<div class="flex gap-2 justify-end">
<button onclick="document.getElementById('snapshot-compare-modal').classList.add('hidden')" 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 onclick="snapshotCompareRun()" class="px-4 py-1.5 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700" data-l10n="Compare">Compare</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 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="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"> <div class="flex justify-between items-center mb-3">
@@ -7897,7 +7913,7 @@ async function profilerCheck(i) {
await profilerRunProfile(p, null, p.name); await profilerRunProfile(p, null, p.name);
} }
async function profilerRunProfile(p, source, title) { async function profilerRunProfile(p, source, title, extraResults) {
profilerSetView('results'); profilerSetView('results');
document.getElementById('profiler-results-title').textContent = title; document.getElementById('profiler-results-title').textContent = title;
const prog = document.getElementById('profiler-progress'); const prog = document.getElementById('profiler-progress');
@@ -7909,6 +7925,7 @@ async function profilerRunProfile(p, source, title) {
prog.textContent = t('Checking') + ' ' + (ri + 1) + '/' + p.rules.length + ' — ' + r.path; prog.textContent = t('Checking') + ' ' + (ri + 1) + '/' + p.rules.length + ' — ' + r.path;
results.push(await profilerRunRule(r, source)); results.push(await profilerRunRule(r, source));
} }
if (extraResults && extraResults.length) results.push(...extraResults);
prog.textContent = ''; prog.textContent = '';
profilerResults = results; profilerResults = results;
profilerRenderResultsView(); profilerRenderResultsView();
@@ -7937,6 +7954,93 @@ async function profilerCheckSnapshot(i, si) {
await profilerRunProfile(p, profilerSnapshotSource(s), p.name + ' — ' + s.name); await profilerRunProfile(p, profilerSnapshotSource(s), p.name + ' — ' + s.name);
} }
function profilerMaskFidForFile(f, maskFids) {
if (!maskFids) return null;
const seg = (f.path || '').toUpperCase().split('/').pop();
if (maskFids.has(seg)) return seg;
for (const fid in PROFILER_MASK_PREFIX4_FIDS) {
if (maskFids.has(fid) && f.name && PROFILER_MASK_PREFIX4_FIDS[fid] === f.name) return fid;
}
return null;
}
function profilerRulesFromSnapshot(master, maskFids) {
const rules = [];
for (const f of (master.files || [])) {
const rule = {
type: 'file',
path: f.path,
name: f.name,
fileType: f.fileType,
fileSize: f.fileSize,
recordLen: f.recordLen,
numRecords: f.numRecords,
fciMode: 'exact',
fciHex: f.fciHex,
content: null,
};
if (f.content) {
if (f.content.kind === 'record') {
rule.content = { mode: 'exact', kind: 'record', records: f.content.records };
} else if (typeof f.content.data === 'string') {
rule.content = profilerMaskFidForFile(f, maskFids)
? { mode: 'mask', kind: 'transparent', expected: profilerMaskPrefix4(f.content.data) }
: { mode: 'exact', kind: 'transparent', expected: f.content.data };
}
}
rules.push(rule);
}
return rules;
}
function snapshotCompareOpen() {
if (!snapshots.length) {
alert(t('No snapshots defined.'));
return;
}
let opts = '';
for (let i = 0; i < snapshots.length; i++) {
opts += '<option value="' + i + '">' + esc(snapshots[i].name) + '</option>';
}
const master = document.getElementById('snapshot-compare-master');
const check = document.getElementById('snapshot-compare-check');
master.innerHTML = opts;
check.innerHTML = opts;
master.value = '0';
check.value = String(snapshots.length > 1 ? 1 : 0);
let mhtml = '';
for (const fid in PROFILER_MASK_PREFIX4_FIDS) {
mhtml += '<label class="flex items-center gap-2 text-sm">' +
'<input type="checkbox" data-mask-fid="' + fid + '" checked>' +
'<span>' + esc(t('Match first 4 bytes for')) + ' ' + esc(PROFILER_MASK_PREFIX4_FIDS[fid]) + '</span></label>';
}
document.getElementById('snapshot-compare-mask').innerHTML = mhtml;
document.getElementById('snapshot-compare-modal').classList.remove('hidden');
}
function profilerExtraFileResults(master, check) {
const masterPaths = new Set((master.files || []).map(f => (f.path || '').toUpperCase()));
return (check.files || []).filter(f => !masterPaths.has((f.path || '').toUpperCase())).map(f => ({
path: f.path,
name: f.name,
status: 'fail',
checks: [{ label: 'extra file', expected: 'absent', actual: 'present', ok: false }],
}));
}
async function snapshotCompareRun() {
const master = snapshots[+document.getElementById('snapshot-compare-master').value];
const check = snapshots[+document.getElementById('snapshot-compare-check').value];
if (!master || !check) return;
const maskFids = new Set();
document.querySelectorAll('#snapshot-compare-mask input[data-mask-fid]').forEach(cb => {
if (cb.checked) maskFids.add(cb.getAttribute('data-mask-fid'));
});
document.getElementById('snapshot-compare-modal').classList.add('hidden');
const pseudo = { name: master.name + ' → ' + check.name, rules: profilerRulesFromSnapshot(master, maskFids) };
await profilerRunProfile(pseudo, profilerSnapshotSource(check), pseudo.name, profilerExtraFileResults(master, check));
}
function profilerToggleMismatchOnly(checked) { function profilerToggleMismatchOnly(checked) {
profilerMismatchOnly = checked; profilerMismatchOnly = checked;
profilerRenderResultsView(); profilerRenderResultsView();
@@ -8458,7 +8562,9 @@ function profilerRenderReport(results) {
if (r.error) html += '<div class="text-xs text-yellow-600 mt-1">' + esc(r.error) + '</div>'; if (r.error) html += '<div class="text-xs text-yellow-600 mt-1">' + esc(r.error) + '</div>';
for (const c of r.checks) { for (const c of r.checks) {
if (c.ok) continue; if (c.ok) continue;
if (profilerRawDataCheck(c)) { if (c.label === 'extra file') {
html += '<div class="text-xs text-red-600 mt-1">' + esc(t('extra file')) + '</div>';
} else if (profilerRawDataCheck(c)) {
html += '<div class="mt-1">'; html += '<div class="mt-1">';
html += '<div class="text-xs text-red-600 font-mono pl-2">' + esc(c.label) + '</div>'; html += '<div class="text-xs text-red-600 font-mono pl-2">' + esc(c.label) + '</div>';
html += '<div class="flex items-center gap-2 mt-0.5 pl-2">' + html += '<div class="flex items-center gap-2 mt-0.5 pl-2">' +
@@ -8687,6 +8793,11 @@ const LANG_RU = {
'Import profile': 'Импорт профиля', 'Import profile': 'Импорт профиля',
'Profiles': 'Профили', 'Profiles': 'Профили',
'Card snapshots': 'Снимки карт', 'Card snapshots': 'Снимки карт',
'Compare snapshots': 'Сравнить снимки',
'Master snapshot': 'Эталонный снимок',
'Snapshot to check': 'Снимок для проверки',
'Compare': 'Сравнить',
'extra file': 'лишний файл',
'Check card snapshot': 'Проверить снимок карты', 'Check card snapshot': 'Проверить снимок карты',
'Select card snapshot': 'Выберите снимок карты', 'Select card snapshot': 'Выберите снимок карты',
'Content not captured in snapshot': 'Содержимое не захвачено в снимок', 'Content not captured in snapshot': 'Содержимое не захвачено в снимок',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'otaman-v87'; const CACHE = 'otaman-v88';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+77 -1
View File
@@ -21,7 +21,7 @@ function extractFunc(src, name, asyncFn) {
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1); return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
} }
const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput', 'profilerScanToggleAll', 'profilerScanIgnoreAllState', 'swapNibbles', 'decIccid', 'profilerSnapshotIccid', 'profilerValidateSnapshot', 'profilerListSwitch', 'profilerScanRefreshOptions', 'profilerLiveSource', 'profilerSnapshotSource', 'profilerVisibleResults']; const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput', 'profilerScanToggleAll', 'profilerScanIgnoreAllState', 'swapNibbles', 'decIccid', 'profilerSnapshotIccid', 'profilerValidateSnapshot', 'profilerListSwitch', 'profilerScanRefreshOptions', 'profilerLiveSource', 'profilerSnapshotSource', 'profilerVisibleResults', 'profilerMaskFidForFile', 'profilerRulesFromSnapshot', 'profilerExtraFileResults'];
let code = ''; let code = '';
for (const f of FNS) code += extractFunc(html, f) + '\n'; for (const f of FNS) code += extractFunc(html, f) + '\n';
code += extractFunc(html, 'profilerBuildFileRule', true) + '\n'; code += extractFunc(html, 'profilerBuildFileRule', true) + '\n';
@@ -1036,3 +1036,79 @@ test('profilerVisibleResults hides passing files when mismatch-only is on', () =
assert.deepStrictEqual(profilerVisibleResults(results, true).map(r => r.status), ['fail', 'error']); assert.deepStrictEqual(profilerVisibleResults(results, true).map(r => r.status), ['fail', 'error']);
assert.strictEqual(profilerVisibleResults([{ status: 'pass' }], true).length, 0); assert.strictEqual(profilerVisibleResults([{ status: 'pass' }], true).length, 0);
}); });
// --- snapshot comparison ---
function masterSnap(files) {
return { files: files };
}
test('profilerRulesFromSnapshot builds exact-FCI rules from the master snapshot', () => {
const master = masterSnap([
snapFile('MF/7F20/6F07', { name: 'EF.IMSI', fileSize: 9, content: { kind: 'transparent', data: '082905911234567890' } }),
snapFile('MF/6F3A', { fileType: 'linear_fixed', fileSize: null, recordLen: 2, numRecords: 1, content: { kind: 'record', records: [{ num: 1, data: 'AABB' }] } }),
snapFile('MF/6F3B', { content: null }),
]);
const rules = profilerRulesFromSnapshot(master, null);
assert.strictEqual(rules.length, 3);
assert.strictEqual(rules[0].fciMode, 'exact');
assert.strictEqual(rules[0].fciHex, '621082024021');
assert.deepStrictEqual(rules[0].content, { mode: 'exact', kind: 'transparent', expected: '082905911234567890' });
assert.deepStrictEqual(rules[1].content, { mode: 'exact', kind: 'record', records: [{ num: 1, data: 'AABB' }] });
assert.strictEqual(rules[2].content, null);
});
test('profilerRulesFromSnapshot masks only the checked FIDs', () => {
const master = masterSnap([
snapFile('MF/7F20/6F07', { name: 'EF.IMSI', content: { kind: 'transparent', data: '082905911234567890' } }),
snapFile('MF/2FE2', { name: 'EF.ICCID', content: { kind: 'transparent', data: '98680012345678901234' } }),
snapFile('MF/6F3A', { content: { kind: 'transparent', data: 'AABBCCDD' } }),
]);
const rules = profilerRulesFromSnapshot(master, new Set(['6F07']));
assert.deepStrictEqual(rules[0].content, { mode: 'mask', kind: 'transparent', expected: '08290591??????????' });
assert.deepStrictEqual(rules[1].content, { mode: 'exact', kind: 'transparent', expected: '98680012345678901234' });
assert.deepStrictEqual(rules[2].content, { mode: 'exact', kind: 'transparent', expected: 'AABBCCDD' });
});
test('profilerRulesFromSnapshot falls back to the symbolic name for masking', () => {
const master = masterSnap([snapFile('MF/CUSTOM1', { name: 'EF.ICCID', content: { kind: 'transparent', data: '98680012345678901234' } })]);
const rules = profilerRulesFromSnapshot(master, new Set(['2FE2']));
assert.strictEqual(rules[0].content.mode, 'mask');
});
test('snapshot comparison passes on an identical snapshot', async () => {
const files = [snapFile('MF/7F20/6F07', { name: 'EF.IMSI', fileSize: 9, content: { kind: 'transparent', data: '082905911234567890' } })];
const rules = profilerRulesFromSnapshot(masterSnap(files), new Set(['6F07']));
const source = profilerSnapshotSource(masterSnap(files.map(f => ({ ...f }))));
for (const r of rules) {
assert.strictEqual((await profilerRunRule(r, source)).status, 'pass');
}
});
test('snapshot comparison fails on a contents difference', async () => {
const master = masterSnap([snapFile('MF/6F3A')]);
const check = masterSnap([snapFile('MF/6F3A', { content: { kind: 'transparent', data: 'CCDD' } })]);
const rules = profilerRulesFromSnapshot(master, null);
const res = await profilerRunRule(rules[0], profilerSnapshotSource(check));
assert.strictEqual(res.status, 'fail');
assert.ok(res.checks.some(c => c.label === 'content' && c.ok === false));
});
test('snapshot comparison mask ignores only the first 4 bytes', async () => {
const master = masterSnap([snapFile('MF/7F20/6F07', { name: 'EF.IMSI', fileSize: 8, content: { kind: 'transparent', data: '0829059112345678' } })]);
const rules = profilerRulesFromSnapshot(master, new Set(['6F07']));
const same = masterSnap([snapFile('MF/7F20/6F07', { name: 'EF.IMSI', fileSize: 8, content: { kind: 'transparent', data: '0829059199999999' } })]);
const other = masterSnap([snapFile('MF/7F20/6F07', { name: 'EF.IMSI', fileSize: 8, content: { kind: 'transparent', data: '0829059912345678' } })]);
assert.strictEqual((await profilerRunRule(rules[0], profilerSnapshotSource(same))).status, 'pass');
assert.strictEqual((await profilerRunRule(rules[0], profilerSnapshotSource(other))).status, 'fail');
});
test('profilerExtraFileResults reports files missing from the master', () => {
const master = masterSnap([snapFile('MF/6F3A')]);
const check = masterSnap([snapFile('MF/6f3a'), snapFile('MF/6F3B')]);
const extras = profilerExtraFileResults(master, check);
assert.strictEqual(extras.length, 1);
assert.strictEqual(extras[0].path, 'MF/6F3B');
assert.strictEqual(extras[0].status, 'fail');
assert.deepStrictEqual(extras[0].checks, [{ label: 'extra file', expected: 'absent', actual: 'present', ok: false }]);
});