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
+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()