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
+30 -31
View File
@@ -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
View File
@@ -1,4 +1,4 @@
const CACHE = 'otaman-v117';
const CACHE = 'otaman-v118';
const URLS = [
'index.html',
'help.html',
+2
View File
@@ -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 () => {
+14 -1
View File
@@ -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,