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).
This commit is contained in:
2026-09-22 00:46:14 +03:00
parent f6d62c225c
commit cb63ff286d
3 changed files with 44 additions and 10 deletions
+11 -4
View File
@@ -54,14 +54,20 @@ def is_euicc(app):
def _select_isdr(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) rs = getattr(app, 'rs', None)
apps = getattr(getattr(rs, 'mf', None), 'applications', None) or {} apps = getattr(getattr(rs, 'mf', None), 'applications', None) or {}
isd_r = apps.get(AID_ISD_R.lower()) isd_r = apps.get(AID_ISD_R.lower())
if isd_r is None: if isd_r is None:
raise EsimError('not_an_euicc') raise EsimError('not_an_euicc')
lchan = rs.lchan[0] lchan = rs.lchan[0]
lchan.select_file(isd_r) lchan.select_file(isd_r, app)
return lchan.scc return lchan.scc
@@ -70,9 +76,10 @@ def _restore(app):
Best effort: a card whose active profile is disabled has no filesystem to Best effort: a card whose active profile is disabled has no filesystem to
select, so the restore can legitimately fail - the selection metadata is 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: try:
app.rs.soft_reset() app.rs.soft_reset(app)
except Exception as e: except Exception as e:
sys.stderr.write('ESIM: selection restore failed: %s\n' % e) sys.stderr.write('ESIM: selection restore failed: %s\n' % e)
+14
View File
@@ -19,6 +19,7 @@ from pysim_simple_server import netstate
from pysim_simple_server import scp81 from pysim_simple_server import scp81
from pysim_simple_server import esim from pysim_simple_server import esim
from smartcard.CardMonitoring import CardMonitor, CardObserver from smartcard.CardMonitoring import CardMonitor, CardObserver
from cmd2.exceptions import CommandSetRegistrationError
import gsm0338 # registers 'gsm03.38' codec import gsm0338 # registers 'gsm03.38' codec
from construct import GreedyBytes from construct import GreedyBytes
@@ -2594,6 +2595,19 @@ def _esim_reinit(server):
sys.stderr.write('ESIM: card gone during re-initialization\n') sys.stderr.write('ESIM: card gone during re-initialization\n')
return False return False
_apply_equipped_card(server) _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') sys.stderr.write('ESIM: re-initialized after profile switch\n')
return True return True
except Exception as e: except Exception as e:
+19 -6
View File
@@ -33,9 +33,11 @@ class FakeLchan:
def __init__(self): def __init__(self):
self.scc = SimpleNamespace(name='lchan-scc') self.scc = SimpleNamespace(name='lchan-scc')
self.selected = None self.selected = None
self.cmd_app = None
def select_file(self, app): def select_file(self, app, cmd_app=None):
self.selected = app self.selected = app
self.cmd_app = cmd_app
def make_app(profile='Consumer eUICC (SGP.22)', isdr=True): 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, rs = SimpleNamespace(profile=profile,
mf=SimpleNamespace(applications=apps), mf=SimpleNamespace(applications=apps),
lchan=[lchan], lchan=[lchan],
resets=0) resets=0,
soft_reset_cmd_app=None)
def soft_reset(): def soft_reset(cmd_app=None):
rs.resets += 1 rs.resets += 1
rs.soft_reset_cmd_app = cmd_app
rs.soft_reset = soft_reset 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, 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.assertEqual(out['profiles'][1]['state'], 'disabled')
self.assertIsNone(out['profiles'][1]['icon']) self.assertIsNone(out['profiles'][1]['icon'])
self.assertIsNone(out['profiles'][1]['icon_size']) 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.assertEqual(lchan.selected.name, 'ISD-R')
self.assertIs(lchan.cmd_app, app)
self.assertEqual(app.rs.resets, 1) self.assertEqual(app.rs.resets, 1)
self.assertIs(app.rs.soft_reset_cmd_app, app)
# the request asks for every ProfileInfo tag # the request asks for every ProfileInfo tag
self.assertIn('9F70', self.calls[0].to_tlv().hex().upper()) self.assertIn('9F70', self.calls[0].to_tlv().hex().upper())
@@ -251,9 +259,12 @@ class EsimTests(unittest.TestCase):
self.assertEqual(out['result'], 'ok') self.assertEqual(out['result'], 'ok')
self.assertFalse(out['refresh_seen']) self.assertFalse(out['refresh_seen'])
self.assertEqual(len(sent), 1) 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.assertEqual(lchan.selected.name, 'ISD-R')
self.assertIs(lchan.cmd_app, app)
self.assertEqual(app.rs.resets, 1) 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): def test_switch_profile_treats_91xx_as_ok_and_runs_the_chain(self):
app, lchan = make_app() app, lchan = make_app()
@@ -268,7 +279,9 @@ class EsimTests(unittest.TestCase):
self.assertTrue(out['refresh_seen']) self.assertTrue(out['refresh_seen'])
self.assertEqual(chain, ['9111']) self.assertEqual(chain, ['9111'])
self.assertEqual(lchan.selected.name, 'ISD-R') self.assertEqual(lchan.selected.name, 'ISD-R')
self.assertIs(lchan.cmd_app, app)
self.assertEqual(app.rs.resets, 1) 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): def test_switch_profile_chain_failure_keeps_the_accepted_switch(self):
app, _ = make_app() app, _ = make_app()