v3.19.0 (docs/engine): test-script guide audit; sms da accepts a leading +

An audit of docs/test-scripts.md against the engine (testscript.py,
events.py, the runner) found a handful of doc-level gaps, now fixed:

- the scp80 param defaults (tar = the preset's ISD role TAR, spi1 = that
  TAR's MSL, spi2 = 01, kvn = the first keyset);
- the counter rule as implemented: the run advances/persists the counter
  after every answered scp80 step when SPI1 requests a counter check
  (refused packets move the local value forward too - it only moves up);
- respond is optional (an omitted object answers ok/0x00);
- the run-log section: the exact READ BINARY/READ RECORD/UPDATE BINARY,
  SCP80 WRAPPED=/SP= and 'TEST-RUN step N failed:' lines;
- Known limitations: an expanded-format 61xx has no in-packet GET RESPONSE
  (TS 102 226 5.2.1.1) - send the GET RESPONSE as a follow-up scp80 step;
- sms check da: a leading + and whitespace are ignored.  The engine now
  drops the + (the TPDU carries the international flag in the dialling
  type, so '+79...' could otherwise never match the parsed digits);
  docs/api.md and the local AGENTS note it.

Tests: 821 frontend + 726 Python.
This commit is contained in:
2026-10-04 21:26:44 +03:00
parent 4a353720d0
commit d5aed563a1
4 changed files with 46 additions and 14 deletions
+2 -1
View File
@@ -702,7 +702,8 @@ SHORT MESSAGE carrying the PoR of the
preceding `scp80` step - PoR-in-submit, SPI2 bit `20`: `status` (the response
status name, `ok` = `por_ok`), `sw` (the R-APDU status word, exact/mask) and
`data` (the R-APDU response data, exact/mask)), `sms` (the SEND SHORT MESSAGE
TPDU: `da` digits, `pid`/`dcs`/`ud` hex exact/mask, `udl` decimal; SMS-SUBMIT
TPDU: `da` digits with whitespace skipped and a leading `+` ignored,
`pid`/`dcs`/`ud` hex exact/mask, `udl` decimal; SMS-SUBMIT
only) or `files` (the File List of a
REFRESH, TS 102 223 8.18; `files` is a list of path hex strings, matched
order-insensitively); `respond` is the TERMINAL RESPONSE in the TS 102 223
+31 -11
View File
@@ -103,8 +103,9 @@ PoR-in-submit:
4. **Iterate**: a mismatch on `data` usually means the mask length differs from
the actual value, or the value changed between runs - mask the varying
bytes (counter, date-time) with `?`.
5. **Counters**: the run starts from the preset's stored counter and persists
each accepted packet's next value; consecutive runs continue correctly.
5. **Counters**: the run starts from the preset's stored counter and advances
it after every scp80 step the card answers (when SPI1 requests a counter
check); a refused packet moves it forward too - see the presets section.
### Reading the run log
@@ -129,8 +130,13 @@ TEST-RUN step 4: MENU-SELECT ENVELOPE=80c2000009d30702020181900101 item=1 ('One'
- A SEND SHORT MESSAGE expectation also logs its parsed TPDU
(`SMS DA=… PID=… DCS=… UDL=… UD=…`) after the `CMD` line, so an applet's own
SMS can be read even without an `sms` check.
- A wrapped scp80 step logs both forms:
`SCP80 C-APDU=<plain> WRAPPED=<wrapped> (<format>)`; a pre-built packet logs
`SCP80 SP=…`.
- Other actions log their send/response pair too (`APDU TX=… -> SW=…`,
`ENVELOPE(Event Download) … -> SW=…`, `READ MF/… -> SW=…`).
`ENVELOPE(Event Download) … -> SW=…`, `READ BINARY <path>`,
`READ RECORD <n> <path>`, `UPDATE BINARY <path>`, each with `-> SW=…`).
- A failing step adds `TEST-RUN step N failed: …` with the reason.
- The shared secured-packet sender logs each segment's answer
(`OTA SEND: ENVELOPE i/N -> <SW>`).
- `--apdu-trace` adds the raw transport view
@@ -164,9 +170,16 @@ preset's id** with the run; the server resolves it and takes:
So before a run the matched preset (ICCID) must contain the keyset and, for
every TAR the script addresses, either a TAR entry with its MSL or an explicit
`spi1` in the step. Counter handling: the run starts from the keyset's stored
counter and persists each accepted packet's next value, so consecutive runs
continue correctly.
`spi1` in the step. A step that omits them gets the preset's ISD role TAR as
`tar`, that TAR's MSL as `spi1` and `01` as `spi2`; `kvn` defaults to the
first keyset.
Counter handling: the run starts from the keyset's stored counter and advances
(and persists) its next value after **every** scp80 step the card answers, when
the packet requests a counter check (SPI1 b5b4 ≠ 00, TS 102 225 §5.1.1) - a
refused packet moves the local value forward too. The local counter therefore
only ever moves up, so a later packet is never below what the card has seen;
consecutive runs continue correctly.
## Actions
@@ -232,7 +245,8 @@ the default is the terminal, matching the Phone tab's forms.
(`response_status == por_ok`), `"none"` (no PoR expected), or an **object**
`{"status"?, "sw"?, "data"?}` asserting the decoded PoR of this very
exchange (at least one field; the same fields as the expectation's `por`
check) - it works for both the inline and the SEND SHORT MESSAGE transport.
check, `status` accepting `ok` for `por_ok`) - it works for both the inline
and the SEND SHORT MESSAGE transport.
Example: `"por": {"status": "por_ok", "sw": "9000"}`.
- A plain hex string is exact; a value containing `?` is a mask. **A mask
must have exactly the same length as the actual value** (`?` is a per-nibble
@@ -276,7 +290,9 @@ the `por` check on the step that completes the sequence (intermediate steps
should use `raw` or no PoR check).
`sms` parses the fetched SEND SHORT MESSAGE's TPDU (SMS-SUBMIT only - any other
MTI fails the check): `da` the destination digits (exact, whitespace ignored),
MTI fails the check): `da` the destination digits as the TPDU carries them
(exact; whitespace and a leading `+` in the script are ignored - the
international flag lives in the TPDU's type-of-number),
`pid`/`dcs` single bytes and `ud` the raw user data (a UDH included) as hex
exact/mask, `udl` the user-data length in decimal (octets, or septets for the
7-bit alphabet - whatever the TPDU carries). Use it for an applet's own SMS;
@@ -285,9 +301,9 @@ field is required.
### `respond`
The TERMINAL RESPONSE for the fetched command, in the TS 102 223 §6.8.0
object order (Command details, Device identities, **Result**, Duration, Text
string, Item identifier, …):
Optional - an omitted (or empty) object answers `ok` (`0x00`). The TERMINAL
RESPONSE follows the TS 102 223 §6.8.0 object order (Command details, Device
identities, **Result**, Duration, Text string, Item identifier, …):
- `result` - name or hex (`ok`, `partial`, `missing`, `refused`,
`not_understood`, `modified`, `cancel`, `back`, `timeout`, `no_response`).
@@ -443,6 +459,10 @@ PoR-in-submit (with the transport asserted by the expectation):
`status` action.
- A `por` check only decodes SEND SHORT MESSAGE commands; the inline transport
is checked on the action (`por`/`data`).
- An expanded-format command (`"format": "expanded"`/`"expanded-ae"`) has no
in-packet GET RESPONSE (TS 102 226 §5.2.1.1): if the card answers `61xx`,
send the GET RESPONSE as a follow-up `scp80` step (its own secured packet,
same format).
- `files` matches the File List as a set (order-insensitive); duplicate paths
are kept.
- Semantic `event` actions cover four events (0x03/0x0B/0x12/0x1D); every
+5 -1
View File
@@ -467,7 +467,11 @@ def _normalise_content_check(c, default_level):
da = c.get('da')
if da is not None and str(da).strip():
d = re.sub(r'\s', '', str(da))
if not re.fullmatch(r'[0-9*#+]{1,20}', d):
# The international flag lives in the TPDU's type-of-number, not
# in the digits: a leading '+' is accepted and dropped.
if d.startswith('+'):
d = d[1:]
if not re.fullmatch(r'[0-9*#]{1,20}', d):
raise ScriptError('sms check: da must be up to 20 digits')
out['da'] = d
for key in ('pid', 'dcs'):
+8 -1
View File
@@ -276,6 +276,13 @@ class TestValidation(unittest.TestCase):
self.assertEqual(sms['dcs'], {'mode': 'exact', 'value': '00'})
self.assertEqual(sms['udl'], 5)
self.assertEqual(sms['ud'], {'mode': 'mask', 'value': '0271??AABB'})
# a leading '+' (the TPDU keeps the international flag in its
# type-of-number) and whitespace are dropped from `da`
script = T.normalise_script({'steps': [
{'type': 'expect', 'command': 'SEND SHORT MESSAGE',
'checks': [{'kind': 'sms', 'da': '+79 332 505 884'}]},
]}, _resolver)
self.assertEqual(script['steps'][0]['checks'][0]['da'], '79332505884')
for check in ({'kind': 'alpha'}, # no value
{'kind': 'alpha', 'mode': 'nope', 'value': 'x'},
{'kind': 'sms'}, # no fields
@@ -1075,7 +1082,7 @@ class TestRunnerActions(RunnerTestCase):
'check': {'sw': {'mode': 'mask', 'value': '91??'}}},
{'type': 'expect', 'command': 'SEND SHORT MESSAGE',
'checks': [{'kind': 'alpha', 'mode': 'exact', 'value': 'Отправка...'},
{'kind': 'sms', 'da': '79332505884', 'pid': '00',
{'kind': 'sms', 'da': '+79 332 505 884', 'pid': '00',
'dcs': '00', 'udl': 8, 'ud': '4B1C12579C9D83'}],
'respond': {'result': 'ok'}},
])