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:
+17
-4
@@ -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}]}
|
||||
|
||||
+30
-31
@@ -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) {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'otaman-v117';
|
||||
const CACHE = 'otaman-v118';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+159
-31
@@ -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:
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for the parent-scoped select helpers in pysim_otaman_server.server.
|
||||
|
||||
The helpers must resolve every model-known file strictly within the requested
|
||||
parent (no pySim global selectables, no probe_file model injection) and must
|
||||
detach any model-unknown file that had to be probed for a custom file.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
PROJECTS = Path(__file__).resolve().parents[2]
|
||||
PY_SIM = PROJECTS / 'pysim'
|
||||
if str(PY_SIM) not in sys.path:
|
||||
sys.path.insert(0, str(PY_SIM))
|
||||
|
||||
from pysim_otaman_server.server import (
|
||||
_app_by_sel,
|
||||
_fid4,
|
||||
_file_by_sel,
|
||||
_find_in_tree,
|
||||
_select_path,
|
||||
_select_with_parent,
|
||||
)
|
||||
|
||||
|
||||
class FakeFile:
|
||||
def __init__(self, fid=None, name=None, parent=None, aid=None):
|
||||
self.fid = fid
|
||||
self.name = name
|
||||
self.parent = parent
|
||||
self.aid = aid
|
||||
self.sfid = None
|
||||
self.children = {}
|
||||
|
||||
def add_files(self, files):
|
||||
for f in files:
|
||||
f.parent = self
|
||||
self.children[f.fid] = f
|
||||
|
||||
|
||||
class FakeMF(FakeFile):
|
||||
def __init__(self):
|
||||
super().__init__(fid='3f00', name='MF')
|
||||
self.applications = {}
|
||||
|
||||
|
||||
class FakeLchan:
|
||||
"""No .select() on purpose: any global-resolution call would fail loudly."""
|
||||
|
||||
def __init__(self, mf):
|
||||
self.selected_file = mf
|
||||
self.selects = []
|
||||
self.probes = []
|
||||
|
||||
def select_file(self, f, app=None):
|
||||
self.selected_file = f
|
||||
self.selects.append(f)
|
||||
|
||||
def probe_file(self, fid, app=None):
|
||||
self.probes.append(fid)
|
||||
f = FakeFile(fid=fid, name='EF.' + fid.upper(), parent=self.selected_file)
|
||||
self.selected_file.add_files([f])
|
||||
self.selected_file = f
|
||||
|
||||
|
||||
def build_model():
|
||||
mf = FakeMF()
|
||||
gsm = FakeFile('7f20', 'DF.GSM', mf)
|
||||
mf.children['7f20'] = gsm
|
||||
spn = FakeFile('6f46', 'EF.SPN', gsm)
|
||||
gsm.children['6f46'] = spn
|
||||
telecom = FakeFile('7f10', 'DF.TELECOM', mf)
|
||||
mf.children['7f10'] = telecom
|
||||
tel_ph = FakeFile('5f3a', 'DF.PHONEBOOK', telecom)
|
||||
telecom.children['5f3a'] = tel_ph
|
||||
usim = FakeFile(None, 'ADF.USIM', mf, aid='A0000000871002')
|
||||
mf.applications['a0000000871002'] = usim
|
||||
imsi = FakeFile('6f07', 'EF.IMSI', usim)
|
||||
usim.children['6f07'] = imsi
|
||||
usim_ph = FakeFile('5f3a', 'DF.PHONEBOOK', usim)
|
||||
usim.children['5f3a'] = usim_ph
|
||||
return mf, usim, usim_ph, telecom, tel_ph, gsm, spn
|
||||
|
||||
|
||||
def setup():
|
||||
mf, usim, usim_ph, telecom, tel_ph, gsm, spn = build_model()
|
||||
app = SimpleNamespace(rs=SimpleNamespace(mf=mf))
|
||||
lchan = FakeLchan(mf)
|
||||
return app, lchan, mf, usim, usim_ph, gsm, spn
|
||||
|
||||
|
||||
class FidHelpersTest(unittest.TestCase):
|
||||
def test_fid4(self):
|
||||
self.assertTrue(_fid4('6F07'))
|
||||
self.assertFalse(_fid4('EF.IMSI'))
|
||||
self.assertFalse(_fid4('6F0'))
|
||||
|
||||
def test_file_by_sel_matches_fid_and_name(self):
|
||||
_, _, _, _, _, gsm, spn = setup()
|
||||
self.assertIs(_file_by_sel(gsm, '6f46'), spn)
|
||||
self.assertIs(_file_by_sel(gsm, 'EF.SPN'), spn)
|
||||
self.assertIsNone(_file_by_sel(gsm, '6f07'))
|
||||
|
||||
def test_find_in_tree_reports_duplicates(self):
|
||||
app, _, mf, _, _, _, _ = setup()
|
||||
self.assertEqual(len(_find_in_tree(mf, '5f3a')), 2)
|
||||
self.assertEqual(len(_find_in_tree(mf, 'EF.IMSI')), 1)
|
||||
|
||||
|
||||
class ParentScopedSelectTest(unittest.TestCase):
|
||||
def test_duplicate_fid_is_resolved_under_the_walked_parent(self):
|
||||
app, lchan, mf, usim, usim_ph, _, _ = setup()
|
||||
target, cleanup = _select_with_parent(lchan, '5f3a', None, app, parent_path=['MF', 'A0000000871002'])
|
||||
self.assertIs(target, usim_ph)
|
||||
self.assertIsNone(cleanup)
|
||||
self.assertEqual(lchan.selects, [mf, usim, usim_ph])
|
||||
self.assertEqual(lchan.probes, [])
|
||||
|
||||
def test_path_with_fids_selects_exactly(self):
|
||||
app, lchan, mf, _, _, gsm, spn = setup()
|
||||
target, cleanup = _select_path(lchan, 'MF/7F20/6F46', app)
|
||||
self.assertIs(target, spn)
|
||||
self.assertIsNone(cleanup)
|
||||
self.assertEqual(lchan.selects, [mf, gsm, spn])
|
||||
|
||||
def test_path_with_aid_root_selects_application(self):
|
||||
app, lchan, _, usim, _, _, _ = setup()
|
||||
target, _ = _select_path(lchan, 'A0000000871002/6F07', app)
|
||||
self.assertEqual(target.fid, '6f07')
|
||||
self.assertEqual(lchan.selected_file.parent, usim)
|
||||
|
||||
def test_ambiguous_legacy_parent_selector_is_rejected(self):
|
||||
app, lchan, _, _, _, _, _ = setup()
|
||||
with self.assertRaisesRegex(RuntimeError, 'Ambiguous'):
|
||||
_select_with_parent(lchan, '6f07', '5f3a', app)
|
||||
|
||||
def test_unknown_name_is_not_probed(self):
|
||||
app, lchan, _, _, _, gsm, _ = setup()
|
||||
with self.assertRaisesRegex(RuntimeError, 'File not found'):
|
||||
_select_with_parent(lchan, 'NOSUCH', None, app, parent_path=['MF', '7F20'])
|
||||
self.assertEqual(lchan.probes, [])
|
||||
|
||||
def test_unknown_fid_without_allow_probe_does_not_touch_the_card(self):
|
||||
app, lchan, _, _, _, gsm, _ = setup()
|
||||
with self.assertRaisesRegex(RuntimeError, 'File not found'):
|
||||
_select_with_parent(lchan, '6f99', None, app, parent_path=['MF', '7F20'])
|
||||
self.assertEqual(lchan.probes, [])
|
||||
|
||||
def test_allow_probe_detaches_the_temporary_file_and_restores_selection(self):
|
||||
app, lchan, mf, _, _, gsm, _ = setup()
|
||||
before = set(gsm.children)
|
||||
target, cleanup = _select_with_parent(lchan, '6f99', None, app, parent_path=['MF', '7F20'], allow_probe=True)
|
||||
self.assertEqual(lchan.probes, ['6f99'])
|
||||
self.assertEqual(target.fid, '6f99')
|
||||
self.assertIsNotNone(cleanup)
|
||||
self.assertIn('6f99', gsm.children)
|
||||
cleanup()
|
||||
self.assertEqual(set(gsm.children), before)
|
||||
self.assertIs(lchan.selected_file, mf)
|
||||
|
||||
def test_custom_path_segments_are_probed_and_detached(self):
|
||||
app, lchan, mf, _, _, gsm, _ = setup()
|
||||
before = set(gsm.children)
|
||||
target, cleanup = _select_path(lchan, 'MF/7F20/A0B1/6F01', app)
|
||||
self.assertEqual(lchan.probes, ['a0b1', '6f01'])
|
||||
self.assertEqual(target.fid, '6f01')
|
||||
cleanup()
|
||||
self.assertEqual(set(gsm.children), before)
|
||||
self.assertIs(lchan.selected_file, mf)
|
||||
|
||||
def test_model_known_selection_never_mutates_the_tree(self):
|
||||
app, lchan, _, _, _, gsm, spn = setup()
|
||||
before = {id(k): k for k in gsm.children}
|
||||
_select_with_parent(lchan, '6f46', None, app, parent_path=['MF', '7F20'])
|
||||
self.assertEqual({id(k): k for k in gsm.children}, before)
|
||||
self.assertEqual(lchan.probes, [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user