v3.17.0: test scripts for the applet handoff - menu by text, spec-order TRs, codings

The applet-testing batch agreed after v3.15/3.16:

- menu-select by text: the step names the item by `text` (+ `mode`
  exact/contains, `case_sensitive`) instead of the install-dependent id
  (both or neither is refused); the runner resolves it against the card's
  cached menu and raises a step error naming the available items when nothing
  (or several items) matches.  The cache is refreshed on every SET UP MENU
  fetch - the proactive chain and the expect step - so an applet install (new
  items/ids after its REFRESH) no longer leaves a stale menu behind.  The
  step editor offers the id or the text + mode and the client-side pre-check
  bounds them.
- TERMINAL RESPONSE object order: both builders emitted the Item identifier
  before the Result (and the scripted builder its Text string before it too);
  TS 102 223 6.8.0 mandates Command details, Device identities, Result,
  Duration, Text string, Item identifier.  `testscript.build_tr` and
  `_menu_send_response` now follow it; `respond.raw` is documented as
  appended after the standard objects, and the exact-byte tests pin the order.
- Text codings: the scripted response codes the GSM default alphabet via the
  GSM 03.38 codec ('00' packed, '04' 8-bit) instead of latin-1, keeps '08'
  UCS2 and sends the spec's null text string for an empty answer; the packing
  helper is shared with the interactive path (`testscript.pack_gsm7`).  A
  UCS2 GET INPUT answer is documented and tested.
- docs/test-scripts.md gained the handoff brief for the applet agent: the
  value-collection table, fill-in worksheets for the two applet scenarios, a
  menu-by-text example, the validation checklist and a format stamp; help
  EN/RU and docs/api.md updated.

Tests: 809 frontend + 705 Python; sw cache simple-v380.
This commit is contained in:
2026-10-04 17:48:29 +03:00
parent 8430dd1694
commit 0b9e9e4c5e
11 changed files with 417 additions and 52 deletions
+58 -19
View File
@@ -33,7 +33,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
VERSION = '3.16.0'
VERSION = '3.17.0'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -5210,6 +5210,17 @@ def _run_proactive_chain(scc, sw91, on_fetch=None, status_poll=True):
entry = _log_proactive(cmd_type, raw, cmd_qual, cmd_num)
except Exception as e:
sys.stderr.write('FETCH log error: %s (raw=%s)\n' % (e, raw.hex()))
try:
if cmd_type == 0x25:
# Keep the cached menu current: text-based menu selection
# and the Phone tab must see the menu the card sends now
# (an applet install changes the items and their ids).
menu = _parse_setup_menu_command(raw)
if menu and _server_ref:
_server_ref.sim_menu = menu
except Exception as e:
sys.stderr.write('SET UP MENU cache error: %s (raw=%s)\n'
% (e, raw.hex()))
try:
if raw and cmd_type in (0x03, 0x04):
_handle_card_poll_command(cmd_type, raw)
@@ -5446,20 +5457,10 @@ def _validate_input_response(pd, text):
def _pack_gsm7(septets):
"""Pack GSM 03.38 septets into 7-bit octets (TS 23.038 4, SMS packing)."""
out = bytearray()
acc = 0
bits = 0
for s in septets:
acc |= (s & 0x7F) << bits
bits += 7
while bits >= 8:
out.append(acc & 0xFF)
acc >>= 8
bits -= 8
if bits:
out.append(acc & 0xFF)
return bytes(out)
"""Pack GSM 03.38 septets into 7-bit octets (TS 23.038 4, SMS packing).
One implementation, shared with the scripted TR builder
(`testscript.pack_gsm7`)."""
return testscript.pack_gsm7(septets)
def _input_text_tlv(pd, text):
@@ -5513,12 +5514,13 @@ def _menu_send_response(server, result, item_id=None, text=None):
duration_tlv = bytes([0x04, 0x02, pd['duration_unit'], value])
cd = bytes([0x81, 0x03, pd['cmd_num'], pd['cmd_type'], 0x00])
di = bytes([0x82, 0x02, pd['dev_dst'], pd['dev_src']])
# TS 102 223 6.8.0 object order: Result (C), Duration (D), Text string (E),
# Item identifier (F).
tr_data = cd + di
tr_data += bytes([0x83, 0x02, gr, 0x00])
tr_data += duration_tlv + text_tlv
if isinstance(item_id, int) and result == 'ok' and pd['type'] == 'select_item':
tr_data += bytes([0x90, 0x01, item_id])
tr_data += bytes([0x83, 0x02, gr, 0x00])
# Table 6.8.0 order after Result: Duration (D) then Text string (E)
tr_data += duration_tlv + text_tlv
tr_hex = '%s140000%02x%s' % (scc.cat_cla, len(tr_data), tr_data.hex())
tr_rv = scc._tp.send_apdu(tr_hex)
sys.stderr.write('TR(menu): cmd=%02x type=%02x result=%02x -> %s\n' % (pd['cmd_num'], pd['cmd_type'], gr, tr_rv[1]))
@@ -5912,6 +5914,34 @@ def _test_run_scp80(server, step, ctx):
return data, sw, sent, por, counter
def _menu_item_by_text(server, p):
"""Resolve a menu-select step's text against the cached SET UP MENU items
(the item ids vary with the applet's install parameters, so a script
matches the text the card actually shows). Raises a ScriptError naming
the available items when nothing - or more than one item - matches."""
menu = getattr(server, 'sim_menu', None) or {}
items = menu.get('items') or []
spec = {'mode': p.get('mode', 'exact'), 'value': p['text'],
'case_sensitive': p.get('case_sensitive', True)}
matches = [it for it in items
if testscript.match_text(spec, it.get('text') or '')]
if not matches:
listing = ', '.join('%s=%r' % (it.get('id'), it.get('text'))
for it in items)
raise testscript.ScriptError(
'menu-select: no menu item matches %r (%s)'
% (p['text'], 'menu: ' + listing if listing
else 'no menu cached - fetch the menu (a SET UP MENU '
'expectation) first'))
if len(matches) > 1:
raise testscript.ScriptError(
'menu-select: %d items match %r (%s) - use the item id'
% (len(matches), p['text'],
', '.join('%s=%r' % (it.get('id'), it.get('text'))
for it in matches)))
return matches[0]['id']
def _test_run_action(server, step, ctx):
scc = server.scc
kind = step['kind']
@@ -5924,7 +5954,10 @@ def _test_run_action(server, step, ctx):
drain=False)
sent = 'ENVELOPE(Event Download) type=0x%02X' % p['event']
elif kind == 'menu-select':
tlv = bytes([0xD3, 0x07, 0x02, 0x02, 0x01, 0x81, 0x90, 0x01, p['item_id']])
item_id = p.get('item_id')
if item_id is None:
item_id = _menu_item_by_text(server, p)
tlv = bytes([0xD3, 0x07, 0x02, 0x02, 0x01, 0x81, 0x90, 0x01, item_id])
sent = '%sc20000%02x%s' % (scc.cat_cla, len(tlv), tlv.hex())
server.menu_active = True
data, sw = scc._tp.send_apdu(sent)
@@ -6061,6 +6094,12 @@ def _test_run_expect(server, step, pending, ctx=None):
# submit mode (SPI2 bit 0x20): decode it with the triggering scp80 step's
# context so the checks can assert the PoR contents.
por = _test_expect_por(ctx, raw) if cmd_type == 0x13 else None
if cmd_type == 0x25:
# The card may re-send its menu (e.g. after an applet install); keep
# the cache that text-based menu selection resolves against current.
menu = _parse_setup_menu_command(raw)
if menu:
server.sim_menu = menu
checks = []
want_type = step['command'].get('type')
type_ok = want_type is None or cmd_type == want_type
+62 -10
View File
@@ -21,10 +21,13 @@ profiler), e.g. ``{"mode": "mask", "value": "91??"}``.
import re
import gsm0338 # registers the 'gsm03.38' codec (the default alphabet coding)
__all__ = [
'ScriptError', 'ACTION_KINDS', 'FAIL_LEVELS', 'POR_CHECKS', 'RESULT_NAMES',
'normalise_script', 'normalise_step', 'normalise_respond',
'match_value', 'match_text', 'match_item', 'combine_levels', 'build_tr',
'pack_gsm7',
]
ACTION_KINDS = ('envelope', 'menu-select', 'file-write', 'file-read', 'apdu',
@@ -216,7 +219,25 @@ def _normalise_params(kind, p):
return {'event': _int(p['event'], 'envelope event', 0, 255),
'data': _data_hex(p.get('data'), 'envelope data', allow_empty=True)}
if kind == 'menu-select':
return {'item_id': _int(p.get('item_id'), 'menu item_id', 1, 255)}
# The item is named by its id, or by its text - the id varies with the
# applet's install parameters, so a script can match the text the card
# actually shows (the runner resolves it against the cached menu).
out = {}
if p.get('item_id') not in (None, ''):
out['item_id'] = _int(p.get('item_id'), 'menu item_id', 1, 255)
text = p.get('text')
if text is not None and str(text).strip():
out['text'] = str(text)
mode = str(p.get('mode') or 'exact').lower()
if mode not in ('exact', 'contains'):
raise ScriptError('menu-select: mode must be exact or contains')
out['mode'] = mode
out['case_sensitive'] = bool(p.get('case_sensitive', True))
if not out:
raise ScriptError('menu-select: item_id or text is required')
if 'item_id' in out and 'text' in out:
raise ScriptError('menu-select: give item_id or text, not both')
return out
if kind in ('file-write', 'file-read'):
path = str(p.get('path') or '').strip()
if not path:
@@ -417,26 +438,57 @@ def normalise_respond(respond, cmd_type=None):
# ─── scripted TERMINAL RESPONSE ─────────────────────────────────────────
def pack_gsm7(septets):
"""Pack GSM 03.38 septets into 7-bit octets (TS 23.038 4, SMS packing)."""
out = bytearray()
acc = 0
bits = 0
for s in septets:
acc |= (s & 0x7F) << bits
bits += 7
while bits >= 8:
out.append(acc & 0xFF)
acc >>= 8
bits -= 8
if bits:
out.append(acc & 0xFF)
return bytes(out)
def _encode_text(text, dcs):
"""Text string coding for a scripted TERMINAL RESPONSE (TS 102 223 8.15):
'00' = GSM default alphabet 7 bits packed, '04' (or any other 8-bit
scheme) = GSM default alphabet 8 bits (TS 23.038 via the gsm03.38 codec,
bit 8 clear), '08' = UCS2. Matches the interactive GET INKEY/GET INPUT
response codings."""
if (dcs & 0x0C) == 0x08:
return text.encode('utf-16-be')
return text.encode('latin-1', 'replace')
if dcs == 0x00:
return pack_gsm7(text.encode('gsm03.38'))
return text.encode('gsm03.38')
def build_tr(cmd_num, cmd_type, dev_dst, dev_src, respond):
"""Flat COMPREHENSION-TLV TERMINAL RESPONSE payload (TS 102 223 6.8):
command details + device identities + optional item identifier / text
string / raw TLVs + result. Matches the interactive menu TR layout."""
"""Flat COMPREHENSION-TLV TERMINAL RESPONSE payload (TS 102 223 6.8) in the
6.8.0 object order: command details, device identities, Result, then the
command-specific objects (Text string for GET INKEY/GET INPUT, Item
identifier for SELECT ITEM) and any extra raw TLVs. Matches the
interactive menu TR layout."""
out = bytearray([0x81, 0x03, cmd_num & 0xFF, cmd_type & 0xFF, 0x00])
out += bytes([0x82, 0x02, dev_dst & 0xFF, dev_src & 0xFF])
result = int(respond.get('result', 0))
out += bytes([0x83, 0x02, result & 0xFF, 0x00])
if respond.get('text') is not None:
text = str(respond['text'])
if text == '':
# TS 102 223 8.15: a null text string is Length 00, no value part
out += bytes([0x8D, 0x00])
else:
dcs = int(respond.get('dcs', 0x00))
body = _encode_text(text, dcs)
out += bytes([0x8D, len(body) + 1, dcs]) + body
if respond.get('item_id') is not None and result == 0x00:
out += bytes([0x90, 0x01, respond['item_id'] & 0xFF])
if respond.get('raw'):
out += bytes.fromhex(respond['raw'])
if respond.get('text') is not None:
dcs = int(respond.get('dcs', 0x00))
body = _encode_text(respond['text'], dcs)
out += bytes([0x8D, len(body) + 1, dcs]) + body
out += bytes([0x83, 0x02, result & 0xFF, 0x00])
return bytes(out)