diff --git a/frontend/help-ru.html b/frontend/help-ru.html index 3a31e6c..eabeb07 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -375,12 +375,13 @@

Правила выполняются последовательно. Правило файловой системы задаётся:

-

Check выполняет каждое правило на подключённой карте и показывает строку прогресса и отчёт прохождения (существование, каждый атрибут FCI и совпадение содержимого).

+

Check выполняет каждое правило на подключённой карте и показывает строку прогресса и отчёт прохождения (существование, каждый атрибут FCP и совпадение содержимого).

Опции сканирования «Profile from card»

-

Диалог сканирования запрашивает имя профиля и предлагает список «Ignore contents of» (все отмечены по умолчанию) часто перезаписываемых файлов, содержимое которых пропускается: EF.LOCI, EF.PSLOCI, EF.EPSLOCI, EF.5GS3GPPLOCI, EF.Keys, EF.KeysPS, EF.SMS, EF.Kc, EF.KcGPRS, EF.LOCIGPRS, EF.CBMID, EF.SMSS. Правила создаются только для файлов, которые реально существуют на карте (возвращён FCI-шаблон); отсутствующие файлы пропускаются. Пользовательские файлы из подвкладки Custom files включаются с той же проверкой существования.

+

Диалог сканирования запрашивает имя профиля и предлагает селектор «FCP/FCI check» (те же три режима, по умолчанию Filetype + size), применяемый ко всем создаваемым правилам, а также список «Ignore contents of» (все отмечены по умолчанию) часто перезаписываемых файлов, содержимое которых пропускается: EF.LOCI, EF.PSLOCI, EF.EPSLOCI, EF.5GS3GPPLOCI, EF.Keys, EF.KeysPS, EF.SMS, EF.Kc, EF.KcGPRS, EF.LOCIGPRS, EF.CBMID, EF.SMSS. Правила создаются только для файлов, которые реально существуют на карте (возвращён FCP-шаблон); отсутствующие файлы пропускаются. Пользовательские файлы из подвкладки Custom files включаются с той же проверкой существования.

5.7 Сценарии использования

diff --git a/frontend/help.html b/frontend/help.html index 1fe3a66..bf9ee27 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -375,12 +375,13 @@

Rules run sequentially. A filesystem rule is defined by:

Check runs every rule against the equipped card and shows a live progress line plus a pass/fail report (existence, each FCI attribute, and the content match).

“Profile from card” scan options

-

The scan dialog asks for a profile name and offers an “Ignore contents of” checklist (all checked by default) of frequently-overwritten files whose contents are skipped: EF.LOCI, EF.PSLOCI, EF.EPSLOCI, EF.5GS3GPPLOCI, EF.Keys, EF.KeysPS, EF.SMS, EF.Kc, EF.KcGPRS, EF.LOCIGPRS, EF.CBMID, EF.SMSS. Rules are created only for files that actually exist on the card (an FCI template is returned); missing files are skipped. Custom files from the Custom files sub-tab are included under the same existence check.

+

The scan dialog asks for a profile name and offers a “FCP/FCI check” selector (the same three modes above, default Filetype + size) applied to every generated rule, plus an “Ignore contents of” checklist (all checked by default) of frequently-overwritten files whose contents are skipped: EF.LOCI, EF.PSLOCI, EF.EPSLOCI, EF.5GS3GPPLOCI, EF.Keys, EF.KeysPS, EF.SMS, EF.Kc, EF.KcGPRS, EF.LOCIGPRS, EF.CBMID, EF.SMSS. Rules are created only for files that actually exist on the card (a FCP template is returned); missing files are skipped. Custom files from the Custom files sub-tab are included under the same existence check.

5.7 Usage scenarios

diff --git a/frontend/index.html b/frontend/index.html index 2eb10fe..eed10e6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -18,7 +18,7 @@
-

OTAMan SIM OTA with a Human Face v1.9.27

+

OTAMan SIM OTA with a Human Face v1.9.28

github @@ -914,6 +914,12 @@
Ignore contents of:
+ +
@@ -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 += ''; } const cm = r.content ? r.content.mode : 'exact'; + const fciMode = r.fciMode || 'type_size'; let html = '
'; html += '
'; html += '#' + (i + 1) + ''; @@ -7322,20 +7338,32 @@ function profilerRenderRule(i, r) { '
'; html += '
'; const vis = profilerFileFields(r.fileType); + const showSize = vis.size && fciMode !== 'type'; + const showRecords = vis.records && fciMode !== 'type'; html += '
'; + html += '
' + + '
'; html += '
' + '
'; - if (vis.size) { + if (showSize) { html += '
' + '
'; } - if (vis.records) { + if (showRecords) { html += '
' + '
'; html += '
' + '
'; } html += '
'; + if (fciMode === 'exact') { + html += '
' + + '
'; + } 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': 'Проверить', diff --git a/frontend/sw.js b/frontend/sw.js index e1e6fca..964b6f8 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'otaman-v52'; +const CACHE = 'otaman-v53'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/profiler.test.js b/frontend/tests/profiler.test.js index ec96df6..b54ba5f 100644 --- a/frontend/tests/profiler.test.js +++ b/frontend/tests/profiler.test.js @@ -21,10 +21,11 @@ function extractFunc(src, name, asyncFn) { return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1); } -const FNS = ['profilerNormHex', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath']; +const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath']; let code = ''; for (const f of FNS) code += extractFunc(html, f) + '\n'; code += extractFunc(html, 'profilerBuildFileRule', true) + '\n'; +code += extractFunc(html, 'profilerRunRule', true) + '\n'; code += html.match(/const PROFILER_MASK_PREFIX4_FIDS = \{[\s\S]*?\n\};/)[0] + '\n'; eval(code); @@ -232,3 +233,80 @@ test('profilerBuildFileRule still ignores when ignoreNames is omitted (back-comp assert.strictEqual(rule.content, null); assert.ok(!calls.includes('/api/read')); }); + +// --- FCP/FCI verification modes --- + +test('profilerNormHexStrict strips non-hex and drops wildcards', () => { + assert.strictEqual(profilerNormHexStrict('62 10 82 02 40 21'), '621082024021'); + assert.strictEqual(profilerNormHexStrict('aabb?cc'), 'AABBCC'); + assert.strictEqual(profilerNormHexStrict(''), ''); +}); + +test('profilerBuildFileRule stores fciMode and fciHex from the select response', async () => { + mockFetch({ '/api/select': () => ({ ...KCGPRS_SELECT, fci_hex: '62 10 82 02 40 21' }) }); + const rule = await profilerBuildFileRule('MF/7F20/6F52', + { fid: '6F52', name: 'EF.KcGPRS' }, new Set(['6F52']), new Set(['EF.KCGPRS']), 'exact'); + assert.strictEqual(rule.fciMode, 'exact'); + assert.strictEqual(rule.fciHex, '62 10 82 02 40 21'); +}); + +test('profilerBuildFileRule defaults fciMode to type_size and fciHex to null when unavailable', async () => { + mockFetch({ '/api/select': () => KCGPRS_SELECT }); + const rule = await profilerBuildFileRule('MF/7F20/6F52', + { fid: '6F52', name: 'EF.KcGPRS' }, new Set(), new Set()); + assert.strictEqual(rule.fciMode, 'type_size'); + assert.strictEqual(rule.fciHex, null); +}); + +function runSelect(extra) { + return { name: 'EF.ADN', fid: '6F3A', file_type: 'transparent', file_size: 4, record_len: null, num_of_rec: null, exists: true, ...(extra || {}) }; +} + +test('profilerRunRule in type mode skips size checks', async () => { + mockFetch({ '/api/select': () => runSelect({ file_size: 99 }) }); + const res = await profilerRunRule({ path: 'MF/7F20/6F3A', fileType: 'transparent', fileSize: 4, fciMode: 'type' }); + assert.strictEqual(res.status, 'pass'); + assert.ok(!res.checks.some(c => c.label === 'fileSize')); +}); + +test('profilerRunRule in type_size mode checks size', async () => { + mockFetch({ '/api/select': () => runSelect({ file_size: 99 }) }); + const res = await profilerRunRule({ path: 'MF/7F20/6F3A', fileType: 'transparent', fileSize: 4, fciMode: 'type_size' }); + assert.strictEqual(res.status, 'fail'); + assert.ok(res.checks.some(c => c.label === 'fileSize' && c.ok === false)); +}); + +test('profilerRunRule legacy rule (no fciMode) still checks size', async () => { + mockFetch({ '/api/select': () => runSelect({ file_size: 4 }) }); + const res = await profilerRunRule({ path: 'MF/7F20/6F3A', fileType: 'transparent', fileSize: 4 }); + assert.strictEqual(res.status, 'pass'); + assert.ok(res.checks.some(c => c.label === 'fileSize' && c.ok === true)); +}); + +test('profilerRunRule exact FCI passes on byte-identical FCI', async () => { + mockFetch({ '/api/select': () => runSelect({ fci_hex: '621082024021' }) }); + const res = await profilerRunRule({ path: 'MF/7F20/6F3A', fileType: 'transparent', fileSize: 4, fciMode: 'exact', fciHex: '62 10 82 02 40 21' }); + assert.strictEqual(res.status, 'pass'); + assert.ok(res.checks.some(c => c.label === 'fci' && c.ok === true)); +}); + +test('profilerRunRule exact FCI fails on byte mismatch', async () => { + mockFetch({ '/api/select': () => runSelect({ fci_hex: '621082024022' }) }); + const res = await profilerRunRule({ path: 'MF/7F20/6F3A', fileType: 'transparent', fileSize: 4, fciMode: 'exact', fciHex: '621082024021' }); + assert.strictEqual(res.status, 'fail'); + assert.ok(res.checks.some(c => c.label === 'fci' && c.ok === false)); +}); + +test('profilerRunRule exact FCI fails when the live FCI is missing', async () => { + mockFetch({ '/api/select': () => runSelect({ fci_hex: null }) }); + const res = await profilerRunRule({ path: 'MF/7F20/6F3A', fileType: 'transparent', fileSize: 4, fciMode: 'exact', fciHex: '621082024021' }); + assert.strictEqual(res.status, 'fail'); + assert.ok(res.checks.some(c => c.label === 'fci' && c.ok === false)); +}); + +test('profilerValidateProfile accepts valid fciMode and rejects unknown', () => { + assert.strictEqual(profilerValidateProfile({ name: 'x', rules: [{ type: 'file', path: 'MF/6F07', fciMode: 'type' }] }), null); + assert.strictEqual(profilerValidateProfile({ name: 'x', rules: [{ type: 'file', path: 'MF/6F07', fciMode: 'type_size' }] }), null); + assert.strictEqual(profilerValidateProfile({ name: 'x', rules: [{ type: 'file', path: 'MF/6F07', fciMode: 'exact' }] }), null); + assert.strictEqual(profilerValidateProfile({ name: 'x', rules: [{ type: 'file', path: 'MF/6F07', fciMode: 'bogus' }] }), 'Invalid FCP/FCI mode: bogus'); +}); diff --git a/pyproject.toml b/pyproject.toml index 30ad310..60b965b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-otaman-server" -version = "1.9.27" +version = "1.9.28" 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. diff --git a/pysim_otaman_server/server.py b/pysim_otaman_server/server.py index 0af90c5..2d7bea6 100644 --- a/pysim_otaman_server/server.py +++ b/pysim_otaman_server/server.py @@ -18,7 +18,7 @@ from osmocom.construct import GsmOrUcs2Adapter from osmocom.tlv import BER_TLV_IE -VERSION = '1.9.27' +VERSION = '1.9.28' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE @@ -1642,6 +1642,7 @@ class PysimHandler(BaseHTTPRequestHandler): '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, + 'fci_hex': (lchan.selected_file_fcp_hex or '').upper() if lchan and lchan.selected_file_fcp_hex else None, 'exists': True, } self._send_json(data)