Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a5cccee17c | |||
| 2a559371ae | |||
| e0e86e31af | |||
| 2ec7a2d6c0 | |||
| 26f0063696 | |||
| ddc6cb8f9d | |||
| 85a66af7a5 | |||
| 92271d6726 |
@@ -490,6 +490,9 @@ Browse the UICC filesystem in a tree view. Files are shown with names, FIDs, and
|
|||||||
- **Read** — reads the selected file (auto-detects transparent vs record files)
|
- **Read** — reads the selected file (auto-detects transparent vs record files)
|
||||||
- **Edit** — switch to edit mode, modify hex data, click **Save** to write back
|
- **Edit** — switch to edit mode, modify hex data, click **Save** to write back
|
||||||
- **Raw / Decoded** — toggle between hex dump and pysim-decoded JSON view
|
- **Raw / Decoded** — toggle between hex dump and pysim-decoded JSON view
|
||||||
|
- Selecting a file shows its FID, file type, size / record layout and the decoded FCI above the contents
|
||||||
|
- Missing files are shown in red (✗); a present but empty DF shows `(empty)`
|
||||||
|
- **Probe all files** — walks the whole tree (incl. custom files), marks every entry present/absent with *N / total* progress, stoppable, and ends with a summary; browsing itself stays lazy
|
||||||
|
|
||||||
### Command Hints
|
### Command Hints
|
||||||
|
|
||||||
|
|||||||
@@ -464,6 +464,9 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
|
|||||||
- **Read** — чтение файла (автоопределение transparent/record)
|
- **Read** — чтение файла (автоопределение transparent/record)
|
||||||
- **Edit** — режим редактирования, измените hex-данные и нажмите **Save** для записи
|
- **Edit** — режим редактирования, измените hex-данные и нажмите **Save** для записи
|
||||||
- **Raw / Decoded** — переключение между hex-дампом и декодированным JSON
|
- **Raw / Decoded** — переключение между hex-дампом и декодированным JSON
|
||||||
|
- При выборе файла над содержимым показываются FID, тип файла, размер / структура записей и декодированный FCI
|
||||||
|
- Отсутствующие файлы показаны красным (✗); существующий пустой DF — `(пусто)`
|
||||||
|
- **Проверить все файлы** — обход всего дерева (включая пользовательские) с пометкой «есть/нет», прогрессом *N / всего*, возможностью остановки и сводкой в конце; сам просмотр остаётся ленивым
|
||||||
|
|
||||||
### Подсказки команд
|
### Подсказки команд
|
||||||
|
|
||||||
|
|||||||
+18
-5
@@ -55,7 +55,7 @@ Returns server version for compatibility checking.
|
|||||||
|
|
||||||
**Example response:**
|
**Example response:**
|
||||||
```json
|
```json
|
||||||
{"version": "2.0.0"}
|
{"version": "2.1.0"}
|
||||||
```
|
```
|
||||||
|
|
||||||
### `GET /api/status`
|
### `GET /api/status`
|
||||||
@@ -272,7 +272,7 @@ Returns the current STK session state.
|
|||||||
Read file content. Auto-detects transparent vs record files.
|
Read file content. Auto-detects transparent vs record files.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"name": "EF.ICCID", "fid": "2FE2", "parent_sel": "3F00", "mode": "raw"}
|
{"name": "EF.ICCID", "fid": "2FE2", "parent_path": ["MF"], "mode": "raw"}
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns transparent data:
|
Returns transparent data:
|
||||||
@@ -298,12 +298,12 @@ it for snapshot timing statistics. Other commands are not reported.
|
|||||||
Write raw hex data to a file.
|
Write raw hex data to a file.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"name": "EF.ICCID", "fid": "2FE2", "data": "A0A1A2...", "parent_sel": "3F00"}
|
{"name": "EF.ICCID", "fid": "2FE2", "data": "A0A1A2...", "parent_path": ["MF"]}
|
||||||
```
|
```
|
||||||
|
|
||||||
For record files:
|
For record files:
|
||||||
```json
|
```json
|
||||||
{"name": "EF.ADN", "fid": "6F3A", "data": "A0A1...", "record_nr": 1, "parent_sel": "7F10"}
|
{"name": "EF.ADN", "fid": "6F3A", "data": "A0A1...", "record_nr": 1, "parent_path": ["MF", "7F10"]}
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -316,9 +316,19 @@ Returns:
|
|||||||
Select a file by name or FID, with optional parent selection.
|
Select a file by name or FID, with optional parent selection.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"name": "EF.ICCID", "fid": "2FE2", "parent_sel": "3F00"}
|
{"name": "EF.ICCID", "fid": "2FE2", "parent_path": ["MF"]}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`parent_path` lists the path segments from MF to the parent (ADF names or
|
||||||
|
FIDs); the legacy single-segment `parent_sel` is still accepted but is only
|
||||||
|
unambiguous for ADFs. Resolution is strictly parent-scoped: model-known files
|
||||||
|
are selected through the requested parent only (pySim `select_file()`), never
|
||||||
|
via pySim's global selectables or its `probe_file()` model injection, so a
|
||||||
|
same-FID file under another parent is never picked and the filesystem model
|
||||||
|
is not modified. `allow_probe: true` (PWA custom files) additionally allows a
|
||||||
|
model-unknown 4-hex FID to be selected directly; any temporary model object
|
||||||
|
created for it is detached again before the response is sent.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
```json
|
```json
|
||||||
{"name": "EF.ICCID", "fid": "2FE2", "file_type": "transparent",
|
{"name": "EF.ICCID", "fid": "2FE2", "file_type": "transparent",
|
||||||
@@ -340,6 +350,9 @@ Get directory listing with typed children.
|
|||||||
{"name": "MF", "fid": "3F00"}
|
{"name": "MF", "fid": "3F00"}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Use `parent_path` (or the legacy `parent_sel`) to list a subdirectory, e.g.
|
||||||
|
`{"name": "DF.GSM-ACCESS", "fid": "5F3B", "parent_path": ["MF", "ADF.USIM"]}`.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
```json
|
```json
|
||||||
{"exists": true, "name": "MF", "fid": "3F00", "file_type": "df", "children": [{"name": "EF.ICCID", "fid": "2fe2", "isDir": false}]}
|
{"exists": true, "name": "MF", "fid": "3F00", "file_type": "df", "children": [{"name": "EF.ICCID", "fid": "2fe2", "isDir": false}]}
|
||||||
|
|||||||
@@ -317,11 +317,12 @@
|
|||||||
<p class="mb-3">Подключение к локальному <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> для работы с картой: введите URL сервера (по умолчанию <code class="font-mono text-sm">http://127.0.0.1:8080</code>) и нажмите <strong>Подключиться</strong>. Область статуса показывает состояние ридера/карты, а <strong>Подключить карту</strong> (пере)инициализирует карту после вставки. Подвкладки: <strong>Файловый менеджер</strong>, <strong>Командная строка pySim</strong> и <strong>Отправка APDU</strong>. <strong>«Профайлер»</strong> и <strong>«Симулятор телефона»</strong> — отдельные вкладки верхнего уровня.</p>
|
<p class="mb-3">Подключение к локальному <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> для работы с картой: введите URL сервера (по умолчанию <code class="font-mono text-sm">http://127.0.0.1:8080</code>) и нажмите <strong>Подключиться</strong>. Область статуса показывает состояние ридера/карты, а <strong>Подключить карту</strong> (пере)инициализирует карту после вставки. Подвкладки: <strong>Файловый менеджер</strong>, <strong>Командная строка pySim</strong> и <strong>Отправка APDU</strong>. <strong>«Профайлер»</strong> и <strong>«Симулятор телефона»</strong> — отдельные вкладки верхнего уровня.</p>
|
||||||
|
|
||||||
<h3 id="file-manager" class="text-lg font-medium mb-2">4.1 Файловый менеджер</h3>
|
<h3 id="file-manager" class="text-lg font-medium mb-2">4.1 Файловый менеджер</h3>
|
||||||
<p class="text-sm mb-2">Дерево файловой системы отображается слева; выбор файла открывает панель деталей справа. Элементы сгруппированы: DF выше EF, сортировка по <strong>FID</strong> или символьному <strong>имени</strong> (пиллы над деревом; выбор сохраняется в <code class="font-mono text-sm">localStorage</code>).</p>
|
<p class="text-sm mb-2">Дерево файловой системы отображается слева; выбор файла открывает панель деталей справа. Элементы сгруппированы: DF выше EF, сортировка по <strong>FID</strong> или символьному <strong>имени</strong> (пиллы над деревом; выбор сохраняется в <code class="font-mono text-sm">localStorage</code>). При выборе файла над содержимым также показываются FID, тип файла, размер / структура записей и декодированный FCI.</p>
|
||||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||||
<li><strong>Прочитать</strong> — чтение файла (автоопределение transparent/record)</li>
|
<li><strong>Прочитать</strong> — чтение файла (автоопределение transparent/record)</li>
|
||||||
<li><strong>Редактировать</strong> — изменение hex-данных, <strong>Сохранить</strong> для записи (или <strong>Отмена</strong>)</li>
|
<li><strong>Редактировать</strong> — изменение hex-данных, <strong>Сохранить</strong> для записи (или <strong>Отмена</strong>)</li>
|
||||||
<li><strong>Данные как на карте / Декодированные данные</strong> — переключение между hex-дампом и декодированным JSON</li>
|
<li><strong>Данные как на карте / Декодированные данные</strong> — переключение между hex-дампом и декодированным JSON</li>
|
||||||
|
<li><strong>Проверить все файлы</strong> — обход всего дерева (включая пользовательские файлы) с пометкой каждого элемента: есть (обычный вид) или нет (красный ✗, без стрелки разворачивания); существующие пустые DF показывают <code class="font-mono text-sm">(пусто)</code>. Отображается прогресс <em>N / всего</em>, обход можно остановить; в конце — сводка «есть/нет». Файлы проверяются только при разворачивании или проверке — просмотр остаётся ленивым.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3 id="pysim-cmdline" class="text-lg font-medium mb-2">4.2 Командная строка pySim</h3>
|
<h3 id="pysim-cmdline" class="text-lg font-medium mb-2">4.2 Командная строка pySim</h3>
|
||||||
|
|||||||
+2
-1
@@ -317,11 +317,12 @@
|
|||||||
<p class="mb-3">Connects to a local <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> for live card operations: enter the server URL (default <code class="font-mono text-sm">http://127.0.0.1:8080</code>) and press <strong>Connect</strong>. The status area shows the reader/card state, and <strong>Equip card</strong> (re)initializes the card after insertion. Sub-tabs: <strong>File manager</strong>, <strong>pySim command line</strong>, and <strong>Raw APDU</strong>. The <strong>Profiler</strong> and <strong>Phone simulator</strong> are separate top-level tabs.</p>
|
<p class="mb-3">Connects to a local <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> for live card operations: enter the server URL (default <code class="font-mono text-sm">http://127.0.0.1:8080</code>) and press <strong>Connect</strong>. The status area shows the reader/card state, and <strong>Equip card</strong> (re)initializes the card after insertion. Sub-tabs: <strong>File manager</strong>, <strong>pySim command line</strong>, and <strong>Raw APDU</strong>. The <strong>Profiler</strong> and <strong>Phone simulator</strong> are separate top-level tabs.</p>
|
||||||
|
|
||||||
<h3 id="file-manager" class="text-lg font-medium mb-2">4.1 File manager</h3>
|
<h3 id="file-manager" class="text-lg font-medium mb-2">4.1 File manager</h3>
|
||||||
<p class="text-sm mb-2">The file system tree is displayed on the left; selecting a file opens its detail pane on the right. Entries are grouped with DFs above EFs and sorted by <strong>FID</strong> or symbolic <strong>Name</strong> (pills above the tree; the choice is remembered in <code class="font-mono text-sm">localStorage</code>).</p>
|
<p class="text-sm mb-2">The file system tree is displayed on the left; selecting a file opens its detail pane on the right. Entries are grouped with DFs above EFs and sorted by <strong>FID</strong> or symbolic <strong>Name</strong> (pills above the tree; the choice is remembered in <code class="font-mono text-sm">localStorage</code>). Selecting a file also shows its FID, file type, size / record layout and the decoded FCI above the content pane.</p>
|
||||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||||
<li><strong>Read</strong> — reads the selected file (auto-detects transparent vs record files)</li>
|
<li><strong>Read</strong> — reads the selected file (auto-detects transparent vs record files)</li>
|
||||||
<li><strong>Edit</strong> — modify hex data, <strong>Save</strong> to write back (or <strong>Cancel</strong>)</li>
|
<li><strong>Edit</strong> — modify hex data, <strong>Save</strong> to write back (or <strong>Cancel</strong>)</li>
|
||||||
<li><strong>Raw / Decoded</strong> — toggle between hex dump and pySim-decoded JSON</li>
|
<li><strong>Raw / Decoded</strong> — toggle between hex dump and pySim-decoded JSON</li>
|
||||||
|
<li><strong>Probe all files</strong> — walks the whole tree (including custom files) and marks every entry present (normal) or absent (red ✗, no expand arrow); empty-but-present DFs show <code class="font-mono text-sm">(empty)</code>. Shows progress <em>N / total</em>, can be stopped, and finishes with a present/absent summary. Files are only verified when expanded or probed — browsing stays lazy.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3 id="pysim-cmdline" class="text-lg font-medium mb-2">4.2 pySim command line</h3>
|
<h3 id="pysim-cmdline" class="text-lg font-medium mb-2">4.2 pySim command line</h3>
|
||||||
|
|||||||
+155
-61
@@ -18,7 +18,7 @@
|
|||||||
<div class="max-w-7xl mx-auto px-6 py-2">
|
<div class="max-w-7xl mx-auto px-6 py-2">
|
||||||
|
|
||||||
<div class="flex items-center justify-between mb-3">
|
<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">v2.0.0</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">v2.1.0</span></h1>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<span id="state-indicator" class="flex items-center select-none" style="cursor:default" title="Connecting...">
|
<span id="state-indicator" class="flex items-center select-none" style="cursor:default" title="Connecting...">
|
||||||
<span id="state-indicator-dot" class="text-xs text-gray-400" title="Connecting...">●</span>
|
<span id="state-indicator-dot" class="text-xs text-gray-400" title="Connecting...">●</span>
|
||||||
@@ -765,7 +765,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="profiler-list-custom" class="hidden">
|
<div id="profiler-list-custom" class="hidden">
|
||||||
<div class="mb-2 text-xs font-mono text-gray-600 dark:text-slate-400" data-l10n="Add known file paths and aliases to the file tree.">Add known file paths and aliases to the file tree.</div>
|
<div class="mb-2 text-xs font-mono text-gray-600 dark:text-slate-400" data-l10n="Custom files are added in file manager tree and included in card scan.">Custom files are added in file manager tree and included in card scan.</div>
|
||||||
<div class="flex gap-2 items-center mb-2">
|
<div class="flex gap-2 items-center mb-2">
|
||||||
<input id="pysim-cf-path" class="font-mono flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2.5 dark:bg-slate-800" placeholder="3F00/6F46" onkeydown="if(event.key==='Enter')pysimCustomSubmit()">
|
<input id="pysim-cf-path" class="font-mono flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2.5 dark:bg-slate-800" placeholder="3F00/6F46" onkeydown="if(event.key==='Enter')pysimCustomSubmit()">
|
||||||
<input id="pysim-cf-name" class="font-mono w-48 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2.5 dark:bg-slate-800" placeholder="EF.SPN" onkeydown="if(event.key==='Enter')pysimCustomSubmit()">
|
<input id="pysim-cf-name" class="font-mono w-48 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2.5 dark:bg-slate-800" placeholder="EF.SPN" onkeydown="if(event.key==='Enter')pysimCustomSubmit()">
|
||||||
@@ -860,11 +860,13 @@
|
|||||||
<span class="pysim-fs-sort-pill px-2 py-0.5 rounded cursor-pointer bg-blue-600 text-white" data-fs-sort="fid" onclick="pysimFsSetSort('fid')">FID</span>
|
<span class="pysim-fs-sort-pill px-2 py-0.5 rounded cursor-pointer bg-blue-600 text-white" data-fs-sort="fid" onclick="pysimFsSetSort('fid')">FID</span>
|
||||||
<span class="pysim-fs-sort-pill px-2 py-0.5 rounded cursor-pointer bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300" data-fs-sort="name" onclick="pysimFsSetSort('name')" data-l10n="Name">Name</span>
|
<span class="pysim-fs-sort-pill px-2 py-0.5 rounded cursor-pointer bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300" data-fs-sort="name" onclick="pysimFsSetSort('name')" data-l10n="Name">Name</span>
|
||||||
</div>
|
</div>
|
||||||
|
<button id="pysim-fs-probe-btn" data-needs="card" onclick="pysimFsProbeAll()" class="w-full mb-1 px-2 py-0.5 rounded bg-gray-600 text-white hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Probe all files">Probe all files</button>
|
||||||
|
<div id="pysim-fs-probe-status" class="hidden mb-1 text-gray-500 dark:text-slate-400"></div>
|
||||||
<div id="pysim-fs-tree"></div>
|
<div id="pysim-fs-tree"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div id="pysim-fs-detail" class="hidden">
|
<div id="pysim-fs-detail" class="hidden">
|
||||||
<div class="text-xs font-mono mb-1"><span data-l10n="File:">File:</span> <b id="pysim-fs-filename"></b></div>
|
<div id="pysim-fs-info" class="mb-1"></div>
|
||||||
<div id="pysim-fs-content" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1.5 bg-gray-100 dark:bg-slate-800 min-h-20 overflow-y-auto"></div>
|
<div id="pysim-fs-content" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1.5 bg-gray-100 dark:bg-slate-800 min-h-20 overflow-y-auto"></div>
|
||||||
<div class="flex gap-2 mt-1 items-center">
|
<div class="flex gap-2 mt-1 items-center">
|
||||||
<button data-needs="card" onclick="pysimFsRead()" id="pysim-fs-read-btn" class="px-2 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Read">Read</button>
|
<button data-needs="card" onclick="pysimFsRead()" id="pysim-fs-read-btn" class="px-2 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Read">Read</button>
|
||||||
@@ -5844,6 +5846,18 @@ function getParentSel(node) {
|
|||||||
return p.name;
|
return p.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getParentPath(node) {
|
||||||
|
const segs = [];
|
||||||
|
let n = node && node.parent;
|
||||||
|
while (n) {
|
||||||
|
if (n.name === 'MF') segs.unshift('MF');
|
||||||
|
else if (n.name && n.name.startsWith('ADF.')) segs.unshift(n.name);
|
||||||
|
else segs.unshift(n.fid || n.name);
|
||||||
|
n = n.parent;
|
||||||
|
}
|
||||||
|
return segs;
|
||||||
|
}
|
||||||
|
|
||||||
function pysimCustomInject(node) {
|
function pysimCustomInject(node) {
|
||||||
if (!node || !node.fid) return;
|
if (!node || !node.fid) return;
|
||||||
const pfid = node.fid.toLowerCase();
|
const pfid = node.fid.toLowerCase();
|
||||||
@@ -5938,16 +5952,11 @@ async function pysimFsRefresh() {
|
|||||||
async function pysimFsLoadChildren(node) {
|
async function pysimFsLoadChildren(node) {
|
||||||
if (node.children) return;
|
if (node.children) return;
|
||||||
try {
|
try {
|
||||||
let body = { name: node.name, fid: node.fid };
|
const body = { name: node.name, fid: node.fid, parent_sel: getParentSel(node), parent_path: getParentPath(node) };
|
||||||
const parentSel = getParentSel(node);
|
if (node.custom) body.allow_probe = true;
|
||||||
if (parentSel) body.parent_sel = parentSel;
|
const data = await pysimFetch('/api/tree', body);
|
||||||
let data = await pysimFetch('/api/tree', body);
|
if (!data || data.exists === false || data.success === false || data.error) {
|
||||||
if (!data || data.exists === false) {
|
throw new Error((data && data.error) || 'File not found');
|
||||||
body = { name: node.name, fid: node.fid };
|
|
||||||
data = await pysimFetch('/api/tree', body);
|
|
||||||
}
|
|
||||||
if (!data || data.exists === false) {
|
|
||||||
throw new Error('File not found');
|
|
||||||
}
|
}
|
||||||
node.children = (data.children || []).map(c => ({
|
node.children = (data.children || []).map(c => ({
|
||||||
name: c.name,
|
name: c.name,
|
||||||
@@ -5963,11 +5972,88 @@ async function pysimFsLoadChildren(node) {
|
|||||||
pysimCustomInject(node);
|
pysimCustomInject(node);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
node.exists = false;
|
node.exists = false;
|
||||||
node.children = [];
|
|
||||||
pysimFsRenderTree();
|
pysimFsRenderTree();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _pysimFsProbe = null;
|
||||||
|
|
||||||
|
function pysimFsSelectBody(node) {
|
||||||
|
const body = { name: node.name, fid: node.fid, parent_sel: getParentSel(node), parent_path: getParentPath(node) };
|
||||||
|
if (node.custom) body.allow_probe = true;
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pysimFsProbeUi(text, stopLabel) {
|
||||||
|
const status = document.getElementById('pysim-fs-probe-status');
|
||||||
|
if (status) {
|
||||||
|
status.textContent = text;
|
||||||
|
status.classList.toggle('hidden', !text);
|
||||||
|
}
|
||||||
|
const btn = document.getElementById('pysim-fs-probe-btn');
|
||||||
|
if (btn) {
|
||||||
|
btn.textContent = stopLabel ? t('Stop') : t('Probe all files');
|
||||||
|
btn.setAttribute('data-l10n', stopLabel ? 'Stop' : 'Probe all files');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pysimFsProbeAll() {
|
||||||
|
if (_pysimFsProbe) { _pysimFsProbe.stop = true; return; }
|
||||||
|
if (!pysimFsTreeRoot) return;
|
||||||
|
const probe = { stop: false, dirs: 0, done: 0, total: 0, present: 0, absent: 0 };
|
||||||
|
_pysimFsProbe = probe;
|
||||||
|
const started = Date.now();
|
||||||
|
let error = null;
|
||||||
|
const count = exists => { if (exists) probe.present++; else probe.absent++; };
|
||||||
|
try {
|
||||||
|
if (!pysimFsTreeRoot.children) await pysimFsLoadChildren(pysimFsTreeRoot);
|
||||||
|
const discover = async node => {
|
||||||
|
for (const child of pysimFsSortChildren(node.children, pysimFsSort)) {
|
||||||
|
if (probe.stop) return;
|
||||||
|
if (!child.isDir) continue;
|
||||||
|
await pysimFsLoadChildren(child);
|
||||||
|
const exists = child.exists === true;
|
||||||
|
probe.dirs++;
|
||||||
|
count(exists);
|
||||||
|
pysimFsRenderTree();
|
||||||
|
pysimFsProbeUi(t('Probing:') + ' ' + t('discovering') + ' (' + probe.dirs + ')', true);
|
||||||
|
if (exists) await discover(child);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await discover(pysimFsTreeRoot);
|
||||||
|
const files = [];
|
||||||
|
const collect = node => {
|
||||||
|
for (const child of (node.children || [])) {
|
||||||
|
if (child.isDir) { if (child.exists === true) collect(child); }
|
||||||
|
else files.push(child);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
collect(pysimFsTreeRoot);
|
||||||
|
probe.total = files.length + probe.dirs;
|
||||||
|
probe.done = probe.dirs;
|
||||||
|
for (const file of files) {
|
||||||
|
if (probe.stop) break;
|
||||||
|
const data = await pysimFetch('/api/select', pysimFsSelectBody(file));
|
||||||
|
const exists = !!(data && data.exists !== false && !data.error);
|
||||||
|
file.exists = exists;
|
||||||
|
count(exists);
|
||||||
|
probe.done++;
|
||||||
|
pysimFsProbeUi(t('Probing:') + ' ' + probe.done + '/' + probe.total, true);
|
||||||
|
if (probe.done % 10 === 0 || probe.done === probe.total) pysimFsRenderTree();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = (e && e.message) ? e.message : String(e);
|
||||||
|
}
|
||||||
|
pysimFsRenderTree();
|
||||||
|
const secs = ((Date.now() - started) / 1000).toFixed(1);
|
||||||
|
let summary = t('Probed:') + ' ' + probe.done + '/' + probe.total + ' ' + t('files') + ' — '
|
||||||
|
+ probe.present + ' ' + t('present') + ', ' + probe.absent + ' ' + t('absent') + ' (' + secs + 's)';
|
||||||
|
if (error) summary = t('Error') + ': ' + error + ' — ' + summary;
|
||||||
|
else if (probe.stop) summary = t('Stopped') + ' — ' + summary;
|
||||||
|
_pysimFsProbe = null;
|
||||||
|
pysimFsProbeUi(summary, false);
|
||||||
|
}
|
||||||
|
|
||||||
function pysimFsRenderTree() {
|
function pysimFsRenderTree() {
|
||||||
const el = document.getElementById('pysim-fs-tree');
|
const el = document.getElementById('pysim-fs-tree');
|
||||||
if (!pysimFsTreeRoot) { el.innerHTML = '(loading)'; return; }
|
if (!pysimFsTreeRoot) { el.innerHTML = '(loading)'; return; }
|
||||||
@@ -6004,8 +6090,12 @@ function pysimFsRenderNode(node, depth) {
|
|||||||
}
|
}
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
if (isDir && node.children && node.expanded) {
|
if (isDir && node.children && node.expanded) {
|
||||||
for (const child of pysimFsSortChildren(node.children, pysimFsSort)) {
|
if (!node.children.length) {
|
||||||
html += pysimFsRenderNode(child, depth + 1);
|
html += '<div class="text-gray-400 dark:text-slate-500">' + pad + ' ' + esc(t('(empty)')) + '</div>';
|
||||||
|
} else {
|
||||||
|
for (const child of pysimFsSortChildren(node.children, pysimFsSort)) {
|
||||||
|
html += pysimFsRenderNode(child, depth + 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return html;
|
return html;
|
||||||
@@ -6034,28 +6124,38 @@ async function pysimFsToggleDir(name) {
|
|||||||
if (node.expanded) pysimRefresh();
|
if (node.expanded) pysimRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let pysimFsSelected = null;
|
||||||
|
|
||||||
|
function pysimFsInfoHtml(sel) {
|
||||||
|
if (!sel) return '';
|
||||||
|
const num = v => (v === null || v === undefined) ? null : String(v);
|
||||||
|
const attrs = [];
|
||||||
|
if (sel.fid) attrs.push('FID: ' + esc(String(sel.fid).toUpperCase()));
|
||||||
|
if (sel.file_type) attrs.push(esc(t('File type')) + ': ' + esc(sel.file_type));
|
||||||
|
if (num(sel.file_size) !== null) attrs.push(esc(t('Size')) + ': ' + esc(num(sel.file_size)));
|
||||||
|
if (num(sel.record_len) !== null) attrs.push(esc(t('Record length')) + ': ' + esc(num(sel.record_len)));
|
||||||
|
if (num(sel.num_of_rec) !== null) attrs.push(esc(t('Record count')) + ': ' + esc(num(sel.num_of_rec)));
|
||||||
|
let html = '';
|
||||||
|
if (attrs.length) html += '<div class="text-xs font-mono text-gray-500 dark:text-slate-400">' + attrs.join(' · ') + '</div>';
|
||||||
|
if (sel.fci_hex) {
|
||||||
|
html += '<div class="text-xs font-mono leading-relaxed text-gray-600 dark:text-slate-400 break-all mt-0.5">' + profilerFciPreviewItems(sel.fci_hex) + '</div>';
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
async function pysimFsClickFile(name) {
|
async function pysimFsClickFile(name) {
|
||||||
pysimFsResetEdit();
|
pysimFsResetEdit();
|
||||||
pysimFsSetMode('raw');
|
pysimFsSetMode('raw');
|
||||||
const node = pysimFsFindNode(name, pysimFsTreeRoot);
|
const node = pysimFsFindNode(name, pysimFsTreeRoot);
|
||||||
let body = { name: name, fid: node ? node.fid : null };
|
const sel = await pysimFetch('/api/select', pysimFsSelectBody(node || { name: name, fid: null }));
|
||||||
const parentSel = getParentSel(node);
|
const exists = !!(sel && sel.exists !== false && !sel.error);
|
||||||
if (parentSel) body.parent_sel = parentSel;
|
|
||||||
let sel = await pysimFetch('/api/select', body);
|
|
||||||
if (!sel || sel.exists === false) {
|
|
||||||
body = { name: name, fid: node ? node.fid : null };
|
|
||||||
sel = await pysimFetch('/api/select', body);
|
|
||||||
}
|
|
||||||
const exists = sel && sel.exists !== false;
|
|
||||||
if (node) {
|
if (node) {
|
||||||
node.exists = exists;
|
node.exists = exists;
|
||||||
if (!exists) node.children = [];
|
if (!exists) node.children = [];
|
||||||
pysimFsRenderTree();
|
pysimFsRenderTree();
|
||||||
}
|
}
|
||||||
const ft = exists ? sel.file_type : null;
|
pysimFsSelected = name;
|
||||||
const label = ({ transparent: 'TR', linear_fixed: 'LF', cyclic: 'CY', ber_tlv: 'BT', df: 'DF' })[ft] || '';
|
document.getElementById('pysim-fs-info').innerHTML = exists ? pysimFsInfoHtml(sel) : '';
|
||||||
const prefix = exists ? '' : '✗ ';
|
|
||||||
document.getElementById('pysim-fs-filename').textContent = prefix + name + (label ? ' [' + label + ']' : '');
|
|
||||||
document.getElementById('pysim-fs-content').innerHTML = '';
|
document.getElementById('pysim-fs-content').innerHTML = '';
|
||||||
document.getElementById('pysim-fs-status').textContent = exists ? '' : '✗ File not found on card';
|
document.getElementById('pysim-fs-status').textContent = exists ? '' : '✗ File not found on card';
|
||||||
if (exists) {
|
if (exists) {
|
||||||
@@ -6067,21 +6167,16 @@ async function pysimFsClickFile(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function pysimFsRead() {
|
async function pysimFsRead() {
|
||||||
const rawName = document.getElementById('pysim-fs-filename').textContent.replace(/ \[.*?\]$/, '').replace(/^✗ /, '');
|
const rawName = pysimFsSelected;
|
||||||
const out = document.getElementById('pysim-fs-content');
|
const out = document.getElementById('pysim-fs-content');
|
||||||
const statusEl = document.getElementById('pysim-fs-status');
|
const statusEl = document.getElementById('pysim-fs-status');
|
||||||
out.innerHTML = '';
|
out.innerHTML = '';
|
||||||
statusEl.textContent = 'Loading...';
|
statusEl.textContent = 'Loading...';
|
||||||
try {
|
try {
|
||||||
const node = pysimFsFindNode(rawName, pysimFsTreeRoot);
|
const node = pysimFsFindNode(rawName, pysimFsTreeRoot);
|
||||||
let body = { name: rawName, fid: node ? node.fid : null, mode: pysimFsDecodedMode ? 'decoded' : 'raw' };
|
const body = pysimFsSelectBody(node || { name: rawName, fid: null });
|
||||||
const parentSel = getParentSel(node);
|
body.mode = pysimFsDecodedMode ? 'decoded' : 'raw';
|
||||||
if (parentSel) body.parent_sel = parentSel;
|
const data = await pysimFetch('/api/read', body);
|
||||||
let data = await pysimFetch('/api/read', body);
|
|
||||||
if (!data.success) {
|
|
||||||
body = { name: rawName, fid: node ? node.fid : null, mode: pysimFsDecodedMode ? 'decoded' : 'raw' };
|
|
||||||
data = await pysimFetch('/api/read', body);
|
|
||||||
}
|
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
statusEl.textContent = 'SW: ' + (data.sw || '?') + ' — ' + (data.error || 'Error');
|
statusEl.textContent = 'SW: ' + (data.sw || '?') + ' — ' + (data.error || 'Error');
|
||||||
return;
|
return;
|
||||||
@@ -6107,18 +6202,6 @@ async function pysimFsRead() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pysimFsSelect() {
|
|
||||||
const name = document.getElementById('pysim-fs-filename').textContent;
|
|
||||||
const node = pysimFsFindNode(name, pysimFsTreeRoot);
|
|
||||||
if (node && node.fid) {
|
|
||||||
await pysimFetch('/api/command', { cmd: 'select ' + node.fid });
|
|
||||||
} else {
|
|
||||||
await pysimFetch('/api/command', { cmd: 'select ' + name });
|
|
||||||
}
|
|
||||||
pysimFsRefresh();
|
|
||||||
pysimRefresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
let pysimFsEditMode = false;
|
let pysimFsEditMode = false;
|
||||||
let pysimFsEditData = null;
|
let pysimFsEditData = null;
|
||||||
|
|
||||||
@@ -6152,7 +6235,7 @@ function pysimFsCancel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function pysimFsSave() {
|
async function pysimFsSave() {
|
||||||
const rawName = document.getElementById('pysim-fs-filename').textContent.replace(/ \[.*?\]$/, '').replace(/^✗ /, '');
|
const rawName = pysimFsSelected;
|
||||||
const statusEl = document.getElementById('pysim-fs-status');
|
const statusEl = document.getElementById('pysim-fs-status');
|
||||||
const out = document.getElementById('pysim-fs-content');
|
const out = document.getElementById('pysim-fs-content');
|
||||||
const node = pysimFsFindNode(rawName, pysimFsTreeRoot);
|
const node = pysimFsFindNode(rawName, pysimFsTreeRoot);
|
||||||
@@ -6161,9 +6244,8 @@ async function pysimFsSave() {
|
|||||||
const textareas = out.querySelectorAll('textarea');
|
const textareas = out.querySelectorAll('textarea');
|
||||||
if (textareas.length === 1) {
|
if (textareas.length === 1) {
|
||||||
// Transparent file
|
// Transparent file
|
||||||
const body = { name: rawName, fid: node ? node.fid : null, data: textareas[0].value.replace(/[^0-9a-fA-F]/g, '') };
|
const body = pysimFsSelectBody(node || { name: rawName, fid: null });
|
||||||
const parentSel = getParentSel(node);
|
body.data = textareas[0].value.replace(/[^0-9a-fA-F]/g, '');
|
||||||
if (parentSel) body.parent_sel = parentSel;
|
|
||||||
const data = await pysimFetch('/api/write', body);
|
const data = await pysimFetch('/api/write', body);
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
statusEl.textContent = 'SW: ' + data.sw + ' OK';
|
statusEl.textContent = 'SW: ' + data.sw + ' OK';
|
||||||
@@ -6183,9 +6265,9 @@ async function pysimFsSave() {
|
|||||||
wroteAny = true;
|
wroteAny = true;
|
||||||
const num = row?.querySelector('span')?.textContent;
|
const num = row?.querySelector('span')?.textContent;
|
||||||
if (num) {
|
if (num) {
|
||||||
const body = { name: rawName, fid: node ? node.fid : null, data: inp.value.replace(/[^0-9a-fA-F]/g, ''), record_nr: parseInt(num) };
|
const body = pysimFsSelectBody(node || { name: rawName, fid: null });
|
||||||
const parentSel = getParentSel(node);
|
body.data = inp.value.replace(/[^0-9a-fA-F]/g, '');
|
||||||
if (parentSel) body.parent_sel = parentSel;
|
body.record_nr = parseInt(num);
|
||||||
const data = await pysimFetch('/api/write', body);
|
const data = await pysimFetch('/api/write', body);
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
statusEl.textContent = 'SW: ' + (data.sw || '?') + ' — ' + (data.error || 'Error') + ' (record ' + num + ')';
|
statusEl.textContent = 'SW: ' + (data.sw || '?') + ' — ' + (data.error || 'Error') + ' (record ' + num + ')';
|
||||||
@@ -7671,6 +7753,7 @@ async function profilerScanCard(ignoreFids, ignoreNames, fciMode, onProgress, ma
|
|||||||
// dir: { name, fid, parentSel (to select this dir), pathPrefix (rule-path segs for its children) }
|
// dir: { name, fid, parentSel (to select this dir), pathPrefix (rule-path segs for its children) }
|
||||||
async function walkDir(dir) {
|
async function walkDir(dir) {
|
||||||
let body = { name: dir.name, fid: dir.fid };
|
let body = { name: dir.name, fid: dir.fid };
|
||||||
|
if (dir.parentPath && dir.parentPath.length) body.parent_path = dir.parentPath;
|
||||||
if (dir.parentSel) body.parent_sel = dir.parentSel;
|
if (dir.parentSel) body.parent_sel = dir.parentSel;
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
@@ -7686,7 +7769,8 @@ async function profilerScanCard(ignoreFids, ignoreNames, fciMode, onProgress, ma
|
|||||||
// ADF roots use the AID, not the generic ADF fid
|
// ADF roots use the AID, not the generic ADF fid
|
||||||
const childPrefix = c.aid ? [c.aid.toUpperCase()]
|
const childPrefix = c.aid ? [c.aid.toUpperCase()]
|
||||||
: dir.pathPrefix.concat(c.fid ? c.fid.toUpperCase() : c.name);
|
: dir.pathPrefix.concat(c.fid ? c.fid.toUpperCase() : c.name);
|
||||||
await walkDir({ name: c.name, fid: c.fid, parentSel: parentSel, pathPrefix: childPrefix });
|
const childParentPath = (dir.parentPath || []).concat(c.aid ? c.aid.toUpperCase() : (c.fid ? c.fid : c.name));
|
||||||
|
await walkDir({ name: c.name, fid: c.fid, parentSel: parentSel, parentPath: childParentPath, pathPrefix: childPrefix });
|
||||||
} else {
|
} else {
|
||||||
const childPath = dir.pathPrefix.concat(c.fid ? c.fid.toUpperCase() : c.name).join('/');
|
const childPath = dir.pathPrefix.concat(c.fid ? c.fid.toUpperCase() : c.name).join('/');
|
||||||
if (seen.has(childPath)) continue;
|
if (seen.has(childPath)) continue;
|
||||||
@@ -7697,7 +7781,7 @@ async function profilerScanCard(ignoreFids, ignoreNames, fciMode, onProgress, ma
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MF-rooted walk
|
// MF-rooted walk
|
||||||
await walkDir({ name: 'MF', fid: null, parentSel: null, pathPrefix: ['MF'] });
|
await walkDir({ name: 'MF', fid: null, parentSel: null, parentPath: ['MF'], pathPrefix: ['MF'] });
|
||||||
|
|
||||||
// Sweep custom files not already covered (map leading MF fid 3F00 -> MF)
|
// Sweep custom files not already covered (map leading MF fid 3F00 -> MF)
|
||||||
for (const cf of pysimCustomFiles) {
|
for (const cf of pysimCustomFiles) {
|
||||||
@@ -9121,8 +9205,7 @@ const LANG_RU = {
|
|||||||
'Edit': 'Редактировать',
|
'Edit': 'Редактировать',
|
||||||
'Execute': 'Выполнить',
|
'Execute': 'Выполнить',
|
||||||
'Send': 'Отправить',
|
'Send': 'Отправить',
|
||||||
'Add known file paths and aliases to the file tree.': 'Добавьте пути и псевдонимы файлов в дерево файлов.',
|
'Custom files are added in file manager tree and included in card scan.': 'Пользовательские файлы добавляются в менеджер файлов и учитываются при чтении карт.',
|
||||||
'File:': 'Файл:',
|
|
||||||
'Cancel': 'Отмена',
|
'Cancel': 'Отмена',
|
||||||
'Timeout': 'Таймаут',
|
'Timeout': 'Таймаут',
|
||||||
'OK': 'OK',
|
'OK': 'OK',
|
||||||
@@ -9147,6 +9230,17 @@ const LANG_RU = {
|
|||||||
'Phone simulator': 'Симулятор телефона',
|
'Phone simulator': 'Симулятор телефона',
|
||||||
'Sort:': 'Сортировка:',
|
'Sort:': 'Сортировка:',
|
||||||
'Name': 'Имя',
|
'Name': 'Имя',
|
||||||
|
'Probe all files': 'Проверить все файлы',
|
||||||
|
'Stop': 'Остановить',
|
||||||
|
'Probing:': 'Проверка:',
|
||||||
|
'discovering': 'поиск файлов',
|
||||||
|
'files': 'файлов',
|
||||||
|
'present': 'есть',
|
||||||
|
'absent': 'нет',
|
||||||
|
'Probed:': 'Проверено:',
|
||||||
|
'Stopped': 'Остановлено',
|
||||||
|
'(empty)': '(пусто)',
|
||||||
|
'Error': 'Ошибка',
|
||||||
'No server connection': 'Нет соединения с сервером',
|
'No server connection': 'Нет соединения с сервером',
|
||||||
'Server connected, no card equipped': 'Сервер подключён, карта не подключена',
|
'Server connected, no card equipped': 'Сервер подключён, карта не подключена',
|
||||||
'Card equipped': 'Карта подключена',
|
'Card equipped': 'Карта подключена',
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'otaman-v115';
|
const CACHE = 'otaman-v123';
|
||||||
const URLS = [
|
const URLS = [
|
||||||
'index.html',
|
'index.html',
|
||||||
'help.html',
|
'help.html',
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
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('(?:async\\s+)?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);
|
||||||
|
}
|
||||||
|
|
||||||
|
let code = '';
|
||||||
|
code += extractFunc(html, 'getParentSel') + '\n';
|
||||||
|
code += extractFunc(html, 'getParentPath') + '\n';
|
||||||
|
code += extractFunc(html, 'pysimFsLoadChildren') + '\n';
|
||||||
|
code += 'globalThis.pysimCustomInject = () => {};\n';
|
||||||
|
eval(code);
|
||||||
|
|
||||||
|
let calls = [];
|
||||||
|
let responses = [];
|
||||||
|
let renders = 0;
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
calls = [];
|
||||||
|
responses = [];
|
||||||
|
renders = 0;
|
||||||
|
globalThis.pysimFetch = async (p, body) => {
|
||||||
|
calls.push({ path: p, body: JSON.parse(JSON.stringify(body)) });
|
||||||
|
const r = responses.shift();
|
||||||
|
if (r instanceof Error) throw r;
|
||||||
|
return JSON.parse(JSON.stringify(r));
|
||||||
|
};
|
||||||
|
globalThis.pysimFsRenderTree = () => { renders++; };
|
||||||
|
const node = { name: 'DF.USIM', fid: '7fff', isDir: true, children: null, exists: null, parent: { name: 'MF', fid: '3f00' } };
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('tree error payload marks the directory as absent', async () => {
|
||||||
|
const node = setup();
|
||||||
|
responses = [
|
||||||
|
{ success: false, error: 'SW ... 6a82', exists: false },
|
||||||
|
{ success: false, error: 'SW ... 6a82', exists: false },
|
||||||
|
];
|
||||||
|
await pysimFsLoadChildren(node);
|
||||||
|
assert.strictEqual(node.exists, false);
|
||||||
|
assert.strictEqual(node.children, null);
|
||||||
|
assert.strictEqual(renders, 1);
|
||||||
|
assert.strictEqual(calls.length, 1);
|
||||||
|
assert.strictEqual(calls[0].body.parent_sel, 'MF');
|
||||||
|
assert.deepStrictEqual(calls[0].body.parent_path, ['MF']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('error payload without exists is not treated as an empty listing', async () => {
|
||||||
|
const node = setup();
|
||||||
|
responses = [
|
||||||
|
{ success: false, error: 'boom' },
|
||||||
|
{ success: false, error: 'boom' },
|
||||||
|
];
|
||||||
|
await pysimFsLoadChildren(node);
|
||||||
|
assert.strictEqual(node.exists, false);
|
||||||
|
assert.strictEqual(node.children, null);
|
||||||
|
assert.strictEqual(renders, 1);
|
||||||
|
assert.strictEqual(calls.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a 200 exists:false response marks the directory absent without retrying', async () => {
|
||||||
|
const node = setup();
|
||||||
|
responses = [{ exists: false }];
|
||||||
|
await pysimFsLoadChildren(node);
|
||||||
|
assert.strictEqual(node.exists, false);
|
||||||
|
assert.strictEqual(node.children, null);
|
||||||
|
assert.strictEqual(calls.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a node with loaded children is not fetched again', async () => {
|
||||||
|
const node = setup();
|
||||||
|
node.exists = true;
|
||||||
|
node.children = [{ name: 'EF.UPLMNWLAN', fid: '4f42', isDir: false, exists: true }];
|
||||||
|
await pysimFsLoadChildren(node);
|
||||||
|
assert.strictEqual(node.exists, true);
|
||||||
|
assert.strictEqual(node.children.length, 1);
|
||||||
|
assert.strictEqual(calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty successful listing keeps the directory present', async () => {
|
||||||
|
const node = setup();
|
||||||
|
responses = [{ exists: true, children: [] }];
|
||||||
|
await pysimFsLoadChildren(node);
|
||||||
|
assert.strictEqual(node.exists, true);
|
||||||
|
assert.deepStrictEqual(node.children, []);
|
||||||
|
});
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
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('(?:async\\s+)?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);
|
||||||
|
}
|
||||||
|
|
||||||
|
let code = 'var pysimFsTreeRoot = null;\nvar _pysimFsProbe = null;\nvar pysimFsSort = "fid";\n';
|
||||||
|
for (const fn of ['getParentSel', 'getParentPath', 'pysimFsSortChildren', 'pysimFsLoadChildren', 'pysimFsSelectBody', 'pysimFsProbeUi', 'pysimFsProbeAll']) {
|
||||||
|
code += extractFunc(html, fn) + '\n';
|
||||||
|
}
|
||||||
|
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\nglobalThis.pysimCustomInject = () => {};\n';
|
||||||
|
eval(code);
|
||||||
|
|
||||||
|
function fakeEl() {
|
||||||
|
const classes = new Set();
|
||||||
|
return {
|
||||||
|
textContent: '',
|
||||||
|
attrs: {},
|
||||||
|
classList: {
|
||||||
|
add: (...cs) => cs.forEach(c => classes.add(c)),
|
||||||
|
remove: (...cs) => cs.forEach(c => classes.delete(c)),
|
||||||
|
contains: c => classes.has(c),
|
||||||
|
toggle: (c, on) => { if (on === undefined ? !classes.has(c) : on) classes.add(c); else classes.delete(c); },
|
||||||
|
},
|
||||||
|
setAttribute(k, v) { this.attrs[k] = v; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let els = {};
|
||||||
|
let calls = [];
|
||||||
|
|
||||||
|
function setup(routes) {
|
||||||
|
els = { 'pysim-fs-probe-status': fakeEl(), 'pysim-fs-probe-btn': fakeEl() };
|
||||||
|
calls = [];
|
||||||
|
globalThis.document = { getElementById: id => els[id] || null };
|
||||||
|
globalThis.pysimFetch = async (p, body) => {
|
||||||
|
calls.push({ path: p, body: body || {} });
|
||||||
|
for (const r of routes) {
|
||||||
|
if (r.path !== p) continue;
|
||||||
|
if (r.name && (!body || body.name !== r.name)) continue;
|
||||||
|
return typeof r.reply === 'function' ? r.reply(body, calls) : JSON.parse(JSON.stringify(r.reply));
|
||||||
|
}
|
||||||
|
throw new Error('unexpected fetch ' + p + ' ' + JSON.stringify(body));
|
||||||
|
};
|
||||||
|
globalThis.pysimFsRenderTree = () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function root(children) {
|
||||||
|
pysimFsTreeRoot = { name: 'MF', fid: '3f00', isDir: true, expanded: true, exists: true, children: null, parent: null };
|
||||||
|
pysimFsTreeRoot.children = children.map(c => Object.assign({ children: null, expanded: false, exists: true, parent: pysimFsTreeRoot }, c));
|
||||||
|
return pysimFsTreeRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
const df = (name, fid) => ({ name, fid, isDir: true });
|
||||||
|
const ef = (name, fid) => ({ name, fid, isDir: false });
|
||||||
|
const selectNames = () => calls.filter(c => c.path === '/api/select').map(c => c.body.name);
|
||||||
|
|
||||||
|
test('probes dirs and files, including custom entries, and reports counts', async () => {
|
||||||
|
root([df('DF.A', '5f01'), ef('EF.ROOT', '2f01'), Object.assign(ef('EF.CUSTOM', '6fcc'), { custom: true })]);
|
||||||
|
let sawStop = null;
|
||||||
|
setup([
|
||||||
|
{ path: '/api/tree', name: 'DF.A', reply: { exists: true, children: [{ name: 'EF.1', fid: '6f01', isDir: false }] } },
|
||||||
|
{ path: '/api/select', name: 'EF.1', reply: () => { sawStop = els['pysim-fs-probe-btn'].textContent; return { exists: true }; } },
|
||||||
|
{ path: '/api/select', name: 'EF.ROOT', reply: { error: 'SW 6a82', exists: false } },
|
||||||
|
{ path: '/api/select', name: 'EF.CUSTOM', reply: { exists: true } },
|
||||||
|
]);
|
||||||
|
await pysimFsProbeAll();
|
||||||
|
assert.strictEqual(sawStop, 'Stop');
|
||||||
|
assert.deepStrictEqual(selectNames(), ['EF.1', 'EF.ROOT', 'EF.CUSTOM']);
|
||||||
|
assert.strictEqual(pysimFsTreeRoot.children[0].children[0].exists, true);
|
||||||
|
assert.strictEqual(pysimFsTreeRoot.children[1].exists, false);
|
||||||
|
assert.strictEqual(pysimFsTreeRoot.children[2].exists, true);
|
||||||
|
const status = els['pysim-fs-probe-status'].textContent;
|
||||||
|
assert.match(status, /4\/4 files/);
|
||||||
|
assert.match(status, /3 present/);
|
||||||
|
assert.match(status, /1 absent/);
|
||||||
|
assert.strictEqual(els['pysim-fs-probe-btn'].textContent, 'Probe all files');
|
||||||
|
assert.strictEqual(els['pysim-fs-probe-btn'].attrs['data-l10n'], 'Probe all files');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an absent directory is marked and its subtree is never fetched', async () => {
|
||||||
|
root([df('DF.B', '5f02'), ef('EF.ROOT', '2f01')]);
|
||||||
|
setup([
|
||||||
|
{ path: '/api/tree', name: 'DF.B', reply: { success: false, error: 'SW 6a82', exists: false } },
|
||||||
|
{ path: '/api/select', name: 'EF.ROOT', reply: { error: 'SW 6a82', exists: false } },
|
||||||
|
]);
|
||||||
|
await pysimFsProbeAll();
|
||||||
|
assert.strictEqual(pysimFsTreeRoot.children[0].exists, false);
|
||||||
|
assert.deepStrictEqual(selectNames(), ['EF.ROOT']);
|
||||||
|
assert.strictEqual(calls.filter(c => c.path === '/api/tree' && c.body.name === 'DF.B').length, 1);
|
||||||
|
const status = els['pysim-fs-probe-status'].textContent;
|
||||||
|
assert.match(status, /2\/2 files/);
|
||||||
|
assert.match(status, /0 present/);
|
||||||
|
assert.match(status, /2 absent/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stop halts the walk and still reports a summary', async () => {
|
||||||
|
root([ef('EF.X', '6f01'), ef('EF.Y', '6f02')]);
|
||||||
|
setup([
|
||||||
|
{ path: '/api/select', name: 'EF.X', reply: () => { _pysimFsProbe.stop = true; return { exists: true }; } },
|
||||||
|
]);
|
||||||
|
await pysimFsProbeAll();
|
||||||
|
assert.deepStrictEqual(selectNames(), ['EF.X']);
|
||||||
|
const status = els['pysim-fs-probe-status'].textContent;
|
||||||
|
assert.ok(status.startsWith('Stopped —'), status);
|
||||||
|
assert.strictEqual(els['pysim-fs-probe-btn'].textContent, 'Probe all files');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('select bodies carry the parent path and set allow_probe only for custom files', async () => {
|
||||||
|
root([df('DF.A', '5f01'), Object.assign(ef('EF.CUSTOM', '6fcc'), { custom: true })]);
|
||||||
|
const body = pysimFsSelectBody(pysimFsTreeRoot.children[0]);
|
||||||
|
assert.deepStrictEqual(body.parent_path, ['MF']);
|
||||||
|
assert.strictEqual(body.parent_sel, 'MF');
|
||||||
|
assert.ok(!body.allow_probe);
|
||||||
|
const custom = pysimFsSelectBody(pysimFsTreeRoot.children[1]);
|
||||||
|
assert.deepStrictEqual(custom.parent_path, ['MF']);
|
||||||
|
assert.strictEqual(custom.allow_probe, true);
|
||||||
|
const nested = { name: 'EF.1', fid: '6f01', isDir: false, parent: pysimFsTreeRoot.children[0] };
|
||||||
|
assert.deepStrictEqual(pysimFsSelectBody(nested).parent_path, ['MF', '5f01']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('children of an absent directory are neither fetched nor selected', async () => {
|
||||||
|
const stale = Object.assign(df('DF.C', '5f03'), {
|
||||||
|
exists: false,
|
||||||
|
children: [{ name: 'EF.STALE', fid: '6f0e', isDir: false, exists: true, children: null }],
|
||||||
|
});
|
||||||
|
root([stale, ef('EF.ROOT', '2f01')]);
|
||||||
|
setup([
|
||||||
|
{ path: '/api/select', name: 'EF.ROOT', reply: { exists: true } },
|
||||||
|
]);
|
||||||
|
await pysimFsProbeAll();
|
||||||
|
assert.deepStrictEqual(selectNames(), ['EF.ROOT']);
|
||||||
|
assert.strictEqual(stale.exists, false);
|
||||||
|
assert.strictEqual(calls.filter(c => c.path === '/api/tree').length, 0);
|
||||||
|
const status = els['pysim-fs-probe-status'].textContent;
|
||||||
|
assert.match(status, /2\/2 files/);
|
||||||
|
assert.match(status, /1 present/);
|
||||||
|
assert.match(status, /1 absent/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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('(?:async\\s+)?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);
|
||||||
|
}
|
||||||
|
|
||||||
|
let code = 'var pysimFsSort = "fid";\n';
|
||||||
|
code += extractFunc(html, 'pysimFsSortChildren') + '\n';
|
||||||
|
code += extractFunc(html, 'pysimFsRenderNode') + '\n';
|
||||||
|
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\n';
|
||||||
|
eval(code);
|
||||||
|
|
||||||
|
const ef = (name, fid) => ({ name, fid, isDir: false, exists: true, children: null, expanded: false });
|
||||||
|
const df = (name, fid, extra) => Object.assign({ name, fid, isDir: true, exists: true, children: null, expanded: false }, extra || {});
|
||||||
|
|
||||||
|
test('absent directory renders a cross and no expand toggle', () => {
|
||||||
|
const node = df('DF.WLAN', '5f40', { exists: false, children: [] });
|
||||||
|
const out = pysimFsRenderNode(node, 0);
|
||||||
|
assert.ok(out.includes('✗'), out);
|
||||||
|
assert.ok(!out.includes('pysimFsToggleDir'), out);
|
||||||
|
assert.ok(!out.includes('▶'), out);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('expanded empty directory shows the (empty) placeholder', () => {
|
||||||
|
const node = df('DF.EMPTY', '5f00', { children: [], expanded: true });
|
||||||
|
const out = pysimFsRenderNode(node, 0);
|
||||||
|
assert.ok(out.includes('(empty)'), out);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('expanded directory with children renders no placeholder', () => {
|
||||||
|
const node = df('DF.GSM', '7f20', { children: [ef('EF.IMSI', '6f07')], expanded: true });
|
||||||
|
const out = pysimFsRenderNode(node, 0);
|
||||||
|
assert.ok(out.includes('EF.IMSI'), out);
|
||||||
|
assert.ok(!out.includes('(empty)'), out);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collapsed directory hides its children', () => {
|
||||||
|
const node = df('DF.GSM', '7f20', { children: [ef('EF.IMSI', '6f07')], expanded: false });
|
||||||
|
const out = pysimFsRenderNode(node, 0);
|
||||||
|
assert.ok(!out.includes('EF.IMSI'), out);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-existing directory hides previously loaded children', () => {
|
||||||
|
const node = df('DF.WLAN', '5f40', { exists: false, expanded: true, children: [ef('EF.UPLMNWLAN', '4f42')] });
|
||||||
|
const out = pysimFsRenderNode(node, 0);
|
||||||
|
assert.ok(out.includes('✗'), out);
|
||||||
|
assert.ok(!out.includes('EF.UPLMNWLAN'), out);
|
||||||
|
assert.ok(!out.includes('(empty)'), out);
|
||||||
|
assert.ok(!out.includes('pysimFsToggleDir'), out);
|
||||||
|
});
|
||||||
@@ -61,3 +61,23 @@ test('custom files form has add/save and cancel controls', () => {
|
|||||||
assert.ok(html.includes("event.key==='Enter')pysimCustomSubmit()"));
|
assert.ok(html.includes("event.key==='Enter')pysimCustomSubmit()"));
|
||||||
assert.ok(!html.includes('pysimCustomAdd'));
|
assert.ok(!html.includes('pysimCustomAdd'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('file manager has a probe-all-files button and status line', () => {
|
||||||
|
assert.match(html, /id="pysim-fs-probe-btn"[^>]*data-needs="card"/);
|
||||||
|
assert.match(html, /id="pysim-fs-probe-btn"[^>]*data-l10n="Probe all files"/);
|
||||||
|
assert.ok(html.includes('onclick="pysimFsProbeAll()"'));
|
||||||
|
assert.ok(html.includes('id="pysim-fs-probe-status"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('file manager shows FCI info and keeps the selection in state, not the DOM', () => {
|
||||||
|
const detail = html.indexOf('id="pysim-fs-detail"');
|
||||||
|
const info = html.indexOf('id="pysim-fs-info"');
|
||||||
|
const content = html.indexOf('id="pysim-fs-content"');
|
||||||
|
assert.ok(detail !== -1 && info > detail && info < content, 'pysim-fs-info must sit above the content');
|
||||||
|
assert.ok(html.includes('function pysimFsInfoHtml'));
|
||||||
|
assert.ok(html.includes("pysimFsInfoHtml(sel)"));
|
||||||
|
assert.ok(!html.includes('pysim-fs-filename'));
|
||||||
|
assert.ok(html.includes('let pysimFsSelected = null;'));
|
||||||
|
assert.ok(html.includes('pysimFsSelected = name;'));
|
||||||
|
assert.ok(!html.includes('pysimFsSelect()'));
|
||||||
|
});
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function extractFunc(src, name, asyncFn) {
|
|||||||
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
|
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput', 'profilerScanToggleAll', 'profilerScanIgnoreAllState', 'swapNibbles', 'decIccid', 'profilerSnapshotIccid', 'profilerValidateSnapshot', 'profilerListSwitch', 'profilerScanRefreshOptions', 'profilerLiveSource', 'profilerSnapshotSource', 'profilerVisibleResults', 'profilerMaskFidForFile', 'profilerRulesFromSnapshot', 'profilerExtraFileResults', 'profilerScanNameKeydown', 'profilerTimingStats', 'profilerTimingAccumulator', 'profilerFormatMs', 'profilerRenderSnapshotSummary', 'profilerSnapshotCountLabel'];
|
const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput', 'profilerScanToggleAll', 'profilerScanIgnoreAllState', 'swapNibbles', 'decIccid', 'profilerSnapshotIccid', 'profilerValidateSnapshot', 'profilerListSwitch', 'profilerScanRefreshOptions', 'profilerLiveSource', 'profilerSnapshotSource', 'profilerVisibleResults', 'profilerMaskFidForFile', 'profilerRulesFromSnapshot', 'profilerExtraFileResults', 'profilerScanNameKeydown', 'profilerTimingStats', 'profilerTimingAccumulator', 'profilerFormatMs', 'profilerRenderSnapshotSummary', 'profilerSnapshotCountLabel', 'pysimFsInfoHtml'];
|
||||||
let code = '';
|
let code = '';
|
||||||
for (const f of FNS) code += extractFunc(html, f) + '\n';
|
for (const f of FNS) code += extractFunc(html, f) + '\n';
|
||||||
code += extractFunc(html, 'profilerBuildFileRule', true) + '\n';
|
code += extractFunc(html, 'profilerBuildFileRule', true) + '\n';
|
||||||
@@ -698,6 +698,33 @@ test('fcpDiffHtml highlights differing FCI parameters', () => {
|
|||||||
delete global.t;
|
delete global.t;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('pysimFsInfoHtml shows FID, type, size and the decoded FCI', () => {
|
||||||
|
global.t = s => s;
|
||||||
|
const hex = '621A82054221000F0583026F4F8A01058B036F06098002004B8801B0';
|
||||||
|
const out = pysimFsInfoHtml({ fid: '6f4f', file_type: 'linear_fixed', file_size: 75, record_len: 15, num_of_rec: 5, fci_hex: hex });
|
||||||
|
assert.ok(out.includes('FID: 6F4F'), out);
|
||||||
|
assert.ok(out.includes('File type: linear_fixed'), out);
|
||||||
|
assert.ok(out.includes('Size: 75'), out);
|
||||||
|
assert.ok(out.includes('Record length: 15'), out);
|
||||||
|
assert.ok(out.includes('Record count: 5'), out);
|
||||||
|
assert.ok(!out.includes('Decoded FCI'), out);
|
||||||
|
assert.ok(out.includes('File descriptor'), out);
|
||||||
|
assert.ok(out.includes('75 bytes'), out);
|
||||||
|
delete global.t;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pysimFsInfoHtml omits the FCI block without fci_hex and skips null fields', () => {
|
||||||
|
global.t = s => s;
|
||||||
|
const out = pysimFsInfoHtml({ fid: '6f07', file_type: 'transparent', file_size: null, record_len: null, num_of_rec: undefined, fci_hex: null });
|
||||||
|
assert.ok(out.includes('FID: 6F07'), out);
|
||||||
|
assert.ok(!out.includes('Size:'), out);
|
||||||
|
assert.ok(!out.includes('Record length:'), out);
|
||||||
|
assert.ok(!out.includes('Record count:'), out);
|
||||||
|
assert.ok(!out.includes('Decoded FCI'), out);
|
||||||
|
assert.strictEqual(pysimFsInfoHtml(null), '');
|
||||||
|
delete global.t;
|
||||||
|
});
|
||||||
|
|
||||||
test('profilerFciPreviewItems renders decoded items and degrades gracefully', () => {
|
test('profilerFciPreviewItems renders decoded items and degrades gracefully', () => {
|
||||||
global.t = s => s;
|
global.t = s => s;
|
||||||
assert.ok(profilerFciPreviewItems(FCP_TRANSPARENT).includes('File size: '));
|
assert.ok(profilerFciPreviewItems(FCP_TRANSPARENT).includes('File size: '));
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pysim-otaman-server"
|
name = "pysim-otaman-server"
|
||||||
version = "2.0.0"
|
version = "2.1.0"
|
||||||
description = "HTTP REST server wrapping pysim for the OTAMan PWA"
|
description = "HTTP REST server wrapping pysim for the OTAMan PWA"
|
||||||
requires-python = ">=3.8"
|
requires-python = ">=3.8"
|
||||||
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
|
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
|
||||||
|
|||||||
+161
-33
@@ -19,7 +19,7 @@ from osmocom.construct import GsmOrUcs2Adapter
|
|||||||
from osmocom.tlv import BER_TLV_IE
|
from osmocom.tlv import BER_TLV_IE
|
||||||
|
|
||||||
|
|
||||||
VERSION = '2.0.0'
|
VERSION = '2.1.0'
|
||||||
|
|
||||||
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
||||||
|
|
||||||
@@ -214,39 +214,143 @@ def _get_file_type(lchan, cur_file):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _select_with_parent(lchan, name, parent_sel, app):
|
def _fid4(sel):
|
||||||
if parent_sel:
|
"""True if sel is a 4-digit hex FID."""
|
||||||
lchan.select(parent_sel, app)
|
return bool(re.fullmatch(r'[0-9a-fA-F]{4}', str(sel or '')))
|
||||||
fcp = lchan.select(name, app)
|
|
||||||
return fcp
|
|
||||||
|
def _file_by_sel(parent, sel):
|
||||||
|
"""Resolve sel (FID or symbolic name, case-insensitive) among parent's direct children."""
|
||||||
|
if parent is None or not sel:
|
||||||
|
return None
|
||||||
|
s = str(sel).strip().lower()
|
||||||
|
for f in (getattr(parent, 'children', None) or {}).values():
|
||||||
|
if f.fid and f.fid.lower() == s:
|
||||||
|
return f
|
||||||
|
if f.name and f.name.lower() == s:
|
||||||
|
return f
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _app_by_sel(rs, sel):
|
||||||
|
"""Resolve an ADF by AID or application name (case-insensitive)."""
|
||||||
|
if rs is None or not sel:
|
||||||
|
return None
|
||||||
|
s = str(sel).strip().lower()
|
||||||
|
for aid, adf in (rs.mf.applications or {}).items():
|
||||||
|
if aid.lower() == s or (adf.name and adf.name.lower() == s):
|
||||||
|
return adf
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_in_tree(root, sel):
|
||||||
|
"""All model files matching sel (fid or name) below root; unique-match helper."""
|
||||||
|
s = str(sel or '').strip().lower()
|
||||||
|
found = []
|
||||||
|
seen = set()
|
||||||
|
stack = [root]
|
||||||
|
while stack:
|
||||||
|
cur = stack.pop()
|
||||||
|
if id(cur) in seen:
|
||||||
|
continue
|
||||||
|
seen.add(id(cur))
|
||||||
|
candidates = list((getattr(cur, 'children', None) or {}).values())
|
||||||
|
candidates += list((getattr(cur, 'applications', None) or {}).values())
|
||||||
|
for f in candidates:
|
||||||
|
if (f.fid and f.fid.lower() == s) or (f.name and f.name.lower() == s):
|
||||||
|
found.append(f)
|
||||||
|
stack.append(f)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _select_with_parent(lchan, name, parent_sel, app, parent_path=None, allow_probe=False):
|
||||||
|
"""Select name strictly within the requested parent.
|
||||||
|
|
||||||
|
Every model-known FID/name is resolved through the parent's children and
|
||||||
|
selected with lchan.select_file(); pySim's global selectables and its
|
||||||
|
probe_file() fallback are never used for model files, so a same-FID file
|
||||||
|
under a different parent can no longer be picked and the model is not
|
||||||
|
mutated. Model-unknown 4-hex segments (custom files) are probed only when
|
||||||
|
allow_probe is set and are detached again via the returned cleanup.
|
||||||
|
|
||||||
|
Returns (selected_file, cleanup): cleanup is None unless a probe happened;
|
||||||
|
handlers must call it in a finally block after using the selection.
|
||||||
|
"""
|
||||||
|
rs = app.rs
|
||||||
|
prev = lchan.selected_file
|
||||||
|
probes = []
|
||||||
|
parent = rs.mf
|
||||||
|
lchan.select_file(parent, app)
|
||||||
|
segs = [s for s in (parent_path or ([parent_sel] if parent_sel else [])) if s]
|
||||||
|
for seg in segs:
|
||||||
|
if str(seg).upper() in ('MF', '3F00'):
|
||||||
|
continue
|
||||||
|
f = _app_by_sel(rs, seg) or _file_by_sel(parent, seg)
|
||||||
|
if f is None and not parent_path:
|
||||||
|
matches = _find_in_tree(rs.mf, seg)
|
||||||
|
if len(matches) > 1:
|
||||||
|
raise RuntimeError('Ambiguous parent selector: %s' % seg)
|
||||||
|
if matches:
|
||||||
|
f = matches[0]
|
||||||
|
if f is None:
|
||||||
|
if allow_probe and _fid4(seg):
|
||||||
|
fid = str(seg).lower()
|
||||||
|
probes.append((parent, fid))
|
||||||
|
lchan.probe_file(fid, app)
|
||||||
|
parent = lchan.selected_file
|
||||||
|
continue
|
||||||
|
raise RuntimeError('File not found: %s' % seg)
|
||||||
|
lchan.select_file(f, app)
|
||||||
|
parent = f
|
||||||
|
target = None
|
||||||
|
if str(name).upper() in ('MF', '3F00'):
|
||||||
|
target = rs.mf
|
||||||
|
if target is None:
|
||||||
|
target = _file_by_sel(parent, name) or _app_by_sel(rs, name)
|
||||||
|
if target is None and not parent_path and not parent_sel:
|
||||||
|
matches = _find_in_tree(rs.mf, name)
|
||||||
|
if len(matches) > 1:
|
||||||
|
raise RuntimeError('Ambiguous file selector: %s' % name)
|
||||||
|
if matches:
|
||||||
|
target = matches[0]
|
||||||
|
if target is not None:
|
||||||
|
lchan.select_file(target, app)
|
||||||
|
elif allow_probe and _fid4(name):
|
||||||
|
fid = str(name).lower()
|
||||||
|
probes.append((parent, fid))
|
||||||
|
lchan.probe_file(fid, app)
|
||||||
|
else:
|
||||||
|
raise RuntimeError('File not found: %s' % name)
|
||||||
|
cleanup = None
|
||||||
|
if probes:
|
||||||
|
def cleanup():
|
||||||
|
for p, fid in reversed(probes):
|
||||||
|
try:
|
||||||
|
(getattr(p, 'children', None) or {}).pop(fid, None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
lchan.select_file(prev, app)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
lchan.select_file(rs.mf, app)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return lchan.selected_file, cleanup
|
||||||
|
|
||||||
|
|
||||||
def _select_path(lchan, path, app):
|
def _select_path(lchan, path, app):
|
||||||
"""Select a file described by a full path.
|
"""Select a file described by a full path.
|
||||||
|
|
||||||
Path is '/' separated; the first element is either 'MF' (or the MF fid
|
Path is '/' separated; the first element is 'MF' (or '3F00'), an ADF AID,
|
||||||
'3F00') or an ADF AID (hex). Remaining elements are FIDs or file names.
|
or an ADF name; remaining elements are FIDs or file names. Resolution is
|
||||||
pySim's lchan.select() cannot select an ADF by its raw AID (selectables are
|
strictly parent-scoped (see _select_with_parent); unknown 4-hex segments
|
||||||
keyed by name/fid only), so ADF roots are resolved through rs.mf.applications.
|
are custom files and are probed without touching the model tree.
|
||||||
"""
|
"""
|
||||||
parts = [p for p in (path or '').split('/') if p]
|
parts = [p for p in (path or '').split('/') if p]
|
||||||
if not parts:
|
if not parts:
|
||||||
raise RuntimeError('Empty path')
|
raise RuntimeError('Empty path')
|
||||||
rs = app.rs
|
return _select_with_parent(lchan, parts[-1], None, app, parent_path=parts[:-1], allow_probe=True)
|
||||||
first = parts[0]
|
|
||||||
if first.upper() in ('MF', '3F00'):
|
|
||||||
lchan.select('MF', app)
|
|
||||||
else:
|
|
||||||
aid = first.lower()
|
|
||||||
adf = rs.mf.applications.get(aid)
|
|
||||||
if not adf:
|
|
||||||
adf = next((v for k, v in rs.mf.applications.items() if k.lower() == aid), None)
|
|
||||||
if not adf:
|
|
||||||
raise RuntimeError('ADF not found: %s' % first)
|
|
||||||
lchan.select_file(adf, app)
|
|
||||||
for seg in parts[1:]:
|
|
||||||
lchan.select(seg, app)
|
|
||||||
return lchan.selected_file
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_tree_output(output):
|
def _parse_tree_output(output):
|
||||||
@@ -1960,22 +2064,25 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
fid = body.get('fid')
|
fid = body.get('fid')
|
||||||
name = fid if fid else body.get('name', '')
|
name = fid if fid else body.get('name', '')
|
||||||
parent_sel = body.get('parent_sel')
|
parent_sel = body.get('parent_sel')
|
||||||
|
parent_path = body.get('parent_path')
|
||||||
|
allow_probe = bool(body.get('allow_probe'))
|
||||||
rs = app.rs
|
rs = app.rs
|
||||||
if not rs:
|
if not rs:
|
||||||
self._send_json({'error': _err('no_card_state', lang)}, 503)
|
self._send_json({'error': _err('no_card_state', lang)}, 503)
|
||||||
self._log_resp({'error': _err('no_card_state', lang)})
|
self._log_resp({'error': _err('no_card_state', lang)})
|
||||||
return
|
return
|
||||||
lchan = rs.lchan[0]
|
lchan = rs.lchan[0]
|
||||||
|
cleanup = None
|
||||||
try:
|
try:
|
||||||
_collect_apdu_times()
|
_collect_apdu_times()
|
||||||
try:
|
try:
|
||||||
if path:
|
if path:
|
||||||
_select_path(lchan, path, app)
|
cur, cleanup = _select_path(lchan, path, app)
|
||||||
else:
|
else:
|
||||||
_select_with_parent(lchan, name, parent_sel, app)
|
cur, cleanup = _select_with_parent(lchan, name, parent_sel, app, parent_path, allow_probe)
|
||||||
finally:
|
finally:
|
||||||
apdu_times = _end_apdu_time_collection()
|
apdu_times = _end_apdu_time_collection()
|
||||||
cur = lchan.selected_file
|
cur = cur or lchan.selected_file
|
||||||
data = {
|
data = {
|
||||||
'name': cur.name if cur else None,
|
'name': cur.name if cur else None,
|
||||||
'fid': cur.fid.upper() if cur and cur.fid else None,
|
'fid': cur.fid.upper() if cur and cur.fid else None,
|
||||||
@@ -1993,6 +2100,9 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
err = {'error': str(e), 'exists': False}
|
err = {'error': str(e), 'exists': False}
|
||||||
self._send_json(err, 404)
|
self._send_json(err, 404)
|
||||||
self._log_resp(err)
|
self._log_resp(err)
|
||||||
|
finally:
|
||||||
|
if cleanup:
|
||||||
|
cleanup()
|
||||||
elif self.path == '/api/read':
|
elif self.path == '/api/read':
|
||||||
app = self.server.app
|
app = self.server.app
|
||||||
if not app:
|
if not app:
|
||||||
@@ -2005,6 +2115,8 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
fid = body.get('fid')
|
fid = body.get('fid')
|
||||||
name = fid if fid else body.get('name', '')
|
name = fid if fid else body.get('name', '')
|
||||||
parent_sel = body.get('parent_sel')
|
parent_sel = body.get('parent_sel')
|
||||||
|
parent_path = body.get('parent_path')
|
||||||
|
allow_probe = bool(body.get('allow_probe'))
|
||||||
mode = body.get('mode', 'raw')
|
mode = body.get('mode', 'raw')
|
||||||
rs = app.rs
|
rs = app.rs
|
||||||
if not rs:
|
if not rs:
|
||||||
@@ -2012,14 +2124,15 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
self._log_resp({'error': _err('no_card_state', lang)})
|
self._log_resp({'error': _err('no_card_state', lang)})
|
||||||
return
|
return
|
||||||
lchan = rs.lchan[0]
|
lchan = rs.lchan[0]
|
||||||
|
cleanup = None
|
||||||
try:
|
try:
|
||||||
_collect_apdu_times()
|
_collect_apdu_times()
|
||||||
try:
|
try:
|
||||||
sel = fid if fid else name
|
sel = fid if fid else name
|
||||||
if path:
|
if path:
|
||||||
_select_path(lchan, path, app)
|
_, cleanup = _select_path(lchan, path, app)
|
||||||
else:
|
else:
|
||||||
_select_with_parent(lchan, sel, parent_sel, app)
|
_, cleanup = _select_with_parent(lchan, sel, parent_sel, app, parent_path, allow_probe)
|
||||||
ft = _get_file_type(lchan, lchan.selected_file)
|
ft = _get_file_type(lchan, lchan.selected_file)
|
||||||
is_record = ft in ('linear_fixed', 'cyclic')
|
is_record = ft in ('linear_fixed', 'cyclic')
|
||||||
if mode == 'decoded':
|
if mode == 'decoded':
|
||||||
@@ -2073,6 +2186,9 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
err = {'success': False, 'error': str(e)}
|
err = {'success': False, 'error': str(e)}
|
||||||
self._send_json(err, 500)
|
self._send_json(err, 500)
|
||||||
self._log_resp(err)
|
self._log_resp(err)
|
||||||
|
finally:
|
||||||
|
if cleanup:
|
||||||
|
cleanup()
|
||||||
elif self.path == '/api/write':
|
elif self.path == '/api/write':
|
||||||
app = self.server.app
|
app = self.server.app
|
||||||
if not app:
|
if not app:
|
||||||
@@ -2086,15 +2202,18 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
fid = body.get('fid')
|
fid = body.get('fid')
|
||||||
record_nr = body.get('record_nr')
|
record_nr = body.get('record_nr')
|
||||||
parent_sel = body.get('parent_sel')
|
parent_sel = body.get('parent_sel')
|
||||||
|
parent_path = body.get('parent_path')
|
||||||
|
allow_probe = bool(body.get('allow_probe'))
|
||||||
rs = app.rs
|
rs = app.rs
|
||||||
if not rs:
|
if not rs:
|
||||||
self._send_json({'error': _err('no_card_state', lang)}, 503)
|
self._send_json({'error': _err('no_card_state', lang)}, 503)
|
||||||
self._log_resp({'error': _err('no_card_state', lang)})
|
self._log_resp({'error': _err('no_card_state', lang)})
|
||||||
return
|
return
|
||||||
lchan = rs.lchan[0]
|
lchan = rs.lchan[0]
|
||||||
|
cleanup = None
|
||||||
try:
|
try:
|
||||||
sel = fid if fid else name
|
sel = fid if fid else name
|
||||||
_select_with_parent(lchan, sel, parent_sel, app)
|
_, cleanup = _select_with_parent(lchan, sel, parent_sel, app, parent_path, allow_probe)
|
||||||
ft = _get_file_type(lchan, lchan.selected_file)
|
ft = _get_file_type(lchan, lchan.selected_file)
|
||||||
is_record = ft in ('linear_fixed', 'cyclic')
|
is_record = ft in ('linear_fixed', 'cyclic')
|
||||||
if record_nr:
|
if record_nr:
|
||||||
@@ -2131,6 +2250,9 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
err = {'success': False, 'error': str(e)}
|
err = {'success': False, 'error': str(e)}
|
||||||
self._send_json(err, 500)
|
self._send_json(err, 500)
|
||||||
self._log_resp(err)
|
self._log_resp(err)
|
||||||
|
finally:
|
||||||
|
if cleanup:
|
||||||
|
cleanup()
|
||||||
elif self.path == '/api/tree':
|
elif self.path == '/api/tree':
|
||||||
app = self.server.app
|
app = self.server.app
|
||||||
if not app:
|
if not app:
|
||||||
@@ -2143,15 +2265,18 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
name = fid if fid else body.get('name', '')
|
name = fid if fid else body.get('name', '')
|
||||||
fid = body.get('fid')
|
fid = body.get('fid')
|
||||||
parent_sel = body.get('parent_sel')
|
parent_sel = body.get('parent_sel')
|
||||||
|
parent_path = body.get('parent_path')
|
||||||
|
allow_probe = bool(body.get('allow_probe'))
|
||||||
rs = app.rs
|
rs = app.rs
|
||||||
if not rs:
|
if not rs:
|
||||||
self._send_json({'error': _err('no_card_state', lang)}, 503)
|
self._send_json({'error': _err('no_card_state', lang)}, 503)
|
||||||
self._log_resp({'error': _err('no_card_state', lang)})
|
self._log_resp({'error': _err('no_card_state', lang)})
|
||||||
return
|
return
|
||||||
lchan = rs.lchan[0]
|
lchan = rs.lchan[0]
|
||||||
|
cleanup = None
|
||||||
try:
|
try:
|
||||||
sel = fid if fid else name
|
sel = fid if fid else name
|
||||||
_select_with_parent(lchan, sel, parent_sel, app)
|
_, cleanup = _select_with_parent(lchan, sel, parent_sel, app, parent_path, allow_probe)
|
||||||
cur = lchan.selected_file
|
cur = lchan.selected_file
|
||||||
out = StringIO()
|
out = StringIO()
|
||||||
old_stdout = app.stdout
|
old_stdout = app.stdout
|
||||||
@@ -2184,9 +2309,12 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
sys.stderr.write('Handler error: %s\n' % e)
|
sys.stderr.write('Handler error: %s\n' % e)
|
||||||
if 'Card' in str(e) or 'Transaction' in str(e) or 'Transmit' in str(e):
|
if 'Card' in str(e) or 'Transaction' in str(e) or 'Transmit' in str(e):
|
||||||
_handle_card_disconnect()
|
_handle_card_disconnect()
|
||||||
err = {'success': False, 'error': str(e)}
|
err = {'success': False, 'error': str(e), 'exists': False}
|
||||||
self._send_json(err, 500)
|
self._send_json(err, 500)
|
||||||
self._log_resp(err)
|
self._log_resp(err)
|
||||||
|
finally:
|
||||||
|
if cleanup:
|
||||||
|
cleanup()
|
||||||
elif self.path == '/api/menu-select':
|
elif self.path == '/api/menu-select':
|
||||||
scc = self.server.scc
|
scc = self.server.scc
|
||||||
if not scc:
|
if not scc:
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Unit tests for the parent-scoped select helpers in pysim_otaman_server.server.
|
||||||
|
|
||||||
|
The helpers must resolve every model-known file strictly within the requested
|
||||||
|
parent (no pySim global selectables, no probe_file model injection) and must
|
||||||
|
detach any model-unknown file that had to be probed for a custom file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
PROJECTS = Path(__file__).resolve().parents[2]
|
||||||
|
PY_SIM = PROJECTS / 'pysim'
|
||||||
|
if str(PY_SIM) not in sys.path:
|
||||||
|
sys.path.insert(0, str(PY_SIM))
|
||||||
|
|
||||||
|
from pysim_otaman_server.server import (
|
||||||
|
_app_by_sel,
|
||||||
|
_fid4,
|
||||||
|
_file_by_sel,
|
||||||
|
_find_in_tree,
|
||||||
|
_select_path,
|
||||||
|
_select_with_parent,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeFile:
|
||||||
|
def __init__(self, fid=None, name=None, parent=None, aid=None):
|
||||||
|
self.fid = fid
|
||||||
|
self.name = name
|
||||||
|
self.parent = parent
|
||||||
|
self.aid = aid
|
||||||
|
self.sfid = None
|
||||||
|
self.children = {}
|
||||||
|
|
||||||
|
def add_files(self, files):
|
||||||
|
for f in files:
|
||||||
|
f.parent = self
|
||||||
|
self.children[f.fid] = f
|
||||||
|
|
||||||
|
|
||||||
|
class FakeMF(FakeFile):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(fid='3f00', name='MF')
|
||||||
|
self.applications = {}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeLchan:
|
||||||
|
"""No .select() on purpose: any global-resolution call would fail loudly."""
|
||||||
|
|
||||||
|
def __init__(self, mf):
|
||||||
|
self.selected_file = mf
|
||||||
|
self.selects = []
|
||||||
|
self.probes = []
|
||||||
|
|
||||||
|
def select_file(self, f, app=None):
|
||||||
|
self.selected_file = f
|
||||||
|
self.selects.append(f)
|
||||||
|
|
||||||
|
def probe_file(self, fid, app=None):
|
||||||
|
self.probes.append(fid)
|
||||||
|
f = FakeFile(fid=fid, name='EF.' + fid.upper(), parent=self.selected_file)
|
||||||
|
self.selected_file.add_files([f])
|
||||||
|
self.selected_file = f
|
||||||
|
|
||||||
|
|
||||||
|
def build_model():
|
||||||
|
mf = FakeMF()
|
||||||
|
gsm = FakeFile('7f20', 'DF.GSM', mf)
|
||||||
|
mf.children['7f20'] = gsm
|
||||||
|
spn = FakeFile('6f46', 'EF.SPN', gsm)
|
||||||
|
gsm.children['6f46'] = spn
|
||||||
|
telecom = FakeFile('7f10', 'DF.TELECOM', mf)
|
||||||
|
mf.children['7f10'] = telecom
|
||||||
|
tel_ph = FakeFile('5f3a', 'DF.PHONEBOOK', telecom)
|
||||||
|
telecom.children['5f3a'] = tel_ph
|
||||||
|
usim = FakeFile(None, 'ADF.USIM', mf, aid='A0000000871002')
|
||||||
|
mf.applications['a0000000871002'] = usim
|
||||||
|
imsi = FakeFile('6f07', 'EF.IMSI', usim)
|
||||||
|
usim.children['6f07'] = imsi
|
||||||
|
usim_ph = FakeFile('5f3a', 'DF.PHONEBOOK', usim)
|
||||||
|
usim.children['5f3a'] = usim_ph
|
||||||
|
return mf, usim, usim_ph, telecom, tel_ph, gsm, spn
|
||||||
|
|
||||||
|
|
||||||
|
def setup():
|
||||||
|
mf, usim, usim_ph, telecom, tel_ph, gsm, spn = build_model()
|
||||||
|
app = SimpleNamespace(rs=SimpleNamespace(mf=mf))
|
||||||
|
lchan = FakeLchan(mf)
|
||||||
|
return app, lchan, mf, usim, usim_ph, gsm, spn
|
||||||
|
|
||||||
|
|
||||||
|
class FidHelpersTest(unittest.TestCase):
|
||||||
|
def test_fid4(self):
|
||||||
|
self.assertTrue(_fid4('6F07'))
|
||||||
|
self.assertFalse(_fid4('EF.IMSI'))
|
||||||
|
self.assertFalse(_fid4('6F0'))
|
||||||
|
|
||||||
|
def test_file_by_sel_matches_fid_and_name(self):
|
||||||
|
_, _, _, _, _, gsm, spn = setup()
|
||||||
|
self.assertIs(_file_by_sel(gsm, '6f46'), spn)
|
||||||
|
self.assertIs(_file_by_sel(gsm, 'EF.SPN'), spn)
|
||||||
|
self.assertIsNone(_file_by_sel(gsm, '6f07'))
|
||||||
|
|
||||||
|
def test_find_in_tree_reports_duplicates(self):
|
||||||
|
app, _, mf, _, _, _, _ = setup()
|
||||||
|
self.assertEqual(len(_find_in_tree(mf, '5f3a')), 2)
|
||||||
|
self.assertEqual(len(_find_in_tree(mf, 'EF.IMSI')), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class ParentScopedSelectTest(unittest.TestCase):
|
||||||
|
def test_duplicate_fid_is_resolved_under_the_walked_parent(self):
|
||||||
|
app, lchan, mf, usim, usim_ph, _, _ = setup()
|
||||||
|
target, cleanup = _select_with_parent(lchan, '5f3a', None, app, parent_path=['MF', 'A0000000871002'])
|
||||||
|
self.assertIs(target, usim_ph)
|
||||||
|
self.assertIsNone(cleanup)
|
||||||
|
self.assertEqual(lchan.selects, [mf, usim, usim_ph])
|
||||||
|
self.assertEqual(lchan.probes, [])
|
||||||
|
|
||||||
|
def test_path_with_fids_selects_exactly(self):
|
||||||
|
app, lchan, mf, _, _, gsm, spn = setup()
|
||||||
|
target, cleanup = _select_path(lchan, 'MF/7F20/6F46', app)
|
||||||
|
self.assertIs(target, spn)
|
||||||
|
self.assertIsNone(cleanup)
|
||||||
|
self.assertEqual(lchan.selects, [mf, gsm, spn])
|
||||||
|
|
||||||
|
def test_path_with_aid_root_selects_application(self):
|
||||||
|
app, lchan, _, usim, _, _, _ = setup()
|
||||||
|
target, _ = _select_path(lchan, 'A0000000871002/6F07', app)
|
||||||
|
self.assertEqual(target.fid, '6f07')
|
||||||
|
self.assertEqual(lchan.selected_file.parent, usim)
|
||||||
|
|
||||||
|
def test_ambiguous_legacy_parent_selector_is_rejected(self):
|
||||||
|
app, lchan, _, _, _, _, _ = setup()
|
||||||
|
with self.assertRaisesRegex(RuntimeError, 'Ambiguous'):
|
||||||
|
_select_with_parent(lchan, '6f07', '5f3a', app)
|
||||||
|
|
||||||
|
def test_unknown_name_is_not_probed(self):
|
||||||
|
app, lchan, _, _, _, gsm, _ = setup()
|
||||||
|
with self.assertRaisesRegex(RuntimeError, 'File not found'):
|
||||||
|
_select_with_parent(lchan, 'NOSUCH', None, app, parent_path=['MF', '7F20'])
|
||||||
|
self.assertEqual(lchan.probes, [])
|
||||||
|
|
||||||
|
def test_unknown_fid_without_allow_probe_does_not_touch_the_card(self):
|
||||||
|
app, lchan, _, _, _, gsm, _ = setup()
|
||||||
|
with self.assertRaisesRegex(RuntimeError, 'File not found'):
|
||||||
|
_select_with_parent(lchan, '6f99', None, app, parent_path=['MF', '7F20'])
|
||||||
|
self.assertEqual(lchan.probes, [])
|
||||||
|
|
||||||
|
def test_allow_probe_detaches_the_temporary_file_and_restores_selection(self):
|
||||||
|
app, lchan, mf, _, _, gsm, _ = setup()
|
||||||
|
before = set(gsm.children)
|
||||||
|
target, cleanup = _select_with_parent(lchan, '6f99', None, app, parent_path=['MF', '7F20'], allow_probe=True)
|
||||||
|
self.assertEqual(lchan.probes, ['6f99'])
|
||||||
|
self.assertEqual(target.fid, '6f99')
|
||||||
|
self.assertIsNotNone(cleanup)
|
||||||
|
self.assertIn('6f99', gsm.children)
|
||||||
|
cleanup()
|
||||||
|
self.assertEqual(set(gsm.children), before)
|
||||||
|
self.assertIs(lchan.selected_file, mf)
|
||||||
|
|
||||||
|
def test_custom_path_segments_are_probed_and_detached(self):
|
||||||
|
app, lchan, mf, _, _, gsm, _ = setup()
|
||||||
|
before = set(gsm.children)
|
||||||
|
target, cleanup = _select_path(lchan, 'MF/7F20/A0B1/6F01', app)
|
||||||
|
self.assertEqual(lchan.probes, ['a0b1', '6f01'])
|
||||||
|
self.assertEqual(target.fid, '6f01')
|
||||||
|
cleanup()
|
||||||
|
self.assertEqual(set(gsm.children), before)
|
||||||
|
self.assertIs(lchan.selected_file, mf)
|
||||||
|
|
||||||
|
def test_model_known_selection_never_mutates_the_tree(self):
|
||||||
|
app, lchan, _, _, _, gsm, spn = setup()
|
||||||
|
before = {id(k): k for k in gsm.children}
|
||||||
|
_select_with_parent(lchan, '6f46', None, app, parent_path=['MF', '7F20'])
|
||||||
|
self.assertEqual({id(k): k for k in gsm.children}, before)
|
||||||
|
self.assertEqual(lchan.probes, [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user