From 130b9d7e3b78eb45688f437664490e2e0df17de2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Fri, 11 Sep 2026 21:38:32 +0300 Subject: [PATCH] profiler: explicit decode-failure display for corrupt FCI, keep partial results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fcpDecode now parses with a partial-aware walker (fcpParseTlvs) that keeps every complete TLV it encounters and reports why it stopped: - 'TLV 62 declares N bytes, only M available' (truncation) - 'Truncated length field at offset X' / 'incomplete TLV header' - 'Invalid length form' / 'Trailing data after TLV 62' - inner A5/C6 errors prefixed with their context ('In A5: ...') The editor preview shows the decoded parameters plus a red 'Decode failed: ' line (RU: 'Ошибка декодирования'), and the check report's decoded FCI diff appends per-side error notes (expected/actual) while still showing whatever decoded on either side. SW cache v74 -> v75. --- frontend/help-ru.html | 2 +- frontend/help.html | 2 +- frontend/index.html | 142 +++++++++++++++++++++++--------- frontend/sw.js | 2 +- frontend/tests/profiler.test.js | 61 +++++++++++++- 5 files changed, 163 insertions(+), 46 deletions(-) diff --git a/frontend/help-ru.html b/frontend/help-ru.html index 65b436e..23658b9 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -381,7 +381,7 @@
  • Атрибуты файла — тип файла, размер, длина записи и число записей из FCP-шаблона (любой можно оставить незаданным).
  • Проверить содержимое (опционально) — Точное (точное совпадение hex) или Маска, где ? — шаблон на один полубайт (маска без ? — совпадение префикса, например 0891 для MCC/MNC из IMSI). Для record-файлов хранится список по записям.
  • -

    Проверить карту выполняет каждое правило на подключённой карте и показывает строку прогресса и отчёт прохождения. Рядом с путём файла указывается, что именно проверялось (например, тип файла и размер, содержимое или полный FCI); если часть проверок прошла, а часть нет — каждый аспект помечается (тип файла ✓, размер ✗, содержимое ✓), а расхождения расписываются ниже. Несовпавшие сырые данные (FCI, содержимое, данные записей) показываются как поля только для чтения с моноширинным шрифтом — ожидаемое над фактическим, в одной и той же колонке — для удобного сравнения; для расхождений FCI дополнительно показывается декодированное сравнение по параметрам (размер файла, дескриптор/структура, жизненный цикл, FID, SFI, проприетарные параметры…). Декодированный просмотр FCP также отображается рядом с полем FCI hex при редактировании правила. Для record-файлов при расхождении содержимого добавляется пометка совпадающие записи: 1-5, 7-10 со списком записей, которые совпали.

    +

    Проверить карту выполняет каждое правило на подключённой карте и показывает строку прогресса и отчёт прохождения. Рядом с путём файла указывается, что именно проверялось (например, тип файла и размер, содержимое или полный FCI); если часть проверок прошла, а часть нет — каждый аспект помечается (тип файла ✓, размер ✗, содержимое ✓), а расхождения расписываются ниже. Несовпавшие сырые данные (FCI, содержимое, данные записей) показываются как поля только для чтения с моноширинным шрифтом — ожидаемое над фактическим, в одной и той же колонке — для удобного сравнения; для расхождений FCI дополнительно показывается декодированное сравнение по параметрам (размер файла, дескриптор/структура, жизненный цикл, FID, SFI, проприетарные параметры…). Декодированный просмотр FCP также отображается рядом с полем FCI hex при редактировании правила. Если данные FCI повреждены, показывается всё, что удалось декодировать до места ошибки, вместе с явным сообщением об ошибке декодирования. Для record-файлов при расхождении содержимого добавляется пометка совпадающие записи: 1-5, 7-10 со списком записей, которые совпали.

    Опции сканирования «Профиль с карты»

    Диалог сканирования запрашивает имя профиля и предлагает селектор «Проверка FCP/FCI» (те же три режима, по умолчанию Тип файла + размер (FCP)), применяемый ко всем создаваемым правилам, а также список «Игнорировать содержимое файлов» (все отмечены по умолчанию) часто перезаписываемых файлов, содержимое которых пропускается: EF.LOCI, EF.PSLOCI, EF.EPSLOCI, EF.5GS3GPPLOCI, EF.Keys, EF.KeysPS, EF.SMS, EF.Kc, EF.KcGPRS, EF.LOCIGPRS, EF.CBMID, EF.SMSS. Ещё две отмеченные по умолчанию опции «Сравнивать первые 4 байта для» EF.IMSI и EF.ICCID захватывают содержимое этих файлов как маску только первых 4 байт (снимите для точного сравнения). Строка прогресса показывает N / всего файлов с текущим путём файла во время сканирования; при сканировании опции скрываются, а кнопки блокируются. Правила создаются только для файлов, которые реально существуют на карте (возвращён FCP-шаблон); отсутствующие файлы пропускаются. Пользовательские файлы из подвкладки «Пользовательские файлы» включаются с той же проверкой существования.

    diff --git a/frontend/help.html b/frontend/help.html index 073976c..44410de 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -381,7 +381,7 @@
  • File attributes — file type, size, record length and record count, taken from the FCP template (any may be left unset).
  • Check contents (optional) — Exact hex equality, or Mask where ? is a per-nibble wildcard (a mask with no ? is a prefix match, e.g. 0891 for the IMSI MCC/MNC). Record files store a per-record list.
  • -

    Check card runs every rule against the equipped card and shows a live progress line plus a pass/fail report. Each row states exactly what was verified next to the file path (e.g. filetype and size, contents or exact FCI); when some checks pass and others fail, each aspect is marked (filetype ✓, size ✗, contents ✓) with the mismatches detailed below. Mismatched raw data (FCI, contents, record data) is shown as read-only monospace fields — expected above actual, aligned in the same column — for easy comparison; FCI mismatches additionally show a decoded per-parameter comparison (file size, file descriptor/structure, life cycle, FID, SFI, proprietary parameters…). A decoded FCP preview is also shown beside the FCI hex field while editing a rule. For record files with a contents mismatch, a matching records: 1-5, 7-10 note lists the records that did match.

    +

    Check card runs every rule against the equipped card and shows a live progress line plus a pass/fail report. Each row states exactly what was verified next to the file path (e.g. filetype and size, contents or exact FCI); when some checks pass and others fail, each aspect is marked (filetype ✓, size ✗, contents ✓) with the mismatches detailed below. Mismatched raw data (FCI, contents, record data) is shown as read-only monospace fields — expected above actual, aligned in the same column — for easy comparison; FCI mismatches additionally show a decoded per-parameter comparison (file size, file descriptor/structure, life cycle, FID, SFI, proprietary parameters…). A decoded FCP preview is also shown beside the FCI hex field while editing a rule. If the FCI data is corrupt, whatever was decoded before the faulty part is shown together with an explicit decode-failure note. For record files with a contents mismatch, a matching records: 1-5, 7-10 note lists the records that did match.

    “Profile from card” scan options

    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 files” 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. Two further checked-by-default options “Match first 4 bytes for” EF.IMSI and EF.ICCID capture those files’ contents as a mask of only the first 4 bytes (uncheck for exact matching). A progress line shows N / total files with the current file path while scanning; during the scan the options are hidden and the buttons are locked. 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.

    diff --git a/frontend/index.html b/frontend/index.html index 6b06952..666460d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7871,75 +7871,136 @@ function fcpDo(key, value) { // Decode a SELECT response (FCI): accepts the FCP template '62', an FCI template // '6F' wrapping FCP/FMD, or bare FCP content. Returns { ok, template, items }. +// Partial-aware BER-TLV walk: parses as many complete TLVs as possible and +// reports where/why it stopped (truncated header/length/value, trailing data). +function fcpParseTlvs(hex) { + const tlvs = []; + let i = 0; + let error = ''; + while (i < hex.length) { + const remaining = hex.length - i; + if (remaining < 2) { error = 'Truncated at offset ' + (i / 2) + ': incomplete tag'; break; } + const tag = hex.substr(i, 2); + let tagBytes = 2; + if ((parseInt(tag, 16) & 0x1F) === 0x1F) { + tagBytes = 4; + if (remaining < 4) { error = 'Truncated at offset ' + (i / 2) + ': incomplete 2-byte tag'; break; } + } + if (remaining < tagBytes + 2) { error = 'Truncated at offset ' + (i / 2) + ': incomplete TLV header'; break; } + const lb = parseInt(hex.substr(i + tagBytes, 2), 16); + const nLen = lb < 128 ? 0 : (lb & 0x7F); + if (i + tagBytes + 2 + nLen * 2 > hex.length) { error = 'Truncated length field at offset ' + ((i + tagBytes) / 2); break; } + const r = parseBerLen(hex, i + tagBytes); + if (!(r.len >= 0)) { error = 'Invalid length form at offset ' + ((i + tagBytes) / 2); break; } + const consumed = tagBytes + r.consumed; + const total = consumed + r.len * 2; + if (remaining < total) { + error = 'TLV ' + tag + ' declares ' + r.len + ' bytes, only ' + Math.floor(Math.max(remaining - consumed, 0) / 2) + ' available'; + break; + } + tlvs.push({ tag, length: r.len, value: hex.substr(i + consumed, r.len * 2), raw: hex.substr(i, total) }); + i += total; + } + return { tlvs, error, consumed: i }; +} + function fcpDecode(hex) { const h = (hex || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase(); - if (!h) return { ok: false, template: null, items: [] }; + if (!h) return { ok: false, template: null, items: [], error: '' }; let template = null; let body = h; - const top = parseTlvList(h); - if (top.length === 1 && top[0].raw.length === h.length && (top[0].tag === '62' || top[0].tag === '6F' || top[0].tag === '64')) { - template = top[0].tag; - body = top[0].value; - if (template === '6F') { - const fcp = parseTlvList(body).find(t => t.tag === '62'); - if (fcp) { template = '62'; body = fcp.value; } + let error = ''; + const first = h.substr(0, 2); + if ((first === '62' || first === '64' || first === '6F') && h.length >= 4) { + const lb = parseInt(h.substr(2, 2), 16); + const nLen = lb < 128 ? 0 : (lb & 0x7F); + if (4 + nLen * 2 > h.length) { + error = 'TLV ' + first + ' has a truncated length field'; + body = ''; + } else { + const r = parseBerLen(h, 2); + const consumed = 2 + r.consumed; + const avail = h.length - consumed; + template = first; + body = h.substr(consumed, Math.min(r.len * 2, Math.max(avail, 0))); + if (r.len * 2 > avail) error = 'TLV ' + first + ' declares ' + r.len + ' bytes, only ' + Math.floor(Math.max(avail, 0) / 2) + ' available'; + else if (consumed + r.len * 2 < h.length) error = 'Trailing data after TLV ' + first; } - } - const tlvs = parseTlvList(body); - const consumed = tlvs.reduce((n, t) => n + t.raw.length, 0); - const ok = tlvs.length > 0 && consumed === body.length; - const items = []; - for (const t of tlvs) { - const d = fcpDo(t.tag, t.value); - items.push({ key: t.tag, tag: t.tag, name: d.name, value: t.value, decoded: d.decoded }); - if (t.tag === 'A5' || t.tag === 'C6') { - for (const sub of parseTlvList(t.value)) { - const sd = fcpDo(t.tag + '/' + sub.tag, sub.value); - items.push({ key: t.tag + '/' + sub.tag, tag: sub.tag, name: sd.name, value: sub.value, decoded: sd.decoded }); + if (template === '6F' && body) { + const inner = fcpParseTlvs(body); + const fcp = inner.tlvs.find(t => t.tag === '62'); + if (fcp) { + template = '62'; + body = fcp.value; + if (!error && inner.error) error = inner.error; } } } - return { ok, template, items }; + const parsed = fcpParseTlvs(body); + if (!error && parsed.error) error = parsed.error; + const items = []; + for (const t of parsed.tlvs) { + const d = fcpDo(t.tag, t.value); + items.push({ key: t.tag, tag: t.tag, name: d.name, value: t.value, decoded: d.decoded }); + if (t.tag === 'A5' || t.tag === 'C6') { + const sub = fcpParseTlvs(t.value); + if (!error && sub.error) error = 'In ' + t.tag + ': ' + sub.error; + for (const s of sub.tlvs) { + const sd = fcpDo(t.tag + '/' + s.tag, s.value); + items.push({ key: t.tag + '/' + s.tag, tag: s.tag, name: sd.name, value: s.value, decoded: sd.decoded }); + } + } + } + return { ok: !error && items.length > 0, template, items, error }; } function fcpDiffHtml(expectedHex, actualHex) { const e = fcpDecode(expectedHex); const a = fcpDecode(actualHex); - if (!e.ok || !a.ok || !e.items.length || !a.items.length) return ''; + const eBad = !!e.error, aBad = !!a.error; + if (!eBad && !aBad && (!e.items.length || !a.items.length)) return ''; const eMap = {}, aMap = {}; e.items.forEach(it => { eMap[it.key] = it; }); a.items.forEach(it => { aMap[it.key] = it; }); const keys = e.items.map(it => it.key); for (const it of a.items) if (!eMap[it.key]) keys.push(it.key); - let html = '
    ' + esc(t('FCP parameters')) + '
    '; - html += ''; - html += '' + - '' + - '' + - ''; - for (const k of keys) { - const ev = eMap[k], av = aMap[k]; - const ed = ev ? (ev.decoded === null ? '' : (ev.decoded || ev.value)) : '—'; - const ad = av ? (av.decoded === null ? '' : (av.decoded || av.value)) : '—'; - const eq = !!ev && !!av && ev.decoded === av.decoded && ev.value === av.value; - const color = eq ? 'text-gray-500 dark:text-slate-400' : 'text-red-600 font-medium'; - html += '' + - '' + - '' + - ''; + let html = ''; + if (keys.length) { + html += '
    ' + esc(t('FCP parameters')) + '
    '; + html += '
    ' + esc(t('Parameter')) + '' + esc(t('expected')) + '' + esc(t('actual')) + '
    ' + esc((ev || av).name) + '' + esc(ed) + '' + esc(ad) + '
    '; + html += '' + + '' + + '' + + ''; + for (const k of keys) { + const ev = eMap[k], av = aMap[k]; + const ed = ev ? (ev.decoded === null ? '' : (ev.decoded || ev.value)) : '—'; + const ad = av ? (av.decoded === null ? '' : (av.decoded || av.value)) : '—'; + const eq = !!ev && !!av && ev.decoded === av.decoded && ev.value === av.value; + const color = eq ? 'text-gray-500 dark:text-slate-400' : 'text-red-600 font-medium'; + html += '' + + '' + + '' + + ''; + } + html += '
    ' + esc(t('Parameter')) + '' + esc(t('expected')) + '' + esc(t('actual')) + '
    ' + esc((ev || av).name) + '' + esc(ed) + '' + esc(ad) + '
    '; } - html += ''; + if (eBad) html += '
    ' + esc(t('expected')) + ': ' + esc(e.error) + '
    '; + if (aBad) html += '
    ' + esc(t('actual')) + ': ' + esc(a.error) + '
    '; return html; } function profilerFciPreviewItems(hex) { const d = fcpDecode(hex); - if (!d.ok || !d.items.length) return ''; + if (!d.items.length && !d.error) return ''; let html = ''; for (const it of d.items) { if (it.decoded === null) continue; html += '
    ' + esc(it.name) + ': ' + esc(it.decoded || it.value) + '
    '; } + if (d.error) { + html += '
    ' + esc(t('Decode failed')) + ': ' + esc(d.error) + '
    '; + } return html; } @@ -8212,6 +8273,7 @@ const LANG_RU = { 'FCP parameters': 'Параметры FCP', 'Parameter': 'Параметр', 'Decoded FCP': 'Декодированный FCP', + 'Decode failed': 'Ошибка декодирования', 'No profiles defined.': 'Профили не заданы.', 'rules': 'правил', 'Check card': 'Проверить карту', diff --git a/frontend/sw.js b/frontend/sw.js index 52a02ec..d51d713 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'otaman-v74'; +const CACHE = 'otaman-v75'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/profiler.test.js b/frontend/tests/profiler.test.js index b053ff3..d76cf84 100644 --- a/frontend/tests/profiler.test.js +++ b/frontend/tests/profiler.test.js @@ -21,7 +21,7 @@ function extractFunc(src, name, asyncFn) { 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', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput']; +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']; let code = ''; for (const f of FNS) code += extractFunc(html, f) + '\n'; code += extractFunc(html, 'profilerBuildFileRule', true) + '\n'; @@ -679,14 +679,15 @@ test('fcpDiffHtml highlights differing FCP parameters', () => { assert.ok(diff.includes('9 bytes')); assert.ok(diff.includes('10 bytes')); assert.ok(diff.includes('text-red-600')); - assert.ok(!fcpDiffHtml('garbage', FCP_TRANSPARENT)); + assert.ok(fcpDiffHtml('garbage', FCP_TRANSPARENT).includes('Truncated length field')); + assert.strictEqual(fcpDiffHtml('', ''), ''); delete global.t; }); test('profilerFciPreviewItems renders decoded items and degrades gracefully', () => { global.t = s => s; assert.ok(profilerFciPreviewItems(FCP_TRANSPARENT).includes('File size: ')); - assert.strictEqual(profilerFciPreviewItems('not hex'), ''); + assert.ok(profilerFciPreviewItems('not hex').includes('Decode failed')); assert.strictEqual(profilerFciPreviewItems(''), ''); delete global.t; }); @@ -720,3 +721,57 @@ test('profilerRenderReport shows the decoded FCI diff for a raw fci mismatch', ( assert.ok(html.includes('text-red-600')); delete global.t; }); + +// --- partial/corrupt FCI decoding --- + +test('fcpDecode reports truncation but keeps the TLVs parsed before it', () => { + // outer declares 22 bytes (0x16) but only the 18-byte body is present + const truncOuter = '6216' + FCP_TRANSPARENT.slice(4); + const d1 = fcpDecode(truncOuter); + assert.strictEqual(d1.ok, false); + assert.match(d1.error, /TLV 62 declares 22 bytes, only 18 available/); + assert.strictEqual(d1.items.find(it => it.key === '80').decoded, '9 bytes'); + + // correct outer length, truncated inner TLV (82 declares 5 bytes, 2 present) + const d2 = fcpDecode('620880020009820541 21'.replace(/ /g, '')); + assert.strictEqual(d2.ok, false); + assert.match(d2.error, /TLV 82 declares 5 bytes, only 2 available/); + assert.strictEqual(d2.items.find(it => it.key === '80').decoded, '9 bytes'); + assert.ok(!d2.items.some(it => it.key === '82')); + + // truncated length field + const d3 = fcpDecode('6281'); + assert.strictEqual(d3.ok, false); + assert.match(d3.error, /truncated length field/); + assert.strictEqual(d3.items.length, 0); + + // truncated inner A5 sub-TLV reports the context + const d4 = fcpDecode('620A82024121A50488050102'); + assert.strictEqual(d4.ok, false); + assert.match(d4.error, /In A5: TLV 88 declares 5 bytes, only 2 available/); +}); + +test('profilerFciPreviewItems shows decoded data plus an explicit failure note', () => { + global.t = s => s; + const trunc = '6216' + FCP_TRANSPARENT.slice(4); + const html = profilerFciPreviewItems(trunc); + assert.ok(html.includes('File size: '), html); + assert.ok(html.includes('Decode failed'), html); + assert.ok(html.includes('declares 22 bytes'), html); + // empty input renders nothing; garbage reports the failure + assert.strictEqual(profilerFciPreviewItems(''), ''); + assert.ok(profilerFciPreviewItems('not hex').includes('Decode failed')); + delete global.t; +}); + +test('fcpDiffHtml appends decode-failure notes for corrupt sides', () => { + global.t = s => s; + const trunc = '6216' + FCP_TRANSPARENT.slice(4); + const html = fcpDiffHtml(trunc, FCP_TRANSPARENT); + assert.ok(html.includes('FCP parameters'), html); + assert.ok(html.includes('9 bytes')); + assert.ok(html.includes('expected: ') && html.includes('TLV 62 declares 22 bytes')); + // both sides empty -> nothing rendered + assert.strictEqual(fcpDiffHtml('', ''), ''); + delete global.t; +});