From cb63ff286d95edb708addc49483b99b39101e7e6 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: Tue, 22 Sep 2026 00:46:14 +0300 Subject: [PATCH] fix: keep pySim's command-set bookkeeping in sync in the ES10 selections After a profile switch the shell was unusable: every file select failed with 'Attribute already exists: do_decode_hex (ShellCommands)' and even equipping did not help - only a server restart recovered it. pySim's equip() unregisters only the command sets of the file that is selected at that moment, then re-registers everything while selecting MF/EF.ICCID and MF with the app as cmd_app. esim._select_isdr() selected the ISD-R ADF without cmd_app, so MF's sets (ShellCommands, ...) stayed registered while the selection moved to the ADF; the equip after the profile switch then died re-registering them (cmd2 raises CommandSetRegistrationError), leaving the app broken. - _select_isdr() passes the shell app as cmd_app, so the old file's sets are unregistered and the ADF's (none) registered. - _restore() passes it to soft_reset() too, so the MF re-selection updates the bookkeeping on both the success and the failure path. - _esim_reinit() probes select('MF', app) after the equip and reports reinitialized: false on a registration error (a card-level select failure, e.g. no active profile, is tolerated). - tests assert the cmd_app plumbing on the ISD-R select and the restore (profiles, chip and both switch paths). --- pysim_simple_server/esim.py | 15 +++++++++++---- pysim_simple_server/server.py | 14 ++++++++++++++ tests/test_esim.py | 25 +++++++++++++++++++------ 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/pysim_simple_server/esim.py b/pysim_simple_server/esim.py index d18c65a..746764c 100644 --- a/pysim_simple_server/esim.py +++ b/pysim_simple_server/esim.py @@ -54,14 +54,20 @@ def is_euicc(app): def _select_isdr(app): - """Select ISD-R on logical channel 0; returns the lchan's scc.""" + """Select ISD-R on logical channel 0; returns the lchan's scc. + + The shell app is passed as cmd_app so pySim keeps its command-set + bookkeeping in sync with the selection: ``equip()`` only unregisters the + sets of the file selected at that moment, so a selection made without + cmd_app leaves the old file's sets registered and the next equip dies + re-registering them (cmd2: 'Attribute already exists').""" rs = getattr(app, 'rs', None) apps = getattr(getattr(rs, 'mf', None), 'applications', None) or {} isd_r = apps.get(AID_ISD_R.lower()) if isd_r is None: raise EsimError('not_an_euicc') lchan = rs.lchan[0] - lchan.select_file(isd_r) + lchan.select_file(isd_r, app) return lchan.scc @@ -70,9 +76,10 @@ def _restore(app): Best effort: a card whose active profile is disabled has no filesystem to select, so the restore can legitimately fail - the selection metadata is - then guarded by the status endpoint instead of crashing it.""" + then guarded by the status endpoint instead of crashing it. The shell app + is passed as cmd_app so the command-set bookkeeping follows the selection.""" try: - app.rs.soft_reset() + app.rs.soft_reset(app) except Exception as e: sys.stderr.write('ESIM: selection restore failed: %s\n' % e) diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index 6a6510a..83a86af 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -19,6 +19,7 @@ from pysim_simple_server import netstate from pysim_simple_server import scp81 from pysim_simple_server import esim from smartcard.CardMonitoring import CardMonitor, CardObserver +from cmd2.exceptions import CommandSetRegistrationError import gsm0338 # registers 'gsm03.38' codec from construct import GreedyBytes @@ -2594,6 +2595,19 @@ def _esim_reinit(server): sys.stderr.write('ESIM: card gone during re-initialization\n') return False _apply_equipped_card(server) + # The equip must leave the shell's command-set registration consistent + # (pySim unregisters only the file selected at equip time). A broken + # registration only shows up on the next select made with the app, so + # probe it here: a card-level select failure (no active profile) is + # acceptable, a registration error is not. + try: + server.app.rs.lchan[0].select('MF', server.app) + except CommandSetRegistrationError as e: + sys.stderr.write('ESIM: shell command registration broken after ' + 're-initialization: %s\n' % e) + return False + except Exception as e: + sys.stderr.write('ESIM: post-equip MF select failed: %s\n' % e) sys.stderr.write('ESIM: re-initialized after profile switch\n') return True except Exception as e: diff --git a/tests/test_esim.py b/tests/test_esim.py index cdfe4a9..01ff100 100644 --- a/tests/test_esim.py +++ b/tests/test_esim.py @@ -33,9 +33,11 @@ class FakeLchan: def __init__(self): self.scc = SimpleNamespace(name='lchan-scc') self.selected = None + self.cmd_app = None - def select_file(self, app): + def select_file(self, app, cmd_app=None): self.selected = app + self.cmd_app = cmd_app def make_app(profile='Consumer eUICC (SGP.22)', isdr=True): @@ -46,13 +48,16 @@ def make_app(profile='Consumer eUICC (SGP.22)', isdr=True): rs = SimpleNamespace(profile=profile, mf=SimpleNamespace(applications=apps), lchan=[lchan], - resets=0) + resets=0, + soft_reset_cmd_app=None) - def soft_reset(): + def soft_reset(cmd_app=None): rs.resets += 1 + rs.soft_reset_cmd_app = cmd_app rs.soft_reset = soft_reset - return SimpleNamespace(rs=rs), lchan + app = SimpleNamespace(rs=rs) + return app, lchan def profile_info(iccid, aid, state=None, nickname=None, cls=None, owner=None, @@ -128,9 +133,12 @@ class EsimTests(unittest.TestCase): self.assertEqual(out['profiles'][1]['state'], 'disabled') self.assertIsNone(out['profiles'][1]['icon']) self.assertIsNone(out['profiles'][1]['icon_size']) - # ISD-R was selected and the previous selection restored + # ISD-R was selected (with the shell app, so pySim's command-set + # bookkeeping follows) and the previous selection restored the same way self.assertEqual(lchan.selected.name, 'ISD-R') + self.assertIs(lchan.cmd_app, app) self.assertEqual(app.rs.resets, 1) + self.assertIs(app.rs.soft_reset_cmd_app, app) # the request asks for every ProfileInfo tag self.assertIn('9F70', self.calls[0].to_tlv().hex().upper()) @@ -251,9 +259,12 @@ class EsimTests(unittest.TestCase): self.assertEqual(out['result'], 'ok') self.assertFalse(out['refresh_seen']) self.assertEqual(len(sent), 1) - # the ISD-R was selected for the command and the selection restored + # the ISD-R was selected for the command (with the shell app) and the + # selection restored the same way self.assertEqual(lchan.selected.name, 'ISD-R') + self.assertIs(lchan.cmd_app, app) self.assertEqual(app.rs.resets, 1) + self.assertIs(app.rs.soft_reset_cmd_app, app) def test_switch_profile_treats_91xx_as_ok_and_runs_the_chain(self): app, lchan = make_app() @@ -268,7 +279,9 @@ class EsimTests(unittest.TestCase): self.assertTrue(out['refresh_seen']) self.assertEqual(chain, ['9111']) self.assertEqual(lchan.selected.name, 'ISD-R') + self.assertIs(lchan.cmd_app, app) self.assertEqual(app.rs.resets, 1) + self.assertIs(app.rs.soft_reset_cmd_app, app) def test_switch_profile_chain_failure_keeps_the_accepted_switch(self): app, _ = make_app()