feat: test script engine and server-side runner (phase 1)
A test script drives a deterministic dialogue with the card: action steps (ENVELOPE event / Menu Selection, raw APDU, SCP80 secured packet with a card-preset, file update/read, STATUS) with SW/data/PoR checks, and proactive-command expectations that fetch, check (command type, qualifier, text/item/raw) and answer with a scripted TERMINAL RESPONSE. - pysim_simple_server/testscript.py: pure engine (validation, exact/mask matchers with '?' nibble wildcards, item/text checks, TERMINAL RESPONSE building). - server.py: worker thread + state, /api/test/run|status|stop|clear, the card-endpoint guard (409 while running), background STATUS polling suspended, 'error terminates / warning continues', a pending command is drained with a cancel TR on stop/error, no-drain modes for the ENVELOPE and SCP80 senders (the pending command belongs to the next expectation). - Expectations never poll: a command must be pending (91XX) from the previous step, otherwise it is an error (TS 102 221 7.4.2.1 / TS 102 223 6.3); scripts add an explicit `status` action (attempts/interval) when the card delivers on poll. - SCP80 steps require a complete card preset, may override TAR/SPI1/SPI2 only, and the counter is advanced per send and reported (`scp80_counter`) for the PWA to write back. - tests/test_testscript.py (26 tests: engine, matchers, TR building, the STK menu dialogue, error/warning termination, status polling, unexpected-command drain, SCP80 preset/counter, file actions, guards). - docs/api.md endpoint reference. 467 Python / 573 frontend green.
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
"""Test script engine (pure): validation, response checks and scripted TRs.
|
||||
|
||||
A test script drives a deterministic dialogue with the card:
|
||||
|
||||
* an **action** step sends something (ENVELOPE, Menu Selection, raw APDU,
|
||||
SCP80 secured packet, file update/read, STATUS) and checks the response
|
||||
(SW exact/mask, data exact/mask, PoR for SCP80);
|
||||
* an **expectation** step fetches the proactive command announced by the
|
||||
previous step (SW ``91XX`` - TS 102 221 7.4.2.1 / TS 102 223 6.3: the UICC
|
||||
announces a pending command in the response to a command and re-announces
|
||||
it with ``91XX`` until it is fetched; it never pushes unsolicited), checks
|
||||
its contents and answers it with a scripted TERMINAL RESPONSE.
|
||||
|
||||
This module has no card access - ``server.py`` runs the steps; only the pure
|
||||
parts live here so they can be tested without hardware.
|
||||
|
||||
Check syntax (SW, data, qualifier): a plain hex string is an exact match,
|
||||
``?`` in mask mode is a per-nibble wildcard (same convention as the
|
||||
profiler), e.g. ``{"mode": "mask", "value": "91??"}``.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
__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',
|
||||
]
|
||||
|
||||
ACTION_KINDS = ('envelope', 'menu-select', 'file-write', 'file-read', 'apdu',
|
||||
'scp80', 'status')
|
||||
FAIL_LEVELS = ('error', 'warning')
|
||||
POR_CHECKS = ('none', 'ok', 'any')
|
||||
|
||||
# TERMINAL RESPONSE result values commonly used by scripts (TS 102 223 8.12).
|
||||
RESULT_NAMES = {
|
||||
'ok': 0x00, 'partial': 0x01, 'missing': 0x02, 'refused': 0x03,
|
||||
'not_understood': 0x04, 'modified': 0x06,
|
||||
'cancel': 0x10, 'back': 0x11, 'timeout': 0x12, 'no_response': 0x22,
|
||||
}
|
||||
|
||||
_HEX_MASK_RE = re.compile(r'^[0-9A-F?]+$')
|
||||
_HEX_RE = re.compile(r'^[0-9A-F]+$')
|
||||
|
||||
|
||||
class ScriptError(ValueError):
|
||||
"""Invalid test script - reported to the client before a run starts."""
|
||||
|
||||
|
||||
# ─── normalisation helpers ──────────────────────────────────────────────
|
||||
|
||||
def _fail_level(value, default='error'):
|
||||
if value in (None, ''):
|
||||
return default
|
||||
v = str(value).lower()
|
||||
if v not in FAIL_LEVELS:
|
||||
raise ScriptError("on_fail must be 'error' or 'warning'")
|
||||
return v
|
||||
|
||||
|
||||
def _int(value, what, lo, hi):
|
||||
try:
|
||||
v = int(str(value).strip(), 0)
|
||||
except (TypeError, ValueError):
|
||||
raise ScriptError('%s must be an integer' % what)
|
||||
if not lo <= v <= hi:
|
||||
raise ScriptError('%s must be %d..%d' % (what, lo, hi))
|
||||
return v
|
||||
|
||||
|
||||
def _data_hex(value, what, allow_empty=False):
|
||||
if value in (None, ''):
|
||||
if allow_empty:
|
||||
return ''
|
||||
raise ScriptError('%s is required' % what)
|
||||
if not isinstance(value, str):
|
||||
raise ScriptError('%s must be a hex string' % what)
|
||||
v = re.sub(r'\s', '', value).upper()
|
||||
if not v:
|
||||
if allow_empty:
|
||||
return ''
|
||||
raise ScriptError('%s is required' % what)
|
||||
if not _HEX_RE.match(v) or len(v) % 2:
|
||||
raise ScriptError('%s must be hex with an even number of digits' % what)
|
||||
return v
|
||||
|
||||
|
||||
def _check_spec(value, what, default_mode='exact'):
|
||||
"""Normalise a check: hex string or {'mode', 'value'}."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, dict):
|
||||
mode = str(value.get('mode') or default_mode).lower()
|
||||
val = value.get('value')
|
||||
else:
|
||||
val = value
|
||||
# a plain string with '?' is a mask ("91??"), no need to spell it out
|
||||
mode = 'mask' if (default_mode == 'exact' and isinstance(val, str)
|
||||
and '?' in val) else default_mode
|
||||
if mode not in ('exact', 'mask'):
|
||||
raise ScriptError('%s: mode must be exact or mask' % what)
|
||||
if not isinstance(val, str) or not val.strip():
|
||||
raise ScriptError('%s: value is required' % what)
|
||||
v = re.sub(r'\s', '', val).upper()
|
||||
if not _HEX_MASK_RE.match(v) or len(v) % 2:
|
||||
raise ScriptError('%s: value must be hex (even length, "?" = wildcard)' % what)
|
||||
if mode == 'exact' and '?' in v:
|
||||
raise ScriptError('%s: "?" is only allowed in mask mode' % what)
|
||||
return {'mode': mode, 'value': v}
|
||||
|
||||
|
||||
# ─── matching (pure) ────────────────────────────────────────────────────
|
||||
|
||||
def match_value(spec, actual):
|
||||
"""Exact/mask hex comparison; no spec means 'no check'."""
|
||||
if not spec:
|
||||
return True
|
||||
if actual is None:
|
||||
return False
|
||||
a = re.sub(r'\s', '', str(actual)).upper()
|
||||
v = spec['value']
|
||||
if len(a) != len(v):
|
||||
return False
|
||||
if spec['mode'] == 'exact':
|
||||
return a == v
|
||||
return all(vc == '?' or vc == ac for vc, ac in zip(v, a))
|
||||
|
||||
|
||||
def match_text(spec, text):
|
||||
if not spec:
|
||||
return True
|
||||
if text is None:
|
||||
return False
|
||||
want, got = spec['value'], str(text)
|
||||
if not spec.get('case_sensitive', True):
|
||||
want, got = want.lower(), got.lower()
|
||||
return want == got if spec['mode'] == 'exact' else want in got
|
||||
|
||||
|
||||
def match_item(items, spec):
|
||||
"""Find a parsed item (SELECT ITEM / SET UP MENU) matching id and/or text.
|
||||
|
||||
Returns ``(ok, detail)`` - the detail names the matching item or lists the
|
||||
decoded items so the report is useful without the raw bytes."""
|
||||
decoded = ', '.join('%s=%r' % (it.get('id'), it.get('text')) for it in (items or []))
|
||||
if not items:
|
||||
return False, 'no items decoded'
|
||||
for it in items:
|
||||
if spec.get('id') is not None and int(it.get('id', -1)) != spec['id']:
|
||||
continue
|
||||
if spec.get('text') is not None:
|
||||
text_spec = {'mode': spec['mode'], 'value': spec['text'],
|
||||
'case_sensitive': spec.get('case_sensitive', True)}
|
||||
if not match_text(text_spec, it.get('text')):
|
||||
continue
|
||||
return True, 'item %s %r' % (it.get('id'), it.get('text'))
|
||||
return False, 'no matching item (decoded: %s)' % (decoded or 'none')
|
||||
|
||||
|
||||
def combine_levels(levels):
|
||||
"""Worst outcome of a step: any error wins, then warning, else ok."""
|
||||
if 'error' in levels:
|
||||
return 'error'
|
||||
if 'warning' in levels:
|
||||
return 'warning'
|
||||
return 'ok'
|
||||
|
||||
|
||||
# ─── script validation ──────────────────────────────────────────────────
|
||||
|
||||
def normalise_script(raw, command_resolver=None):
|
||||
"""Validate/normalise a script. ``command_resolver(name) -> int|None``
|
||||
resolves proactive command names (provided by server.py)."""
|
||||
if not isinstance(raw, dict):
|
||||
raise ScriptError('script must be an object')
|
||||
name = str(raw.get('name') or '').strip() or 'test script'
|
||||
steps_raw = raw.get('steps')
|
||||
if not isinstance(steps_raw, list) or not steps_raw:
|
||||
raise ScriptError('script must have at least one step')
|
||||
steps = [normalise_step(s, command_resolver) for s in steps_raw]
|
||||
return {'name': name, 'steps': steps}
|
||||
|
||||
|
||||
def normalise_step(step, command_resolver=None):
|
||||
if not isinstance(step, dict):
|
||||
raise ScriptError('each step must be an object')
|
||||
typ = step.get('type')
|
||||
if typ == 'action':
|
||||
return _normalise_action(step)
|
||||
if typ == 'expect':
|
||||
return _normalise_expect(step, command_resolver)
|
||||
raise ScriptError("step type must be 'action' or 'expect'")
|
||||
|
||||
|
||||
def _normalise_action(step):
|
||||
kind = str(step.get('kind') or '').lower()
|
||||
if kind not in ACTION_KINDS:
|
||||
raise ScriptError("unknown action kind %r" % step.get('kind'))
|
||||
params = _normalise_params(kind, step.get('params') or {})
|
||||
on_fail = _fail_level(step.get('on_fail'))
|
||||
check = _normalise_check(step.get('check'), kind, params)
|
||||
out = {'type': 'action', 'kind': kind, 'params': params,
|
||||
'check': check, 'on_fail': on_fail}
|
||||
if step.get('label'):
|
||||
out['label'] = str(step['label'])
|
||||
return out
|
||||
|
||||
|
||||
def _normalise_params(kind, p):
|
||||
if not isinstance(p, dict):
|
||||
raise ScriptError('%s: params must be an object' % kind)
|
||||
if kind == 'envelope':
|
||||
if p.get('event') is None:
|
||||
raise ScriptError('envelope: event is required')
|
||||
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)}
|
||||
if kind in ('file-write', 'file-read'):
|
||||
path = str(p.get('path') or '').strip()
|
||||
if not path:
|
||||
raise ScriptError('%s: path is required' % kind)
|
||||
mode = str(p.get('mode') or 'auto').lower()
|
||||
if mode not in ('auto', 'binary', 'record'):
|
||||
raise ScriptError('%s: mode must be auto, binary or record' % kind)
|
||||
out = {'path': path, 'mode': mode}
|
||||
if mode == 'record' or p.get('record') is not None:
|
||||
out['record'] = _int(p.get('record') or 1, '%s record' % kind, 1, 255)
|
||||
if kind == 'file-write':
|
||||
out['data'] = _data_hex(p.get('data'), 'file-write data')
|
||||
return out
|
||||
if kind == 'apdu':
|
||||
return {'apdu': _data_hex(p.get('apdu'), 'apdu')}
|
||||
if kind == 'scp80':
|
||||
out = {}
|
||||
if p.get('sp'):
|
||||
out['sp'] = _data_hex(p.get('sp'), 'secured packet')
|
||||
elif p.get('apdu'):
|
||||
out['apdu'] = _data_hex(p.get('apdu'), 'scp80 apdu')
|
||||
else:
|
||||
raise ScriptError('scp80: apdu or sp is required')
|
||||
for key in ('tar', 'spi1', 'spi2'):
|
||||
if p.get(key) not in (None, ''):
|
||||
out[key] = _data_hex(p[key], 'scp80 %s' % key)
|
||||
if out.get('tar') and len(out['tar']) != 6:
|
||||
raise ScriptError('scp80: tar must be 3 bytes')
|
||||
for key in ('spi1', 'spi2'):
|
||||
if out.get(key) and len(out[key]) != 2:
|
||||
raise ScriptError('scp80: %s must be 1 byte' % key)
|
||||
return out
|
||||
if kind == 'status':
|
||||
attempts = p.get('attempts')
|
||||
attempts = _int(1 if attempts is None else attempts, 'status attempts', 1, 1000)
|
||||
interval = p.get('interval_ms')
|
||||
interval = _int(200 if interval is None else interval, 'status interval_ms', 0, 10000)
|
||||
return {'attempts': attempts, 'interval_ms': interval}
|
||||
raise ScriptError('unknown action kind %r' % kind)
|
||||
|
||||
|
||||
def _normalise_check(check, kind, params):
|
||||
if check is None:
|
||||
check = {}
|
||||
if not isinstance(check, dict):
|
||||
raise ScriptError('check must be an object')
|
||||
sw = _check_spec(check.get('sw'), 'check.sw')
|
||||
if sw is None:
|
||||
# A STATUS poll for a pending proactive command ends on 91XX
|
||||
# (TS 102 221 7.4.2.1); a single STATUS normally ends on 9000.
|
||||
if kind == 'status' and params.get('attempts', 1) > 1:
|
||||
sw = {'mode': 'mask', 'value': '91??'}
|
||||
else:
|
||||
sw = {'mode': 'exact', 'value': '9000'}
|
||||
data = _check_spec(check.get('data'), 'check.data')
|
||||
por = check.get('por')
|
||||
if por is None:
|
||||
por = 'any'
|
||||
else:
|
||||
por = str(por).lower()
|
||||
if kind != 'scp80':
|
||||
raise ScriptError('check.por is only valid for scp80 actions')
|
||||
if por not in POR_CHECKS:
|
||||
raise ScriptError('check.por must be none, ok or any')
|
||||
return {'sw': sw, 'data': data, 'por': por}
|
||||
|
||||
|
||||
def _normalise_expect(step, command_resolver=None):
|
||||
cmd = step.get('command')
|
||||
if cmd is None or isinstance(cmd, bool):
|
||||
raise ScriptError('expect: command is required')
|
||||
if isinstance(cmd, int):
|
||||
ctype, cname = cmd, None
|
||||
else:
|
||||
s = str(cmd).strip()
|
||||
up = s.upper()
|
||||
if up in ('ANY', '*'):
|
||||
ctype, cname = None, 'ANY'
|
||||
elif re.fullmatch(r'(0X)?[0-9A-F]{2}', up):
|
||||
ctype, cname = int(up.replace('0X', ''), 16), None
|
||||
elif command_resolver is not None:
|
||||
ctype = command_resolver(up)
|
||||
if ctype is None:
|
||||
raise ScriptError('expect: unknown proactive command %r' % s)
|
||||
cname = up
|
||||
else:
|
||||
raise ScriptError('expect: command must be a hex type code')
|
||||
qualifier = _check_spec(step.get('qualifier'), 'qualifier')
|
||||
if qualifier and '?' not in qualifier['value'] and len(qualifier['value']) != 2:
|
||||
raise ScriptError('qualifier must be one byte')
|
||||
on_fail = _fail_level(step.get('on_fail'))
|
||||
checks_raw = step.get('checks') or []
|
||||
if not isinstance(checks_raw, list):
|
||||
raise ScriptError('expect: checks must be a list')
|
||||
checks = [_normalise_content_check(c, on_fail) for c in checks_raw]
|
||||
respond = normalise_respond(step.get('respond') or {}, ctype)
|
||||
return {'type': 'expect', 'command': {'type': ctype, 'name': cname},
|
||||
'qualifier': qualifier, 'checks': checks, 'respond': respond,
|
||||
'on_fail': on_fail}
|
||||
|
||||
|
||||
def _normalise_content_check(c, default_level):
|
||||
if not isinstance(c, dict):
|
||||
raise ScriptError('expect: each check must be an object')
|
||||
kind = str(c.get('kind') or '').lower()
|
||||
level = _fail_level(c.get('on_fail'), default_level)
|
||||
if kind == 'text':
|
||||
mode = str(c.get('mode') or 'contains').lower()
|
||||
if mode not in ('contains', 'exact'):
|
||||
raise ScriptError('text check: mode must be contains or exact')
|
||||
if c.get('value') is None:
|
||||
raise ScriptError('text check: value is required')
|
||||
return {'kind': 'text', 'mode': mode, 'value': str(c['value']),
|
||||
'case_sensitive': bool(c.get('case_sensitive', True)),
|
||||
'on_fail': level}
|
||||
if kind == 'item':
|
||||
item_id = c.get('id')
|
||||
if item_id is not None:
|
||||
item_id = _int(item_id, 'item check id', 1, 255)
|
||||
text = c.get('text')
|
||||
if item_id is None and text is None:
|
||||
raise ScriptError('item check: id or text is required')
|
||||
mode = str(c.get('mode') or 'contains').lower()
|
||||
if mode not in ('contains', 'exact'):
|
||||
raise ScriptError('item check: mode must be contains or exact')
|
||||
return {'kind': 'item', 'id': item_id,
|
||||
'text': str(text) if text is not None else None, 'mode': mode,
|
||||
'case_sensitive': bool(c.get('case_sensitive', True)),
|
||||
'on_fail': level}
|
||||
if kind == 'raw':
|
||||
spec = _check_spec(c.get('value') if 'value' in c else c, 'raw check')
|
||||
return {'kind': 'raw', 'mode': spec['mode'], 'value': spec['value'],
|
||||
'on_fail': level}
|
||||
raise ScriptError('unknown check kind %r' % c.get('kind'))
|
||||
|
||||
|
||||
def normalise_respond(respond, cmd_type=None):
|
||||
"""Validate the scripted TERMINAL RESPONSE for an expected command."""
|
||||
if not isinstance(respond, dict):
|
||||
raise ScriptError('respond must be an object')
|
||||
res = respond.get('result', 0x00)
|
||||
if isinstance(res, str):
|
||||
key = res.strip().lower()
|
||||
if re.fullmatch(r'(0x)?[0-9a-f]{2}', key):
|
||||
res = int(key.replace('0x', ''), 16)
|
||||
elif key in RESULT_NAMES:
|
||||
res = RESULT_NAMES[key]
|
||||
else:
|
||||
raise ScriptError('respond: unknown result %r' % respond.get('result'))
|
||||
else:
|
||||
res = _int(res, 'respond result', 0, 255)
|
||||
out = {'result': res}
|
||||
if respond.get('item_id') is not None:
|
||||
out['item_id'] = _int(respond['item_id'], 'respond item_id', 1, 255)
|
||||
if respond.get('text') is not None:
|
||||
out['text'] = str(respond['text'])
|
||||
dcs = respond.get('dcs')
|
||||
out['dcs'] = _int(dcs, 'respond dcs', 0, 255) if dcs is not None else 0x00
|
||||
extra = respond.get('raw')
|
||||
if extra not in (None, ''):
|
||||
out['raw'] = _data_hex(extra, 'respond raw')
|
||||
return out
|
||||
|
||||
|
||||
# ─── scripted TERMINAL RESPONSE ─────────────────────────────────────────
|
||||
|
||||
def _encode_text(text, dcs):
|
||||
if (dcs & 0x0C) == 0x08:
|
||||
return text.encode('utf-16-be')
|
||||
return text.encode('latin-1', 'replace')
|
||||
|
||||
|
||||
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."""
|
||||
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))
|
||||
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([0x0D, len(body) + 1, dcs]) + body
|
||||
out += bytes([0x83, 0x02, result & 0xFF, 0x00])
|
||||
return bytes(out)
|
||||
Reference in New Issue
Block a user