Compare commits

...

14 Commits

Author SHA1 Message Date
catarrh dbf9e8559b profiler: show raw-data mismatches as aligned monospace fields
FCI and content (incl. record) mismatches are now rendered as two read-only
monospace inputs — expected on top, actual directly beneath — sharing a
fixed-width right-aligned label column (w-24) so both fields start at
exactly the same horizontal position and stretch to fill the row
(flex-1/min-w-0, no overlap). A title attribute shows the full value on
hover. Non-raw checks (fileSize, recordLen, numRecords, content.records,
read failure) keep the inline line.

New profilerRawDataCheck helper; 2 new tests. SW cache v59 -> v60.
2026-09-10 23:14:50 +03:00
catarrh ad543fd731 profiler: dedupe redundant record-count failure in check report
A record file with both a numRecords metadata check and record content
checks reported the same count mismatch twice (numRecords and
content.records). profilerRunRule now omits the content.records line when
the numRecords check already covers it, keeping it only when no numRecords
check ran (FCP/FCI 'type' mode or numRecords cleared) or when numRecords
passes but the read returns a different count.

3 new tests. SW cache v58 -> v59.
2026-09-10 23:08:17 +03:00
catarrh 16c183f79c profiler: mask-first-4-bytes options + lock scan form during scan
Profile-from-card scan form gains two checked-by-default options
'Match first 4 bytes for EF.IMSI / EF.ICCID', surfacing the previously
hardcoded mask behavior. Unchecking captures those files' contents
exactly. profilerBuildFileRule now takes a maskFids set (undefined keeps
the legacy mask default); scan start collects it and threads it through
profilerScanCard.

While a scan runs the options form (ignore list, mask options, FCP/FCI
selector) is hidden, Cancel and Scan are disabled (with visible
disabled styling), and the profile-name input becomes read-only — fixing
the latent cancel-mid-scan bug that left the scan running and popped the
editor open. profilerFromCard performs a full fresh-open reset.

4 new tests (mask/exact/legacy + scan threading). RU i18n key, help docs
synced. SW cache v57 -> v58.
2026-09-10 23:04:17 +03:00
catarrh d7204b3e96 profiler: richer check report (what was verified, matched records)
The Check report now states exactly what was verified for each file:
- passing files append a plain summary to the path line
  ('filetype and size, contents' / 'filetype and records, contents' /
  'Exact FCI, contents')
- mixed results mark each aspect inline
  ('filetype ✓, size ✗, contents ✓'), with the existing red detail lines
  for the mismatches; 'Exact FCI' subsumes type/size/records
- record files with a contents mismatch add a 'matching records: 1-5,
  7-10' note listing the records that did match

New pure helpers: profilerResultAspects (derives checked aspects from the
result checks), profilerAspectSummary (plain vs marked text),
profilerNumRanges (compresses record numbers into ranges). profilerRunRule
now also returns res.recordsMatched. 6 new tests incl. an end-to-end report
render. RU i18n keys added. Help docs synced. SW cache v56 -> v57.
2026-09-10 22:53:13 +03:00
catarrh 8f24200537 profiler: drop percentage from scan progress line
Show only 'N / total files — path' during 'Profile from card'; the
percentage was redundant. Help docs updated. SW cache v55 -> v56.
2026-09-10 22:40:59 +03:00
catarrh 0ecfd5f720 profiler: compact rule editor layout
- Path field narrowed to w-72 with the FCP/FCI selector to its right in
  one row
- File type + size (or record length + count) now sit in a single flex
  row (fixes the accidental wrap from the unavailable md:grid-cols-4 class
  in the prebuilt CSS)
- 'Contents' label renamed to 'Check contents' and the None/Exact/Mask
  radios moved inline to its right, saving one line per rule

Rendering-only change; no logic or test behavior affected. SW cache v54 -> v55.
2026-09-10 22:31:25 +03:00
catarrh d9b2cb7db2 profiler: show scan progress during 'Profile from card'
Split profilerScanCard into two phases so the file total is known up
front: phase 1 walks the filesystem (tree calls only) collecting every
file entry, phase 2 builds a rule per file. The optional onProgress(done,
total, path) callback reports 'N / total files (%) — path'; the scan
dialog shows it under the FCP/FCI selector, starting with 'Discovering…'
during phase 1. No extra card I/O (tree calls are no longer interleaved
with select/read). Backward compatible: onProgress is optional.

2 new tests (progress sequence 0/total then 1..total with paths; scan
without callback). RU i18n keys (Discovering..., files). Help docs synced.
SW cache v53 -> v54.
2026-09-10 22:26:30 +03:00
catarrh 486f57eb30 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.
2026-09-10 22:17:57 +03:00
catarrh 69b4fbfb42 profiler editor: show symbolic file names next to rule paths
Rules now persist the pySim symbolic name captured during 'Profile from
card' (profilerBuildFileRule stores c.name || sel.name), and the profile
edit view renders it next to the Path label using the same lookup order
as the check view: profilerCustomNameForPath(path) || rule.name.

Path edits call the new profilerUpdateRulePath, which drops the stale
scan-time name and refreshes the label from the custom-files dictionary
live, so the displayed name always corresponds to the current path.

Manually added rules get name: null (resolved via custom-file lookup).
Old profiles without rule.name still work.

4 new tests (rule.name from child/select/null, custom name lookup, path
edit clears name + refreshes label). SW cache v51 -> v52.
2026-09-10 21:54:32 +03:00
catarrh 5355a62d88 profiler: apply 'ignore contents' to real card files (KcGPRS FID fix + name fallback)
The 'Profile from card' ignore checkboxes matched only by FID, but the
list had EF.KcGPRS at 4F52 (TS 31.102 DF.GSM-ACCESS) while the live card
exposes it at 6F52 (TS 51.011 DF.GSM, verified in TS 51.011 v4.15.0
10.3.32 and the DF.GSM allocation table). The miss made profilerBuildFileRule
capture full contents (Exact) for a file the user had checked to ignore.

- Correct EF.KcGPRS FID to 6F52 (spec-verified)
- Match ignores by FID OR by pySim name: each checkbox now carries
  data-ignore-name, profilerScanStart builds an ignoreNames set, and
  profilerBuildFileRule checks both. Covers FID variants (6F52 vs 4F52)
  and future constant typos; ignoreNames is optional for back-compat.
- 6 new tests: ignore-list FID/name sanity (incl. KcGPRS=6F52), duplicates,
  ignored-by-FID, ignored-by-name-only regression case, non-ignored control,
  back-compat when ignoreNames is omitted.

SW cache v50 -> v51.
2026-09-10 21:49:51 +03:00
catarrh 7614067b8f v1.9.27: align version markers (server VERSION, pyproject.toml, PWA header) 2026-09-10 21:31:16 +03:00
catarrh 181544b360 frontend: add HTTP OTA constructor (GP RAM-over-HTTP v1.1)
New 'HTTP OTA' sub-tab in the C-APDU constructor builds Remote Application
Management over HTTP payloads (GPC v2.2 Amendment B v1.1 §4.7):

- Trigger (Push SMS): 81 > 83 > 84/[85]/[86]/89, with optional Command
  Scripting template ('AA') wrap per TS 102 226 §5.2.1
- Store (SD admin params): STORE DATA TLV mode (80 E2 90 00), tag 85/A5
- Connection parameters: OPEN CHANNEL comprehension-TLV row editor + presets
  (Device Identities 02, Alpha 80, Bearer 01)
- Security parameters (Table 4-6): LV PSK Identity + LV KVN/KID
- Retry policy (Table 4-7): retry counter + TS 102 223 timer TLV (25 03)
- HTTP POST (Tables 4-8/9/10): 8A host / 8B agent / 8C URI, text→octets
- 'Pack into Secured packet' reuses packToSp for SPI/counter filling

Pure builder functions covered by 12 new tests reproducing the byte layout
from the spec tables. i18n entries, help.html/help-ru.html §2.7 added.
SW cache bumped otaman-v49 → v50.
2026-09-10 21:30:06 +03:00
catarrh c31885d40e ui: move chain delete button to right, use ✕ glyph
- C-APDU chain rows: delete button moved after the hex field (matches
  Expanded Script layout)
- Use ✕ uniformly across delete icons (was × in chain rows)

SW cache v49.
2026-09-10 09:21:37 +03:00
catarrh 3ca014991a ui: unify C-APDU chain add buttons with Expanded Script style
- Regular chain add buttons: faint blue tint -> solid blue (bg-blue-600)
- '+ GET RESPONSE' (SIM/USIM/RAM): gray/blue -> emerald to stand out
  (also fixes RAM GET RESPONSE which was styled inconsistently)

SW cache v48.
2026-09-10 09:14:44 +03:00
8 changed files with 1277 additions and 109 deletions
+25 -5
View File
@@ -42,7 +42,7 @@
<section class="mb-10">
<h2 id="c-apdu" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">2. Вкладка C-APDU</h2>
<p class="mb-3">Построение командных APDU (C-APDU). Пять подвкладок охватывают разные поколения карт и наборы команд: <strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong> и <strong>C-APDU Parser</strong>.</p>
<p class="mb-3">Построение командных APDU (C-APDU). Шесть подвкладок охватывают разные поколения карт и наборы команд: <strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong>, <strong>HTTP OTA</strong> и <strong>C-APDU Parser</strong>.</p>
<h3 id="sim-rfm" class="text-lg font-medium mb-2">2.1 SIM RFM</h3>
<p class="mb-2">CLA = <code class="font-mono text-sm">A0</code> (GSM 11.11 / TS 151 011, ISO 7816-4). Удалённое управление файлами классических SIM-карт.</p>
@@ -204,7 +204,26 @@
</ul>
<h3 id="c-apdu-parser" class="text-lg font-medium mb-2">2.6 C-APDU Parser</h3>
<p class="text-sm mb-3">Вставка raw APDU hex и отображение сворачиваемого дерева. Автоматически определяет контейнер: <strong>Expanded Script</strong> (начало <code class="font-mono text-sm">AA</code> или <code class="font-mono text-sm">AE80</code>, декодируется по ETSI TS 102 226 &sect;5.2.1) или <strong>Compact C-APDU chain</strong> (последовательность C-APDU ISO 7816). Каждый узел показывает метку, hex и краткое описание; родительские узлы раскрываются в подэлементы.
<p class="text-sm mb-3">Вставка raw APDU hex и отображение сворачиваемого дерева. Автоматически определяет контейнер: <strong>Expanded Script</strong> (начало <code class="font-mono text-sm">AA</code> или <code class="font-mono text-sm">AE80</code>, декодируется по ETSI TS 102 226 &sect;5.2.1) или <strong>Compact C-APDU chain</strong> (последовательность C-APDU ISO 7816). Каждый узел показывает метку, hex и краткое описание; родительские узлы раскрываются в подэлементы.</p>
<h3 id="http-ota" class="text-lg font-medium mb-2">2.7 HTTP OTA</h3>
<p class="text-sm mb-3">Сборка payload&rsquo;ов Remote Application Management over HTTP по GlobalPlatform <strong>GPC v2.2 Amendment B v1.1</strong> (&sect;4.7). Два режима:</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Trigger (Push SMS)</strong> — параметры запуска административной сессии (<code class="font-mono text-sm">81 &gt; 83 &gt; 84/[85]/[86]/89</code>, таблица 4-3). Сообщение, которое просит Security Domain карты исходящим запросом начать HTTP-сессию.</li>
<li><strong>Store (SD admin params)</strong> — записывает те же параметры как данные карты (параметры Security Domain) командой <strong>STORE DATA в TLV-режиме</strong> (<code class="font-mono text-sm">80 E2 90 00</code>, P1=90 = последний блок + BER-TLV по GP v2.2 Amendment B v1.1.3), обёрнутые в тег <code class="font-mono text-sm">85</code> (или <code class="font-mono text-sm">A5</code>) по таблице 4-4.</li>
</ul>
<p class="text-sm mb-2">Разделы соответствуют таблицам спецификации:</p>
<table class="w-full text-sm mb-3 border-collapse">
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Раздел</th><th class="text-left py-1 px-2">Tag</th><th class="text-left py-1 px-2">Содержимое</th></tr></thead>
<tbody>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Параметры соединения</td><td class="py-1 px-2 font-mono">84</td><td class="py-1 px-2">Любые COMPREHENSION-TLV для открытия TCP-соединения (OPEN CHANNEL по TS 102 223): Device Identities <code class="font-mono text-sm">02</code>, Alpha <code class="font-mono text-sm">80</code>, Bearer <code class="font-mono text-sm">01</code>, вендорские TLV. Редактор строк + пресеты, редактируемый hex.</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Параметры безопасности</td><td class="py-1 px-2 font-mono">85</td><td class="py-1 px-2">Таблица 4-6: LV PSK Identity (текст), LV Key version/KID. Идентифицирует ключ PSK TLS (RFC 4279).</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Политика повтора</td><td class="py-1 px-2 font-mono">86</td><td class="py-1 px-2">Таблица 4-7: счётчик повторов (2 байта, напр. <code class="font-mono text-sm">B000</code>), задержка повторной попытки как timer TLV из TS 102 223 (<code class="font-mono text-sm">25 03 HH MM SS</code>), опциональный вендорский TLV отчёта о сбое.</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">HTTP POST</td><td class="py-1 px-2 font-mono">89</td><td class="py-1 px-2">Таблицы 4-8/9/10: Host-заголовок (<code class="font-mono text-sm">8A</code>), X-Admin-From agent ID (<code class="font-mono text-sm">8B</code>), URI (<code class="font-mono text-sm">8C</code>) — текст преобразуется в октеты.</td></tr>
</tbody>
</table>
<p class="text-sm mb-2"><strong>Command Scripting template</strong> (флажок) оборачивает всю команду <code class="font-mono text-sm">81</code> в формат Expanded Remote Application data с определённой длиной (<code class="font-mono text-sm">AA</code>, ETSI TS 102 226 &sect;5.2.1) для TAR-ов, обрабатывающих расширенный формат (RAM-over-HTTP &sect;4.7).</p>
<p class="text-sm mb-2"><strong>Pack into Secured packet</strong> отправляет готовый payload на вкладку SCP80 для заполнения SPI/счётчика — там укажите TAR, который слушает SD (обычно TAR OTASD).</p>
<section class="mb-10">
@@ -356,12 +375,13 @@
<p class="text-sm mb-2">Правила выполняются последовательно. Правило файловой системы задаётся:</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Путь</strong> — начинается с <code class="font-mono text-sm">MF</code> (например, <code class="font-mono text-sm">MF/7F10/6F3A</code>) или с AID ADF (например, <code class="font-mono text-sm">A0000000871002/6F07</code>).</li>
<li><strong>Атрибуты файла</strong> тип файла, размер, длина записи и число записей из FCI-шаблона (любой можно оставить незаданным).</li>
<li><strong>Проверка FCP/FCI</strong> — какая часть информации об управлении файлом проверяется: <strong>Filetype only (FCP)</strong> (существование + тип файла), <strong>Filetype + size (FCP)</strong> (добавляются размер файла либо длина/число записей для record-файлов) или <strong>Exact FCI</strong> (добавляется побайтовое сравнение сырого ответа SELECT — шаблона FCP <code class="font-mono text-sm">'62'</code> — выявляет изменения FID/AID, жизненного цикла, атрибутов безопасности и проприетарных параметров).</li>
<li><strong>Атрибуты файла</strong> — тип файла, размер, длина записи и число записей из FCP-шаблона (любой можно оставить незаданным).</li>
<li><strong>Содержимое</strong> (опционально) — <strong>Exact</strong> (точное совпадение hex) или <strong>Mask</strong>, где <code class="font-mono text-sm">?</code> — шаблон на один полубайт (маска без <code class="font-mono text-sm">?</code> — совпадение префикса, например <code class="font-mono text-sm">0891</code> для MCC/MNC из IMSI). Для record-файлов хранится список по записям.</li>
</ul>
<p class="text-sm mb-3"><strong>Check</strong> выполняет каждое правило на подключённой карте и показывает строку прогресса и отчёт прохождения (существование, каждый атрибут FCI и совпадение содержимого).</p>
<p class="text-sm mb-3"><strong>Check</strong> выполняет каждое правило на подключённой карте и показывает строку прогресса и отчёт прохождения. Рядом с путём файла указывается, что именно проверялось (например, <em>тип файла и размер, содержимое</em> или <em>точный FCI</em>); если часть проверок прошла, а часть нет — каждый аспект помечается (<em>тип файла ✓, размер ✗, содержимое ✓</em>), а расхождения расписываются ниже. Для record-файлов при расхождении содержимого добавляется пометка <em>совпадающие записи: 1-5, 7-10</em> со списком записей, которые совпали.</p>
<h4 class="font-medium mb-1">Опции сканирования &laquo;Profile from card&raquo;</h4>
<p class="text-sm mb-2">Диалог сканирования запрашивает имя профиля и предлагает список <strong>&laquo;Ignore contents of&raquo;</strong> (все отмечены по умолчанию) часто перезаписываемых файлов, содержимое которых пропускается: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>. Правила создаются только для файлов, которые реально существуют на карте (возвращён FCI-шаблон); отсутствующие файлы пропускаются. Пользовательские файлы из подвкладки <strong>Custom files</strong> включаются с той же проверкой существования.</p>
<p class="text-sm mb-2">Диалог сканирования запрашивает имя профиля и предлагает селектор <strong>&laquo;FCP/FCI check&raquo;</strong> (те же три режима, по умолчанию <strong>Filetype + size</strong>), применяемый ко всем создаваемым правилам, а также список <strong>&laquo;Ignore contents of&raquo;</strong> (все отмечены по умолчанию) часто перезаписываемых файлов, содержимое которых пропускается: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>. Ещё две отмеченные по умолчанию опции <strong>&laquo;Match first 4 bytes for&raquo;</strong> <code class="font-mono text-sm">EF.IMSI</code> и <code class="font-mono text-sm">EF.ICCID</code> захватывают содержимое этих файлов как маску только первых 4 байт (снимите для точного сравнения). Строка прогресса показывает <em>N / всего файлов</em> с текущим путём файла во время сканирования; при сканировании опции скрываются, а кнопки блокируются. Правила создаются только для файлов, которые реально существуют на карте (возвращён FCP-шаблон); отсутствующие файлы пропускаются. Пользовательские файлы из подвкладки <strong>Custom files</strong> включаются с той же проверкой существования.</p>
<h3 id="usage-scenarios" class="text-lg font-medium mb-2">5.7 Сценарии использования</h3>
+25 -5
View File
@@ -42,7 +42,7 @@
<section class="mb-10">
<h2 id="c-apdu" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">2. C-APDU tab</h2>
<p class="mb-3">Builds command APDUs (C-APDUs). Five sub-tabs cover different card generations and command sets: <strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong>, and <strong>C-APDU Parser</strong>.</p>
<p class="mb-3">Builds command APDUs (C-APDUs). Six sub-tabs cover different card generations and command sets: <strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong>, <strong>HTTP OTA</strong>, and <strong>C-APDU Parser</strong>.</p>
<h3 id="sim-rfm" class="text-lg font-medium mb-2">2.1 SIM RFM</h3>
<p class="mb-2">CLA = <code class="font-mono text-sm">A0</code> (GSM 11.11 / TS 151 011, ISO 7816-4). Remote File Management for classic SIM cards.</p>
@@ -204,7 +204,26 @@
</ul>
<h3 id="c-apdu-parser" class="text-lg font-medium mb-2">2.6 C-APDU Parser</h3>
<p class="text-sm mb-3">Pastes raw APDU hex and renders a collapsible tree. It auto-detects the container: an <strong>Expanded Script</strong> (leading <code class="font-mono text-sm">AA</code> or <code class="font-mono text-sm">AE80</code>, decoded per ETSI TS 102 226 &sect;5.2.1) or a <strong>Compact C-APDU chain</strong> (a sequence of ISO 7816 C-APDUs). Each node shows its label, hex and a short description; parent nodes expand to reveal their sub-elements.
<p class="text-sm mb-3">Pastes raw APDU hex and renders a collapsible tree. It auto-detects the container: an <strong>Expanded Script</strong> (leading <code class="font-mono text-sm">AA</code> or <code class="font-mono text-sm">AE80</code>, decoded per ETSI TS 102 226 &sect;5.2.1) or a <strong>Compact C-APDU chain</strong> (a sequence of ISO 7816 C-APDUs). Each node shows its label, hex and a short description; parent nodes expand to reveal their sub-elements.</p>
<h3 id="http-ota" class="text-lg font-medium mb-2">2.7 HTTP OTA</h3>
<p class="text-sm mb-3">Builds the Remote Application Management over HTTP payloads defined in GlobalPlatform <strong>GPC v2.2 Amendment B v1.1</strong> (&sect;4.7). Two modes:</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Trigger (Push SMS)</strong> — administration session triggering parameters (<code class="font-mono text-sm">81 &gt; 83 &gt; 84/[85]/[86]/89</code>, Table 4-3). This is the message that asks the card's Security Domain to dial out and start an HTTP session.</li>
<li><strong>Store (SD admin params)</strong> — writes the same parameters as card (Security Domain) data via <strong>STORE DATA in TLV mode</strong> (<code class="font-mono text-sm">80 E2 90 00</code>, P1=90 = last block + BER-TLV per GP v2.2 Amendment B v1.1.3), wrapped in tag <code class="font-mono text-sm">85</code> (or <code class="font-mono text-sm">A5</code>) per Table 4-4.</li>
</ul>
<p class="text-sm mb-2">Sections mirror the spec tables:</p>
<table class="w-full text-sm mb-3 border-collapse">
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Section</th><th class="text-left py-1 px-2">Tag</th><th class="text-left py-1 px-2">Contents</th></tr></thead>
<tbody>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Connection parameters</td><td class="py-1 px-2 font-mono">84</td><td class="py-1 px-2">Any COMPREHENSION-TLV needed to open the TCP connection (OPEN CHANNEL per TS 102 223): Device Identities <code class="font-mono text-sm">02</code>, Alpha <code class="font-mono text-sm">80</code>, Bearer <code class="font-mono text-sm">01</code>, vendor TLVs. Row editor + presets, editable hex.</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Security parameters</td><td class="py-1 px-2 font-mono">85</td><td class="py-1 px-2">Table 4-6: LV PSK Identity (text), LV Key version/KID. Identifies the PSK TLS key (RFC 4279).</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Retry policy</td><td class="py-1 px-2 font-mono">86</td><td class="py-1 px-2">Table 4-7: retry counter (2 bytes, e.g. <code class="font-mono text-sm">B000</code>), retry waiting delay as the TS 102 223 timer TLV (<code class="font-mono text-sm">25 03 HH MM SS</code>), optional vendor-specific report-failure TLV.</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">HTTP POST</td><td class="py-1 px-2 font-mono">89</td><td class="py-1 px-2">Tables 4-8/9/10: Host header (<code class="font-mono text-sm">8A</code>), X-Admin-From agent ID (<code class="font-mono text-sm">8B</code>), URI (<code class="font-mono text-sm">8C</code>) — text converted to octets.</td></tr>
</tbody>
</table>
<p class="text-sm mb-2"><strong>Command Scripting template</strong> (checkbox) wraps the whole <code class="font-mono text-sm">81</code> triggering command in the definite-length Expanded Remote Application data format (<code class="font-mono text-sm">AA</code>, ETSI TS 102 226 &sect;5.2.1) for TARs that process the expanded format (RAM-over-HTTP &sect;4.7).</p>
<p class="text-sm mb-2"><strong>Pack into Secured packet</strong> sends the built payload to the SCP80 tab for SPI/counter filling &mdash; insert the TAR the SD listens on (typically the OTASD TAR) there.</p>
<section class="mb-10">
@@ -356,12 +375,13 @@
<p class="text-sm mb-2">Rules run sequentially. A filesystem rule is defined by:</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Path</strong> — starts with <code class="font-mono text-sm">MF</code> (e.g. <code class="font-mono text-sm">MF/7F10/6F3A</code>) or an ADF AID (e.g. <code class="font-mono text-sm">A0000000871002/6F07</code>).</li>
<li><strong>File attributes</strong> — file type, size, record length and record count, taken from the FCI template (any may be left unset).</li>
<li><strong>FCP/FCI check</strong> — how much of the file control information to verify: <strong>Filetype only (FCP)</strong> (existence + file type), <strong>Filetype + size (FCP)</strong> (adds file size, or record length/count for record files), or <strong>Exact FCI</strong> (adds a byte-for-byte comparison of the raw SELECT response — the FCP template <code class="font-mono text-sm">'62'</code> — catching FID/AID, life-cycle status, security-attribute and proprietary-parameter changes).</li>
<li><strong>File attributes</strong> — file type, size, record length and record count, taken from the FCP template (any may be left unset).</li>
<li><strong>Contents</strong> (optional) — <strong>Exact</strong> hex equality, or <strong>Mask</strong> where <code class="font-mono text-sm">?</code> is a per-nibble wildcard (a mask with no <code class="font-mono text-sm">?</code> is a prefix match, e.g. <code class="font-mono text-sm">0891</code> for the IMSI MCC/MNC). Record files store a per-record list.</li>
</ul>
<p class="text-sm mb-3"><strong>Check</strong> 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).</p>
<p class="text-sm mb-3"><strong>Check</strong> 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. <em>filetype and size, contents</em> or <em>exact FCI</em>); when some checks pass and others fail, each aspect is marked (<em>filetype ✓, size ✗, contents ✓</em>) with the mismatches detailed below. For record files with a contents mismatch, a <em>matching records: 1-5, 7-10</em> note lists the records that did match.</p>
<h4 class="font-medium mb-1">&ldquo;Profile from card&rdquo; scan options</h4>
<p class="text-sm mb-2">The scan dialog asks for a profile name and offers an <strong>&ldquo;Ignore contents of&rdquo;</strong> checklist (all checked by default) of frequently-overwritten files whose contents are skipped: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>. 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 <strong>Custom files</strong> sub-tab are included under the same existence check.</p>
<p class="text-sm mb-2">The scan dialog asks for a profile name and offers a <strong>&ldquo;FCP/FCI check&rdquo;</strong> selector (the same three modes above, default <strong>Filetype + size</strong>) applied to every generated rule, plus an <strong>&ldquo;Ignore contents of&rdquo;</strong> checklist (all checked by default) of frequently-overwritten files whose contents are skipped: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>. Two further checked-by-default options <strong>&ldquo;Match first 4 bytes for&rdquo;</strong> <code class="font-mono text-sm">EF.IMSI</code> and <code class="font-mono text-sm">EF.ICCID</code> capture those files&rsquo; contents as a mask of only the first 4 bytes (uncheck for exact matching). A progress line shows <em>N / total files</em> 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 <strong>Custom files</strong> sub-tab are included under the same existence check.</p>
<h3 id="usage-scenarios" class="text-lg font-medium mb-2">5.7 Usage scenarios</h3>
+617 -93
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.26</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>
@@ -43,25 +43,26 @@
<button class="c-apdu-subtab px-4 py-1.5 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-sub="usim" onclick="cApduSwitchSubtab('usim')">USIM RFM</button>
<button class="c-apdu-subtab px-4 py-1.5 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-sub="ber" onclick="cApduSwitchSubtab('ber')">Expanded Script</button>
<button class="c-apdu-subtab px-4 py-1.5 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-sub="ram" onclick="cApduSwitchSubtab('ram')">RAM/GP</button>
<button class="c-apdu-subtab px-4 py-1.5 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-sub="parse" onclick="cApduSwitchSubtab('parse')" data-l10n="C-APDU Parser">C-APDU Parser</button>
<button class="c-apdu-subtab px-4 py-1.5 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-sub="httpota" onclick="cApduSwitchSubtab('httpota')">HTTP OTA</button>
<button class="c-apdu-subtab px-4 py-1.5 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-sub="parse" onclick="cApduSwitchSubtab('parse')" data-l10n="C-APDU Parser">C-APDU Parser</button>
</div>
<div id="c-apdu-sub-sim">
<div id="chain-sim-rows"></div>
<div class="flex flex-wrap gap-1 mb-3">
<button onclick="chainAddRow('chain-sim','select')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ SELECT</button>
<button onclick="chainAddRow('chain-sim','read-record')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ READ RECORD</button>
<button onclick="chainAddRow('chain-sim','read-binary')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ READ BINARY</button>
<button onclick="chainAddRow('chain-sim','update-record')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ UPDATE RECORD</button>
<button onclick="chainAddRow('chain-sim','update-binary')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ UPDATE BINARY</button>
<button onclick="chainAddRow('chain-sim','erase-binary')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ ERASE BINARY</button>
<button onclick="chainAddRow('chain-sim','activate-file')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ ACTIVATE</button>
<button onclick="chainAddRow('chain-sim','deactivate-file')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ DEACTIVATE</button>
<button onclick="chainAddRow('chain-sim','verify')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ VERIFY PIN</button>
<button onclick="chainAddRow('chain-sim','change')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ CHANGE PIN</button>
<button onclick="chainAddRow('chain-sim','disable')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ DISABLE PIN</button>
<button onclick="chainAddRow('chain-sim','enable')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ ENABLE PIN</button>
<button onclick="chainAddRow('chain-sim','unblock')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ UNBLOCK PIN</button>
<button onclick="chainAddRow('chain-sim','get-response')" class="px-2 py-1 text-xs bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded hover:bg-gray-300 dark:hover:bg-gray-600">+ GET RESPONSE</button>
<button onclick="chainAddRow('chain-sim','select')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ SELECT</button>
<button onclick="chainAddRow('chain-sim','read-record')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ READ RECORD</button>
<button onclick="chainAddRow('chain-sim','read-binary')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ READ BINARY</button>
<button onclick="chainAddRow('chain-sim','update-record')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ UPDATE RECORD</button>
<button onclick="chainAddRow('chain-sim','update-binary')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ UPDATE BINARY</button>
<button onclick="chainAddRow('chain-sim','erase-binary')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ ERASE BINARY</button>
<button onclick="chainAddRow('chain-sim','activate-file')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ ACTIVATE</button>
<button onclick="chainAddRow('chain-sim','deactivate-file')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ DEACTIVATE</button>
<button onclick="chainAddRow('chain-sim','verify')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ VERIFY PIN</button>
<button onclick="chainAddRow('chain-sim','change')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ CHANGE PIN</button>
<button onclick="chainAddRow('chain-sim','disable')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ DISABLE PIN</button>
<button onclick="chainAddRow('chain-sim','enable')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ ENABLE PIN</button>
<button onclick="chainAddRow('chain-sim','unblock')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ UNBLOCK PIN</button>
<button onclick="chainAddRow('chain-sim','get-response')" class="text-xs px-2 py-1 bg-emerald-600 text-white rounded hover:bg-emerald-700">+ GET RESPONSE</button>
</div>
<textarea id="chain-sim-preview" rows="3" readonly class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 bg-gray-100 dark:bg-slate-800" placeholder="Chain preview — add commands above"></textarea>
<button onclick="packToSp('chain-sim-preview')" id="chain-sim-pack-btn" disabled class="mb-3 px-5 py-2.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Pack into Secured packet">Pack into Secured packet</button>
@@ -70,19 +71,19 @@
<div id="c-apdu-sub-usim" class="hidden">
<div id="chain-usim-rows"></div>
<div class="flex flex-wrap gap-1 mb-3">
<button onclick="chainAddRow('chain-usim','select')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ SELECT</button>
<button onclick="chainAddRow('chain-usim','read-record')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ READ RECORD</button>
<button onclick="chainAddRow('chain-usim','read-binary')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ READ BINARY</button>
<button onclick="chainAddRow('chain-usim','update-record')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ UPDATE RECORD</button>
<button onclick="chainAddRow('chain-usim','update-binary')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ UPDATE BINARY</button>
<button onclick="chainAddRow('chain-usim','activate-file')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ ACTIVATE</button>
<button onclick="chainAddRow('chain-usim','deactivate-file')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ DEACTIVATE</button>
<button onclick="chainAddRow('chain-usim','verify')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ VERIFY PIN</button>
<button onclick="chainAddRow('chain-usim','change')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ CHANGE PIN</button>
<button onclick="chainAddRow('chain-usim','disable')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ DISABLE PIN</button>
<button onclick="chainAddRow('chain-usim','enable')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ ENABLE PIN</button>
<button onclick="chainAddRow('chain-usim','unblock')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ UNBLOCK PIN</button>
<button onclick="chainAddRow('chain-usim','get-response')" class="px-2 py-1 text-xs bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded hover:bg-gray-300 dark:hover:bg-gray-600">+ GET RESPONSE</button>
<button onclick="chainAddRow('chain-usim','select')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ SELECT</button>
<button onclick="chainAddRow('chain-usim','read-record')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ READ RECORD</button>
<button onclick="chainAddRow('chain-usim','read-binary')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ READ BINARY</button>
<button onclick="chainAddRow('chain-usim','update-record')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ UPDATE RECORD</button>
<button onclick="chainAddRow('chain-usim','update-binary')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ UPDATE BINARY</button>
<button onclick="chainAddRow('chain-usim','activate-file')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ ACTIVATE</button>
<button onclick="chainAddRow('chain-usim','deactivate-file')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ DEACTIVATE</button>
<button onclick="chainAddRow('chain-usim','verify')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ VERIFY PIN</button>
<button onclick="chainAddRow('chain-usim','change')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ CHANGE PIN</button>
<button onclick="chainAddRow('chain-usim','disable')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ DISABLE PIN</button>
<button onclick="chainAddRow('chain-usim','enable')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ ENABLE PIN</button>
<button onclick="chainAddRow('chain-usim','unblock')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ UNBLOCK PIN</button>
<button onclick="chainAddRow('chain-usim','get-response')" class="text-xs px-2 py-1 bg-emerald-600 text-white rounded hover:bg-emerald-700">+ GET RESPONSE</button>
</div>
<textarea id="chain-usim-preview" rows="3" readonly class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 bg-gray-100 dark:bg-slate-800" placeholder="Chain preview — add commands above"></textarea>
<button onclick="packToSp('chain-usim-preview')" id="chain-usim-pack-btn" disabled class="mb-3 px-5 py-2.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Pack into Secured packet">Pack into Secured packet</button>
@@ -116,20 +117,20 @@
<div id="c-apdu-sub-ram" class="hidden">
<div id="chain-ram-rows"></div>
<div class="flex flex-wrap gap-1 mb-3">
<button onclick="chainAddRow('chain-ram','install-load')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ INSTALL [for load]</button>
<button onclick="chainAddRow('chain-ram','install-install')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ INSTALL [for install]</button>
<button onclick="chainAddRow('chain-ram','install-make-sel')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ INSTALL [for make selectable]</button>
<button onclick="chainAddRow('chain-ram','install-reg-update')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ INSTALL [for registry update]</button>
<button onclick="chainAddRow('chain-ram','install-extradition')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ INSTALL [for extradition]</button>
<button onclick="chainAddRow('chain-ram','load')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ LOAD</button>
<button onclick="chainAddRow('chain-ram','delete')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ DELETE</button>
<button onclick="chainAddRow('chain-ram','get-status')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ GET STATUS</button>
<button onclick="chainAddRow('chain-ram','get-data')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ GET DATA</button>
<button onclick="chainAddRow('chain-ram','store-data')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ STORE DATA</button>
<button onclick="chainAddRow('chain-ram','set-status')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ SET STATUS</button>
<button onclick="chainAddRow('chain-ram','ext-auth')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ EXTERNAL AUTH</button>
<button onclick="chainAddRow('chain-ram','int-auth')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ INTERNAL AUTH</button>
<button onclick="chainAddRow('chain-ram','get-response')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ GET RESPONSE</button>
<button onclick="chainAddRow('chain-ram','install-load')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ INSTALL [for load]</button>
<button onclick="chainAddRow('chain-ram','install-install')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ INSTALL [for install]</button>
<button onclick="chainAddRow('chain-ram','install-make-sel')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ INSTALL [for make selectable]</button>
<button onclick="chainAddRow('chain-ram','install-reg-update')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ INSTALL [for registry update]</button>
<button onclick="chainAddRow('chain-ram','install-extradition')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ INSTALL [for extradition]</button>
<button onclick="chainAddRow('chain-ram','load')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ LOAD</button>
<button onclick="chainAddRow('chain-ram','delete')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ DELETE</button>
<button onclick="chainAddRow('chain-ram','get-status')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ GET STATUS</button>
<button onclick="chainAddRow('chain-ram','get-data')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ GET DATA</button>
<button onclick="chainAddRow('chain-ram','store-data')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ STORE DATA</button>
<button onclick="chainAddRow('chain-ram','set-status')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ SET STATUS</button>
<button onclick="chainAddRow('chain-ram','ext-auth')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ EXTERNAL AUTH</button>
<button onclick="chainAddRow('chain-ram','int-auth')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ INTERNAL AUTH</button>
<button onclick="chainAddRow('chain-ram','get-response')" class="text-xs px-2 py-1 bg-emerald-600 text-white rounded hover:bg-emerald-700">+ GET RESPONSE</button>
</div>
<textarea id="chain-ram-preview" rows="3" readonly class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 bg-gray-100 dark:bg-slate-800" placeholder="Chain preview — add commands above"></textarea>
<button onclick="packToSp('chain-ram-preview')" id="chain-ram-pack-btn" disabled class="mb-3 px-5 py-2.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Pack into Secured packet">Pack into Secured packet</button>
@@ -142,6 +143,119 @@
<button onclick="parseHex()" class="mb-3 px-5 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700" data-l10n="Decode">Decode</button>
<div id="parse-output"></div>
</div>
<div id="c-apdu-sub-httpota" class="hidden">
<div class="flex gap-2 mb-3">
<button id="hota-mode-trigger" onclick="hotaSetMode('trigger')" class="px-4 py-1.5 text-sm rounded-full bg-blue-600 text-white" data-l10n="Trigger (Push SMS)">Trigger (Push SMS)</button>
<button id="hota-mode-store" onclick="hotaSetMode('store')" class="px-4 py-1.5 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-l10n="Store (SD admin params)">Store (SD admin params)</button>
</div>
<div id="hota-store-tag-row" class="mb-3 hidden">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="SD params tag">SD params tag</label>
<select id="hota-store-tag" onchange="hotaUpdatePreview()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
<option value="85">'85' — GP v2.2 Amendment B v1.1 (Table 4-4)</option>
<option value="A5">'A5' — GP v2.2 Amendment B v1.1.3</option>
</select>
</div>
<div class="border border-gray-300 dark:border-slate-600 rounded p-3 bg-gray-50 dark:bg-slate-800 mb-3">
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
<input type="checkbox" id="hota-include-conn" checked onchange="hotaUpdatePreview()" class="rounded">
<span data-l10n="Connection parameters ('84')">Connection parameters ('84')</span>
</label>
<div id="hota-conn-tlvs" class="hidden"></div>
<div class="flex flex-wrap gap-1 mt-2">
<button onclick="hotaAddTlv()" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700" data-l10n="+ TLV">+ TLV</button>
<button onclick="hotaAddPreset('devid')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700" data-l10n="+ Device Identities">+ Device Identities</button>
<button onclick="hotaAddPreset('alpha')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700" data-l10n="+ Alpha (empty)">+ Alpha (empty)</button>
<button onclick="hotaAddPreset('bearer')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700" data-l10n="+ Bearer (GPRS)">+ Bearer (GPRS)</button>
</div>
</div>
<div class="border border-gray-300 dark:border-slate-600 rounded p-3 bg-gray-50 dark:bg-slate-800 mb-3">
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
<input type="checkbox" id="hota-include-sec" onchange="hotaUpdatePreview()" class="rounded">
<span data-l10n="Security parameters ('85')">Security parameters ('85')</span>
</label>
<div id="hota-sec-fields" class="hidden space-y-2">
<div class="flex gap-2">
<div class="flex-1"><label class="block text-xs text-gray-600 dark:text-slate-400" data-l10n="PSK Identity (text)">PSK Identity (text)</label>
<input id="hota-psk-text" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="Test123">
</div>
<div class="w-16"><label class="block text-xs text-gray-600 dark:text-slate-400">KVN</label>
<input id="hota-kvn" maxlength="2" value="01" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
</div>
<div class="w-16"><label class="block text-xs text-gray-600 dark:text-slate-400">KID</label>
<input id="hota-kid" maxlength="2" value="01" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
</div>
</div>
</div>
</div>
<div class="border border-gray-300 dark:border-slate-600 rounded p-3 bg-gray-50 dark:bg-slate-800 mb-3">
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
<input type="checkbox" id="hota-include-retry" onchange="hotaUpdatePreview()" class="rounded">
<span data-l10n="Retry policy ('86')">Retry policy ('86')</span>
</label>
<div id="hota-retry-fields" class="hidden space-y-2">
<div class="flex gap-2">
<div class="flex-1"><label class="block text-xs text-gray-600 dark:text-slate-400" data-l10n="Retry counter (B0,00=unlimited)">Retry counter (B0,00=unlimited)</label>
<input id="hota-retry-counter" maxlength="4" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="B000">
</div>
</div>
<div class="flex gap-2">
<div class="w-16"><label class="block text-xs text-gray-600 dark:text-slate-400" data-l10n="Hours">Hours</label>
<input id="hota-delay-h" type="number" min="0" max="255" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="0">
</div>
<div class="w-16"><label class="block text-xs text-gray-600 dark:text-slate-400" data-l10n="Minutes">Minutes</label>
<input id="hota-delay-m" type="number" min="0" max="59" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="0">
</div>
<div class="w-16"><label class="block text-xs text-gray-600 dark:text-slate-400" data-l10n="Seconds">Seconds</label>
<input id="hota-delay-s" type="number" min="0" max="59" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="0">
</div>
</div>
<div class="flex gap-2 items-end">
<div class="flex-1"><label class="block text-xs text-gray-600 dark:text-slate-400" data-l10n="Report failure (vendor-specific TLV)">Report failure (vendor-specific TLV)</label>
<input id="hota-retry-report" maxlength="40" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="0A08...">
</div>
</div>
</div>
</div>
<div class="border border-gray-300 dark:border-slate-600 rounded p-3 bg-gray-50 dark:bg-slate-800 mb-3">
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
<input type="checkbox" id="hota-include-http" onchange="hotaUpdatePreview()" class="rounded">
<span data-l10n="HTTP POST ('89', 8A=host,8B=agent,8C=URI)">HTTP POST ('89', 8A=host,8B=agent,8C=URI)</span>
</label>
<div id="hota-http-fields" class="hidden space-y-2">
<div class="flex gap-2">
<div class="flex-1"><label class="block text-xs text-gray-600 dark:text-slate-400" data-l10n="Host (text→hex)">Host (text→hex)</label>
<input id="hota-host" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="megafon.ru">
</div>
</div>
<div class="flex gap-2">
<div class="flex-1"><label class="block text-xs text-gray-600 dark:text-slate-400" data-l10n="HTTP agent ID (text, X-Admin-From)">HTTP agent ID (text, X-Admin-From)</label>
<input id="hota-agent" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="v1.0">
</div>
</div>
<div class="flex gap-2">
<div class="flex-1"><label class="block text-xs text-gray-600 dark:text-slate-400" data-l10n="URI (text→hex)">URI (text→hex)</label>
<input id="hota-uri" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="http://megafon.ru/sdp">
</div>
</div>
</div>
</div>
<div class="border border-gray-300 dark:border-slate-600 rounded p-3 bg-gray-50 dark:bg-slate-800 mb-3 hidden" id="hota-expanded-row">
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-slate-300">
<input type="checkbox" id="hota-expanded" onchange="hotaUpdatePreview()" class="rounded">
<span data-l10n="Wrapped in Command Scripting template ('AA')">Wrapped in Command Scripting template ('AA')</span>
</label>
</div>
<textarea id="hota-preview" rows="5" readonly oninput="hotaPreviewEdited()" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 bg-gray-100 dark:bg-slate-800 mb-3" placeholder="RAM-over-HTTP payload will appear here..."></textarea>
<button onclick="packToSp('hota-preview')" id="hota-pack-btn" disabled class="mb-3 px-5 py-2.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Pack into Secured packet">Pack into Secured packet</button>
</div>
</div>
<div class="w-1/4">
<div class="border border-gray-300 dark:border-slate-600 rounded p-3 bg-gray-50 dark:bg-slate-800">
@@ -798,12 +912,22 @@
<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-options">
<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-mask" class="space-y-1 mb-3"></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>
<div id="profiler-scan-progress" class="text-xs text-gray-500 dark:text-slate-400 mb-2 hidden"></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>
<button id="profiler-scan-cancel-btn" 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 disabled:opacity-40 disabled:cursor-not-allowed" 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 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Scan">Scan</button>
</div>
</div>
</div>
@@ -874,12 +998,178 @@ function cApduSwitchSubtab(name) {
btn.classList.toggle('text-gray-700', !active);
btn.classList.toggle('dark:text-slate-300', !active);
});
['sim', 'usim', 'ber', 'ram', 'parse'].forEach(s => {
['sim', 'usim', 'ber', 'ram', 'parse', 'httpota'].forEach(s => {
const el = document.getElementById('c-apdu-sub-' + s);
if (el) el.classList.toggle('hidden', s !== name);
});
setHelpAnchor({sim:'sim-rfm', usim:'usim-rfm', ber:'ber-tlv', ram:'ram-gp', parse:'c-apdu'}[name] || 'c-apdu');
setHelpAnchor({sim:'sim-rfm', usim:'usim-rfm', ber:'ber-tlv', ram:'ram-gp', parse:'c-apdu', httpota:'http-ota'}[name] || 'c-apdu');
}
// ===== HTTP OTA constructor =====
let _hotaMode = 'trigger';
function _hotaHex(s) { return (s || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase(); }
function _hotaAsciiHex(s) { return (s || '').split('').map(c => c.charCodeAt(0).toString(16).padStart(2, '0')).join('').toUpperCase(); }
function _hotaBerLen(n) {
if (n < 128) return n.toString(16).padStart(2, '0').toUpperCase();
if (n < 256) return '81' + n.toString(16).padStart(2, '0').toUpperCase();
return '82' + n.toString(16).padStart(4, '0').toUpperCase();
}
function _hotaTlv(tag8, val8) { const v = _hotaHex(val8); return tag8.toUpperCase() + _hotaBerLen(v.length / 2) + v; }
function hotaTlv(tag, value) { return _hotaTlv(tag, value); }
function hotaBuildConn(tlvs) {
let content = '';
for (const t of tlvs) {
const v = _hotaHex(t.value);
if (!t.tag) continue;
content += t.tag.toUpperCase() + _hotaBerLen(v.length / 2) + v;
}
return '84' + _hotaBerLen(content.length / 2) + content;
}
function hotaBuildSec(s) {
const psk = _hotaAsciiHex(s.pskIdentity);
const kvn = _hotaHex(s.kvn).padStart(2, '0');
const kid = _hotaHex(s.kid).padStart(2, '0');
const data = (psk.length / 2).toString(16).padStart(2, '0').toUpperCase() + psk + '02' + kvn + kid;
return '85' + _hotaBerLen(data.length / 2) + data;
}
function hotaBuildRetry(r) {
const counter = _hotaHex(r.counter);
const hh = (parseInt(r.delayH) || 0).toString(16).padStart(2, '0').toUpperCase();
const mm = (parseInt(r.delayM) || 0).toString(16).padStart(2, '0').toUpperCase();
const ss = (parseInt(r.delayS) || 0).toString(16).padStart(2, '0').toUpperCase();
const timerTlv = '2503' + hh + mm + ss;
const report = _hotaHex(r.reportFailure);
const content = counter + timerTlv + (report && report.length % 2 === 0 ? report : '');
return '86' + _hotaBerLen(content.length / 2) + content;
}
function hotaBuildHttpPost(h) {
const host = _hotaAsciiHex(h.host);
const agent = _hotaAsciiHex(h.agent);
const uri = _hotaAsciiHex(h.uri);
const content = _hotaTlv('8A', host) + _hotaTlv('8B', agent) + _hotaTlv('8C', uri);
return '89' + _hotaBerLen(content.length / 2) + content;
}
function hotaBuildTrigger(conn, sec, retry, httpPost, expanded) {
let content = conn;
if (sec) content += sec;
if (retry) content += retry;
if (httpPost) content += httpPost;
const sd = '83' + _hotaBerLen(content.length / 2) + content;
const trig = '81' + _hotaBerLen(sd.length / 2) + sd;
if (expanded) return 'AA' + _hotaBerLen(trig.length / 2) + trig;
return trig;
}
function hotaBuildStore(conn, sec, retry, httpPost, storeTag) {
const inner = _hotaTlv(storeTag, conn + (sec || '') + (retry || '') + (httpPost || ''));
return '80E29000' + _hotaBerLen(inner.length / 2) + inner;
}
function hotaBuild(mode, conn, sec, retry, httpPost, storeTag, expanded) {
return mode === 'store'
? hotaBuildStore(conn, sec, retry, httpPost, storeTag)
: hotaBuildTrigger(conn, sec, retry, httpPost, expanded);
}
function hotaSetMode(mode) {
_hotaMode = mode;
document.getElementById('hota-mode-trigger').className = 'px-4 py-1.5 text-sm rounded-full ' + (mode === 'trigger' ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600');
document.getElementById('hota-mode-store').className = 'px-4 py-1.5 text-sm rounded-full ' + (mode === 'store' ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600');
document.getElementById('hota-store-tag-row').classList.toggle('hidden', mode !== 'store');
document.getElementById('hota-expanded-row').classList.toggle('hidden', mode !== 'trigger');
hotaUpdatePreview();
}
function hotaToggleConn() {
const on = document.getElementById('hota-include-conn').checked;
document.getElementById('hota-conn-tlvs').classList.toggle('hidden', !on);
hotaUpdatePreview();
}
document.addEventListener('change', function(e) {
if (e.target && e.target.id === 'hota-include-conn') hotaToggleConn();
else if (e.target && e.target.id === 'hota-include-sec') document.getElementById('hota-sec-fields').classList.toggle('hidden', !e.target.checked);
else if (e.target && e.target.id === 'hota-include-retry') document.getElementById('hota-retry-fields').classList.toggle('hidden', !e.target.checked);
else if (e.target && e.target.id === 'hota-include-http') document.getElementById('hota-http-fields').classList.toggle('hidden', !e.target.checked);
});
hotaToggleConn();
function hotaAddTlv() {
const container = document.getElementById('hota-conn-tlvs');
const row = document.createElement('div');
row.className = 'flex gap-1 items-center mb-1';
row.innerHTML =
'<input maxlength="2" oninput="hotaUpdatePreview()" class="font-mono w-14 border border-gray-300 dark:border-slate-600 text-sm rounded px-1.5 py-1 dark:bg-slate-800" placeholder="Tag">' +
'<input oninput="hotaUpdatePreview()" class="font-mono flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-1.5 py-1 dark:bg-slate-800" placeholder="Value">' +
'<button onclick="this.parentElement.remove();hotaUpdatePreview()" class="text-gray-400 hover:text-red-500 px-1" title="Delete">&times;</button>';
container.appendChild(row);
hotaUpdatePreview();
}
function hotaAddPreset(name) {
const container = document.getElementById('hota-conn-tlvs');
const row = document.createElement('div');
row.className = 'flex gap-1 items-center mb-1';
const presets = {
devid: ['02', '8182'],
alpha: ['80', ''],
bearer: ['01', '014001'],
};
const [tag, value] = presets[name] || ['', ''];
row.innerHTML =
'<input maxlength="2" value="' + tag + '" oninput="hotaUpdatePreview()" class="font-mono w-14 border border-gray-300 dark:border-slate-600 text-sm rounded px-1.5 py-1 dark:bg-slate-800" placeholder="Tag">' +
'<input value="' + value + '" oninput="hotaUpdatePreview()" class="font-mono flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-1.5 py-1 dark:bg-slate-800" placeholder="Value">' +
'<button onclick="this.parentElement.remove();hotaUpdatePreview()" class="text-gray-400 hover:text-red-500 px-1" title="Delete">&times;</button>';
container.appendChild(row);
hotaUpdatePreview();
}
function hotaReadConnTlvs() {
const tlvs = [];
document.querySelectorAll('#hota-conn-tlvs .flex').forEach(row => {
const inputs = row.querySelectorAll('input');
if (inputs.length >= 2) tlvs.push({ tag: _hotaHex(inputs[0].value), value: inputs[1].value });
});
return tlvs;
}
function hotaUpdatePreview() {
const connOn = document.getElementById('hota-include-conn').checked;
const secOn = document.getElementById('hota-include-sec').checked;
const retryOn = document.getElementById('hota-include-retry').checked;
const httpOn = document.getElementById('hota-include-http').checked;
const expanded = document.getElementById('hota-expanded').checked;
const storeTag = document.getElementById('hota-store-tag').value;
const conn = connOn ? hotaBuildConn(hotaReadConnTlvs()) : '';
const sec = secOn ? hotaBuildSec({
pskIdentity: document.getElementById('hota-psk-text').value,
kvn: document.getElementById('hota-kvn').value,
kid: document.getElementById('hota-kid').value,
}) : '';
const retry = retryOn ? hotaBuildRetry({
counter: document.getElementById('hota-retry-counter').value,
delayH: document.getElementById('hota-delay-h').value,
delayM: document.getElementById('hota-delay-m').value,
delayS: document.getElementById('hota-delay-s').value,
reportFailure: document.getElementById('hota-retry-report').value,
}) : '';
const http = httpOn ? hotaBuildHttpPost({
host: document.getElementById('hota-host').value,
agent: document.getElementById('hota-agent').value,
uri: document.getElementById('hota-uri').value,
}) : '';
const hex = hotaBuild(_hotaMode, conn, sec, retry, http, storeTag, expanded);
const preview = document.getElementById('hota-preview');
preview.value = hex;
document.getElementById('hota-pack-btn').disabled = !hex;
}
function hotaPreviewEdited() {
const hex = document.getElementById('hota-preview').value;
document.getElementById('hota-pack-btn').disabled = !hex;
}
document.querySelectorAll('#c-apdu-sub-httpota input, #c-apdu-sub-httpota select').forEach(el => {
if (el.type === 'checkbox') return;
el.addEventListener('input', hotaUpdatePreview);
});
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
});
@@ -1223,8 +1513,8 @@ function chainRenderRow(chainId, idx, row) {
const hex = chainBuildRowHex(chainId, idx);
return '<div class="flex items-center gap-2 mb-2">' +
'<select onchange="chainUpdateCmd(\'' + chainId + '\',' + idx + ',this.value)" class="flex-shrink-0 w-52 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">' + cmdOpts + '</select>' +
'<button onclick="chainDeleteRow(\'' + chainId + '\',' + idx + ')" class="text-red-500 hover:text-red-700 text-lg font-bold px-1" title="Delete">&times;</button>' +
'<input id="' + chainId + '-row-' + idx + '-hex" readonly value="' + escHtml(hex) + '" class="flex-1 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-gray-50 dark:bg-slate-900">' +
'<button onclick="chainDeleteRow(\'' + chainId + '\',' + idx + ')" class="text-red-500 hover:text-red-700 text-lg font-bold px-1" title="Delete"></button>' +
'</div>' +
'<div class="pl-4">' + fieldsHtml + '</div>';
}
@@ -6666,7 +6956,7 @@ const PROFILER_IGNORE_FILES = [
{ fid: '6F09', name: 'EF.KeysPS' },
{ fid: '6F3C', name: 'EF.SMS' },
{ fid: '6F20', name: 'EF.Kc' },
{ fid: '4F52', name: 'EF.KcGPRS' },
{ fid: '6F52', name: 'EF.KcGPRS' },
{ fid: '6F53', name: 'EF.LOCIGPRS' },
{ fid: '6F48', name: 'EF.CBMID' },
{ fid: '6F43', name: 'EF.SMSS' },
@@ -6698,6 +6988,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);
@@ -6757,6 +7052,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;
}
@@ -6802,15 +7098,30 @@ function profilerNew() {
function profilerFromCard() {
document.getElementById('profiler-scan-name').value = '';
document.getElementById('profiler-scan-name').readOnly = false;
document.getElementById('profiler-scan-name').classList.remove('opacity-50');
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>' +
'<input type="checkbox" data-ignore-fid="' + f.fid + '" data-ignore-name="' + f.name + '" checked>' +
'<span>' + esc(t('Ignore contents of')) + ' ' + esc(f.name) + '</span></label>';
}
ignore.innerHTML = html;
const mask = document.getElementById('profiler-scan-mask');
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>';
}
mask.innerHTML = mhtml;
document.getElementById('profiler-scan-options').classList.remove('hidden');
document.getElementById('profiler-scan-progress').classList.add('hidden');
document.getElementById('profiler-scan-error').classList.add('hidden');
document.getElementById('profiler-scan-cancel-btn').disabled = false;
document.getElementById('profiler-scan-btn').disabled = false;
document.getElementById('profiler-scan-btn').textContent = t('Scan');
document.getElementById('profiler-scan-modal').classList.remove('hidden');
}
@@ -6827,14 +7138,34 @@ async function profilerScanStart() {
return;
}
const ignoreFids = new Set();
const ignoreNames = new Set();
document.querySelectorAll('#profiler-scan-ignore input[data-ignore-fid]').forEach(cb => {
if (cb.checked) ignoreFids.add(cb.getAttribute('data-ignore-fid').toUpperCase());
if (cb.checked) {
ignoreFids.add(cb.getAttribute('data-ignore-fid').toUpperCase());
ignoreNames.add((cb.getAttribute('data-ignore-name') || '').toUpperCase());
}
});
const maskFids = new Set();
document.querySelectorAll('#profiler-scan-mask input[data-mask-fid]').forEach(cb => {
if (cb.checked) maskFids.add(cb.getAttribute('data-mask-fid').toUpperCase());
});
const fciMode = document.getElementById('profiler-scan-fci-mode').value;
const btn = document.getElementById('profiler-scan-btn');
const cancelBtn = document.getElementById('profiler-scan-cancel-btn');
const nameEl = document.getElementById('profiler-scan-name');
const progEl = document.getElementById('profiler-scan-progress');
document.getElementById('profiler-scan-options').classList.add('hidden');
cancelBtn.disabled = true;
nameEl.readOnly = true;
nameEl.classList.add('opacity-50');
progEl.classList.remove('hidden');
progEl.textContent = t('Discovering...');
btn.disabled = true;
btn.textContent = t('Scanning...');
try {
const rules = await profilerScanCard(ignoreFids);
const rules = await profilerScanCard(ignoreFids, ignoreNames, fciMode, (done, total, path) => {
progEl.textContent = done + ' / ' + total + ' ' + t('files') + (path ? ' — ' + path : '');
}, maskFids);
const profile = { id: profilerNewId(), name: name, created: new Date().toISOString(), rules: rules };
profiles.push(profile);
profilerSave();
@@ -6844,14 +7175,23 @@ async function profilerScanStart() {
errEl.textContent = e.message;
errEl.classList.remove('hidden');
} finally {
document.getElementById('profiler-scan-options').classList.remove('hidden');
cancelBtn.disabled = false;
nameEl.readOnly = false;
nameEl.classList.remove('opacity-50');
progEl.classList.add('hidden');
btn.disabled = false;
btn.textContent = t('Scan');
}
}
// Recursively walk the card filesystem and build rules for files that exist.
async function profilerScanCard(ignoreFids) {
// Phase 1 discovers every file (tree calls only) to know the total; phase 2
// builds a rule per file, reporting progress via the optional onProgress
// callback as onProgress(done, total, path).
async function profilerScanCard(ignoreFids, ignoreNames, fciMode, onProgress, maskFids) {
const rules = [];
const files = [];
const seen = new Set();
// dir: { name, fid, parentSel (to select this dir), pathPrefix (rule-path segs for its children) }
@@ -6877,8 +7217,7 @@ async function profilerScanCard(ignoreFids) {
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);
files.push({ path: childPath, child: c });
}
}
}
@@ -6896,13 +7235,21 @@ async function profilerScanCard(ignoreFids) {
const path = segs.join('/');
if (seen.has(path)) continue;
seen.add(path);
const rule = await profilerBuildFileRule(path, { fid: cf.fid, name: cf.name }, ignoreFids);
files.push({ path: path, child: { fid: cf.fid, name: cf.name } });
}
const total = files.length;
if (onProgress) onProgress(0, total, '');
for (let i = 0; i < files.length; i++) {
const entry = files[i];
if (onProgress) onProgress(i + 1, total, entry.path);
const rule = await profilerBuildFileRule(entry.path, entry.child, ignoreFids, ignoreNames, fciMode, maskFids);
if (rule) rules.push(rule);
}
return rules;
}
async function profilerBuildFileRule(path, c, ignoreFids) {
async function profilerBuildFileRule(path, c, ignoreFids, ignoreNames, fciMode, maskFids) {
let sel;
try {
sel = await pysimFetch('/api/select', { path: path });
@@ -6912,14 +7259,18 @@ async function profilerBuildFileRule(path, c, ignoreFids) {
const rule = {
type: 'file',
path: path,
name: c.name || sel.name || null,
fileType: sel.file_type || null,
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();
if (ignoreFids.has(fid)) return rule;
const name = (c.name || sel.name || '').toUpperCase();
if (ignoreFids.has(fid) || (ignoreNames && ignoreNames.has(name))) return rule;
try {
const rd = await pysimFetch('/api/read', { path: path, mode: 'raw' });
if (rd && rd.success) {
@@ -6929,7 +7280,8 @@ async function profilerBuildFileRule(path, c, ignoreFids) {
records: rd.records.map(r => ({ num: r.num, data: r.data })),
};
} else if (rd.data) {
if (PROFILER_MASK_PREFIX4_FIDS[fid]) {
const useMask = maskFids ? maskFids.has(fid) : !!PROFILER_MASK_PREFIX4_FIDS[fid];
if (useMask) {
rule.content = { mode: 'mask', kind: 'transparent', expected: profilerMaskPrefix4(rd.data) };
} else {
rule.content = { mode: 'exact', kind: 'transparent', expected: rd.data };
@@ -6992,7 +7344,7 @@ function profilerBackToList() {
function profilerAddRule() {
if (!profilerDraft) return;
profilerDraft.rules.push({ type: 'file', path: '', 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();
}
@@ -7024,30 +7376,45 @@ 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>';
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>';
const sname = profilerCustomNameForPath(r.path) || r.name || '';
html += '<div class="flex gap-2 mb-2">';
html += '<div class="w-72 shrink-0"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('Path')) +
' <span class="profiler-rule-name font-normal text-gray-400 dark:text-slate-500">' + esc(sname) + '</span></label>' +
'<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 class="flex-1"><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>';
const vis = profilerFileFields(r.fileType);
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>' +
const showSize = vis.size && fciMode !== 'type';
const showRecords = vis.records && fciMode !== 'type';
html += '<div class="flex gap-2 mb-2">';
html += '<div class="flex-1"><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) {
html += '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('Size')) + '</label>' +
if (showSize) {
html += '<div class="flex-1"><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) {
html += '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('Record length')) + '</label>' +
if (showRecords) {
html += '<div class="flex-1"><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>' +
html += '<div class="flex-1"><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);
}
@@ -7058,12 +7425,13 @@ function profilerRenderRule(i, r) {
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">';
let html = '<div class="flex items-center justify-between mb-1">';
html += '<span class="text-xs text-gray-600 dark:text-slate-400">' + esc(t('Check contents')) + '</span>';
html += '<div class="flex items-center gap-3">';
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>';
html += '</div></div>';
if (c) {
if (c.kind === 'record') {
html += '<div class="space-y-1">';
@@ -7090,6 +7458,17 @@ function profilerUpdateRule(i, field, value) {
profilerDraft.rules[i][field] = value;
}
// Path edits drop the scan-time symbolic name (it no longer corresponds);
// a custom-file lookup still supplies a display name for known paths.
function profilerUpdateRulePath(i, value, input) {
if (!profilerDraft) return;
const r = profilerDraft.rules[i];
r.path = value;
r.name = null;
const span = input && input.parentElement ? input.parentElement.querySelector('.profiler-rule-name') : null;
if (span) span.textContent = profilerCustomNameForPath(value) || '';
}
function profilerUpdateRuleFileType(i, value) {
if (!profilerDraft) return;
const r = profilerDraft.rules[i];
@@ -7108,6 +7487,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];
@@ -7214,20 +7599,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;
@@ -7247,14 +7642,19 @@ async function profilerRunRule(r) {
const n = Math.min(exp.length, act.length);
if (exp.length !== act.length) {
res.status = 'fail';
res.checks.push({ label: 'content.records', expected: exp.length + ' records', actual: act.length + ' records', ok: false });
const numRec = res.checks.find(c => c.label === 'numRecords');
if (!numRec || numRec.ok) {
res.checks.push({ label: 'content.records', expected: exp.length + ' records', actual: act.length + ' records', ok: false });
}
}
const matched = [];
for (let j = 0; j < n; j++) {
const dataOk = sizeMismatch ? profilerMatchMin(r.content.mode, exp[j].data, act[j].data) : profilerMatch(r.content.mode, exp[j].data, act[j].data);
const ok = exp[j].num === act[j].num && dataOk;
if (!ok) res.status = 'fail';
if (!ok) res.status = 'fail'; else matched.push(exp[j].num);
res.checks.push({ label: 'content.rec' + exp[j].num, expected: exp[j].data, actual: act[j].data, ok: ok });
}
if (matched.length) res.recordsMatched = matched;
} else {
const ok = sizeMismatch ? profilerMatchMin(r.content.mode, r.content.expected, rd.data) : profilerMatch(r.content.mode, r.content.expected, rd.data);
if (!ok) res.status = 'fail';
@@ -7278,6 +7678,73 @@ function profilerCustomNameForPath(path) {
return null;
}
// Group a rule's result checks into display aspects, derived from the checks
// that actually ran. 'Exact FCI' subsumes filetype/size/records; 'exists' is
// implicit and omitted.
function profilerResultAspects(res) {
const groups = { filetype: [], size: [], records: [], 'Exact FCI': [], contents: [] };
for (const c of (res.checks || [])) {
const l = c.label;
if (l === 'fileType') groups.filetype.push(c);
else if (l === 'fileSize') groups.size.push(c);
else if (l === 'recordLen' || l === 'numRecords') groups.records.push(c);
else if (l === 'fci') groups['Exact FCI'].push(c);
else if (l === 'content' || l.startsWith('content.')) groups.contents.push(c);
}
const out = [];
if (groups['Exact FCI'].length) {
out.push({ key: 'Exact FCI', ok: groups['Exact FCI'].every(c => c.ok) });
} else {
if (groups.filetype.length) out.push({ key: 'filetype', ok: groups.filetype.every(c => c.ok) });
if (groups.size.length) out.push({ key: 'size', ok: groups.size.every(c => c.ok) });
if (groups.records.length) out.push({ key: 'records', ok: groups.records.every(c => c.ok) });
}
if (groups.contents.length) out.push({ key: 'contents', ok: groups.contents.every(c => c.ok) });
return out;
}
function profilerAspectSummary(aspects, marked, tr) {
tr = tr || (s => s);
if (!aspects || !aspects.length) return '';
if (marked) return aspects.map(a => tr(a.key) + ' ' + (a.ok ? '✓' : '✗')).join(', ');
const has = k => aspects.some(a => a.key === k);
let fcp = '';
if (has('Exact FCI')) fcp = tr('Exact FCI');
else if (has('filetype') && has('records')) fcp = tr('filetype and records');
else if (has('filetype') && has('size')) fcp = tr('filetype and size');
else if (has('filetype')) fcp = tr('filetype');
else if (has('records')) fcp = tr('records');
else if (has('size')) fcp = tr('size');
const parts = [];
if (fcp) parts.push(fcp);
if (has('contents')) parts.push(tr('contents'));
return parts.join(', ');
}
// Compress a list of record numbers into ranges: [1,2,3,5,7] -> "1-3, 5, 7".
function profilerNumRanges(nums) {
if (!nums || !nums.length) return '';
const sorted = nums.slice().sort((a, b) => a - b);
const parts = [];
let start = null, prev = null;
for (const n of sorted) {
if (start === null) { start = prev = n; continue; }
if (n === prev + 1) { prev = n; continue; }
parts.push(start === prev ? String(start) : start + '-' + prev);
start = prev = n;
}
if (start !== null) parts.push(start === prev ? String(start) : start + '-' + prev);
return parts.join(', ');
}
// Checks whose expected/actual are raw data (hex), rendered as aligned fields.
function profilerRawDataCheck(c) {
if (c.label === 'fci') return true;
if (/^content\.rec\d+$/.test(c.label)) return true;
if (c.label === 'content') return c.expected !== 'readable';
return false;
}
function profilerRenderReport(results) {
let html = '';
for (const r of results) {
@@ -7287,12 +7754,32 @@ function profilerRenderReport(results) {
const title = name
? '<span>' + esc(name) + '</span> <span class="text-gray-400 dark:text-slate-500">(' + esc(r.path) + ')</span>'
: '<span>' + esc(r.path) + '</span>';
const aspects = profilerResultAspects(r);
const marked = aspects.some(a => !a.ok);
const summary = profilerAspectSummary(aspects, marked, t);
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">' + title + '</span></div>';
html += '<div class="flex items-center gap-2"><span class="' + color + ' font-bold">' + icon + '</span><span class="font-mono text-sm">' + title + '</span>' +
(summary ? '<span class="text-xs text-gray-500 dark:text-slate-400">: ' + esc(summary) + '</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>';
if (profilerRawDataCheck(c)) {
html += '<div class="mt-1">';
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">' +
'<span class="text-xs text-gray-500 dark:text-slate-400 w-24 text-right shrink-0">' + esc(t('expected')) + '</span>' +
'<input readonly title="' + escHtml(String(c.expected)) + '" value="' + escHtml(String(c.expected)) + '" class="flex-1 min-w-0 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-gray-100 dark:bg-slate-800"></div>';
html += '<div class="flex items-center gap-2 mt-0.5 pl-2">' +
'<span class="text-xs text-gray-500 dark:text-slate-400 w-24 text-right shrink-0">' + esc(t('actual')) + '</span>' +
'<input readonly title="' + escHtml(String(c.actual)) + '" value="' + escHtml(String(c.actual)) + '" class="flex-1 min-w-0 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-gray-100 dark:bg-slate-800"></div>';
html += '</div>';
} else {
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>';
}
}
const recFailed = (r.checks || []).some(c => /^content\.rec\d+$/.test(c.label) && !c.ok);
if (recFailed && r.recordsMatched && r.recordsMatched.length) {
html += '<div class="text-xs text-emerald-600 mt-1">' + esc(t('matching records')) + ': ' + esc(profilerNumRanges(r.recordsMatched)) + '</div>';
}
html += '</div>';
}
@@ -7504,8 +7991,16 @@ const LANG_RU = {
'Back to list': 'Назад к списку',
'Add rule': 'Добавить правило',
'Scan': 'Сканировать',
'Discovering...': 'Поиск файлов...',
'files': 'файлов',
'Ignore contents of:': 'Игнорировать содержимое:',
'Ignore contents of': 'Игнорировать содержимое',
'Match first 4 bytes for': 'Сравнивать первые 4 байта для',
'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': 'Проверить',
@@ -7520,7 +8015,14 @@ const LANG_RU = {
'Size': 'Размер',
'Record length': 'Длина записи',
'Record count': 'Кол-во записей',
'Contents': 'Содержимое',
'Check contents': 'Проверить содержимое',
'filetype': 'тип файла',
'size': 'размер',
'records': 'записи',
'contents': 'содержимое',
'filetype and size': 'тип файла и размер',
'filetype and records': 'тип файла и записи',
'matching records': 'совпадающие записи',
'None': 'Нет',
'Exact': 'Точное',
'Mask': 'Маска',
@@ -7530,6 +8032,28 @@ const LANG_RU = {
'errors': 'ошибок',
'expected': 'ожидалось',
'actual': 'фактически',
'HTTP OTA': 'HTTP OTA',
'Trigger (Push SMS)': 'Триггер (Push SMS)',
'Store (SD admin params)': 'Store (параметры SD)',
'SD params tag': 'Tag параметров SD',
"Connection parameters ('84')": "Параметры соединения ('84')",
'+ TLV': '+ TLV',
'+ Device Identities': '+ Device Identities',
'+ Alpha (empty)': '+ Alpha (пустой)',
'+ Bearer (GPRS)': '+ Bearer (GPRS)',
"Security parameters ('85')": "Параметры безопасности ('85')",
'PSK Identity (text)': 'PSK Identity (текст)',
"Retry policy ('86')": "Политика повтора ('86')",
'Retry counter (B0,00=unlimited)': 'Счётчик повторов (B0,00=бесконечно)',
'Hours': 'Часы',
'Minutes': 'Минуты',
'Seconds': 'Секунды',
"Report failure (vendor-specific TLV)": "Отчёт о сбое (специфичный TLV)",
"HTTP POST ('89', 8A=host,8B=agent,8C=URI)": "HTTP POST ('89', 8A=host,8B=agent,8C=URI)",
'Host (text→hex)': 'Хост (текст→hex)',
"HTTP agent ID (text, X-Admin-From)": "ID HTTP агента (текст, X-Admin-From)",
'URI (text→hex)': 'URI (текст→hex)',
"Wrapped in Command Scripting template ('AA')": "Обёртка в Command Scripting template ('AA')",
};
let currentLang = 'en';
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'otaman-v47';
const CACHE = 'otaman-v60';
const URLS = [
'index.html',
'help.html',
+142
View File
@@ -0,0 +1,142 @@
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 = ['_hotaHex', '_hotaAsciiHex', '_hotaBerLen', '_hotaTlv',
'hotaTlv', 'hotaBuildConn', 'hotaBuildSec', 'hotaBuildRetry',
'hotaBuildHttpPost', 'hotaBuildTrigger', 'hotaBuildStore', 'hotaBuild'];
let code = '';
for (const f of FNS) code += extractFunc(html, f) + '\n';
eval(code);
test('_hotaHex strips non-hex and uppercases', () => {
assert.strictEqual(_hotaHex(' 81 82 '), '8182');
assert.strictEqual(_hotaHex('b0-00'), 'B000');
assert.strictEqual(_hotaHex(''), '');
assert.strictEqual(_hotaHex(undefined), '');
});
test('_hotaAsciiHex encodes text as ASCII bytes', () => {
assert.strictEqual(_hotaAsciiHex('megafon.ru'), '6D656761666F6E2E7275');
assert.strictEqual(_hotaAsciiHex('v1.0'), '76312E30');
assert.strictEqual(_hotaAsciiHex('/sd'), '2F7364');
assert.strictEqual(_hotaAsciiHex(''), '');
});
test('_hotaBerLen encodes definite lengths', () => {
assert.strictEqual(_hotaBerLen(0), '00');
assert.strictEqual(_hotaBerLen(11), '0B');
assert.strictEqual(_hotaBerLen(127), '7F');
assert.strictEqual(_hotaBerLen(128), '8180');
assert.strictEqual(_hotaBerLen(255), '81FF');
assert.strictEqual(_hotaBerLen(256), '820100');
});
test('hotaTlv wraps tag + definite length + value', () => {
assert.strictEqual(hotaTlv('02', '8182'), '02028182');
assert.strictEqual(hotaTlv('05', ''), '0500');
assert.strictEqual(hotaTlv('8A', '6D656761666F6E2E7275'), '8A0A6D656761666F6E2E7275');
});
test('hotaBuildConn produces a full 84 TLV', () => {
assert.strictEqual(hotaBuildConn([{ tag: '02', value: '8182' }]), '840402028182');
assert.strictEqual(
hotaBuildConn([
{ tag: '01', value: '014001' },
{ tag: '02', value: '81 82' },
{ tag: '05', value: '' },
]),
'840B0103014001020281820500');
assert.strictEqual(hotaBuildConn([{ tag: '02', value: '8182' }, { tag: '', value: 'FF' }]), '840402028182');
assert.strictEqual(hotaBuildConn([]), '8400');
});
test('hotaBuildSec produces a full 85 TLV per Table 4-6', () => {
assert.strictEqual(
hotaBuildSec({ pskIdentity: 'Test123', kvn: '01', kid: '01' }),
'850B0754657374313233020101');
assert.strictEqual(
hotaBuildSec({ pskIdentity: '', kvn: '01', kid: '01' }),
'850400020101');
});
test('hotaBuildRetry uses TS 102 223 timer TLV (25 03) and wraps in 86', () => {
assert.strictEqual(
hotaBuildRetry({ counter: 'B000', delayH: 1, delayM: 2, delayS: 3, reportFailure: '' }),
'8607B0002503010203');
assert.strictEqual(
hotaBuildRetry({ counter: 'b0 00', delayH: '1', delayM: '2', delayS: '3', reportFailure: '0A080102030405060708' }),
'8611B00025030102030A080102030405060708');
assert.strictEqual(
hotaBuildRetry({ counter: '0000', delayH: 0, delayM: 0, delayS: 0, reportFailure: '0A0' }),
'860700002503000000');
});
test('hotaBuildHttpPost wraps 8A/8B/8C in a full 89 TLV', () => {
assert.strictEqual(
hotaBuildHttpPost({ host: '', agent: '', uri: '' }),
'89068A008B008C00');
assert.strictEqual(
hotaBuildHttpPost({ host: 'megafon.ru', agent: 'v1.0', uri: '/sd' }),
'89178A0A6D656761666F6E2E72758B0476312E308C032F7364');
});
test('hotaBuildTrigger wraps 81 > 83 > (84/85/86/89)', () => {
const conn = '840402028182';
const sec = '850400020101';
const retry = '8607B0002503010203';
const httpPost = '89068A008B008C00';
assert.strictEqual(
hotaBuildTrigger(conn, sec, retry, httpPost, false),
'811F831D8404020281828504000201018607B000250301020389068A008B008C00');
});
test('hotaBuildTrigger expanded wraps the 81 command in Command Scripting template AA', () => {
const conn = '840402028182';
const plain = hotaBuildTrigger(conn, '', '', '', false);
const expanded = hotaBuildTrigger(conn, '', '', '', true);
const byteLen = plain.length / 2;
assert.strictEqual(expanded, 'AA' + _hotaBerLen(byteLen) + plain);
assert.ok(expanded.startsWith('AA'));
});
test('hotaBuildStore emits STORE DATA TLV-mode APDU (80 E2 90 00)', () => {
assert.strictEqual(
hotaBuildStore('840402028182', '', '', '', '85'),
'80E29000088506840402028182');
assert.strictEqual(
hotaBuildStore('840402028182', '', '', '', 'A5'),
'80E2900008A506840402028182');
});
test('hotaBuild dispatches on mode', () => {
assert.strictEqual(
hotaBuild('trigger', '840402028182', '', '', '', '85', false),
hotaBuildTrigger('840402028182', '', '', '', false));
assert.strictEqual(
hotaBuild('store', '840402028182', '', '', '', '85', false),
hotaBuildStore('840402028182', '', '', '', '85'));
assert.strictEqual(
hotaBuild('store', '840402028182', '', '8607B0002503010203', '', '85', false),
hotaBuildStore('840402028182', '', '8607B0002503010203', '', '85'));
});
+464 -3
View File
@@ -5,7 +5,7 @@ const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
function extractFunc(src, name, asyncFn) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
@@ -18,12 +18,16 @@ function extractFunc(src, name) {
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
}
const FNS = ['profilerNormHex', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile'];
const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport'];
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 += extractFunc(html, 'profilerScanCard', true) + '\n';
code += html.match(/const PROFILER_MASK_PREFIX4_FIDS = \{[\s\S]*?\n\};/)[0] + '\n';
eval(code);
test('profilerNormHex uppercases and strips non-hex', () => {
@@ -107,3 +111,460 @@ test('profile validation', () => {
assert.strictEqual(profilerValidateProfile({ name: 'x', rules: [{ type: 'file' }] }), 'Rule missing path');
assert.strictEqual(profilerValidateProfile({ name: 'x', rules: [{ type: 'file', path: 'MF/7F10/6F3A' }] }), null);
});
// --- "Profile from card" ignore list ---
function parseIgnoreFiles() {
const raw = html.match(/const PROFILER_IGNORE_FILES = \[([\s\S]*?)\n\];/)[1];
return [...raw.matchAll(/\{ fid: '([^']*)', name: '([^']*)' \}/g)].map(m => ({ fid: m[1], name: m[2] }));
}
test('ignore list FIDs are well-formed and KcGPRS uses the TS 51.011 FID (6F52)', () => {
const files = parseIgnoreFiles();
assert.ok(files.length >= 12);
for (const f of files) {
assert.match(f.fid, /^[0-9A-F]{4}$/, f.name + ' has a malformed FID');
assert.ok(f.name.startsWith('EF.'), f.fid + ' has a malformed name');
}
assert.strictEqual(files.find(f => f.name === 'EF.KcGPRS').fid, '6F52');
});
test('ignore list has no duplicate FIDs or names', () => {
const files = parseIgnoreFiles();
assert.strictEqual(new Set(files.map(f => f.fid)).size, files.length);
assert.strictEqual(new Set(files.map(f => f.name)).size, files.length);
});
function mockFetch(handlers) {
const calls = [];
global.pysimFetch = async (path, body) => {
calls.push(path);
if (handlers[path]) return handlers[path](body);
throw new Error('unexpected fetch: ' + path);
};
return calls;
}
const KCGPRS_SELECT = {
name: 'EF.KcGPRS', fid: '6F52', file_type: 'transparent',
file_size: 9, record_len: null, num_of_rec: null, exists: true,
};
test('profilerBuildFileRule skips contents for an ignored FID', async () => {
const calls = mockFetch({ '/api/select': () => KCGPRS_SELECT });
const rule = await profilerBuildFileRule('MF/7F20/6F52',
{ fid: '6f52', name: 'EF.KcGPRS' }, new Set(['6F52']), new Set(['EF.KCGPRS']));
assert.strictEqual(rule.content, null);
assert.strictEqual(rule.path, 'MF/7F20/6F52');
assert.strictEqual(rule.fileType, 'transparent');
assert.strictEqual(rule.fileSize, 9);
assert.strictEqual(rule.name, 'EF.KcGPRS');
assert.ok(!calls.includes('/api/read'), 'contents must not be read for ignored files');
});
test('profilerBuildFileRule skips contents when the name matches but the FID differs', async () => {
// Regression: a KcGPRS copy at the TS 31.102 DF.GSM-ACCESS FID (4F52) must
// still be ignored when the user checked EF.KcGPRS in the ignore list.
const calls = mockFetch({
'/api/select': () => ({ name: 'EF.KcGPRS', fid: '4F52', file_type: 'transparent', file_size: 9, record_len: null, num_of_rec: null, exists: true }),
});
const rule = await profilerBuildFileRule('MF/5F3B/4F52',
{ fid: '4f52', name: 'EF.KcGPRS' }, new Set(['6F52']), new Set(['EF.KCGPRS']));
assert.strictEqual(rule.content, null);
assert.ok(!calls.includes('/api/read'), 'contents must not be read for name-matched files');
});
test('profilerBuildFileRule captures contents for non-ignored files', async () => {
const calls = mockFetch({
'/api/select': () => ({ name: 'EF.ADN', fid: '6F3A', file_type: 'transparent', file_size: 4, record_len: null, num_of_rec: null, exists: true }),
'/api/read': () => ({ success: true, data: 'AABBCCDD' }),
});
const rule = await profilerBuildFileRule('MF/7F20/6F3A',
{ fid: '6f3a', name: 'EF.ADN' }, new Set(['6F52']), new Set(['EF.KCGPRS']));
assert.ok(rule.content);
assert.strictEqual(rule.content.mode, 'exact');
assert.strictEqual(rule.content.expected, 'AABBCCDD');
assert.strictEqual(rule.name, 'EF.ADN');
assert.ok(calls.includes('/api/read'));
});
test('profilerBuildFileRule falls back to the select name when the child has none', async () => {
mockFetch({ '/api/select': () => KCGPRS_SELECT });
const rule = await profilerBuildFileRule('MF/7F20/6F52', { fid: '6F52' }, new Set(), new Set());
assert.strictEqual(rule.name, 'EF.KcGPRS');
});
test('profilerBuildFileRule stores null when no symbolic name is known', async () => {
mockFetch({ '/api/select': () => ({ fid: '6F3A', file_type: 'transparent', file_size: 0, exists: true }) });
const rule = await profilerBuildFileRule('MF/7F20/6F3A', { fid: '6F3A' }, new Set(), new Set());
assert.strictEqual(rule.name, null);
});
test('profilerCustomNameForPath resolves saved custom file names', () => {
global.pysimCustomFiles = [
{ path: '3F00/7F10/6F3A', fid: '6F3A', name: 'My ADN' },
{ path: 'MF/7F20/6F7E', fid: '6F7E', name: 'My LOCI' },
];
assert.strictEqual(profilerCustomNameForPath('MF/7F10/6F3A'), 'My ADN');
assert.strictEqual(profilerCustomNameForPath('MF/7F20/6F7E'), 'My LOCI');
assert.strictEqual(profilerCustomNameForPath('MF/7F20/6F3A'), null);
assert.strictEqual(profilerCustomNameForPath(''), null);
assert.strictEqual(profilerCustomNameForPath(null), null);
});
test('profilerUpdateRulePath clears the scan-time name and refreshes the label', () => {
global.pysimCustomFiles = [{ path: 'MF/7F10/6F3A', fid: '6F3A', name: 'My ADN' }];
global.profilerDraft = { rules: [{ path: 'MF/7F20/6F3A', name: 'EF.ADN' }] };
const span = { textContent: '' };
const input = { parentElement: { querySelector: () => span } };
profilerUpdateRulePath(0, 'MF/7F10/6F3A', input);
assert.strictEqual(global.profilerDraft.rules[0].path, 'MF/7F10/6F3A');
assert.strictEqual(global.profilerDraft.rules[0].name, null);
assert.strictEqual(span.textContent, 'My ADN');
profilerUpdateRulePath(0, 'MF/7F10/6FB1', input);
assert.strictEqual(span.textContent, '');
global.profilerDraft = null;
profilerUpdateRulePath(0, 'x', input);
});
test('profilerBuildFileRule still ignores when ignoreNames is omitted (back-compat)', async () => {
const calls = mockFetch({ '/api/select': () => KCGPRS_SELECT });
const rule = await profilerBuildFileRule('MF/7F20/6F52',
{ fid: '6F52', name: 'EF.KcGPRS' }, new Set(['6F52']));
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');
});
// --- scan progress ---
test('profilerScanCard reports discovery total then per-file progress', async () => {
const SEL = (fid, name) => ({ name, fid, file_type: 'transparent', file_size: 4, record_len: null, num_of_rec: null, exists: true });
const tree = {
MF: { exists: true, name: 'MF', children: [
{ name: 'DF.GSM', fid: '7f20', isDir: true },
{ name: 'EF.IMSI', fid: '6f07', isDir: false },
] },
'DF.GSM': { exists: true, name: 'DF.GSM', children: [
{ name: 'EF.ADN', fid: '6f3a', isDir: false },
{ name: 'EF.LOCI', fid: '6f7e', isDir: false },
] },
};
global.pysimCustomFiles = [{ path: 'MF/7F10/6F3A', fid: '6F3A', name: 'My ADN' }];
global.pysimFetch = async (path, body) => {
if (path === '/api/tree') {
const key = body.name === 'DF.GSM' ? 'DF.GSM' : 'MF';
return tree[key];
}
if (path === '/api/select') {
const fid = body.path.split('/').pop();
return SEL(fid, 'EF.' + fid);
}
throw new Error('unexpected fetch: ' + path);
};
const progress = [];
const rules = await profilerScanCard(new Set(), new Set(), 'type', (done, total, path) => {
progress.push([done, total, path]);
});
assert.strictEqual(rules.length, 4);
// initial 0/total report
assert.deepStrictEqual(progress[0], [0, 4, '']);
// then 1..4 with the file path, in scan order
assert.strictEqual(progress.length, 5);
assert.strictEqual(progress[4][0], 4);
assert.strictEqual(progress[4][1], 4);
const paths = progress.slice(1).map(p => p[2]);
assert.deepStrictEqual(paths, ['MF/7F20/6F3A', 'MF/7F20/6F7E', 'MF/6F07', 'MF/7F10/6F3A']);
});
test('profilerScanCard without onProgress still works (back-compat)', async () => {
global.pysimCustomFiles = [];
global.pysimFetch = async (path, body) => {
if (path === '/api/tree') return { exists: true, name: 'MF', children: [{ name: 'EF.IMSI', fid: '6f07', isDir: false }] };
if (path === '/api/select') return { name: 'EF.IMSI', fid: '6F07', file_type: 'transparent', file_size: 9, record_len: null, num_of_rec: null, exists: true };
throw new Error('unexpected fetch: ' + path);
};
const rules = await profilerScanCard(new Set(), new Set(), 'type_size');
assert.strictEqual(rules.length, 1);
});
// --- mask first 4 bytes (EF.IMSI / EF.ICCID) ---
const IMSI_SELECT = { name: 'EF.IMSI', fid: '6F07', file_type: 'transparent', file_size: 9, record_len: null, num_of_rec: null, exists: true };
const IMSI_DATA = '082905911234567890';
test('profilerBuildFileRule masks first 4 bytes when the FID is in maskFids', async () => {
mockFetch({ '/api/select': () => IMSI_SELECT, '/api/read': () => ({ success: true, data: IMSI_DATA }) });
const rule = await profilerBuildFileRule('MF/6F07', { fid: '6F07', name: 'EF.IMSI' }, new Set(), new Set(), 'type_size', new Set(['6F07']));
assert.strictEqual(rule.content.mode, 'mask');
assert.strictEqual(rule.content.expected, '08290591??????????');
});
test('profilerBuildFileRule uses exact contents when maskFids does not contain the FID', async () => {
mockFetch({ '/api/select': () => IMSI_SELECT, '/api/read': () => ({ success: true, data: IMSI_DATA }) });
const rule = await profilerBuildFileRule('MF/6F07', { fid: '6F07', name: 'EF.IMSI' }, new Set(), new Set(), 'type_size', new Set());
assert.strictEqual(rule.content.mode, 'exact');
assert.strictEqual(rule.content.expected, IMSI_DATA);
});
test('profilerBuildFileRule keeps the legacy mask default when maskFids is omitted', async () => {
mockFetch({ '/api/select': () => IMSI_SELECT, '/api/read': () => ({ success: true, data: IMSI_DATA }) });
const rule = await profilerBuildFileRule('MF/6F07', { fid: '6F07', name: 'EF.IMSI' }, new Set(), new Set(), 'type_size');
assert.strictEqual(rule.content.mode, 'mask');
assert.strictEqual(rule.content.expected, '08290591??????????');
});
test('profilerScanCard threads maskFids through to rule building', async () => {
global.pysimCustomFiles = [];
global.pysimFetch = async (path, body) => {
if (path === '/api/tree') return { exists: true, name: 'MF', children: [{ name: 'EF.IMSI', fid: '6f07', isDir: false }] };
if (path === '/api/select') return IMSI_SELECT;
if (path === '/api/read') return { success: true, data: IMSI_DATA };
throw new Error('unexpected fetch: ' + path);
};
const rules = await profilerScanCard(new Set(), new Set(), 'type_size', undefined, new Set());
assert.strictEqual(rules.length, 1);
assert.strictEqual(rules[0].content.mode, 'exact');
assert.strictEqual(rules[0].content.expected, IMSI_DATA);
});
// --- result report (aspects, summary, matching records) ---
test('profilerNumRanges compresses consecutive record numbers', () => {
assert.strictEqual(profilerNumRanges([]), '');
assert.strictEqual(profilerNumRanges([1]), '1');
assert.strictEqual(profilerNumRanges([1, 2, 3, 4, 5, 7, 8, 9, 10]), '1-5, 7-10');
assert.strictEqual(profilerNumRanges([1, 3, 5]), '1, 3, 5');
assert.strictEqual(profilerNumRanges([7, 8, 1, 2]), '1-2, 7-8');
assert.strictEqual(profilerNumRanges([6]), '6');
});
test('profilerResultAspects groups checks and lets Exact FCI subsume type/size', () => {
assert.deepStrictEqual(
profilerResultAspects({ checks: [
{ label: 'fileType', ok: true }, { label: 'fileSize', ok: true }, { label: 'content', ok: true },
] }),
[{ key: 'filetype', ok: true }, { key: 'size', ok: true }, { key: 'contents', ok: true }]);
assert.deepStrictEqual(
profilerResultAspects({ checks: [
{ label: 'fileType', ok: true }, { label: 'fileSize', ok: true }, { label: 'fci', ok: false }, { label: 'content', ok: true },
] }),
[{ key: 'Exact FCI', ok: false }, { key: 'contents', ok: true }]);
assert.deepStrictEqual(
profilerResultAspects({ checks: [
{ label: 'recordLen', ok: true }, { label: 'numRecords', ok: false },
] }),
[{ key: 'records', ok: false }]);
assert.deepStrictEqual(profilerResultAspects({ checks: [{ label: 'exists', ok: true }] }), []);
});
test('profilerAspectSummary renders plain list when nothing failed', () => {
const tr = s => s;
assert.strictEqual(
profilerAspectSummary([{ key: 'filetype', ok: true }, { key: 'size', ok: true }, { key: 'contents', ok: true }], false, tr),
'filetype and size, contents');
assert.strictEqual(
profilerAspectSummary([{ key: 'filetype', ok: true }, { key: 'records', ok: true }, { key: 'contents', ok: true }], false, tr),
'filetype and records, contents');
assert.strictEqual(
profilerAspectSummary([{ key: 'Exact FCI', ok: true }, { key: 'contents', ok: true }], false, tr),
'Exact FCI, contents');
assert.strictEqual(profilerAspectSummary([{ key: 'filetype', ok: true }], false, tr), 'filetype');
assert.strictEqual(profilerAspectSummary([], false, tr), '');
});
test('profilerAspectSummary marks per-aspect when mixed', () => {
const tr = s => s;
assert.strictEqual(
profilerAspectSummary([{ key: 'filetype', ok: true }, { key: 'size', ok: false }, { key: 'contents', ok: true }], true, tr),
'filetype ✓, size ✗, contents ✓');
assert.strictEqual(
profilerAspectSummary([{ key: 'Exact FCI', ok: false }, { key: 'contents', ok: true }], true, tr),
'Exact FCI ✗, contents ✓');
});
test('profilerRunRule records which records matched on a record mismatch', async () => {
mockFetch({
'/api/select': () => ({ name: 'EF.X', fid: '6F3A', file_type: 'linear_fixed', file_size: null, record_len: 2, num_of_rec: 3, exists: true }),
'/api/read': () => ({ success: true, records: [{ num: 1, data: 'AA' }, { num: 2, data: 'XX' }, { num: 3, data: 'CC' }] }),
});
const res = await profilerRunRule({
path: 'MF/7F20/6F3A', fileType: 'linear_fixed', recordLen: 2, numRecords: 3, fciMode: 'type_size',
content: { mode: 'exact', kind: 'record', records: [{ num: 1, data: 'AA' }, { num: 2, data: 'BB' }, { num: 3, data: 'CC' }] },
});
assert.strictEqual(res.status, 'fail');
assert.deepStrictEqual(res.recordsMatched, [1, 3]);
});
const REC_SELECT_10 = { name: 'EF.X', fid: '6F3A', file_type: 'linear_fixed', file_size: null, record_len: 2, num_of_rec: 10, exists: true };
const REC_EXP_30 = (() => { const r = []; for (let i = 1; i <= 30; i++) r.push({ num: i, data: 'AB' }); return r; })();
const REC_ACT_10 = (() => { const r = []; for (let i = 1; i <= 10; i++) r.push({ num: i, data: 'AB' }); return r; })();
test('record count mismatch is reported once when numRecords is checked', async () => {
mockFetch({ '/api/select': () => REC_SELECT_10, '/api/read': () => ({ success: true, records: REC_ACT_10 }) });
const res = await profilerRunRule({
path: 'MF/7F20/6F3A', fileType: 'linear_fixed', recordLen: 2, numRecords: 30, fciMode: 'type_size',
content: { mode: 'exact', kind: 'record', records: REC_EXP_30 },
});
assert.strictEqual(res.status, 'fail');
assert.ok(res.checks.some(c => c.label === 'numRecords' && c.ok === false));
assert.ok(!res.checks.some(c => c.label === 'content.records'));
});
test('record count mismatch keeps content.records when numRecords is not checked (type mode)', async () => {
mockFetch({ '/api/select': () => REC_SELECT_10, '/api/read': () => ({ success: true, records: REC_ACT_10 }) });
const res = await profilerRunRule({
path: 'MF/7F20/6F3A', fileType: 'linear_fixed', recordLen: 2, numRecords: 30, fciMode: 'type',
content: { mode: 'exact', kind: 'record', records: REC_EXP_30 },
});
assert.strictEqual(res.status, 'fail');
assert.ok(res.checks.some(c => c.label === 'content.records' && c.ok === false));
assert.ok(!res.checks.some(c => c.label === 'numRecords'));
});
test('record count mismatch keeps content.records when numRecords passes but read differs', async () => {
mockFetch({
'/api/select': () => ({ ...REC_SELECT_10, num_of_rec: 3 }),
'/api/read': () => ({ success: true, records: REC_ACT_10.slice(0, 2) }),
});
const res = await profilerRunRule({
path: 'MF/7F20/6F3A', fileType: 'linear_fixed', recordLen: 2, numRecords: 3, fciMode: 'type_size',
content: { mode: 'exact', kind: 'record', records: [{ num: 1, data: 'AB' }, { num: 2, data: 'AB' }, { num: 3, data: 'AB' }] },
});
assert.strictEqual(res.status, 'fail');
assert.ok(res.checks.some(c => c.label === 'numRecords' && c.ok === true));
assert.ok(res.checks.some(c => c.label === 'content.records' && c.ok === false));
});
test('profilerRenderReport includes the checked-aspects summary and matching-record note', () => {
global.t = s => s;
global.pysimCustomFiles = [];
const html = profilerRenderReport([
{ path: 'MF/7F20/6F3F', name: 'EF.GID2', status: 'pass', checks: [
{ label: 'fileType', ok: true }, { label: 'fileSize', ok: true }, { label: 'content', ok: true },
] },
{ path: 'MF/7F20/6F3A', name: 'EF.X', status: 'fail', checks: [
{ label: 'fileType', ok: true }, { label: 'fileSize', ok: false, expected: 4, actual: 99 }, { label: 'content', ok: true },
] },
{ path: 'MF/7F20/6F4E', name: 'EF.Y', status: 'fail', checks: [
{ label: 'content.rec6', ok: false, expected: 'BB', actual: 'XX' },
{ label: 'content.rec1', ok: true }, { label: 'content.rec2', ok: true }, { label: 'content.rec5', ok: true },
], recordsMatched: [1, 2, 5] },
]);
assert.ok(html.includes('filetype and size, contents'));
assert.ok(html.includes('filetype ✓, size ✗, contents ✓'));
assert.ok(html.includes('matching records'));
assert.ok(html.includes('1-2, 5'));
delete global.t;
});
test('profilerRawDataCheck identifies raw-data checks', () => {
assert.strictEqual(profilerRawDataCheck({ label: 'fci' }), true);
assert.strictEqual(profilerRawDataCheck({ label: 'content', expected: 'AABBCC' }), true);
assert.strictEqual(profilerRawDataCheck({ label: 'content.rec3' }), true);
assert.strictEqual(profilerRawDataCheck({ label: 'content', expected: 'readable' }), false);
assert.strictEqual(profilerRawDataCheck({ label: 'content.records' }), false);
assert.strictEqual(profilerRawDataCheck({ label: 'fileSize' }), false);
});
test('profilerRenderReport renders raw-data mismatches as aligned readonly fields', () => {
global.t = s => s;
global.pysimCustomFiles = [];
const html = profilerRenderReport([
{ path: 'MF/6F07', name: 'EF.IMSI', status: 'fail', checks: [
{ label: 'fci', ok: false, expected: '621082024021', actual: '621082024022' },
] },
{ path: 'MF/7F20/6F3A', name: 'EF.X', status: 'fail', checks: [
{ label: 'content.records', ok: false, expected: '30 records', actual: '10 records' },
] },
]);
const fciBlock = html.split('</div>').find(s => s.includes('621082024021'));
assert.ok(html.includes('readonly'));
assert.ok((html.match(/readonly/g) || []).length >= 2);
assert.ok(html.includes('font-mono'));
assert.ok(html.includes('value="621082024021"'));
assert.ok(html.includes('value="621082024022"'));
assert.ok(html.includes('w-24 text-right'));
// non-raw check stays inline
assert.ok(html.includes('content.records: expected'));
assert.ok(!fciBlock.includes(': expected'));
delete global.t;
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-otaman-server"
version = "1.9.26"
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.
+2 -1
View File
@@ -18,7 +18,7 @@ from osmocom.construct import GsmOrUcs2Adapter
from osmocom.tlv import BER_TLV_IE
VERSION = '1.9.26'
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)