From b0b8d02fe7fd15538facd8b52f96523f76fef2c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Wed, 16 Sep 2026 23:47:30 +0300 Subject: [PATCH] custom files: any DF parent path at any depth (v2.2.11) The parent field was a select over custom DFs only, so a standard DF (e.g. MF/7F20) could not be a parent and canonical entries under one were dropped on import. It is now a free-text path with suggestions from the root, custom DFs and DFs of the loaded tree (any depth); a bare FID chain is completed from the root selector. Validation accepts standard or not-yet-seen parents (flagged in the list), rejects a parent known to be an EF, and import keeps such entries. Custom-file edits re-inject the loaded tree: new nodes appear at once, renamed model nodes are restored and injected nodes removed when their entry goes away. Help updated (the form lives in Profiler, not Card reader). SW cache otaman-v176. --- frontend/help-ru.html | 6 +- frontend/help.html | 6 +- frontend/index.html | 166 +++++++++++++++++++++++---- frontend/sw.js | 2 +- frontend/tests/custom_files.test.js | 98 +++++++++++++--- frontend/tests/custom_inject.test.js | 27 ++++- pyproject.toml | 2 +- pysim_otaman_server/server.py | 2 +- 8 files changed, 255 insertions(+), 54 deletions(-) diff --git a/frontend/help-ru.html b/frontend/help-ru.html index 3de52a0..87d0fce 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -338,8 +338,8 @@

Сценарий A — Работа с файлами, не входящими в модель pySim («Пользовательские файлы»)

  1. Получите FID целевого файла (документация вендора или анализ ATR/файловой системы; такие файлы часто отсутствуют в открытых спецификациях).
  2. -
  3. Откройте вкладку «Картридер» → подвкладку «Пользовательские файлы».
  4. -
  5. Введите полный путь (например, 3F00/7F20/6F46) и псевдоним (например, EF.SPN).
  6. +
  7. Откройте вкладку «Профайлер» → подвкладку «Пользовательские файлы».
  8. +
  9. Выберите корень, введите путь родительского DF (стандартный DF из дерева или пользовательский DF, любой вложенности — при вводе появляются подсказки), 4-hex FID и псевдоним (например, EF.SPN).
  10. Нажмите Добавить — файл появится в дереве курсивом (непроверенный).
  11. Кликните по файлу для проверки существования; при успехе (9000) он работает как обычный файл.
  12. Читайте, редактируйте и сохраняйте hex-данные; переключайте Данные как на карте / Декодированные данные.
  13. @@ -409,7 +409,7 @@

    Пользовательские файлы

    -

    Добавление файлов, не покрытых моделью pySim. Пользовательский файл всегда имеет полный путь от MF или ADF (ADF.USIM, ADF.ISIM): выберите корень, родительский DF (сам корень или DF, уже добавленный здесь — родитель должен существовать до добавления его файлов), 4-hex FID и псевдоним (EF.…/DF.…; префикс определяет, EF это или DF). Канонический путь убирает прежнюю неоднозначность, когда один и тот же файл можно было описать и относительно, и абсолютно. Добавленные файлы появляются в дереве «Файловый менеджер»; в каждой строке есть «Редактировать» (путь + псевдоним; изменение пути DF перепривязывает дочерние записи — кнопка становится «Сохранить», «Отмена» отменяет) и «Удалить» (удаление DF удаляет и его дочерние записи после подтверждения). Список сохраняется в localStorage; обмен — Экспорт в JSON / Экспорт в файл / Импорт; старые относительные пути (например, a153/4954) разрешаются при загрузке, неразрешимые отбрасываются и отмечаются в списке.

    +

    Добавление файлов, не покрытых моделью pySim. Пользовательский файл всегда имеет полный путь от MF или ADF (ADF.USIM, ADF.ISIM): выберите корень, введите путь родительского DF (сам корень, стандартный DF из дерева файлового менеджера или пользовательский DF — любой вложенности, с подсказками при вводе; родитель, ещё не встречавшийся в дереве, остаётся допустимым и помечается ), 4-hex FID и псевдоним (EF.…/DF.…; префикс определяет, EF это или DF). Канонический путь убирает прежнюю неоднозначность, когда один и тот же файл можно было описать и относительно, и абсолютно. Добавленные файлы появляются в дереве «Файловый менеджер»; в каждой строке есть «Редактировать» (путь + псевдоним; изменение пути DF перепривязывает дочерние записи — кнопка становится «Сохранить», «Отмена» отменяет) и «Удалить» (удаление DF удаляет и его дочерние записи после подтверждения). Список сохраняется в localStorage; обмен — Экспорт в JSON / Экспорт в файл / Импорт; старые относительные пути (например, a153/4954) разрешаются при загрузке, неразрешимые отбрасываются и отмечаются в списке.

    6. Симулятор телефона

    diff --git a/frontend/help.html b/frontend/help.html index ea260e1..a6967b6 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -338,8 +338,8 @@

    Scenario A — Working with files not in pySim’s model (Custom files)

    1. Obtain the FID of the target file (vendor documentation or ATR/file-system analysis; such files are often not in public specs).
    2. -
    3. Open the Card reader tab → Custom files sub-tab.
    4. -
    5. Enter the full path (e.g. 3F00/7F20/6F46) and an alias (e.g. EF.SPN).
    6. +
    7. Open the Profiler tab → Custom files sub-tab.
    8. +
    9. Pick the root, type the parent DF path (a standard DF from the tree or a custom DF, any depth — suggestions appear while you type), the 4-hex FID and an alias (e.g. EF.SPN).
    10. Click Add — the file appears in the tree in italics (unverified).
    11. Click the file to verify existence; on success (9000) it behaves like a normal file.
    12. Read, edit and save hex data; toggle Raw/Decoded views.
    13. @@ -409,7 +409,7 @@

      Custom files

      -

      Add files that pySim’s model does not cover. A custom file always has a full path rooted at MF or an ADF (ADF.USIM, ADF.ISIM): pick the root, the parent DF (the root itself, or a DF that was already added here — a parent must exist before its files can be added), the 4-hex FID and an alias (EF.…/DF.…; the prefix decides whether the entry is an EF or a DF). The canonical path removes the old ambiguity where the same file could be described both relatively and absolutely. Added files appear in the File manager tree; each row has Edit (path + alias; changing a DF’s path re-points its child entries — the button becomes Save, Cancel aborts) and Delete (deleting a DF also deletes its child entries after a confirmation). The list persists in localStorage and can be shared with Export as JSON / Export to file / Import; legacy relative paths (e.g. a153/4954) are resolved on load, unresolvable ones are dropped and reported in the list.

      +

      Add files that pySim’s model does not cover. A custom file always has a full path rooted at MF or an ADF (ADF.USIM, ADF.ISIM): pick the root, type the parent DF path (the root itself, a standard DF known from the file-manager tree, or a custom DF — any depth, with suggestions while you type; a parent that has not been seen in the tree yet stays valid and is marked ), the 4-hex FID and an alias (EF.…/DF.…; the prefix decides whether the entry is an EF or a DF). The canonical path removes the old ambiguity where the same file could be described both relatively and absolutely. Added files appear in the File manager tree; each row has Edit (path + alias; changing a DF’s path re-points its child entries — the button becomes Save, Cancel aborts) and Delete (deleting a DF also deletes its child entries after a confirmation). The list persists in localStorage and can be shared with Export as JSON / Export to file / Import; legacy relative paths (e.g. a153/4954) are resolved on load, unresolvable ones are dropped and reported in the list.

      6. Phone simulator

      diff --git a/frontend/index.html b/frontend/index.html index a6c87c4..309645b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -18,7 +18,7 @@
      -

      OTAMan SIM OTA with a Human Face v2.2.10

      +

      OTAMan SIM OTA with a Human Face v2.2.11

      @@ -744,7 +744,8 @@
      - + +
      @@ -757,7 +758,7 @@
      -
      Full path from MF or an ADF; the parent DF must be defined here first.
      +
      The parent may be any DF path — a standard DF from the tree or a custom DF, any depth; parents not yet seen in the tree are marked ⚠.
      @@ -6317,6 +6318,7 @@ function pysimCustomInject(node) { const fid = pysimCustomFid(c); const existing = (node.children || []).find(ch => (ch.fid || '').toUpperCase() === fid); if (existing) { + if (existing.modelName === undefined) existing.modelName = existing.name; existing.name = c.name; existing.kind = c.kind; existing.custom = true; @@ -6338,6 +6340,37 @@ function pysimCustomInject(node) { } } +// Re-apply the custom-file list to the already-loaded tree: drop injected +// nodes whose entry is gone, restore renamed model nodes, inject anew. +function pysimCustomRefreshTree() { + if (!pysimFsTreeRoot) return; + const prune = node => { + if (!node.children) return; + node.children = node.children.filter(ch => { + if (!ch.custom) return true; + if (pysimCustomFiles.some(c => c.path === ch.customPath)) return true; + if (ch.modelName !== undefined) { + ch.name = ch.modelName; + delete ch.modelName; + delete ch.custom; + delete ch.customPath; + delete ch.kind; + return true; + } + return false; + }); + }; + const walk = node => { + prune(node); + pysimCustomInject(node); + for (const ch of (node.children || [])) { + if (ch.isDir) walk(ch); + } + }; + walk(pysimFsTreeRoot); + pysimFsRenderTree(); +} + let pysimFsSort = localStorage.getItem('otaman_fs_sort') === 'name' ? 'name' : 'fid'; function pysimFsSortChildren(children, mode) { @@ -6583,6 +6616,23 @@ function pysimFsFindNode(name, root) { return null; } +// Find a node by its canonical path ("MF/7F20/6F46", "ADF.USIM/6F07"), so +// custom-file parents can be resolved against the loaded tree (standard DFs +// included, at any depth). Returns null when the path is not loaded yet. +function pysimFsFindNodeByPath(path) { + const want = pysimCustomNormPath(path); + if (!want || !pysimFsTreeRoot) return null; + const walk = node => { + if (pysimCustomNormPath(pysimFsNodePath(node)) === want) return node; + for (const c of (node.children || [])) { + const hit = walk(c); + if (hit) return hit; + } + return null; + }; + return walk(pysimFsTreeRoot); +} + async function pysimFsToggleDir(name) { const node = pysimFsFindNode(name, pysimFsTreeRoot); if (!node || !node.isDir) return; @@ -8730,20 +8780,66 @@ function pysimCustomFid(entry) { return entry.path.split('/').pop(); } function pysimCustomParent(entry) { return entry.path.split('/').slice(0, -1).join('/'); } function pysimCustomRoot(entry) { return entry.path.split('/')[0]; } +// Parent DF suggestions for the current root: the root, custom DFs and every +// DF node known from the loaded file-manager tree (standard pySim files), +// at any depth. +function pysimCustomKnownDfPaths(root) { + const out = []; + const seen = new Set(); + const add = p => { if (p && !seen.has(p)) { seen.add(p); out.push(p); } }; + add(root); + for (const c of pysimCustomFiles) { + if (c.kind === 'df' && pysimCustomRoot(c) === root) add(c.path); + } + const walk = node => { + for (const ch of (node.children || [])) { + if (!ch.isDir) continue; + const p = pysimFsNodePath(ch); + if (p && p.split('/')[0] === root) add(p); + walk(ch); + } + }; + if (pysimFsTreeRoot) walk(pysimFsTreeRoot); + return out.sort(); +} + +// Where a parent path comes from: root, a custom DF, a DF of the loaded tree, +// a known non-DF (error), or unknown (allowed, flagged as a warning). +function pysimCustomParentStatus(parentPath) { + const p = pysimCustomNormPath(parentPath); + if (!p || p.split('/').length <= 1) return {status: 'root', source: null}; + const c = pysimCustomFiles.find(e => e.path === p); + if (c) return {status: c.kind === 'df' ? 'df' : 'not-df', source: 'custom'}; + const node = (typeof pysimFsFindNodeByPath === 'function') ? pysimFsFindNodeByPath(p) : null; + if (node) return {status: node.isDir ? 'df' : 'not-df', source: 'tree'}; + return {status: 'unknown', source: null}; +} + function pysimCustomValidate(root, parentPath, fid, name, entries, editIndex) { - if (!CUSTOM_ROOTS.includes(root)) { + parentPath = pysimCustomNormPath(parentPath) || root; + // a bare FID chain typed without the root is completed from the selector + const first = parentPath.split('/')[0]; + if (!CUSTOM_ROOTS.includes(first) && /^[0-9A-F]{4}$/.test(first)) { + parentPath = pysimCustomNormPath(root + '/' + parentPath); + } + if (!CUSTOM_ROOTS.includes(parentPath.split('/')[0])) { return {error: t('Root must be MF, ADF.USIM or ADF.ISIM')}; } + const segs = parentPath.split('/'); + if (segs.length > 1 && !segs.slice(1).every(s => /^[0-9A-F]{4}$/.test(s))) { + return {error: t('Parent must be the root or a chain of 4-hex FIDs (e.g. MF/7F20)')}; + } fid = (fid || '').trim().toUpperCase(); if (!/^[0-9A-F]{4}$/.test(fid)) return {error: t('FID must be 4 hex characters')}; const kind = pysimCustomKindForName(name); if (!kind) return {error: t('Alias must start with "EF." or "DF."')}; - if (parentPath !== root) { - const idx = entries.findIndex((e, i) => i !== editIndex && e.path === parentPath); - if (idx < 0) return {error: t('Parent DF is not defined as a custom file:') + ' ' + parentPath}; - if (entries[idx].kind !== 'df') return {error: t('Parent is not a DF:') + ' ' + parentPath}; + // A parent known here to be an EF is an error; a parent that is a standard + // DF (or not seen yet) is fine - the tree resolves it later. + const parent = entries.find((e, i) => i !== editIndex && e.path === parentPath); + if (parent && parent.kind !== 'df') { + return {error: t('Parent is not a DF:') + ' ' + parentPath}; } - const path = (parentPath === root ? root : parentPath) + '/' + fid; + const path = parentPath + '/' + fid; if (entries.some((e, i) => i !== editIndex && e.path === path)) { return {error: t('File already defined:') + ' ' + path}; } @@ -8836,25 +8932,36 @@ function pysimCustomRenderRoots() { } function pysimCustomRenderParents() { - const sel = document.getElementById('pysim-cf-parent'); - if (!sel) return; + const list = document.getElementById('pysim-cf-parent-list'); + if (!list) return; const root = document.getElementById('pysim-cf-root').value || CUSTOM_ROOTS[0]; - const prev = sel.value; - let html = ''; - for (const c of pysimCustomFiles) { - if (c.kind !== 'df' || pysimCustomRoot(c) !== root) continue; - html += ''; - } - sel.innerHTML = html; - if (prev && (prev === root || pysimCustomFiles.some(c => c.path === prev && c.kind === 'df'))) { - sel.value = prev; + let html = ''; + for (const p of pysimCustomKnownDfPaths(root)) { + const c = pysimCustomFiles.find(e => e.path === p && e.kind === 'df'); + html += ''; } + list.innerHTML = html; } function pysimCustomRootChanged() { + const sel = document.getElementById('pysim-cf-root'); + const inp = document.getElementById('pysim-cf-parent'); + const first = pysimCustomNormPath(inp && inp.value).split('/')[0]; + if (CUSTOM_ROOTS.includes(first) && first !== sel.value) inp.value = sel.value; pysimCustomRenderParents(); } +// Keep the root selector (and its suggestions) in step with a typed parent path. +function pysimCustomParentInput() { + const sel = document.getElementById('pysim-cf-root'); + const inp = document.getElementById('pysim-cf-parent'); + const first = pysimCustomNormPath(inp && inp.value).split('/')[0]; + if (CUSTOM_ROOTS.includes(first) && sel.value !== first) { + sel.value = first; + pysimCustomRenderParents(); + } +} + function pysimCustomSubmit() { const root = document.getElementById('pysim-cf-root').value; const parentPath = document.getElementById('pysim-cf-parent').value || root; @@ -8863,6 +8970,11 @@ function pysimCustomSubmit() { const name = nameEl.value.trim(); const res = pysimCustomValidate(root, parentPath, fid, name, pysimCustomFiles, pysimCustomEditIndex); if (res.error) { alert(res.error); return; } + const parent = pysimCustomParent({path: res.path}); + if (pysimCustomParentStatus(parent).status === 'not-df') { + alert(t('Parent is not a DF:') + ' ' + parent); + return; + } if (pysimCustomEditIndex !== null) { const oldPath = pysimCustomFiles[pysimCustomEditIndex].path; if (oldPath !== res.path) pysimCustomRewriteDescendants(pysimCustomFiles, oldPath, res.path); @@ -8873,6 +8985,7 @@ function pysimCustomSubmit() { pysimCustomSave(); pysimCustomRender(); pysimCustomEditCancel(); + pysimCustomRefreshTree(); } function pysimCustomEdit(i) { @@ -8919,6 +9032,7 @@ function pysimCustomRemove(i) { } pysimCustomSave(); pysimCustomRender(); + pysimCustomRefreshTree(); } function pysimCustomRender() { @@ -8936,8 +9050,11 @@ function pysimCustomRender() { } for (let i = 0; i < pysimCustomFiles.length; i++) { const c = pysimCustomFiles[i]; + const warn = pysimCustomParentStatus(pysimCustomParent(c)).status === 'unknown' + ? ' ' + : ''; html += '
      '; - html += '' + esc(c.path) + ' ' + esc(c.name) + ''; + html += '' + warn + esc(c.path) + ' ' + esc(c.name) + ''; html += '(' + esc(c.kind.toUpperCase()) + ')'; html += ''; html += ''; @@ -8969,6 +9086,7 @@ async function pysimCustomImport(text) { pysimCustomFiles = res.files; pysimCustomSave(); pysimCustomRender(); + pysimCustomRefreshTree(); const el = document.getElementById('pysim-cf-io'); el.value = 'Imported ' + added + ' file(s)' + (res.dropped ? ', dropped ' + res.dropped : '') + '.'; setTimeout(() => { el.classList.add('hidden'); }, 2000); @@ -11309,14 +11427,14 @@ const LANG_RU = { 'Parent DF': 'Родительский DF', 'FID (4 hex)': 'FID (4 hex)', 'Alias (EF. / DF.)': 'Псевдоним (EF. / DF.)', - 'Full path from MF or an ADF; the parent DF must be defined here first.': 'Полный путь от MF или ADF; родительский DF должен быть создан здесь заранее.', + 'The parent may be any DF path — a standard DF from the tree or a custom DF, any depth; parents not yet seen in the tree are marked ⚠.': 'Родителем может быть любой путь DF — стандартный DF из дерева или пользовательский DF, любой вложенности; DF, ещё не встречавшиеся в дереве, помечаются ⚠.', 'Root must be MF, ADF.USIM or ADF.ISIM': 'Корень должен быть MF, ADF.USIM или ADF.ISIM', 'FID must be 4 hex characters': 'FID должен состоять из 4 hex-символов', 'Alias must start with "EF." or "DF."': 'Псевдоним должен начинаться с «EF.» или «DF.»', - 'Parent DF is not defined as a custom file:': 'Родительский DF не создан как пользовательский файл:', + 'Parent must be the root or a chain of 4-hex FIDs (e.g. MF/7F20)': 'Родитель — корень или цепочка 4-hex FID (например, MF/7F20)', 'Parent is not a DF:': 'Родитель не является DF:', + 'Parent DF not seen in the tree yet': 'Родительский DF ещё не встречался в дереве', 'File already defined:': 'Файл уже задан:', - '— root —': '— корень —', 'and': 'и', 'child file(s)?': 'дочерних файлов?', 'Dropped invalid legacy entries:': 'Отброшено некорректных старых записей:', diff --git a/frontend/sw.js b/frontend/sw.js index 59f95a8..9dda01f 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'otaman-v175'; +const CACHE = 'otaman-v176'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/custom_files.test.js b/frontend/tests/custom_files.test.js index 465c5cb..7d82950 100644 --- a/frontend/tests/custom_files.test.js +++ b/frontend/tests/custom_files.test.js @@ -21,17 +21,18 @@ function extractFunc(src, name) { return src.slice(m.index, i + 1); } -let code = 'var pysimCustomFiles = [];\nvar pysimCustomEditIndex = null;\nvar _pysimCustomDropped = 0;\n'; +let code = 'var pysimCustomFiles = [];\nvar pysimCustomEditIndex = null;\nvar _pysimCustomDropped = 0;\nvar pysimFsTreeRoot = null;\n'; for (const fn of ['pysimCustomNormPath', 'pysimCustomKindForName', 'pysimCustomFid', - 'pysimCustomParent', 'pysimCustomRoot', 'pysimCustomValidate', - 'pysimCustomRewriteDescendants', 'pysimCustomNormalizeEntries', 'pysimCustomSave', - 'pysimCustomRenderRoots', 'pysimCustomRenderParents', 'pysimCustomRootChanged', - 'pysimCustomSubmit', 'pysimCustomEdit', 'pysimCustomEditCancel', - 'pysimCustomRemove', 'pysimCustomRender']) { + 'pysimCustomParent', 'pysimCustomRoot', 'pysimCustomKnownDfPaths', 'pysimCustomParentStatus', + 'pysimCustomValidate', 'pysimCustomRewriteDescendants', 'pysimCustomNormalizeEntries', + 'pysimCustomSave', 'pysimCustomRenderRoots', 'pysimCustomRenderParents', + 'pysimCustomRootChanged', 'pysimCustomParentInput', 'pysimCustomSubmit', + 'pysimCustomEdit', 'pysimCustomEditCancel', 'pysimCustomRemove', 'pysimCustomRender', + 'pysimFsNodePath', 'pysimFsFindNodeByPath', 'pysimCustomInject', 'pysimCustomRefreshTree']) { code += extractFunc(html, fn) + '\n'; } code += html.match(/const CUSTOM_ROOTS = \[[^\]]*\];/)[0].replace('const ', 'var ') + '\n'; -code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\n'; +code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\nglobalThis.pysimFsRenderTree = () => {};\n'; eval(code); function fakeEl(id) { @@ -53,6 +54,7 @@ function setup(entries) { const els = { 'pysim-cf-root': fakeEl('pysim-cf-root'), 'pysim-cf-parent': fakeEl('pysim-cf-parent'), + 'pysim-cf-parent-list': fakeEl('pysim-cf-parent-list'), 'pysim-cf-fid': fakeEl('pysim-cf-fid'), 'pysim-cf-name': fakeEl('pysim-cf-name'), 'pysim-cf-list': fakeEl('pysim-cf-list'), @@ -73,6 +75,7 @@ function setup(entries) { pysimCustomFiles = (entries || []).map(e => Object.assign({}, e)); pysimCustomEditIndex = null; _pysimCustomDropped = 0; + pysimFsTreeRoot = null; pysimCustomRenderRoots(); pysimCustomRenderParents(); return els; @@ -115,12 +118,24 @@ test('migration resolves legacy relative paths and drops the unresolvable', () = assert.strictEqual(res.dropped, 2); }); -test('validation requires a defined parent DF and a valid FID/alias', () => { +test('validation accepts standard/unknown parents but rejects known EFs', () => { const files = [{ path: 'MF/A153', name: 'DF.A1', kind: 'df' }]; - // parent not defined - assert.match(pysimCustomValidate('MF', 'MF/A999', '6F46', 'EF.SPN', files, null).error, /not defined/); + // a parent that is not a custom entry is accepted: it may be a standard DF + // from the card model, or simply not seen in the tree yet + const unknown = pysimCustomValidate('MF', 'MF/A999', '6F46', 'EF.SPN', files, null); + assert.strictEqual(unknown.error, null); + assert.strictEqual(unknown.path, 'MF/A999/6F46'); // parent defined -> ok assert.strictEqual(pysimCustomValidate('MF', 'MF/A153', '6F46', 'EF.SPN', files, null).path, 'MF/A153/6F46'); + // a bare FID chain typed without the root is completed from the selector + assert.strictEqual(pysimCustomValidate('MF', 'A153', '6F46', 'EF.SPN', files, null).path, 'MF/A153/6F46'); + // deep chains are fine + assert.strictEqual(pysimCustomValidate('MF', 'MF/7F20/5F01', '6F46', 'EF.DEEP', [], null).path, 'MF/7F20/5F01/6F46'); + // a parent known here to be an EF is rejected + assert.match(pysimCustomValidate('MF', 'MF/A153/6F46', '1234', 'EF.X', + files.concat([{ path: 'MF/A153/6F46', name: 'EF.OTHER', kind: 'ef' }]), null).error, /not a DF/); + // parent segments must be 4-hex FIDs + assert.match(pysimCustomValidate('MF', 'MF/FOO', '6F46', 'EF.SPN', files, null).error, /4-hex/); // bad FID assert.match(pysimCustomValidate('MF', 'MF', '6F4', 'EF.SPN', files, null).error, /4 hex/); // bad alias @@ -135,21 +150,62 @@ test('validation requires a defined parent DF and a valid FID/alias', () => { assert.match(pysimCustomValidate('MFX', 'MFX', '6F46', 'EF.SPN', files, null).error, /Root/); }); -test('root and parent selectors list roots and defined DFs', () => { +test('root and parent suggestions list roots, custom DFs and tree DFs', () => { const els = setup([ { path: 'MF/A153', name: 'DF.A1', kind: 'df' }, { path: 'MF/A153/4954', name: 'EF.SPNS', kind: 'ef' }, { path: 'ADF.USIM/6F07', name: 'EF.IMSI', kind: 'ef' }, ]); assert.deepStrictEqual(els['pysim-cf-root'].options(), ['MF', 'ADF.USIM', 'ADF.ISIM']); - const parents = els['pysim-cf-parent'].options(); + let parents = els['pysim-cf-parent-list'].options(); assert.ok(parents.includes('MF')); assert.ok(parents.includes('MF/A153')); assert.ok(!parents.includes('MF/A153/4954'), 'EFs are not parent options'); + // DFs known from the loaded file tree are suggested, at any depth + pysimFsTreeRoot = { name: 'MF', fid: '3F00', isDir: true, parent: null, children: [] }; + const df = { name: 'DF.TELECOM', fid: '7F10', isDir: true, parent: pysimFsTreeRoot, children: [] }; + const sub = { name: 'DF.SUB', fid: '5F01', isDir: true, parent: df, children: [] }; + df.children = [sub]; + pysimFsTreeRoot.children = [df]; + pysimCustomRenderParents(); + parents = els['pysim-cf-parent-list'].options(); + assert.ok(parents.includes('MF/7F10')); + assert.ok(parents.includes('MF/7F10/5F01')); // the ADF root is always a valid parent els['pysim-cf-root'].value = 'ADF.USIM'; pysimCustomRootChanged(); - assert.deepStrictEqual(els['pysim-cf-parent'].options(), ['ADF.USIM']); + assert.deepStrictEqual(els['pysim-cf-parent-list'].options(), ['ADF.USIM']); +}); + +test('parent status classifies root, custom, tree and unknown parents', () => { + setup([ + { path: 'MF/A153', name: 'DF.A1', kind: 'df' }, + { path: 'MF/A153/4954', name: 'EF.SPNS', kind: 'ef' }, + ]); + assert.strictEqual(pysimCustomParentStatus('MF').status, 'root'); + assert.strictEqual(pysimCustomParentStatus('MF/A153').status, 'df'); + assert.strictEqual(pysimCustomParentStatus('MF/A153/4954').status, 'not-df'); + assert.strictEqual(pysimCustomParentStatus('MF/FFFF').status, 'unknown'); + pysimFsTreeRoot = { name: 'MF', fid: '3F00', isDir: true, parent: null, children: [] }; + const df = { name: 'DF.GSM', fid: '7F20', isDir: true, parent: pysimFsTreeRoot, children: [] }; + df.children = [{ name: 'EF.SPN', fid: '6F46', isDir: false, parent: df, children: null }]; + pysimFsTreeRoot.children = [df]; + assert.strictEqual(pysimCustomParentStatus('MF/7F20').status, 'df'); + assert.strictEqual(pysimCustomParentStatus('MF/7F20/6F46').status, 'not-df'); + assert.strictEqual(pysimCustomParentStatus('MF/7F20/9999').status, 'unknown'); +}); + +test('import keeps canonical entries whose parent is outside the custom list', () => { + const res = pysimCustomNormalizeEntries([ + { path: '3F00/7f20/5f01/6f46', name: 'EF.DEEP' }, + { path: 'mf/ffff/6f46', name: 'EF.ORPHAN' }, + { path: 'MF/6F46', name: 'NOPE' }, + ], []); + assert.deepStrictEqual(res.files, [ + { path: 'MF/7F20/5F01/6F46', name: 'EF.DEEP', kind: 'ef' }, + { path: 'MF/FFFF/6F46', name: 'EF.ORPHAN', kind: 'ef' }, + ]); + assert.strictEqual(res.dropped, 1); }); test('submit adds files under the root and under a defined DF', () => { @@ -165,13 +221,17 @@ test('submit adds files under the root and under a defined DF', () => { pysimCustomSubmit(); assert.deepStrictEqual(pysimCustomFiles.map(c => c.path), ['MF/6F46', 'MF/A153', 'MF/A153/4954']); - // adding under an undefined parent is rejected - fill(els, 'MF', 'MF', '1111', 'EF.X'); - els['pysim-cf-parent'].value = 'MF/A153'; - els['pysim-cf-fid'].value = '2222'; - els['pysim-cf-name'].value = 'BAD'; + // a parent that is not a custom entry is allowed (it may be a standard DF + // the tree has not loaded yet) + fill(els, 'MF', 'MF/A153', '2222', 'EF.ORPHAN'); pysimCustomSubmit(); - assert.ok(globalThis.alertCalls.length === 1); + assert.deepStrictEqual(pysimCustomFiles.map(c => c.path), + ['MF/6F46', 'MF/A153', 'MF/A153/4954', 'MF/A153/2222']); + assert.strictEqual(globalThis.alertCalls.length, 0); + // a parent known here to be an EF is rejected + fill(els, 'MF', 'MF/A153/4954', '3333', 'EF.NOPE'); + pysimCustomSubmit(); + assert.deepStrictEqual(globalThis.alertCalls, ['Parent is not a DF: MF/A153/4954']); }); test('editing a DF FID rewrites its descendants', () => { diff --git a/frontend/tests/custom_inject.test.js b/frontend/tests/custom_inject.test.js index a5ae495..f1caa80 100644 --- a/frontend/tests/custom_inject.test.js +++ b/frontend/tests/custom_inject.test.js @@ -21,10 +21,11 @@ function extractFunc(src, name) { return src.slice(m.index, i + 1); } -let code = 'var pysimCustomFiles = [];\n'; -for (const fn of ['pysimFsNodePath', 'pysimCustomInject', 'pysimCustomParent', 'pysimCustomFid']) { +let code = 'var pysimCustomFiles = [];\nvar pysimFsTreeRoot = null;\n'; +for (const fn of ['pysimFsNodePath', 'pysimCustomInject', 'pysimCustomRefreshTree', 'pysimCustomParent', 'pysimCustomFid']) { code += extractFunc(html, fn) + '\n'; } +code += 'globalThis.pysimFsRenderTree = () => {};\n'; eval(code); function node(name, fid, parent, children) { @@ -100,3 +101,25 @@ test('injected DFs are directories and can host their own children', () => { assert.strictEqual(df.children.length, 1); assert.strictEqual(df.children[0].name, 'EF.UNDER-NEW'); }); + +test('refresh restores renamed model nodes and drops injected ones', () => { + const t = tree(); + pysimFsTreeRoot = t.mf; + pysimCustomFiles = [{ path: 'MF/5F03/6F46', name: 'EF.RENAMED', kind: 'ef' }]; + pysimCustomRefreshTree(); + assert.strictEqual(t.dfC.children[0].name, 'EF.RENAMED'); + assert.strictEqual(t.dfC.children[0].custom, true); + // deleting the entry brings the model name back + pysimCustomFiles = []; + pysimCustomRefreshTree(); + assert.strictEqual(t.dfC.children[0].name, 'EF.X'); + assert.ok(!t.dfC.children[0].custom); + assert.strictEqual(t.dfC.children[0].modelName, undefined); + // injected nodes disappear together with their entry + pysimCustomFiles = [{ path: 'MF/5F10', name: 'DF.NEW', kind: 'df' }]; + pysimCustomRefreshTree(); + assert.ok(t.mf.children.some(c => c.fid === '5F10')); + pysimCustomFiles = []; + pysimCustomRefreshTree(); + assert.ok(!t.mf.children.some(c => c.fid === '5F10')); +}); diff --git a/pyproject.toml b/pyproject.toml index 41cb073..7075f93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-otaman-server" -version = "2.2.10" +version = "2.2.11" description = "HTTP REST server wrapping pysim for the OTAMan PWA" requires-python = ">=3.8" # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. diff --git a/pysim_otaman_server/server.py b/pysim_otaman_server/server.py index c96ae83..02a0a34 100644 --- a/pysim_otaman_server/server.py +++ b/pysim_otaman_server/server.py @@ -21,7 +21,7 @@ from osmocom.construct import GsmOrUcs2Adapter from osmocom.tlv import BER_TLV_IE -VERSION = '2.2.10' +VERSION = '2.2.11' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE