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.
This commit is contained in:
2026-09-12 21:45:57 +03:00
parent 85a66af7a5
commit ddc6cb8f9d
7 changed files with 406 additions and 68 deletions
+17 -4
View File
@@ -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}]}
+30 -31
View File
@@ -5846,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();
@@ -5940,9 +5952,8 @@ async function pysimFsRefresh() {
async function pysimFsLoadChildren(node) { async function pysimFsLoadChildren(node) {
if (node.children) return; if (node.children) return;
try { try {
const 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); const data = await pysimFetch('/api/tree', body);
if (!data || data.exists === false || data.success === false || data.error) { if (!data || data.exists === false || data.success === false || data.error) {
throw new Error((data && data.error) || 'File not found'); throw new Error((data && data.error) || 'File not found');
@@ -5968,9 +5979,8 @@ async function pysimFsLoadChildren(node) {
let _pysimFsProbe = null; let _pysimFsProbe = null;
function pysimFsSelectBody(node) { function pysimFsSelectBody(node) {
const 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;
return body; return body;
} }
@@ -6118,15 +6128,8 @@ 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 = [];
@@ -6154,14 +6157,9 @@ async function pysimFsRead() {
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;
@@ -6241,9 +6239,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';
@@ -6263,9 +6260,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 + ')';
@@ -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) } // 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 {
@@ -7766,7 +7764,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;
@@ -7777,7 +7776,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) {
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'otaman-v117'; const CACHE = 'otaman-v118';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+2
View File
@@ -23,6 +23,7 @@ function extractFunc(src, name) {
let code = ''; let code = '';
code += extractFunc(html, 'getParentSel') + '\n'; code += extractFunc(html, 'getParentSel') + '\n';
code += extractFunc(html, 'getParentPath') + '\n';
code += extractFunc(html, 'pysimFsLoadChildren') + '\n'; code += extractFunc(html, 'pysimFsLoadChildren') + '\n';
code += 'globalThis.pysimCustomInject = () => {};\n'; code += 'globalThis.pysimCustomInject = () => {};\n';
eval(code); eval(code);
@@ -58,6 +59,7 @@ test('tree error payload marks the directory as absent', async () => {
assert.strictEqual(renders, 1); assert.strictEqual(renders, 1);
assert.strictEqual(calls.length, 1); assert.strictEqual(calls.length, 1);
assert.strictEqual(calls[0].body.parent_sel, 'MF'); 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 () => { test('error payload without exists is not treated as an empty listing', async () => {
+14 -1
View File
@@ -22,7 +22,7 @@ function extractFunc(src, name) {
} }
let code = 'var pysimFsTreeRoot = null;\nvar _pysimFsProbe = null;\nvar pysimFsSort = "fid";\n'; 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 += extractFunc(html, fn) + '\n';
} }
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\nglobalThis.pysimCustomInject = () => {};\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'); 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 () => { test('children of an absent directory are neither fetched nor selected', async () => {
const stale = Object.assign(df('DF.C', '5f03'), { const stale = Object.assign(df('DF.C', '5f03'), {
exists: false, exists: false,
+159 -31
View File
@@ -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
@@ -2187,6 +2312,9 @@ class PysimHandler(BaseHTTPRequestHandler):
err = {'success': False, 'error': str(e), 'exists': False} 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:
+183
View File
@@ -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()