From ddc6cb8f9dfa515ca07509ec789c76f04a308471 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: Sat, 12 Sep 2026 21:45:57 +0300 Subject: [PATCH] fix: parent-scoped file selection; never mutate the pySim model pySim's lchan.select() resolves names against global selectables (self + parent chain + MF children + applications) and falls back to probe_file(), which blindly SELECTs an unknown FID and permanently injects a dynamically named DF.XXXX/EF.XXXX into the running filesystem model. Probing a whole tree or scanning a snapshot with custom files therefore polluted the model, made tree branches show children of the wrong object, and could persist phantom files into snapshots. - server: new _select_with_parent()/_select_path() walk the requested parent path (new parent_path field, parent_sel kept as legacy fallback) strictly through the model and call lchan.select_file() only; model-unknown 4-hex segments are probed only with allow_probe and the temporary child pySim adds is detached again via the cleanup callable that the four handlers (/api/tree|select|read|write) now run in a finally block - frontend: getParentPath() builds the segment chain (MF, ADF names, FIDs) and all tree/select/read/write bodies plus the snapshot/profile walker send parent_path; allow_probe is set only for custom files; the blind retries in the file manager were dropped - tests: tests/test_select_scope.py (duplicate-FID resolution, no APDU for unknown non-custom files, probe+detach, model unchanged); fs_load/fs_probe assertions for parent_path and allow_probe; docs/api.md and AGENTS.md document the contract; SW cache v117 -> v118. --- docs/api.md | 21 +++- frontend/index.html | 61 +++++----- frontend/sw.js | 2 +- frontend/tests/fs_load.test.js | 2 + frontend/tests/fs_probe.test.js | 15 ++- pysim_otaman_server/server.py | 190 ++++++++++++++++++++++++++------ tests/test_select_scope.py | 183 ++++++++++++++++++++++++++++++ 7 files changed, 406 insertions(+), 68 deletions(-) create mode 100644 tests/test_select_scope.py diff --git a/docs/api.md b/docs/api.md index c5ca26e..89e1044 100644 --- a/docs/api.md +++ b/docs/api.md @@ -272,7 +272,7 @@ Returns the current STK session state. Read file content. Auto-detects transparent vs record files. ```json -{"name": "EF.ICCID", "fid": "2FE2", "parent_sel": "3F00", "mode": "raw"} +{"name": "EF.ICCID", "fid": "2FE2", "parent_path": ["MF"], "mode": "raw"} ``` Returns transparent data: @@ -298,12 +298,12 @@ it for snapshot timing statistics. Other commands are not reported. Write raw hex data to a file. ```json -{"name": "EF.ICCID", "fid": "2FE2", "data": "A0A1A2...", "parent_sel": "3F00"} +{"name": "EF.ICCID", "fid": "2FE2", "data": "A0A1A2...", "parent_path": ["MF"]} ``` For record files: ```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: @@ -316,9 +316,19 @@ Returns: Select a file by name or FID, with optional parent selection. ```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: ```json {"name": "EF.ICCID", "fid": "2FE2", "file_type": "transparent", @@ -340,6 +350,9 @@ Get directory listing with typed children. {"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: ```json {"exists": true, "name": "MF", "fid": "3F00", "file_type": "df", "children": [{"name": "EF.ICCID", "fid": "2fe2", "isDir": false}]} diff --git a/frontend/index.html b/frontend/index.html index 132f3f8..fb2d836 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5846,6 +5846,18 @@ function getParentSel(node) { 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) { if (!node || !node.fid) return; const pfid = node.fid.toLowerCase(); @@ -5940,9 +5952,8 @@ async function pysimFsRefresh() { async function pysimFsLoadChildren(node) { if (node.children) return; try { - const body = { name: node.name, fid: node.fid }; - const parentSel = getParentSel(node); - if (parentSel) body.parent_sel = parentSel; + const body = { name: node.name, fid: node.fid, parent_sel: getParentSel(node), parent_path: getParentPath(node) }; + if (node.custom) body.allow_probe = true; const data = await pysimFetch('/api/tree', body); if (!data || data.exists === false || data.success === false || data.error) { throw new Error((data && data.error) || 'File not found'); @@ -5968,9 +5979,8 @@ async function pysimFsLoadChildren(node) { let _pysimFsProbe = null; function pysimFsSelectBody(node) { - const body = { name: node.name, fid: node.fid }; - const parentSel = getParentSel(node); - if (parentSel) body.parent_sel = parentSel; + 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; } @@ -6118,15 +6128,8 @@ async function pysimFsClickFile(name) { pysimFsResetEdit(); pysimFsSetMode('raw'); const node = pysimFsFindNode(name, pysimFsTreeRoot); - let body = { name: name, fid: node ? node.fid : null }; - const parentSel = getParentSel(node); - 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; + const sel = await pysimFetch('/api/select', pysimFsSelectBody(node || { name: name, fid: null })); + const exists = !!(sel && sel.exists !== false && !sel.error); if (node) { node.exists = exists; if (!exists) node.children = []; @@ -6154,14 +6157,9 @@ async function pysimFsRead() { statusEl.textContent = 'Loading...'; try { const node = pysimFsFindNode(rawName, pysimFsTreeRoot); - let body = { name: rawName, fid: node ? node.fid : null, mode: pysimFsDecodedMode ? 'decoded' : 'raw' }; - const parentSel = getParentSel(node); - if (parentSel) body.parent_sel = parentSel; - 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); - } + const body = pysimFsSelectBody(node || { name: rawName, fid: null }); + body.mode = pysimFsDecodedMode ? 'decoded' : 'raw'; + const data = await pysimFetch('/api/read', body); if (!data.success) { statusEl.textContent = 'SW: ' + (data.sw || '?') + ' — ' + (data.error || 'Error'); return; @@ -6241,9 +6239,8 @@ async function pysimFsSave() { const textareas = out.querySelectorAll('textarea'); if (textareas.length === 1) { // Transparent file - const body = { name: rawName, fid: node ? node.fid : null, data: textareas[0].value.replace(/[^0-9a-fA-F]/g, '') }; - const parentSel = getParentSel(node); - if (parentSel) body.parent_sel = parentSel; + const body = pysimFsSelectBody(node || { name: rawName, fid: null }); + body.data = textareas[0].value.replace(/[^0-9a-fA-F]/g, ''); const data = await pysimFetch('/api/write', body); if (data.success) { statusEl.textContent = 'SW: ' + data.sw + ' OK'; @@ -6263,9 +6260,9 @@ async function pysimFsSave() { wroteAny = true; const num = row?.querySelector('span')?.textContent; 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 parentSel = getParentSel(node); - if (parentSel) body.parent_sel = parentSel; + const body = pysimFsSelectBody(node || { name: rawName, fid: null }); + body.data = inp.value.replace(/[^0-9a-fA-F]/g, ''); + body.record_nr = parseInt(num); const data = await pysimFetch('/api/write', body); if (!data.success) { statusEl.textContent = 'SW: ' + (data.sw || '?') + ' — ' + (data.error || 'Error') + ' (record ' + num + ')'; @@ -7751,6 +7748,7 @@ async function profilerScanCard(ignoreFids, ignoreNames, fciMode, onProgress, ma // dir: { name, fid, parentSel (to select this dir), pathPrefix (rule-path segs for its children) } async function walkDir(dir) { 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; let data; try { @@ -7766,7 +7764,8 @@ async function profilerScanCard(ignoreFids, ignoreNames, fciMode, onProgress, ma // ADF roots use the AID, not the generic ADF fid const childPrefix = c.aid ? [c.aid.toUpperCase()] : 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 { const childPath = dir.pathPrefix.concat(c.fid ? c.fid.toUpperCase() : c.name).join('/'); if (seen.has(childPath)) continue; @@ -7777,7 +7776,7 @@ async function profilerScanCard(ignoreFids, ignoreNames, fciMode, onProgress, ma } // 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) for (const cf of pysimCustomFiles) { diff --git a/frontend/sw.js b/frontend/sw.js index 3939048..a0b5857 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'otaman-v117'; +const CACHE = 'otaman-v118'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/fs_load.test.js b/frontend/tests/fs_load.test.js index 9ead305..23bdc70 100644 --- a/frontend/tests/fs_load.test.js +++ b/frontend/tests/fs_load.test.js @@ -23,6 +23,7 @@ function extractFunc(src, name) { let code = ''; code += extractFunc(html, 'getParentSel') + '\n'; +code += extractFunc(html, 'getParentPath') + '\n'; code += extractFunc(html, 'pysimFsLoadChildren') + '\n'; code += 'globalThis.pysimCustomInject = () => {};\n'; eval(code); @@ -58,6 +59,7 @@ test('tree error payload marks the directory as absent', async () => { 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 () => { diff --git a/frontend/tests/fs_probe.test.js b/frontend/tests/fs_probe.test.js index 1cd100a..d8b446f 100644 --- a/frontend/tests/fs_probe.test.js +++ b/frontend/tests/fs_probe.test.js @@ -22,7 +22,7 @@ function extractFunc(src, name) { } let code = 'var pysimFsTreeRoot = null;\nvar _pysimFsProbe = null;\nvar pysimFsSort = "fid";\n'; -for (const fn of ['getParentSel', 'pysimFsSortChildren', 'pysimFsLoadChildren', 'pysimFsSelectBody', 'pysimFsProbeUi', 'pysimFsProbeAll']) { +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'; @@ -123,6 +123,19 @@ test('stop halts the walk and still reports a summary', async () => { 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, diff --git a/pysim_otaman_server/server.py b/pysim_otaman_server/server.py index 5ee5599..b709b40 100644 --- a/pysim_otaman_server/server.py +++ b/pysim_otaman_server/server.py @@ -214,39 +214,143 @@ def _get_file_type(lchan, cur_file): return None -def _select_with_parent(lchan, name, parent_sel, app): - if parent_sel: - lchan.select(parent_sel, app) - fcp = lchan.select(name, app) - return fcp +def _fid4(sel): + """True if sel is a 4-digit hex FID.""" + return bool(re.fullmatch(r'[0-9a-fA-F]{4}', str(sel or ''))) + + +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): """Select a file described by a full path. - Path is '/' separated; the first element is either 'MF' (or the MF fid - '3F00') or an ADF AID (hex). Remaining elements are FIDs or file names. - pySim's lchan.select() cannot select an ADF by its raw AID (selectables are - keyed by name/fid only), so ADF roots are resolved through rs.mf.applications. + Path is '/' separated; the first element is 'MF' (or '3F00'), an ADF AID, + or an ADF name; remaining elements are FIDs or file names. Resolution is + strictly parent-scoped (see _select_with_parent); unknown 4-hex segments + are custom files and are probed without touching the model tree. """ parts = [p for p in (path or '').split('/') if p] if not parts: raise RuntimeError('Empty path') - rs = app.rs - 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 + return _select_with_parent(lchan, parts[-1], None, app, parent_path=parts[:-1], allow_probe=True) def _parse_tree_output(output): @@ -1960,22 +2064,25 @@ class PysimHandler(BaseHTTPRequestHandler): fid = body.get('fid') name = fid if fid else body.get('name', '') parent_sel = body.get('parent_sel') + parent_path = body.get('parent_path') + allow_probe = bool(body.get('allow_probe')) rs = app.rs if not rs: self._send_json({'error': _err('no_card_state', lang)}, 503) self._log_resp({'error': _err('no_card_state', lang)}) return lchan = rs.lchan[0] + cleanup = None try: _collect_apdu_times() try: if path: - _select_path(lchan, path, app) + cur, cleanup = _select_path(lchan, path, app) else: - _select_with_parent(lchan, name, parent_sel, app) + cur, cleanup = _select_with_parent(lchan, name, parent_sel, app, parent_path, allow_probe) finally: apdu_times = _end_apdu_time_collection() - cur = lchan.selected_file + cur = cur or lchan.selected_file data = { 'name': cur.name if cur 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} self._send_json(err, 404) self._log_resp(err) + finally: + if cleanup: + cleanup() elif self.path == '/api/read': app = self.server.app if not app: @@ -2005,6 +2115,8 @@ class PysimHandler(BaseHTTPRequestHandler): fid = body.get('fid') name = fid if fid else body.get('name', '') parent_sel = body.get('parent_sel') + parent_path = body.get('parent_path') + allow_probe = bool(body.get('allow_probe')) mode = body.get('mode', 'raw') rs = app.rs if not rs: @@ -2012,14 +2124,15 @@ class PysimHandler(BaseHTTPRequestHandler): self._log_resp({'error': _err('no_card_state', lang)}) return lchan = rs.lchan[0] + cleanup = None try: _collect_apdu_times() try: sel = fid if fid else name if path: - _select_path(lchan, path, app) + _, cleanup = _select_path(lchan, path, app) 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) is_record = ft in ('linear_fixed', 'cyclic') if mode == 'decoded': @@ -2073,6 +2186,9 @@ class PysimHandler(BaseHTTPRequestHandler): err = {'success': False, 'error': str(e)} self._send_json(err, 500) self._log_resp(err) + finally: + if cleanup: + cleanup() elif self.path == '/api/write': app = self.server.app if not app: @@ -2086,15 +2202,18 @@ class PysimHandler(BaseHTTPRequestHandler): fid = body.get('fid') record_nr = body.get('record_nr') parent_sel = body.get('parent_sel') + parent_path = body.get('parent_path') + allow_probe = bool(body.get('allow_probe')) rs = app.rs if not rs: self._send_json({'error': _err('no_card_state', lang)}, 503) self._log_resp({'error': _err('no_card_state', lang)}) return lchan = rs.lchan[0] + cleanup = None try: 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) is_record = ft in ('linear_fixed', 'cyclic') if record_nr: @@ -2131,6 +2250,9 @@ class PysimHandler(BaseHTTPRequestHandler): err = {'success': False, 'error': str(e)} self._send_json(err, 500) self._log_resp(err) + finally: + if cleanup: + cleanup() elif self.path == '/api/tree': app = self.server.app if not app: @@ -2143,15 +2265,18 @@ class PysimHandler(BaseHTTPRequestHandler): name = fid if fid else body.get('name', '') fid = body.get('fid') parent_sel = body.get('parent_sel') + parent_path = body.get('parent_path') + allow_probe = bool(body.get('allow_probe')) rs = app.rs if not rs: self._send_json({'error': _err('no_card_state', lang)}, 503) self._log_resp({'error': _err('no_card_state', lang)}) return lchan = rs.lchan[0] + cleanup = None try: 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 out = StringIO() old_stdout = app.stdout @@ -2187,6 +2312,9 @@ class PysimHandler(BaseHTTPRequestHandler): err = {'success': False, 'error': str(e), 'exists': False} self._send_json(err, 500) self._log_resp(err) + finally: + if cleanup: + cleanup() elif self.path == '/api/menu-select': scc = self.server.scc if not scc: diff --git a/tests/test_select_scope.py b/tests/test_select_scope.py new file mode 100644 index 0000000..a5fc0f8 --- /dev/null +++ b/tests/test_select_scope.py @@ -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()